mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
Compose published drawer refinements with reviewed maintenance
Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
+707
-613
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
|
||||
|
||||
+26
-6
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
+26
-4
@@ -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(() => (
|
||||
<ResourceDetailDrawer
|
||||
resource={baseResource({
|
||||
id: 'app-container:edge-web',
|
||||
type: 'app-container',
|
||||
name: 'edge-web',
|
||||
status: 'online',
|
||||
docker: { containerId: 'abc', containerState: 'running', health: 'unhealthy' },
|
||||
})}
|
||||
/>
|
||||
));
|
||||
// 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(() => <ResourceDetailDrawer resource={baseResource({ status: 'online' })} />);
|
||||
// Other resources keep the shared run-status dot.
|
||||
expect(screen.queryByRole('img', { name: 'Unhealthy' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+8
-5
@@ -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();
|
||||
|
||||
+2
-1
@@ -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();
|
||||
|
||||
+105
-3
@@ -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();
|
||||
|
||||
+2
-1
@@ -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'));
|
||||
};
|
||||
|
||||
+2
-1
@@ -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'));
|
||||
};
|
||||
|
||||
+1
-1
@@ -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', () => {
|
||||
|
||||
+3
-1
@@ -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();
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -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 (
|
||||
<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
|
||||
@@ -424,10 +499,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>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-1
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user