diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 4d1dec3cc..4c3eae6cb 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -280,6 +280,10 @@ 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. +When `Service details` is expanded, each service card remains summary-first and +pushes heavier breakdowns or update controls behind one more service-local +reveal, so the opened state still scans as current state before deeper +operations. 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 fb7223382..40f683b31 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -145,6 +145,9 @@ const DrawerContent: Component = (props) => { const [showDiscoveryContext, setShowDiscoveryContext] = createSignal(false); const [showHostDetails, setShowHostDetails] = createSignal(false); const [showServiceDetails, setShowServiceDetails] = createSignal(false); + const [showDockerUpdateControls, setShowDockerUpdateControls] = createSignal(false); + const [showPbsJobDetail, setShowPbsJobDetail] = createSignal(false); + const [showPmgMailFlowDetail, setShowPmgMailFlowDetail] = createSignal(false); const displayName = createMemo(() => getPreferredResourceDisplayName(props.resource)); const kubernetesClusterName = createMemo(() => @@ -622,6 +625,16 @@ const DrawerContent: Component = (props) => { return null; }); + const pbsServiceSummary = createMemo(() => { + const pbs = pbsData(); + if (!pbs) return null; + return `${formatInteger(pbs.datastoreCount)} datastores · ${formatInteger(pbsJobTotal())} jobs`; + }); + const pmgServiceSummary = createMemo(() => { + const pmg = pmgData(); + if (!pmg) return null; + return `${formatInteger(pmg.nodeCount)} node${pmg.nodeCount === 1 ? '' : 's'} · ${formatInteger(pmg.queueTotal)} queue total · ${formatInteger(pmgQueueBacklog())} backlog`; + }); const workloadsHref = createMemo(() => buildWorkloadsHref(props.resource)); const headerIdentity = createMemo(() => getPrimaryResourceIdentity(props.resource)); const relatedLinks = createMemo(() => { @@ -1128,139 +1141,151 @@ const DrawerContent: Component = (props) => { - -
-
- Command - - {formatIdentifierLabel(dockerHostCommand()?.type, { - fallback: 'command', - })} - -
-
- Status - - {formatIdentifierLabel(dockerHostCommand()?.status, { - fallback: 'unknown', - })} - -
- -
- {dockerHostCommand()?.message} + +
+ +
+
+ Command + + {formatIdentifierLabel(dockerHostCommand()?.type, { + fallback: 'command', + })} + +
+
+ Status + + {formatIdentifierLabel(dockerHostCommand()?.status, { + fallback: 'unknown', + })} + +
+ +
+ {dockerHostCommand()?.message} +
+
+ +
+ {dockerHostCommand()?.failureReason} +
+
- -
- {dockerHostCommand()?.failureReason} + + +
+ {dockerActionError()}
+ +
+ {dockerActionNote()} +
+
+ +
+ + + +
- -
- {dockerActionError()} -
-
- -
- {dockerActionNote()} -
-
- -
- - - -
+
@@ -1305,38 +1330,57 @@ const DrawerContent: Component = (props) => {
-
-
-
Datastores
-
- {formatInteger(pbs().datastoreCount)} + +
+
Backup summary
+
+ {pbsServiceSummary()}
-
-
Total Jobs
-
- {formatInteger(pbsJobTotal())} + + +
+
+
+
Datastores
+
+ {formatInteger(pbs().datastoreCount)} +
+
+
+
Total Jobs
+
+ {formatInteger(pbsJobTotal())} +
+
+
+ + Job breakdown + {pbsVisibleJobBreakdown().length} types + +
+ + {(entry) => ( + + {entry.label}:{' '} + + {formatInteger(entry.value)} + + + )} + +
+
-
-
- - Job breakdown - {pbsVisibleJobBreakdown().length} types - -
- - {(entry) => ( - - {entry.label}:{' '} - - {formatInteger(entry.value)} - - - )} - -
-
+ +
); @@ -1383,74 +1427,93 @@ const DrawerContent: Component = (props) => {
-
-
-
Nodes
-
- {formatInteger(pmg().nodeCount)} + +
+
Mail flow summary
+
+ {pmgServiceSummary()}
-
-
Queue Total
-
0 ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`} - > - {formatInteger(pmg().queueTotal)} + + +
+
+
+
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()} +
+
+
-
-
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 084faaf4a..27867cf30 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -324,6 +324,42 @@ describe('ResourceDetailDrawer change history section', () => { expect(panel.queryByText('Runs on')).toBeNull(); }); + it('keeps service details summary-first until the service-local reveal is opened', () => { + facetBundleMock.getFacetBundle.mockResolvedValueOnce({ + capabilities: [], + relationships: [], + recentChanges: [], + }); + + const resource = baseResource({ + id: 'pbs-1', + type: 'pbs', + name: 'pbs-main', + displayName: 'PBS Main', + platformId: 'pbs-main', + platformType: 'proxmox-pbs', + platformData: { + sources: ['pbs'], + pbs: { + hostname: 'pbs-main.local', + connectionHealth: 'online', + datastoreCount: 2, + backupJobCount: 3, + }, + }, + }); + + render(() => ); + + expect(screen.getByText('Service details')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Show service details' })); + expect(screen.getByText('PBS Service')).toBeInTheDocument(); + expect(screen.getByText('Backup summary')).toBeInTheDocument(); + expect(screen.queryByText('Job breakdown')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Show job detail' })); + expect(screen.getByText('Job breakdown')).toBeInTheDocument(); + }); + it('filters timeline entries by kind and source type', async () => { const unfilteredFacetBundle = { capabilities: [ 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 66e109db7..f98e7b9f9 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 @@ -76,6 +76,9 @@ describe('ResourceDetailDrawer service cards', () => { fireEvent.click(getByRole('button', { name: 'Show service details' })); expect(getByText('PBS Service')).toBeInTheDocument(); expect(getAllByText('pbs-main.local').length).toBeGreaterThan(0); + expect(getByText('Backup summary')).toBeInTheDocument(); + expect(queryByText('Job breakdown')).toBeNull(); + fireEvent.click(getByRole('button', { name: 'Show job detail' })); expect(getByText('Datastores')).toBeInTheDocument(); expect(getByText('Total Jobs')).toBeInTheDocument(); expect(getByText('Job breakdown')).toBeInTheDocument(); @@ -121,6 +124,10 @@ describe('ResourceDetailDrawer service cards', () => { expect(getByText('Mail Gateway')).toBeInTheDocument(); }); expect(getAllByText('pmg-main.local').length).toBeGreaterThan(0); + expect(getByText('Mail flow summary')).toBeInTheDocument(); + expect(queryByText('Queue breakdown')).toBeNull(); + expect(queryByText('Mail processing')).toBeNull(); + fireEvent.click(getByRole('button', { name: 'Show mail flow detail' })); expect(getByText('Queue Total')).toBeInTheDocument(); expect(getByText('Backlog')).toBeInTheDocument(); expect(getByText('Queue breakdown')).toBeInTheDocument(); @@ -130,4 +137,39 @@ describe('ResourceDetailDrawer service cards', () => { '/alerts/thresholds/mail-gateway', ); }); + + it('keeps docker update controls behind a secondary reveal', () => { + const resource = baseResource({ + id: 'docker-host-1', + type: 'docker-host', + name: 'docker-main', + displayName: 'Docker Main', + platformId: 'docker-main', + platformType: 'docker', + sourceType: 'agent', + platformData: { + sources: ['docker', 'agent'], + docker: { + hostSourceId: 'docker-host-1', + hostname: 'docker-main.local', + runtime: 'Docker Engine 28.0', + containerCount: 18, + updatesAvailableCount: 4, + }, + }, + }); + + const { getByText, getByRole, queryByText } = render(() => ( + + )); + + expect(getByText('Service details')).toBeInTheDocument(); + expect(getByText('18 containers · 4 updates')).toBeInTheDocument(); + fireEvent.click(getByRole('button', { name: 'Show service details' })); + expect(getByText('Container Updates')).toBeInTheDocument(); + expect(queryByText('Check Updates')).toBeNull(); + fireEvent.click(getByRole('button', { name: 'Show update controls' })); + expect(getByText('Check Updates')).toBeInTheDocument(); + expect(getByText('Update All (4)')).toBeInTheDocument(); + }); });