diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e8918466ec..7b9df510c0 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5519,12 +5519,10 @@ Pulse observation or action coverage, primary reachability, protection gaps, pending updates, or an identified service. Routine health, placement, and metric values already visible in the row must not be restated merely to fill the drawer. Curated raw IDs, kernel/build strings, interface and disk facts, and -provider metadata stay visible through `TechnicalDetailsSection` and the same -compact `DetailSectionTable` rows as Overview; those facts must not switch to a -provider-local card mosaic or require another tap. Only genuinely large or -interactive provider-support content belongs behind -`TechnicalDetailsDisclosure`, which owns its collapsed, lazily mounted -boundary. +provider metadata use the same compact `DetailSectionTable` rows as Overview; +those facts must not switch to a provider-local card mosaic. Since 2026-10-14 +every object drawer keeps them under its one closed Details disclosure +(below). The Proxmox guest and node drawers (`GuestDrawerOverview.tsx`, `NodeDrawerOverview.tsx`) are the exception, by maintainer decision on 2026-10-13 (demand ledger "Drawer Overview leads with attention and trends", @@ -5582,8 +5580,25 @@ one-line facts strip carries OS, the amounts the charts' percentages are of and root disk, capacity only while its readings are withheld), address, a healthy last completed backup and tags. The complete inventory sits in a closed "Details" `TechnicalDetailsDisclosure` with `keepMounted`, so its rows stay in the -document for find-in-page and tests while hidden. Other drawers keep the rule -above until that bet is graded. "Worth checking" renders through +document for find-in-page and tests while hidden. On 2026-10-14 the maintainer +carried this layout to every object drawer: the shared resource-detail drawer +(agents, containers, Kubernetes, TrueNAS, vSphere and other platform rows) and +the Docker host drawer now open with the same alerts line, "Worth checking", +charts, a facts line (container: image, Compose project and service, address; +host: logical CPUs, used-of-total RAM and disk, address; Docker host: named OS, +RAM and disk, the engine version being in its row) and one closed "Details" +holding every inventory section, healthy availability checks and, for the +resource drawer, the provider "Platform details" that had their own disclosure. +Facts state used amounts only while the resource is online or running and its +memory observation, if any, is current. A row is lifted when it is warning- or +danger-toned or carries `lift` (an available container image update), unless +one of the resource's own alerts already states it +(`getAlertStatedRowLabels`, `partitionDrawerExceptionRows`): a container's +health alert leaves "Health check: Failing" in Details rather than repeating +it. An offline resource's "Observed state" is danger-toned with when it was +last seen, so it is lifted without an alert. A passing availability check moves into +Details, except on an endpoint that is itself a check (`network-endpoint`, +platform `availability`), whose cards are its whole reading and stay in front. "Worth checking" renders through `DrawerExceptionList.tsx` directly under the alerts line, above the charts: one compact label/value line per lifted row with the shared `detailValueToneClass` tone, not a card per section. A guest memory reading diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 2dca89f9d5..98308b27b0 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2392,6 +2392,19 @@ TrueNAS dataset's state (`state:readonly`, `state:locked`, `state:unmounted`) because a read-only dataset raises no incident, and uses the rollup's category label (Resource Health Issue, Capacity Pressure) only when no reason is more specific. +Below the charts, the resource and Docker host Overviews read their +inventory as unified-resource facts: `useResourceSummarySections` and +`getResourceDrawerFacts` (`ResourceDetailSummary.tsx`) take Compose labels, +image, agent CPU count, `memory`/`disk` used and total, and the primary +address from the resource itself, state used amounts only for an online or +running resource whose memory observation (if any) is current, and lift an +offline resource's state with its `lastSeen`. A row an open alert of the +resource's own already states (`getAlertStatedRowLabels`) stays in Details. +The drawer header's status dot for an `app-container` uses the container +row's mapper (`mapDockerContainerStatus`), so its run state and health check +decide the colour and label in both places: a running container failing its +health check is red "Unhealthy" in the row and the header alike. Other +resources keep `getAgentStatusIndicator` on `status`. Those open alerts come from the websocket `activeAlerts` map through `getAlertsForUnifiedResource` in `frontend-modern/src/utils/alerts.ts`, which the Docker host drawer (`DockerHostDrawerOverview.tsx`) uses the same way. diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs index bcc376eded..a8d285cb42 100644 --- a/frontend-modern/browser-tests/proxmox-docker-rows.cjs +++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs @@ -67,6 +67,8 @@ const observations = (mode) => [ runtimeVersion: '27.5.1', image: 'nginx:latest', containerState: 'running', + // Running but failing its health check: row and drawer must both say so. + health: 'unhealthy', restartCount: 7, updateStatus: { updateAvailable: true, @@ -367,9 +369,21 @@ const observations = (mode) => [ const row = page.locator('[data-docker-container-row="edge-web"]'); if (width === 390) await row.locator('td').first().tap(); else await row.getByRole('button', { name: /details/i }).click(); - const detail = page.getByTestId('resource-docker-container-section'); + // A worrying restart count is lifted in front of the drawer's charts. + const detail = page.getByTestId('resource-drawer-exceptions'); await detail.getByText('Restarts', { exact: true }).waitFor(); await detail.getByText('7', { exact: true }).waitFor(); + // The drawer header dot agrees with the row's: unhealthy is red, not green. + const headerDot = page.getByRole('img', { name: 'Unhealthy' }).first(); + await headerDot.waitFor(); + const rowDot = row.locator('[title="Unhealthy"]').first(); + const colours = await Promise.all( + [headerDot, rowDot].map((dot) => + dot.evaluate((el) => getComputedStyle(el).backgroundColor), + ), + ); + assert.equal(colours[0], colours[1], JSON.stringify(colours)); + assert.match(colours[0], /^rgb\((2[0-9]{2}), ([0-9]{1,2}|1[0-2][0-9]), /); }, ); await capture('container-detail'); diff --git a/frontend-modern/browser-tests/truenas-disk-thresholds.cjs b/frontend-modern/browser-tests/truenas-disk-thresholds.cjs index 56725a1b11..77a99b9b13 100644 --- a/frontend-modern/browser-tests/truenas-disk-thresholds.cjs +++ b/frontend-modern/browser-tests/truenas-disk-thresholds.cjs @@ -152,7 +152,7 @@ const config = (patch = {}) => ({ const toggle = page.getByRole('button', { name: /Expand details for nvme-raised/i }); await toggle.focus(); await toggle.press('Enter'); - await page.getByTestId('resource-platform-details').locator('summary').click(); + await page.getByTestId('resource-technical-details').locator('summary').first().click(); await page.getByRole('button', { name: 'Show TrueNAS', exact: true }).click(); const temperatureRow = page .getByTestId('resource-truenas-details-section') diff --git a/frontend-modern/browser-verification/901dd7e8e5111428.json b/frontend-modern/browser-verification/901dd7e8e5111428.json new file mode 100644 index 0000000000..4f0366dcc1 --- /dev/null +++ b/frontend-modern/browser-verification/901dd7e8e5111428.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "base_sha": "86a244bc04dbddf4ce8c648702d96338528077e3", + "verified_at": "2026-10-14T13:00:12Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", + "frontend-modern/src/components/Workloads/drawerOverviewModel.ts", + "frontend-modern/src/components/shared/detailSectionModel.ts", + "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "be7885cb33f33171b3310081b331346853943a8c8e756231385a4c9499a7c2a6", + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "c4407e3ca6f6a357bfb4f35a93249f167a7c7d522fdfe6677b2689e75dcc62f8", + "frontend-modern/src/components/Workloads/drawerOverviewModel.ts": "2c45344a61e9f6b3523e8660f291cb68bd48edba65b4f2476a4494797ffe8cb2", + "frontend-modern/src/components/shared/detailSectionModel.ts": "2e2ded8ebb760693cdb7cb510b58174c4127df742e6471f0a37dc92f5bcdbc62", + "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "6edec9d5f1377116bcd420a7fc88acb2304232a323d53c1475bad780e8dd501e" + }, + "routes": [ + "/docker (container and Docker host drawers), /standalone/machines (agent host drawer), /proxmox (reference node drawer): private mock preview on pulse-dev, sidecar Vite + mock backend, Chromium via Playwright 1.56.1", + "browser-tests: drawer-snapshot, pbs-identity-boundary, pbs-history-source-isolation, resource-history-provenance, resource-stopped-filesystem, table-row-touch, truenas-disk-thresholds, proxmox-docker-rows, linked-guest-history, scripts/check-backup-app-refresh.mjs" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "container drawer: No active alerts, charts, facts line (image, Compose project/service), closed Details (runtime context, container, identity)", + "container with an available image update: Worth checking \"Image update: Available\"", + "unhealthy container with its alert (round 2) and healthy after the mock rotated (final)", + "agent host drawer: facts line (logical CPUs, RAM and disk used of total, address), one Details including platform context", + "Docker host drawer: Worth checking \"Updates: 1 available\", facts without the engine version, closed Details (operator context, runtime, memory, storage, telemetry)", + "phone 390px: Details entrance above the bottom navigation when scrolled; Worth checking and facts wrap", + "availability endpoint drawer keeps its check cards in front (drawer-snapshot)", + "reviewed twice by Sol from full-resolution before/after screenshots" + ], + "interactions": [ + "row expand by keyboard; Details summary open/close; Docker tabs; range picker unchanged", + "proof flows: target replacement, availability failure, platform details expansion inside Details, container restart count lifted" + ] +} diff --git a/frontend-modern/browser-verification/eb8c958def6282ab.json b/frontend-modern/browser-verification/eb8c958def6282ab.json new file mode 100644 index 0000000000..642ae1592f --- /dev/null +++ b/frontend-modern/browser-verification/eb8c958def6282ab.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "base_sha": "e72df20395b0659aadd08deacd4a1549cda9b26b", + "verified_at": "2026-10-14T13:37:16Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "095e0365fc6bf30c8a5b5d88ac03616576bd66a71496d21a158330817dbdff5d" + }, + "routes": [ + "browser-tests/proxmox-docker-rows.html (production DockerContainersTable row and ResourceDetailDrawer, Chromium via Playwright 1.56.1 on pulse-dev)" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "running container failing its health check: row dot and drawer header dot both red \"Unhealthy\" (old code: header green)", + "exited container row unchanged", + "reviewed by Sol from the desktop and phone screenshots" + ], + "interactions": [ + "row expand by pointer (desktop) and tap (phone)" + ] +} diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 29859c1bca..f792f9030b 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -52,10 +52,10 @@ { "id": "operator-first-technical-section", "category": "drawer-information-hierarchy", - "summary": "Object drawers keep curated deeper inventory visible through the same compact section rows as Overview; only genuinely large or interactive provider-support material stays behind a disclosure.", + "summary": "Object drawers lift any warning or actionable inventory row in front (DrawerExceptionList), then keep the rest of their inventory, provider-support material included, under one shared collapsed Details disclosure (maintainer decision 2026-10-13, carried to every object drawer 2026-10-14).", "canonical": { "path": "src/components/shared/TechnicalDetailsDisclosure.tsx", - "export": "TechnicalDetailsSection" + "export": "TechnicalDetailsDisclosure" }, "requiredConsumers": [ { diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 15c76961d2..2427fe8a49 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -13,7 +13,14 @@ import { getAlertAttentionCopy } from '@/features/alerts/metricAlertPresentation import { useAlertsActivation } from '@/stores/alertsActivation'; import { getAlertsForUnifiedResource, getOwnAlertsForUnifiedResource } from '@/utils/alerts'; import { GuestDrawerHistory } from '@/components/Workloads/GuestDrawerHistory'; -import { getDrawerChartAlerts } from '@/components/Workloads/drawerOverviewModel'; +import { + getAlertStatedRowLabels, + getDrawerChartAlerts, + isDrawerAvailabilityHealthy, + partitionDrawerExceptionRows, +} from '@/components/Workloads/drawerOverviewModel'; +import { DrawerExceptionList } from '@/components/Workloads/DrawerExceptionList'; +import { ALERTS_EMPTY_STATE } from '@/utils/alertOverviewPresentation'; import { formatUptime, formatRelativeTime } from '@/utils/format'; import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import { SystemInfoCard } from '@/components/shared/cards/SystemInfoCard'; @@ -34,13 +41,10 @@ import { getAllFilterOptionLabel } from '@/components/shared/filterOptionPresent import { getServiceHealthPresentation } from '@/utils/serviceHealthPresentation'; import { ResourceCorrelationSummary } from './ResourceCorrelationSummary'; import { ResourceFacetSummary } from './ResourceFacetSummary'; -import { InlineResourceSummaryTables } from './ResourceDetailSummary'; +import { getResourceDrawerFacts, useResourceSummarySections } from './ResourceDetailSummary'; import { ResourceInvestigationContextTables } from './ResourceInvestigationContextTables'; import { DetailSectionTable } from '@/components/shared/DetailSectionTable'; -import { - TechnicalDetailsDisclosure, - TechnicalDetailsSection, -} from '@/components/shared/TechnicalDetailsDisclosure'; +import { TechnicalDetailsDisclosure } from '@/components/shared/TechnicalDetailsDisclosure'; import { DrawerAttentionSection, type DrawerAttentionItem, @@ -386,9 +390,70 @@ export const ResourceDetailDrawerOverviewTab: Component + partitionDrawerExceptionRows( + summarySections(), + getAlertStatedRowLabels( + getOwnAlertsForUnifiedResource( + resource(), + activeAlerts, + alertsActivation.detectionEnabled(), + ), + ), + ); + const exceptionRows = () => inventory().lifted; + const detailSections = () => inventory().details; + const detailsSubtitle = () => + detailSections() + .map((section) => section.label.toLowerCase()) + .join(', '); + const facts = () => getResourceDrawerFacts(resource(), drawer.identityIpValues()[0]); + const hasPlatformDetails = () => + drawer.hasServiceDetails() || + drawer.hasVMwareDetails() || + drawer.hasTrueNASDetails() || + drawer.hasKubernetesDetails() || + drawer.hasHostDetails() || + drawer.hasInvestigationContext(); + const availabilityChecks = () => [ + ...(resource().availability ? [resource().availability!] : []), + ...(resource().availabilityChecks ?? []), + ]; + // A failing or unknown check stays in front; a passing one is summarised in + // the alerts line and kept under Details. An endpoint that is itself an + // availability check keeps its cards in front: they are its whole reading. + const isAvailabilityResource = () => + resource().type === 'network-endpoint' || resource().platformType === 'availability'; + const availabilityHealthy = () => + !isAvailabilityResource() && + availabilityChecks().length > 0 && + availabilityChecks().every((check) => isDrawerAvailabilityHealthy(check, now())); + return (
+ +

+ {ALERTS_EMPTY_STATE} + + · availability checks passing + +

+
+ {/* Stored history leads the reading path: the charts answer "is this normal or getting worse?" before the inventory below. */} @@ -407,17 +472,7 @@ export const ResourceDetailDrawerOverviewTab: Component )} - - - - - 0}> + 0 && !availabilityHealthy()}>
+ 0}> +
+ {(fact) => {fact}} +
+
+ + 0 || availabilityHealthy() || hasPlatformDetails()}> + + + +
+ +
+
+ {/* Provider, service and host context share the one Details + entrance rather than a second disclosure beside it. */} + +
+

+ Platform details +

+
+ + + + + + + + + + drawer.setShowInvestigationContext((value) => !value)} + showLabel="Show context" + hideLabel="Hide context" + class="h-full" + contentClass="mt-3 space-y-3" + dataTestId="resource-investigation-context" + > + + + + + + drawer.setShowServiceDetails((value) => !value)} + showLabel="Show service" + hideLabel="Hide service" + class="h-full" + contentClass="mt-3 space-y-3" + dataTestId="resource-service-details-section" + > + +
+
+
+ Docker runtime +
+ + + {drawer.dockerHostData()?.runtime} + + +
+ +
+ + 0 + ? 'text-sky-700 dark:text-sky-300' + : '' + } + /> + + + + + +
+ +
+ + + +
+ {drawer.dockerHostCommand()?.message} +
+
+ +
+ {drawer.dockerHostCommand()?.failureReason} +
+
+
+
+ + +
+ {drawer.dockerActionError()} +
+
+ +
+ {drawer.dockerActionNote()} +
+
+ +
+ + + +
+
+
+ + +
+
+
+ + + {(pbs) => { + const connection = getServiceHealthPresentation( + resource().status, + pbs().connectionHealth, + ); + return ( +
+
+
+ PBS +
+ + + {pbs().hostname} + + +
+
+ + + + + + + + 0}> + + + +
+
+
+
Datastores
+
+ {formatInteger(pbs().datastoreCount)} +
+
+
+
Jobs
+
+ {formatInteger(drawer.pbsJobTotal())} +
+
+ 0}> +
+
Active
+
+ {formatInteger(drawer.pbsActiveTaskCount())} +
+
+
+
+ 0}> +
+
+ + Active tasks + + + {formatInteger(drawer.pbsActiveTaskCount())} + +
+
+ + {(task) => ( +
+
+
+ {task.label} +
+ +
+ {task.context} +
+
+ +
+ {task.error} +
+
+
+ + {task.statusLabel} + +
+ )} +
+
+
+
+ 0}> +
+
+ + Job health evidence + + + {pbsJobHealthEvidence().countLabel} + +
+
+ + {(entry) => ( +
+
+
+
+ {entry.label} +
+
+ {entry.sourceLabel} +
+
+
+ + {(badge) => ( + + {badge.label} + + )} + +
+
+
+ + + {entry.context} + + + + + State:{' '} + + {entry.stateLabel} + + + + + + {entry.freshnessLabel} + + + + + {entry.postureReason} + + + + + {entry.error} + + +
+
+ )} +
+
+
+
+
+ + Types + + {drawer.pbsVisibleJobBreakdown().length} + + +
+ + {(entry) => ( + + {entry.label}:{' '} + + {formatInteger(entry.value)} + + + )} + +
+
+
+
+ +
+
+ ); + }} +
+ + + {(pmg) => { + const connection = getServiceHealthPresentation( + resource().status, + pmg().connectionHealth, + ); + return ( +
+
+
+ PMG +
+ + + {pmg().hostname} + + +
+
+ + + + + + + + +
+
+
+
Queue
+
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(drawer.pmgQueueBacklog())} +
+
+
+ +
+ + + + + + +
+
+
+ + Queue detail + +
+ + {(entry) => ( + + )} + +
+
+
+ + Mail detail + +
+ + {(entry) => ( + + )} + +
+
+
+
+ +
+
+ ); + }} +
+
+
+ + + + + + + + +
+
+
+
+
+
@@ -678,602 +1368,6 @@ export const ResourceDetailDrawerOverviewTab: Component - - - -
- - - - - - - - - - drawer.setShowInvestigationContext((value) => !value)} - showLabel="Show context" - hideLabel="Hide context" - class="h-full" - contentClass="mt-3 space-y-3" - dataTestId="resource-investigation-context" - > - - - - - - drawer.setShowServiceDetails((value) => !value)} - showLabel="Show service" - hideLabel="Hide service" - class="h-full" - contentClass="mt-3 space-y-3" - dataTestId="resource-service-details-section" - > - -
-
-
- Docker runtime -
- - - {drawer.dockerHostData()?.runtime} - - -
- -
- - 0 - ? 'text-sky-700 dark:text-sky-300' - : '' - } - /> - - - - - -
- -
- - - -
- {drawer.dockerHostCommand()?.message} -
-
- -
- {drawer.dockerHostCommand()?.failureReason} -
-
-
-
- - -
- {drawer.dockerActionError()} -
-
- -
- {drawer.dockerActionNote()} -
-
- -
- - - -
-
-
- - -
-
-
- - - {(pbs) => { - const connection = getServiceHealthPresentation( - resource().status, - pbs().connectionHealth, - ); - return ( -
-
-
- PBS -
- - - {pbs().hostname} - - -
-
- - - - - - - - 0}> - - - -
-
-
-
Datastores
-
- {formatInteger(pbs().datastoreCount)} -
-
-
-
Jobs
-
- {formatInteger(drawer.pbsJobTotal())} -
-
- 0}> -
-
Active
-
- {formatInteger(drawer.pbsActiveTaskCount())} -
-
-
-
- 0}> -
-
- - Active tasks - - - {formatInteger(drawer.pbsActiveTaskCount())} - -
-
- - {(task) => ( -
-
-
- {task.label} -
- -
- {task.context} -
-
- -
- {task.error} -
-
-
- - {task.statusLabel} - -
- )} -
-
-
-
- 0}> -
-
- - Job health evidence - - - {pbsJobHealthEvidence().countLabel} - -
-
- - {(entry) => ( -
-
-
-
- {entry.label} -
-
- {entry.sourceLabel} -
-
-
- - {(badge) => ( - - {badge.label} - - )} - -
-
-
- - - {entry.context} - - - - - State:{' '} - - {entry.stateLabel} - - - - - - {entry.freshnessLabel} - - - - - {entry.postureReason} - - - - - {entry.error} - - -
-
- )} -
-
-
-
-
- - Types - - {drawer.pbsVisibleJobBreakdown().length} - - -
- - {(entry) => ( - - {entry.label}:{' '} - - {formatInteger(entry.value)} - - - )} - -
-
-
-
- -
-
- ); - }} -
- - - {(pmg) => { - const connection = getServiceHealthPresentation( - resource().status, - pmg().connectionHealth, - ); - return ( -
-
-
- PMG -
- - - {pmg().hostname} - - -
-
- - - - - - - - -
-
-
-
Queue
-
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(drawer.pmgQueueBacklog())} -
-
-
- -
- - - - - - -
-
-
- - Queue detail - -
- - {(entry) => ( - - )} - -
-
-
- - Mail detail - -
- - {(entry) => ( - - )} - -
-
-
-
- -
-
- ); - }} -
-
-
- - - - - - - - -
-
-
); diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index ddb078b0a8..8fb4f1d1f1 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -138,6 +138,8 @@ const dockerSection = (docker: NonNullable, now: number): De makeDetailRow('Image update', updateState, { title: update?.error, tone: update?.updateAvailable ? 'accent' : update?.error ? 'danger' : 'default', + // An available update is something to act on: drawers lift it in front. + lift: update?.updateAvailable === true, }), makeDetailRow('Current digest', trimmedDockerValue(update?.currentDigest), { valueClass: 'break-all font-mono text-[10px]', @@ -221,12 +223,40 @@ const dockerSection = (docker: NonNullable, now: number): De return { label: 'Container', rows, testId: 'resource-docker-container-section' }; }; -export const InlineResourceSummaryTables: Component = (props) => { +// A resource that is not reporting must say so in front, not only in Details: +// offline is danger-toned (with when it was last seen), and a state Pulse +// cannot vouch for is warning-toned, so the drawer lifts either. +const observedStateRow = (status: string | undefined, lastSeen: string | undefined) => { + const state = (status || 'unknown').trim().toLowerCase(); + const label = state.charAt(0).toUpperCase() + state.slice(1); + const offline = state === 'offline'; + return makeDetailRow( + 'Observed state', + offline && lastSeen ? `${label} · last seen ${lastSeen}` : label, + { + tone: offline + ? 'danger' + : ['unknown', 'degraded', 'stale', 'warning'].includes(state) + ? 'warning' + : undefined, + }, + ); +}; + +/** + * The resource drawer's inventory as detail sections: operator context, + * runtime, container, controller and identity. The drawer Overview lifts any + * warning row in front and keeps the rest under Details; the table form below + * renders them all. + */ +export const useResourceSummarySections = ( + props: Omit, +): (() => DetailSection[]) => { // An open drawer keeps its rows while the resource's timestamps stay the // same, so container and controller ages read the shared clock to keep // moving. const now = useRelativeTimeNow(); - const sections = (): DetailSection[] => { + return (): DetailSection[] => { const docker = dockerContainerMeta(props.resource); // An offline resource has no current uptime: the retained value is from // its last report, and the Last seen row already says when that was. @@ -313,9 +343,7 @@ export const InlineResourceSummaryTables: Component { + const docker = dockerContainerMeta(resource); + if (docker) { + const labels = docker.labels; + const project = + trimmedDockerValue(docker.podman?.composeProject) || composeLabelValue(labels, 'project'); + const service = + trimmedDockerValue(docker.podman?.composeService) || composeLabelValue(labels, 'service'); + return [ + trimmedDockerValue(docker.image), + project && service ? `Compose ${project} / ${service}` : project ? `Compose ${project}` : '', + primaryIp ?? '', + ].filter(Boolean); + } + // Used amounts only from a current reading: an offline or unvouched resource, + // or a retained memory observation, states its size alone. + const status = (resource.status || '').trim().toLowerCase(); + const reporting = status === 'online' || status === 'running'; + const size = (metric: Resource['memory'], label: string) => { + const total = metric?.total ?? 0; + if (!(total > 0)) return ''; + const used = metric?.used; + const current = !metric?.observation || metric.observation.state === 'current'; + return reporting && current && typeof used === 'number' && used >= 0 + ? `${formatBytes(used)} of ${formatBytes(total)} ${label}` + : `${formatBytes(total)} ${label}`; + }; + const cpus = resource.agent?.cpuCount ?? 0; + return [ + cpus > 0 ? `${cpus} logical CPUs` : '', + size(resource.memory, 'RAM'), + size(resource.disk, 'disk'), + primaryIp ?? '', + ].filter(Boolean); +}; + +export const InlineResourceSummaryTables: Component = (props) => { + const sections = useResourceSummarySections(props); return ( { /> )); + // The facts line names the image and Compose service in front. + const facts = screen.getByTestId('resource-drawer-facts'); + expect(facts).toHaveTextContent('ghcr.io/example/edge-web:2026.05'); + expect(facts).toHaveTextContent(/Compose orion/); const section = screen.getByTestId('resource-docker-container-section'); expect(within(section).getByText('Image')).toBeInTheDocument(); expect(within(section).getByText('ghcr.io/example/edge-web:2026.05')).toBeInTheDocument(); - expect(within(section).getByText('Image update')).toBeInTheDocument(); - expect(within(section).getByText('Available').closest('td')).toHaveClass('text-cyan-700'); + // An available update is something to act on, so it is lifted in front. + const updateRow = screen.getByTestId('resource-drawer-exceptions'); + expect(within(updateRow).getByText('Image update')).toBeInTheDocument(); + expect(within(updateRow).getByText('Available').closest('td')).toHaveClass('text-cyan-700'); + expect(within(section).queryByText('Image update')).not.toBeInTheDocument(); expect(within(section).getByText('Current digest')).toBeInTheDocument(); expect(within(section).getByText('sha256:current-digest')).toBeInTheDocument(); expect(within(section).getByText('Target digest')).toBeInTheDocument(); expect(within(section).getByText('sha256:target-digest')).toBeInTheDocument(); expect(within(section).queryByText('Release information')).not.toBeInTheDocument(); - expect(within(section).getByText('Restarts')).toBeInTheDocument(); - expect(within(section).getByText('7').closest('td')).toHaveClass('text-rose-700'); + // A worrying restart count moves in front of the charts, not repeated below. + const lifted = screen.getByTestId('resource-drawer-exceptions'); + expect(within(lifted).getByText('Restarts')).toBeInTheDocument(); + expect(within(lifted).getByText('7').closest('td')).toHaveClass('text-rose-700'); + expect(within(section).queryByText('Restarts')).not.toBeInTheDocument(); expect(within(section).getByText('Created')).toBeInTheDocument(); expect(within(section).getByText('Started')).toBeInTheDocument(); expect(within(section).getByText('Finished')).toBeInTheDocument(); @@ -316,10 +326,17 @@ describe('ResourceDetailDrawer for Docker containers', () => { /> )); + // The header dot agrees with the row: running but unhealthy is red, not green. + const dot = screen.getByRole('img', { name: 'Unhealthy' }); + expect(dot).toHaveClass('bg-red-500'); const runtime = screen.getByTestId('resource-runtime-context-section'); expect(within(runtime).getByText('Running')).toBeInTheDocument(); - const health = within(runtime).getByText('Failing'); + // The failing check is lifted into Worth checking, in front of the charts. + const lifted = screen.getByTestId('resource-drawer-exceptions'); + expect(within(lifted).getByText('Health check')).toBeInTheDocument(); + const health = within(lifted).getByText('Failing'); expect(health.closest('td')).toHaveClass('text-rose-700'); + expect(within(runtime).queryByText('Failing')).not.toBeInTheDocument(); expect(health.closest('td')).toHaveAttribute('title', 'Its health check is failing'); expect(within(runtime).getByText('Uptime')).toBeInTheDocument(); }); @@ -343,8 +360,11 @@ describe('ResourceDetailDrawer for Docker containers', () => { /> )); + // A failed exit is red in the header, and its stale health check says nothing. + expect(screen.getByRole('img', { name: 'Exited (1)' })).toHaveClass('bg-red-500'); const runtime = screen.getByTestId('resource-runtime-context-section'); - expect(within(runtime).getByText('Exited (1)').closest('td')).toHaveClass('text-rose-700'); + const lifted = screen.getByTestId('resource-drawer-exceptions'); + expect(within(lifted).getByText('Exited (1)').closest('td')).toHaveClass('text-rose-700'); expect(within(runtime).queryByText('Health check')).not.toBeInTheDocument(); expect(within(runtime).queryByText('Uptime')).not.toBeInTheDocument(); }); 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 ac84659ac2..da3472401d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -805,7 +805,8 @@ describe('ResourceDetailDrawer change history section', () => { screen.queryByText('Supporting metadata only. The web interface path above stays primary.'), ).toBeNull(); expect(screen.queryByRole('button', { name: 'Show access' })).toBeNull(); - expect(screen.queryByText('Details')).toBeNull(); + // One Details disclosure holds the inventory; no per-card "Show details". + expect(screen.getAllByText('Details')).toHaveLength(1); expect(screen.queryByRole('button', { name: 'Show details' })).toBeNull(); expect(screen.queryByText('Platform ID')).toBeNull(); expect(within(identitySection).getByText('Tags')).toBeInTheDocument(); @@ -837,10 +838,8 @@ describe('ResourceDetailDrawer change history section', () => { expect(screen.getByText('Correlations')).toBeInTheDocument(); expect(screen.getByText('Storage 1 alias')).toBeInTheDocument(); expect(screen.getByText('VM Child')).toBeInTheDocument(); + // Platform context sits inside the Overview's Details, opened by render(). await screen.findByText('Platform details'); - const platformDetails = screen.getByTestId('resource-platform-details') as HTMLDetailsElement; - platformDetails.open = true; - fireEvent(platformDetails, new Event('toggle')); expect(screen.getByText('Context')).toBeInTheDocument(); expect(screen.queryByText('Capabilities 1')).toBeNull(); expect(screen.queryByText('Relationships 1')).toBeNull(); @@ -1774,3 +1773,26 @@ describe('ResourceDetailDrawer Overview alert sources', () => { expect(resourceDetailDrawerOverviewSource).not.toContain('message: alert.message,'); }); }); + +describe('ResourceDetailDrawer header status dot', () => { + it("reads a container's run state and health check, as its row does", () => { + const { unmount } = render(() => ( + + )); + // Running but failing its check is red with the row's label, not green. + expect(screen.getByRole('img', { name: 'Unhealthy' })).toHaveClass('bg-red-500'); + unmount(); + + render(() => ); + // Other resources keep the shared run-status dot. + expect(screen.queryByRole('img', { name: 'Unhealthy' })).toBeNull(); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index dbc547049c..8c374dfa12 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -665,7 +665,8 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { expect(within(getByTestId('resource-header-badges')).getByText('PMG')).toBeInTheDocument(); expect(queryByText('Mode')).toBeNull(); expect(queryByText('Platform ID')).toBeNull(); - expect(queryByText('Details')).toBeNull(); + // Routine inventory waits under the Overview's Details disclosure. + expect(getByTestId('resource-technical-details').tagName).toBe('DETAILS'); expect(queryByText('Show details')).toBeNull(); }); @@ -834,7 +835,11 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { const inlineAliases = inlineRender.getByText('Aliases'); expect(inlineAliases).toBeInTheDocument(); expect(inlineAliases.closest('summary')).toBeNull(); - expect(inlineRender.container.querySelectorAll('details')).toHaveLength(1); + // Few aliases need no collapse of their own: their only enclosing + // disclosure is the Overview's Details section. + expect(inlineAliases.closest('details')).toBe( + inlineRender.getByTestId('resource-technical-details'), + ); expect(inlineRender.getByText('agent-inline-1')).toBeInTheDocument(); // A long alias wraps inside the drawer cell instead of spilling past it. expect(inlineRender.getByText('agent-inline-1')).toHaveClass('max-w-full', 'wrap-anywhere'); @@ -939,10 +944,8 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { )); + // Platform context sits inside the Overview's Details, opened by render(). await waitFor(() => expect(getByTestId('resource-platform-details')).toBeInTheDocument()); - const platformDetails = getByTestId('resource-platform-details') as HTMLDetailsElement; - platformDetails.open = true; - fireEvent(platformDetails, new Event('toggle')); await waitFor(() => expect(getByText('Context')).toBeInTheDocument()); expect(queryByText('Analysis')).toBeNull(); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.k8s-capabilities.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.k8s-capabilities.test.tsx index 33a225344f..75d2e2df95 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.k8s-capabilities.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.k8s-capabilities.test.tsx @@ -91,7 +91,8 @@ describe('ResourceDetailDrawer kubernetes capabilities', () => { )); - const details = (await screen.findByTestId('resource-platform-details')) as HTMLDetailsElement; + // Platform context sits inside the Overview's one Details disclosure. + const details = (await screen.findByTestId('resource-technical-details')) as HTMLDetailsElement; details.open = true; fireEvent(details, new Event('toggle')); expect(queryByText('Platform signals')).toBeNull(); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx index 8f7c9bb1a5..e22c399c5b 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx @@ -11,7 +11,8 @@ vi.mock('@/contexts/appRuntime', async (importOriginal) => ({ })); const expandPlatformDetails = (): void => { - const details = screen.getByTestId('resource-platform-details') as HTMLDetailsElement; + // Platform context sits inside the Overview's one Details disclosure. + const details = screen.getByTestId('resource-technical-details') as HTMLDetailsElement; details.open = true; fireEvent(details, new Event('toggle')); }; @@ -77,6 +78,101 @@ afterEach(() => { vi.clearAllMocks(); }); +describe('ResourceDetailDrawer machine inventory', () => { + const gib = 1024 ** 3; + it('states one facts line and keeps the inventory under a closed Details', () => { + render(() => ( + + )); + expect(screen.getByTestId('drawer-no-attention')).toHaveTextContent('No active alerts'); + expect(screen.queryByTestId('resource-drawer-exceptions')).toBeNull(); + const facts = screen.getByTestId('resource-drawer-facts'); + expect(facts).toHaveTextContent('8 logical CPUs'); + expect(facts).toHaveTextContent('8.00 GB of 16.0 GB RAM'); + expect(facts).toHaveTextContent('50.0 GB of 200 GB disk'); + const details = screen.getByTestId('resource-technical-details') as HTMLDetailsElement; + expect(details.open).toBe(false); + expect(within(details).getByTestId('resource-runtime-context-section')).toBeInTheDocument(); + }); + + it('lifts an offline machine in front and states its sizes, not stale used amounts', () => { + render(() => ( + + )); + const lifted = screen.getByTestId('resource-drawer-exceptions'); + expect(within(lifted).getByText('Observed state')).toBeInTheDocument(); + expect(within(lifted).getByText(/^Offline · last seen/)).toBeInTheDocument(); + const facts = screen.getByTestId('resource-drawer-facts'); + expect(facts).toHaveTextContent('16.0 GB RAM'); + expect(facts).not.toHaveTextContent('of 16.0 GB'); + }); + + it('keeps a passing check under Details for a machine but in front for an endpoint', () => { + const passing = { + available: true, + aggregateState: 'healthy', + pollIntervalSeconds: 30, + lastChecked: new Date(Date.now() - 10_000).toISOString(), + } as Resource['availability']; + const machine = render(() => ( + + )); + expect(screen.getByTestId('drawer-no-attention')).toHaveTextContent( + 'availability checks passing', + ); + const details = screen.getByTestId('resource-technical-details'); + expect(details.contains(screen.getByTestId('availability-probe-status'))).toBe(true); + machine.unmount(); + + render(() => ( + + )); + // The check is the endpoint's whole reading, so it never folds away. + const card = screen.getByTestId('availability-probe-status'); + expect(card.closest('details')).toBeNull(); + }); + + it('states sizes only while the memory reading is retained rather than current', () => { + render(() => ( + + )); + const facts = screen.getByTestId('resource-drawer-facts'); + expect(facts).toHaveTextContent('16.0 GB RAM'); + expect(facts).not.toHaveTextContent('of 16.0 GB'); + }); +}); + describe('ResourceDetailDrawer machine metrics history', () => { it('charts metrics history on Overview for Pulse Agent machines, with no History tab', async () => { render(() => ( @@ -215,7 +311,10 @@ describe('ResourceDetailDrawer machine metrics history', () => { /> )); - expect(screen.queryByTestId('resource-host-details-section')).toBeNull(); + // Provider host facts wait inside the closed Overview Details disclosure. + expect( + screen.getByTestId('resource-host-details-section').closest('details'), + ).not.toHaveAttribute('open'); expandPlatformDetails(); const machineSection = screen.getByTestId('resource-host-details-section'); expect(within(machineSection).getByText('Machine')).toBeInTheDocument(); @@ -255,7 +354,10 @@ describe('ResourceDetailDrawer machine metrics history', () => { /> )); - expect(screen.queryByTestId('resource-host-details-section')).toBeNull(); + // Provider host facts wait inside the closed Overview Details disclosure. + expect( + screen.getByTestId('resource-host-details-section').closest('details'), + ).not.toHaveAttribute('open'); expandPlatformDetails(); const hostSection = screen.getByTestId('resource-host-details-section'); expect(within(hostSection).getByText('Host')).toBeInTheDocument(); 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 5d1d521867..b5889bd8a8 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 @@ -5,7 +5,8 @@ import type { Resource } from '@/types/resource'; import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer'; const expandPlatformDetails = (getByTestId: (id: string) => HTMLElement): void => { - const details = getByTestId('resource-platform-details') as HTMLDetailsElement; + // Platform context sits inside the Overview's one Details disclosure. + const details = getByTestId('resource-technical-details') as HTMLDetailsElement; details.open = true; fireEvent(details, new Event('toggle')); }; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx index 1d8b6e9c69..2ae172853c 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.truenas-details.test.tsx @@ -62,7 +62,8 @@ const baseResource = (overrides: Partial): Resource => }) as Resource; const expandPlatformDetails = (getByTestId: (id: string) => HTMLElement): void => { - const details = getByTestId('resource-platform-details') as HTMLDetailsElement; + // Platform context sits inside the Overview's one Details disclosure. + const details = getByTestId('resource-technical-details') as HTMLDetailsElement; details.open = true; fireEvent(details, new Event('toggle')); }; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.identityValues.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.identityValues.test.tsx index 043a55fc37..80431b2570 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.identityValues.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.identityValues.test.tsx @@ -111,7 +111,7 @@ describe('resource drawer identity values', () => { // Descriptive rows keep the single-line default. const runtime = screen.getByTestId('resource-runtime-context-section'); - expect(valueSpan(runtime, 'online').className).toContain('truncate'); + expect(valueSpan(runtime, 'Online').className).toContain('truncate'); }); it('wraps container image references, pod and compose names, and label chips', () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.offline.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.offline.test.tsx index 530048ca63..df3840898d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.offline.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.offline.test.tsx @@ -43,7 +43,9 @@ describe('resource drawer runtime context for an offline resource', () => { )); const section = screen.getByTestId('resource-runtime-context-section'); - expect(within(section).getByText('offline')).toBeInTheDocument(); + // Offline says when it was last seen and is danger-toned, so drawers lift it. + const state = within(section).getByText('Offline · last seen 14 mins ago'); + expect(state.closest('td')).toHaveClass('text-rose-700'); expect(within(section).queryByText('Uptime')).toBeNull(); expect(within(section).getByText('Last seen')).toBeInTheDocument(); expect(within(section).getByText('14 mins ago')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index f80753c422..91cb75ef5e 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -6,6 +6,7 @@ import type { ResourceVMwareMeta } from '@/types/resource'; import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import { getAgentStatusIndicator } from '@/utils/status'; +import { mapDockerContainerStatus } from '@/features/docker/dockerPageModel'; import { dedupeResourceBadges, getInfrastructureSystemIdentityBadges, @@ -113,7 +114,14 @@ export const useResourceDetailDrawerDerivedState = ( const kubernetesClusterName = createMemo(() => getPreferredResourceClusterName(resource()) ?? ''); const resolveResourceLabel = (resourceId: string): string => resolveResourceLabelInput?.(resourceId)?.trim() || resourceId; - const statusIndicator = createMemo(() => getAgentStatusIndicator({ status: resource().status })); + // A container's dot reads its run state and health check, the same mapper + // its table row uses, so a running but unhealthy container is not green here + // while red in the row above. + const statusIndicator = createMemo(() => + resource().type === 'app-container' + ? mapDockerContainerStatus(resource()) + : getAgentStatusIndicator({ status: resource().status }), + ); const healthIssue = createMemo(() => getResourceHealthIssuePresentation(resource())); // An open drawer keeps its rows while a resource's timestamps stay the same, // which is exactly when a silent resource's Last seen must keep aging, so diff --git a/frontend-modern/src/components/Workloads/__tests__/drawerOverviewModel.test.ts b/frontend-modern/src/components/Workloads/__tests__/drawerOverviewModel.test.ts index 457207f3b2..39f8dca348 100644 --- a/frontend-modern/src/components/Workloads/__tests__/drawerOverviewModel.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/drawerOverviewModel.test.ts @@ -3,6 +3,8 @@ import type { DetailSection } from '@/components/shared/detailSectionModel'; import type { Alert } from '@/types/api'; import { collectDrawerExceptionRows, + getAlertStatedRowLabels, + partitionDrawerExceptionRows, getDrawerChartAlerts, getDrawerMetricAlert, isDrawerAvailabilityHealthy, @@ -122,6 +124,43 @@ describe('drawer exception rows', () => { ]); }); + it('lifts a row flagged as actionable whatever its tone', () => { + const update: DetailSection[] = [ + { + label: 'Container', + rows: [ + { label: 'Image', value: 'nginx:1.27' }, + { label: 'Image update', value: 'Available', tone: 'accent', lift: true }, + ], + }, + ]; + expect(collectDrawerExceptionRows(update).map((row) => row.label)).toEqual(['Image update']); + expect(withoutDrawerExceptionRows(update)[0].rows.map((row) => row.label)).toEqual(['Image']); + }); + + it("keeps a row in Details when the resource's own alert already states it", () => { + const container: DetailSection[] = [ + { + label: 'Runtime context', + rows: [ + { label: 'Observed state', value: 'Running' }, + { label: 'Health check', value: 'Failing', tone: 'danger' }, + { label: 'Restarts', value: '7', tone: 'danger' }, + ], + }, + ]; + const stated = getAlertStatedRowLabels([{ type: 'docker-container-health' }, { type: 'cpu' }]); + expect([...stated]).toEqual(['Health check']); + const { lifted, details } = partitionDrawerExceptionRows(container, stated); + expect(lifted.map((row) => row.label)).toEqual(['Restarts']); + expect(details[0].rows.map((row) => row.label)).toEqual(['Observed state', 'Health check']); + // Without a stating alert both rows lift, as before. + expect(partitionDrawerExceptionRows(container).lifted.map((row) => row.label)).toEqual([ + 'Health check', + 'Restarts', + ]); + }); + it('moves lifted rows out of Details and drops sections they emptied', () => { const remaining = withoutDrawerExceptionRows(sections); expect(remaining.map((section) => section.label)).toEqual(['Summary']); diff --git a/frontend-modern/src/components/Workloads/drawerOverviewModel.ts b/frontend-modern/src/components/Workloads/drawerOverviewModel.ts index 155eefe4c3..922d11c20e 100644 --- a/frontend-modern/src/components/Workloads/drawerOverviewModel.ts +++ b/frontend-modern/src/components/Workloads/drawerOverviewModel.ts @@ -79,7 +79,8 @@ export const getDrawerChartAlerts = ( return result; }; -const isExceptionRow = (row: DetailRow): boolean => row.tone === 'warning' || row.tone === 'danger'; +const isExceptionRow = (row: DetailRow): boolean => + row.tone === 'warning' || row.tone === 'danger' || row.lift === true; // The drawer demotes inventory to a collapsed Details section, but a fact that // says something is wrong or untrustworthy must stay in front whichever section @@ -108,6 +109,40 @@ export const withoutDrawerExceptionRows = (sections: readonly DetailSection[]): })), ); +// An active alert states these rows' condition already, at its own severity. +// The row then stays in Details rather than repeating the alert in front. +const ALERT_STATED_ROW_LABELS: Record = { + 'docker-container-health': 'Health check', + 'docker-container-state': 'Observed state', + 'docker-container-exit': 'Observed state', + 'docker-container-restart-loop': 'Restarts', + 'docker-container-update': 'Image update', + 'docker-host-offline': 'Observed state', + 'host-offline': 'Observed state', + offline: 'Observed state', +}; + +export const getAlertStatedRowLabels = (alerts: readonly { type: string }[]): Set => + new Set(alerts.map((alert) => ALERT_STATED_ROW_LABELS[alert.type]).filter(Boolean)); + +/** + * Splits inventory into rows lifted in front and the sections left for + * Details. A warning, danger or actionable row is lifted unless one of the + * resource's own alerts already states it, in which case it stays in Details. + */ +export const partitionDrawerExceptionRows = ( + sections: readonly DetailSection[], + statedByAlerts: ReadonlySet = new Set(), +): { lifted: DetailRow[]; details: DetailSection[] } => { + const lifts = (row: DetailRow) => isExceptionRow(row) && !statedByAlerts.has(row.label); + return { + lifted: collectDrawerExceptionRows(sections).filter(lifts), + details: compactDetailSections( + sections.map((section) => ({ ...section, rows: section.rows.filter((row) => !lifts(row)) })), + ), + }; +}; + // "Passing" needs a healthy result that is still fresh. A success whose // evidence has expired, or whose freshness is unknown, is not summarised away: // its card stays in front, where it says Stale. diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 949c2cc2c6..16c1cccb19 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -8735,7 +8735,9 @@ describe('shared primitive guardrails', () => { for (const consumerPath of overviewConsumers) { const source = readFrontendSource(consumerPath); expect(source).toContain('DrawerAttentionSection'); - expect(source).toContain('TechnicalDetailsSection'); + // Warning rows lift in front; the rest waits under one Details disclosure. + expect(source).toContain('DrawerExceptionList'); + expect(source).toContain(' { 'src/features/docker/DockerHostDrawerOverview.tsx', ]) { const source = readFrontendSource(overviewPath); - expect(source).toContain(' { const resourceOverviewSource = readFrontendSource( 'src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx', ); - expect(resourceOverviewSource).toContain('content="all"'); + expect(resourceOverviewSource).toContain("content: 'all'"); const registry = JSON.parse(readFrontendSource('scripts/shared-template-registry.json')) as { rules?: Array<{ id: string; requiredConsumers?: Array<{ path: string }> }>; diff --git a/frontend-modern/src/components/shared/detailSectionModel.ts b/frontend-modern/src/components/shared/detailSectionModel.ts index 12244cec32..bf4d9d95ac 100644 --- a/frontend-modern/src/components/shared/detailSectionModel.ts +++ b/frontend-modern/src/components/shared/detailSectionModel.ts @@ -20,6 +20,11 @@ export type DetailRow = { /** Put long identifying labels above the value instead of in the compact label column. */ layout?: 'stacked'; progress?: DetailRowProgress; + /** + * Something the reader can act on, though not wrong (an available update): + * drawers lift it in front with the warning- and danger-toned rows. + */ + lift?: boolean; }; export type DetailSection = { @@ -34,7 +39,7 @@ export const makeDetailRow = ( value?: string | null, options: Pick< DetailRow, - 'title' | 'tone' | 'wrap' | 'layout' | 'progress' | 'valueContent' | 'valueClass' + 'title' | 'tone' | 'wrap' | 'layout' | 'progress' | 'valueContent' | 'valueClass' | 'lift' > = {}, ): DetailRow | null => { const trimmed = value?.trim(); diff --git a/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx b/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx index a628e3e7d4..ac2eeecd49 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resetAIRuntimeState } from '@/stores/aiRuntimeState'; @@ -158,3 +158,53 @@ describe('DockerHostDrawer typed-helper summary mode', () => { expect(screen.queryByTestId('docker-host-management-actions')).toBeNull(); }); }); + +describe('DockerHostDrawer inventory', () => { + const inventoryHost = (overrides: Partial = {}): Resource => + ({ + ...host(), + memory: { current: 50, total: 8 * 1024 ** 3, used: 4 * 1024 ** 3 }, + disk: { current: 25, total: 100 * 1024 ** 3, used: 25 * 1024 ** 3 }, + docker: { + hostSourceId: 'docker-source-1', + runtime: 'docker', + dockerVersion: '27.3.1', + os: 'Debian GNU/Linux 12', + kernelVersion: '6.1.0', + updatesAvailableCount: 2, + }, + ...overrides, + }) as Resource; + + it('lifts what needs checking, states one facts line and keeps the rest under Details', () => { + render(() => ); + expect(screen.getByTestId('drawer-no-attention')).toHaveTextContent('No active alerts'); + const lifted = screen.getByTestId('docker-host-drawer-exceptions'); + expect(within(lifted).getByText('Updates')).toBeInTheDocument(); + expect(within(lifted).getByText('2 available')).toBeInTheDocument(); + const facts = screen.getByTestId('docker-host-drawer-facts'); + // The row already states the engine version. + expect(facts).not.toHaveTextContent('Docker 27.3.1'); + expect(facts).toHaveTextContent('Debian GNU/Linux 12'); + expect(facts).toHaveTextContent('4.00 GB of 8.00 GB RAM'); + expect(facts).toHaveTextContent('25.0 GB of 100 GB disk'); + const details = screen.getByTestId('docker-host-technical-details') as HTMLDetailsElement; + expect(details.tagName).toBe('DETAILS'); + expect(details.open).toBe(false); + expect(within(details).getByText('Kernel')).toBeInTheDocument(); + expect(within(details).queryByText('Updates')).toBeNull(); + }); + + it('lifts an offline host and states sizes, not used amounts that would read as live', () => { + render(() => ( + + )); + const lifted = screen.getByTestId('docker-host-drawer-exceptions'); + expect(within(lifted).getByText(/^Offline · last seen/)).toBeInTheDocument(); + const facts = screen.getByTestId('docker-host-drawer-facts'); + expect(facts).toHaveTextContent('8.00 GB RAM'); + expect(facts).not.toHaveTextContent('of 8.00 GB'); + }); +}); diff --git a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx index 925aae0fdf..f9b7a51860 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo } from 'solid-js'; +import { For, Show, createMemo } from 'solid-js'; import { buildDrawerDiskListItems, @@ -6,9 +6,12 @@ import { } from '@/components/Workloads/DrawerDiskListCard'; import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard'; import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame'; -import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDisclosure'; +import { TechnicalDetailsDisclosure } from '@/components/shared/TechnicalDetailsDisclosure'; +import { DrawerExceptionList } from '@/components/Workloads/DrawerExceptionList'; +import { ALERTS_EMPTY_STATE } from '@/utils/alertOverviewPresentation'; import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection'; import { + DetailSectionTable, compactDetailRows, compactDetailSections, makeDetailRow, @@ -35,7 +38,12 @@ import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature' import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import type { HistoryTimeRange } from '@/api/charts'; import { GuestDrawerHistory } from '@/components/Workloads/GuestDrawerHistory'; -import { getDrawerChartAlerts } from '@/components/Workloads/drawerOverviewModel'; +import { + getAlertStatedRowLabels, + getDrawerChartAlerts, + isDrawerAvailabilityHealthy, + partitionDrawerExceptionRows, +} from '@/components/Workloads/drawerOverviewModel'; import type { GuestDrawerHistoryGroupConfig, GuestDrawerHistoryTarget, @@ -360,6 +368,16 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) { { label: 'Operator context', rows: compactDetailRows([ + // A host that is not reporting says so in front of the charts. + props.host.status === 'offline' + ? makeDetailRow( + 'Observed state', + props.host.lastSeen + ? `Offline · last seen ${formatRelativeTime(props.host.lastSeen, { now: now() })}` + : 'Offline', + { tone: 'danger' }, + ) + : null, makeDetailRow('Runtime', runtimeLabel()), makeDetailRow('System', osLabel()), makeDetailRow( @@ -378,39 +396,96 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) { ]); }; + // Inventory follows the Proxmox drawers: anything wrong is lifted in front, + // one facts line adds what the row does not say, and the rest waits under a + // closed Details section rather than as five equal-weight cards. + const inventorySections = () => [...overviewSections(), ...technicalSections()]; + const inventory = () => + partitionDrawerExceptionRows( + inventorySections(), + getAlertStatedRowLabels( + getOwnAlertsForUnifiedResource( + props.host, + activeAlerts, + alertsActivation.detectionEnabled(), + ), + ), + ); + const exceptionRows = () => inventory().lifted; + const detailSections = () => inventory().details; + const sizeFact = ( + source: { total?: number; used?: number; free?: number } | undefined, + label: string, + ): string => { + const total = source?.total ?? 0; + if (!(total > 0)) return ''; + if (source && 'usageUnavailable' in source && source.usageUnavailable === true) { + return `${formatBytes(total)} ${label}`; + } + const used = + typeof source?.used === 'number' + ? source.used + : typeof source?.free === 'number' + ? total - source.free + : undefined; + const observation = (source as { observation?: { state?: string } } | undefined)?.observation; + const current = !observation || observation.state === 'current'; + const reporting = props.host.status === 'online' || props.host.status === 'running'; + return reporting && current && typeof used === 'number' && used >= 0 + ? `${formatBytes(used)} of ${formatBytes(total)} ${label}` + : `${formatBytes(total)} ${label}`; + }; + const facts = () => { + const system = osLabel(); + // The row already states the engine version; the facts add what it lacks. + return [ + // A bare "linux" from the engine adds nothing; a named OS does. + system && system.toLowerCase() !== 'linux' ? system : '', + sizeFact(memorySource(), 'RAM'), + sizeFact(diskSource(), 'disk'), + ].filter(Boolean); + }; + const availabilityChecks = () => [ + ...(props.host.availability ? [props.host.availability] : []), + ...(props.host.availabilityChecks ?? []), + ]; + const availabilityHealthy = () => + availabilityChecks().length > 0 && + availabilityChecks().every((check) => isDrawerAvailabilityHealthy(check, now())); + const attentionItems = () => [ + ...(summaryOnly() + ? [ + { + id: `${props.host.id}:typed-helper-summary`, + subject: 'Reduced container coverage', + message: + 'The typed helper reports container summaries only. Stats, secondary inventory, update checks, and lifecycle actions are unavailable.', + severity: 'warning' as const, + }, + ] + : []), + ...hostAlerts().map((alert) => ({ + id: alert.id, + ...getAlertAttentionCopy(alert, now), + subject: cleanText(alert.resourceName) || props.host.displayName || props.host.name, + metric: alertTypeDisplayLabel(alert.type), + severity: alert.level, + acknowledged: alert.acknowledged, + })), + ]; + return (
- ({ - id: alert.id, - ...getAlertAttentionCopy(alert, now), - subject: cleanText(alert.resourceName) || props.host.displayName || props.host.name, - metric: alertTypeDisplayLabel(alert.type), - severity: alert.level, - acknowledged: alert.acknowledged, - })), - ]} - /> - -
- -
+ + +

+ {ALERTS_EMPTY_STATE} + + · availability checks passing + +

+ {(target) => ( )} - + {/* A failing check stays in front, after the charts as in every drawer. */} + 0 && !availabilityHealthy()}> +
+ +
+
+ 0}> +
+ {(fact) => {fact}} +
+
+ 0 || availabilityHealthy()}> + section.label.toLowerCase()), + availabilityHealthy() ? 'availability checks' : '', + ] + .filter(Boolean) + .join(', ')} + class="border-t border-border pt-2" + contentClass="mt-2 space-y-3" + > + + +
+ +
+
+
+
); } diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx index 3d92f71010..a899084cad 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx @@ -276,7 +276,10 @@ describe('KubernetesControllersTable', () => { expect(within(job).getByText('5m')).toBeInTheDocument(); expect(within(job).getByText('Target')).toBeInTheDocument(); expect(within(job).getByText('10 completions')).toBeInTheDocument(); - expect(within(job).getByText('Failed').closest('tr')).toHaveTextContent('2'); + // Failed pods are worth checking, so the drawer lifts that row in front. + const lifted = screen.getByTestId('resource-drawer-exceptions'); + expect(within(lifted).getByText('Failed').closest('tr')).toHaveTextContent('2'); + expect(within(job).queryByText('Failed')).toBeNull(); fireEvent.click(screen.getByText(kubernetesName('billing-rollup'))); const cron = screen.getByTestId('resource-kubernetes-controller-section'); diff --git a/tests/integration/tests/04-mobile.spec.ts b/tests/integration/tests/04-mobile.spec.ts index 780c95a0c1..6b256aa798 100644 --- a/tests/integration/tests/04-mobile.spec.ts +++ b/tests/integration/tests/04-mobile.spec.ts @@ -554,6 +554,11 @@ test.describe("Mobile viewport flows", () => { await expect( row.getByRole("button", { name: /^Collapse details for / }), ).toHaveAttribute("aria-expanded", "true"); + // Identity and container inventory sit under the drawer's Details. + await page + .locator('[data-testid="resource-technical-details"] > summary') + .first() + .click(); if (requireRows) { // The default mock's first container and pod carry these long rows. await expect(