diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ab1fb8013..4d1dec3cc 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -276,6 +276,10 @@ The identity card now follows the same rule: canonical identity rows stay in the primary summary, while aliases, IPs, and tags live in a smaller `Supporting context` block so the drawer answers "what is this resource" before showing every attached label. +Type-specific Docker, PBS, and PMG operational panels now also live inside a +collapsed `Service details` support block, so lane-specific controls and +breakdowns stay available without displacing the common runtime and identity +hierarchy on first read. The same facet bundle now also returns grouped recent-change counts by canonical change kind, so the detail drawer can surface the distribution of state transitions, restarts, config updates, and anomalies without diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index 029d9157f..fb7223382 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -144,6 +144,7 @@ const DrawerContent: Component = (props) => { const [showCorrelationContext, setShowCorrelationContext] = createSignal(false); const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false); const [showHostDetails, setShowHostDetails] = createSignal(false); + const [showServiceDetails, setShowServiceDetails] = createSignal(false); const displayName = createMemo(() => getPreferredResourceDisplayName(props.resource)); const kubernetesClusterName = createMemo(() => @@ -601,6 +602,26 @@ const DrawerContent: Component = (props) => { return `${hostDetailCards().length} detail card${hostDetailCards().length === 1 ? '' : 's'} covering ${categories}.`; }); + const hasServiceDetails = createMemo( + () => props.resource.type === 'docker-host' || Boolean(pbsData()) || Boolean(pmgData()), + ); + const serviceDetailsSummary = createMemo(() => { + if (props.resource.type === 'docker-host') { + return `${formatInteger(dockerContainerCount())} containers · ${formatInteger(dockerUpdatesAvailable())} updates`; + } + + const pbs = pbsData(); + if (pbs) { + return `${formatInteger(pbs.datastoreCount)} datastores · ${formatInteger(pbsJobTotal())} jobs`; + } + + const pmg = pmgData(); + if (pmg) { + return `${formatInteger(pmg.queueTotal)} queue total · ${formatInteger(pmgQueueBacklog())} backlog`; + } + + return null; + }); const workloadsHref = createMemo(() => buildWorkloadsHref(props.resource)); const headerIdentity = createMemo(() => getPrimaryResourceIdentity(props.resource)); const relatedLinks = createMemo(() => { @@ -1041,371 +1062,403 @@ const DrawerContent: Component = (props) => { - -
-
-
- Container Updates + +
+
+
+
+ Service details +
+
+ Secondary service-specific operations and breakdowns. +
+ +
{serviceDetailsSummary()}
+
- - - {dockerHostData()?.runtime} - - + +
-
-
- Containers - - {formatInteger(dockerContainerCount())} - -
-
- Updates Available - 0 ? 'text-sky-700 dark:text-sky-300' : 'text-base-content'}`} - > - {formatInteger(dockerUpdatesAvailable())} - -
- -
- Last Check - - {dockerUpdatesCheckedRelative()} - -
-
- - -
-
- Command - - {formatIdentifierLabel(dockerHostCommand()?.type, { fallback: 'command' })} - -
-
- Status - - {formatIdentifierLabel(dockerHostCommand()?.status, { - fallback: 'unknown', - })} - -
- -
- {dockerHostCommand()?.message} -
-
- -
- {dockerHostCommand()?.failureReason} -
-
-
-
- - -
- {dockerActionError()} -
-
- -
- {dockerActionNote()} -
-
- -
- - - -
-
-
-
- - - {(pbs) => { - const connection = getServiceHealthPresentation( - props.resource.status, - pbs().connectionHealth, - ); - return ( -
-
-
- PBS Service -
- - - {pbs().hostname} - - -
-
-
- Connection - {connection.label} -
- -
- Version - {pbs().version} -
-
- -
- Uptime - - {formatUptime(pbs().uptimeSeconds ?? props.resource.uptime ?? 0)} - -
-
-
-
-
Datastores
-
- {formatInteger(pbs().datastoreCount)} + +
+ +
+
+
+ Container Updates
+ + + {dockerHostData()?.runtime} + +
-
-
Total Jobs
-
- {formatInteger(pbsJobTotal())} + +
+
+ Containers + + {formatInteger(dockerContainerCount())} +
-
-
-
- - Job breakdown - {pbsVisibleJobBreakdown().length} types - -
- - {(entry) => ( - - {entry.label}:{' '} - - {formatInteger(entry.value)} - +
+ Updates Available + 0 ? 'text-sky-700 dark:text-sky-300' : 'text-base-content'}`} + > + {formatInteger(dockerUpdatesAvailable())} + +
+ +
+ Last Check + + {dockerUpdatesCheckedRelative()} - )} - -
-
-
-
- ); - }} -
+
+
- - {(pmg) => { - const connection = getServiceHealthPresentation( - props.resource.status, - pmg().connectionHealth, - ); - return ( -
-
-
- Mail Gateway -
- - - {pmg().hostname} - - -
-
-
- Connection - {connection.label} -
- -
- Version - {pmg().version} -
-
- -
- Uptime - - {formatUptime(pmg().uptimeSeconds ?? props.resource.uptime ?? 0)} - -
-
-
-
-
Nodes
-
- {formatInteger(pmg().nodeCount)} -
-
-
-
Queue Total
-
0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`} - > - {formatInteger(pmg().queueTotal)} -
-
-
-
Backlog
-
0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`} - > - {formatInteger(pmgQueueBacklog())} -
-
-
-
- - Queue breakdown - {pmgVisibleQueueBreakdown().length} signals - -
- - {(entry) => ( - - {entry.label}:{' '} + +
+
+ Command + + {formatIdentifierLabel(dockerHostCommand()?.type, { + fallback: 'command', + })} + +
+
+ Status - {formatInteger(entry.value)} + {formatIdentifierLabel(dockerHostCommand()?.status, { + fallback: 'unknown', + })} - - )} - -
-
-
- - Mail processing - {pmgVisibleMailBreakdown().length} signals - -
- - {(entry) => ( - - {entry.label}:{' '} - - {formatInteger(entry.value)} - - - )} - -
- -
- Updated - {pmgUpdatedRelative()} +
+ +
+ {dockerHostCommand()?.message} +
+
+ +
+ {dockerHostCommand()?.failureReason} +
+
+
+ + + +
+ {dockerActionError()} +
+
+ +
+ {dockerActionNote()} +
+
+ +
+ + +
- - -
+
+
+ + + + {(pbs) => { + const connection = getServiceHealthPresentation( + props.resource.status, + pbs().connectionHealth, + ); + return ( +
+
+
+ PBS Service +
+ + + {pbs().hostname} + + +
+
+
+ Connection + {connection.label} +
+ +
+ Version + {pbs().version} +
+
+ +
+ Uptime + + {formatUptime(pbs().uptimeSeconds ?? props.resource.uptime ?? 0)} + +
+
+
+
+
Datastores
+
+ {formatInteger(pbs().datastoreCount)} +
+
+
+
Total Jobs
+
+ {formatInteger(pbsJobTotal())} +
+
+
+
+ + Job breakdown + {pbsVisibleJobBreakdown().length} types + +
+ + {(entry) => ( + + {entry.label}:{' '} + + {formatInteger(entry.value)} + + + )} + +
+
+
+
+ ); + }} +
+ + + {(pmg) => { + const connection = getServiceHealthPresentation( + props.resource.status, + pmg().connectionHealth, + ); + return ( +
+
+
+ Mail Gateway +
+ + + {pmg().hostname} + + +
+
+
+ Connection + {connection.label} +
+ +
+ Version + {pmg().version} +
+
+ +
+ Uptime + + {formatUptime(pmg().uptimeSeconds ?? props.resource.uptime ?? 0)} + +
+
+
+
+
Nodes
+
+ {formatInteger(pmg().nodeCount)} +
+
+
+
Queue Total
+
0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`} + > + {formatInteger(pmg().queueTotal)} +
+
+
+
Backlog
+
0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`} + > + {formatInteger(pmgQueueBacklog())} +
+
+
+
+ + Queue breakdown + {pmgVisibleQueueBreakdown().length} signals + +
+ + {(entry) => ( + + {entry.label}:{' '} + + {formatInteger(entry.value)} + + + )} + +
+
+
+ + Mail processing + {pmgVisibleMailBreakdown().length} signals + +
+ + {(entry) => ( + + {entry.label}:{' '} + + {formatInteger(entry.value)} + + + )} + +
+ +
+ Updated + {pmgUpdatedRelative()} +
+
+
+
+
+ ); + }} +
- ); - }} + +
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 23e66cd70..084faaf4a 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -188,6 +188,7 @@ describe('ResourceDetailDrawer change history section', () => { expect(screen.queryByRole('button', { name: 'Discovery' })).toBeNull(); expect(screen.getByText('Change history')).toBeInTheDocument(); expect(screen.queryByText('Host details')).toBeNull(); + expect(screen.queryByText('Service details')).toBeNull(); expect(screen.queryByText('Supporting context')).toBeNull(); expect(screen.getByText('Discovery context')).toBeInTheDocument(); expect( diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx index ef3c98bea..66e109db7 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { render } from '@solidjs/testing-library'; +import { fireEvent, render, waitFor } from '@solidjs/testing-library'; import type { Resource } from '@/types/resource'; import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer'; @@ -66,10 +66,14 @@ describe('ResourceDetailDrawer service cards', () => { }, }); - const { getByText, getAllByText, getByRole } = render(() => ( + const { getByText, getAllByText, getByRole, queryByText } = render(() => ( )); + expect(getByText('Service details')).toBeInTheDocument(); + expect(getByText('2 datastores · 3 jobs')).toBeInTheDocument(); + expect(queryByText('PBS Service')).toBeNull(); + fireEvent.click(getByRole('button', { name: 'Show service details' })); expect(getByText('PBS Service')).toBeInTheDocument(); expect(getAllByText('pbs-main.local').length).toBeGreaterThan(0); expect(getByText('Datastores')).toBeInTheDocument(); @@ -81,7 +85,7 @@ describe('ResourceDetailDrawer service cards', () => { ); }); - it('renders PMG card with compact summary and queue/mail breakdown sections', () => { + it('renders PMG card with compact summary and queue/mail breakdown sections', async () => { const resource = baseResource({ id: 'pmg-1', type: 'pmg', @@ -105,11 +109,17 @@ describe('ResourceDetailDrawer service cards', () => { }, }); - const { getByText, getAllByText, getByRole } = render(() => ( + const { getByText, getAllByText, getByRole, queryByText } = render(() => ( )); - expect(getByText('Mail Gateway')).toBeInTheDocument(); + expect(getByText('Service details')).toBeInTheDocument(); + expect(getByText('519 queue total · 16 backlog')).toBeInTheDocument(); + expect(queryByText('Mail Gateway')).toBeNull(); + fireEvent.click(getByRole('button', { name: 'Show service details' })); + await waitFor(() => { + expect(getByText('Mail Gateway')).toBeInTheDocument(); + }); expect(getAllByText('pmg-main.local').length).toBeGreaterThan(0); expect(getByText('Queue Total')).toBeInTheDocument(); expect(getByText('Backlog')).toBeInTheDocument();