Give agent, container and Docker host drawers the Proxmox Overview

The Proxmox guest and node drawers lead with alerts, lift anything wrong
into Worth checking, show the charts, one facts line and a closed Details
section. The other object drawers still opened large always-visible
cards: an agent's Operator Context card holding only an IP, a
container's digests and label inventory, five Docker host cards that
mostly restated the row.

The shared resource drawer and the Docker host drawer now follow the
same Overview. A "No active alerts" line, rows lifted in front, the
charts, a facts line that adds what the row lacks (container image and
Compose service; host CPUs, RAM and disk used of total, address), and
every inventory section, provider Platform details included, under one
closed Details.

Lifting follows evidence, not only colour: an available image update
carries an explicit lift flag; an offline resource's state is
danger-toned with when it was last seen, so it is never hidden behind
Details; a row the resource's own alert already states stays in Details
instead of repeating it. Facts state used amounts only for an online,
current reading. An availability endpoint keeps its check cards in
front, since they are its whole reading.

Reviewed twice by Sol from before/after screenshots at desktop and
phone width; the second pass confirmed the first round's findings
resolved and led to the alert de-duplication.
This commit is contained in:
rcourtman
2026-10-14 14:00:23 +01:00
parent 86a244bc04
commit b6e1fd18bc
24 changed files with 1314 additions and 696 deletions
@@ -5446,12 +5446,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",
@@ -5509,8 +5507,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
@@ -2382,6 +2382,14 @@ 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.
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.
@@ -367,7 +367,8 @@ 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();
},
@@ -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')
@@ -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"
]
}
@@ -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": [
{
File diff suppressed because it is too large Load Diff
@@ -138,6 +138,8 @@ const dockerSection = (docker: NonNullable<Resource['docker']>, 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<Resource['docker']>, now: number): De
return { label: 'Container', rows, testId: 'resource-docker-container-section' };
};
export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationProps> = (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<ResourceSummaryPresentationProps, 'dataTestId'>,
): (() => 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<ResourceSummaryPresentationP
'Observed state',
getDockerContainerRunStatePresentation(props.resource),
)
: makeDetailRow('Observed state', props.resource.status || 'unknown', {
valueClass: 'capitalize',
}),
: observedStateRow(props.resource.status, props.drawer.lastSeen()),
docker
? dockerRuntimeRow(
'Health check',
@@ -342,6 +370,54 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
: null,
]);
};
};
/**
* The facts line under a resource drawer's charts: what the row does not
* already say. A container states its image and Compose project and service
* (a phone row hides the image); a host its
* CPU count, the RAM and disk the row's percentages are of, and its primary
* address. An offline resource states sizes only, not used amounts that would
* read as live.
*/
export const getResourceDrawerFacts = (resource: Resource, primaryIp?: string): string[] => {
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<ResourceSummaryPresentationProps> = (props) => {
const sections = useResourceSummarySections(props);
return (
<DetailSectionTable
@@ -260,18 +260,28 @@ describe('ResourceDetailDrawer for Docker containers', () => {
/>
));
// 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();
@@ -318,8 +328,12 @@ describe('ResourceDetailDrawer for Docker containers', () => {
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();
});
@@ -344,7 +358,8 @@ describe('ResourceDetailDrawer for Docker containers', () => {
));
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();
});
@@ -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();
@@ -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', () => {
<ResourceDetailDrawer resource={resource} />
));
// 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();
@@ -91,7 +91,8 @@ describe('ResourceDetailDrawer kubernetes capabilities', () => {
<ResourceDetailDrawer resource={buildKubernetesResource()} />
));
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();
@@ -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(() => (
<ResourceDetailDrawer
resource={resource({
id: 'agent-facts',
memory: { current: 50, total: 16 * gib, used: 8 * gib },
disk: { current: 25, total: 200 * gib, used: 50 * gib },
agent: { agentId: 'agent-facts', cpuCount: 8 } as Resource['agent'],
})}
/>
));
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(() => (
<ResourceDetailDrawer
resource={resource({
id: 'agent-offline',
status: 'offline',
lastSeen: Date.now() - 14 * 60_000,
memory: { current: 50, total: 16 * gib, used: 8 * gib },
})}
/>
));
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(() => (
<ResourceDetailDrawer resource={resource({ id: 'agent-checked', availability: passing })} />
));
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(() => (
<ResourceDetailDrawer
resource={resource({
id: 'endpoint-a',
type: 'network-endpoint',
platformType: 'availability',
availability: passing,
})}
/>
));
// 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(() => (
<ResourceDetailDrawer
resource={resource({
id: 'agent-retained',
memory: {
current: 50,
total: 16 * gib,
used: 8 * gib,
observation: { state: 'last-known', source: 'agent' },
},
})}
/>
));
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();
@@ -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'));
};
@@ -62,7 +62,8 @@ const baseResource = (overrides: Partial<Resource>): 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'));
};
@@ -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', () => {
@@ -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();
@@ -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']);
@@ -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<string, string> = {
'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<string> =>
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<string> = 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.
@@ -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('<TechnicalDetailsDisclosure');
expect(source).not.toContain('ResourceOperatorStateSection');
}
@@ -8802,7 +8804,7 @@ describe('shared primitive guardrails', () => {
'src/features/docker/DockerHostDrawerOverview.tsx',
]) {
const source = readFrontendSource(overviewPath);
expect(source).toContain('<TechnicalDetailsSection');
expect(source).toMatch(/<TechnicalDetails(Section|Disclosure)\b/);
expect(source).toContain('...technicalSections()');
expect(source).not.toContain('const DetailCard');
}
@@ -8810,7 +8812,7 @@ describe('shared primitive guardrails', () => {
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 }> }>;
@@ -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();
@@ -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> = {}): 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(() => <DockerHostDrawer host={inventoryHost()} />);
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(() => (
<DockerHostDrawer
host={inventoryHost({ status: 'offline', lastSeen: Date.now() - 600_000 })}
/>
));
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');
});
});
@@ -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,
@@ -365,6 +373,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(
@@ -383,39 +401,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 (
<div class="space-y-3">
<DrawerAttentionSection
items={[
...(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',
},
]
: []),
...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,
})),
]}
/>
<Show when={props.host.availability || props.host.availabilityChecks?.length}>
<div class="max-w-sm">
<AvailabilityProbeStatusCards
availability={props.host.availability}
checks={props.host.availabilityChecks}
/>
</div>
<DrawerAttentionSection items={attentionItems()} />
<Show when={attentionItems().length === 0}>
<p data-testid="drawer-no-attention" class="text-xs text-muted">
<span class="font-medium text-base-content">{ALERTS_EMPTY_STATE}</span>
<Show when={availabilityHealthy()}>
<span> · availability checks passing</span>
</Show>
</p>
</Show>
<DrawerExceptionList testId="docker-host-drawer-exceptions" rows={exceptionRows()} />
<Show when={props.historyTarget}>
{(target) => (
<GuestDrawerHistory
@@ -429,10 +504,51 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
/>
)}
</Show>
<TechnicalDetailsSection
dataTestId="docker-host-technical-details"
sections={[...overviewSections(), ...technicalSections()]}
/>
{/* A failing check stays in front, after the charts as in every drawer. */}
<Show when={availabilityChecks().length > 0 && !availabilityHealthy()}>
<div class="max-w-sm">
<AvailabilityProbeStatusCards
availability={props.host.availability}
checks={props.host.availabilityChecks}
/>
</div>
</Show>
<Show when={facts().length > 0}>
<div
data-testid="docker-host-drawer-facts"
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted"
>
<For each={facts()}>{(fact) => <span class="wrap-break-word">{fact}</span>}</For>
</div>
</Show>
<Show when={detailSections().length > 0 || availabilityHealthy()}>
<TechnicalDetailsDisclosure
dataTestId="docker-host-technical-details"
title="Details"
keepMounted
subtitle={[
...detailSections().map((section) => section.label.toLowerCase()),
availabilityHealthy() ? 'availability checks' : '',
]
.filter(Boolean)
.join(', ')}
class="border-t border-border pt-2"
contentClass="mt-2 space-y-3"
>
<DetailSectionTable
sections={detailSections()}
dataTestId="docker-host-technical-sections"
/>
<Show when={availabilityHealthy()}>
<div class="max-w-sm">
<AvailabilityProbeStatusCards
availability={props.host.availability}
checks={props.host.availabilityChecks}
/>
</div>
</Show>
</TechnicalDetailsDisclosure>
</Show>
</div>
);
}
@@ -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(