Unify drawer detail density

This commit is contained in:
rcourtman
2026-08-24 14:59:02 +01:00
parent f1aee5ccd1
commit 08e501bbc8
18 changed files with 537 additions and 899 deletions
@@ -3600,9 +3600,13 @@ operator context that the parent table row cannot carry, such as OS/runtime,
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. `TechnicalDetailsDisclosure` owns the collapsed, lazily mounted
inventory boundary for raw IDs, kernel/build strings, full interface and disk
lists, and provider metadata. Monitoring mode, lifecycle, notes, maintenance,
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. Monitoring mode, lifecycle, notes, maintenance,
automatic-action policy, saved access configuration, and action audit belong
to a dedicated `Manage` tab and must not render inside Overview. Guest, node,
Docker-host, and unified-resource drawers must compose these shared primitives
@@ -1362,18 +1362,24 @@ but it must not add host powercap reads, sensor-specific history reads,
per-row polling, browser-side command assumptions, or table-wide aggregation
work.
The Proxmox node drawer overview should follow the existing guest drawer
compact detail-card pattern and expose node-specific context such as platform,
compact detail-section pattern and expose node-specific context such as platform,
kernel, hardware, raw capacity, telemetry, and thermal facts rather than
repeating the metric cells already visible in the grouped table row.
Object drawers across workload, node, Docker, and unified-resource surfaces
must keep their default render bounded to active attention plus a small
additive context projection. Full inventory and provider-specific support
tables remain lazy behind collapsed technical disclosures, while operator
additive context projection. Full provider-specific support workflows remain
lazy behind collapsed support disclosures, while curated technical inventory
stays visible through shared compact section rows. Operator
policy, update actions, and saved access mount only after the Manage tab is
selected. Alert lookup may consume the already-loaded active-alert map through
the resource's bounded canonical/provider identity candidate list; it must not
add a per-drawer fetch, rescan the workload inventory, or eagerly mount hidden
technical sections.
Guest, node, and Docker-host technical rows must compose
`TechnicalDetailsSection`, `DetailSectionTable`, and `detailSectionModel.ts`
instead of local detail-card grids. The projection may only format the
already-selected resource payload and existing drawer state; making those rows
visible must not add a fetch or inventory scan.
Guest and resource drawer headers must not initialize clipboard-context or
Assistant-handoff state for generic header buttons. Removing those controls
also removes their drawer-local signal, API, and formatting wiring; the global
@@ -1923,11 +1929,14 @@ and workload-derived navigation state live in
`frontend-modern/src/components/Workloads/useGuestDrawerState.ts`. Future
drawer runtime and overview-surface changes must extend through those owners
instead of adding more mixed state and helper drift back into the shell.
Compact drawer card frames in `GuestDrawerOverview.tsx`,
`NodeDrawerOverview.tsx`, and shared drawer helpers are a frontend-primitives
dependency: the Workloads hot path owns which cards render and what data they
show, but the repeated bordered `bg-surface p-3 shadow-sm` frame must compose
`InfoCardFrame` instead of carrying workload-local frame constants.
Compact drawer technical sections in `GuestDrawerOverview.tsx` and
`NodeDrawerOverview.tsx` are a frontend-primitives dependency: the Workloads
hot path owns which sections and rows render and what data they show, while row
compaction, table rendering, and tone classes must compose
`TechnicalDetailsSection`, `DetailSectionTable`, and `detailSectionModel.ts`
instead of workload-local card frames or detail loops. Non-technical secondary
cards still compose `InfoCardFrame` where a framed interactive surface is
genuinely required.
The guest drawer header follows the same dependency boundary for its Assistant,
copy-context, and close actions: Workloads owns availability and click
handlers, while `frontend-modern/src/components/shared/Button.tsx` owns the
@@ -1246,6 +1246,14 @@ AI-only summary payloads, or page-local heuristics.
`ResourceActionHistory.tsx`, and `DiscoveryTab.tsx` must compose
`InfoCardFrame` for that shell instead of restoring local card-frame
classes.
Curated technical inventory follows the shared compact-row contract instead
of the secondary-card contract. Docker-host drawers must project system,
runtime, memory, storage, and telemetry facts through
`TechnicalDetailsSection` with canonical `DetailSection[]` data, while the
unified-resource drawer keeps its existing compact technical summary tables
visible. Only genuinely large or interactive provider-support content stays
lazy behind `TechnicalDetailsDisclosure`; technical inventory must not
restore a local card mosaic or add a drawer-open fetch.
9. Keep dashboard and infrastructure freshness on the canonical unified-resource
ownership path. `frontend-modern/src/stores/websocket.ts`,
`frontend-modern/src/utils/resourceStateAdapters.ts`, and
+25 -28
View File
@@ -1,33 +1,31 @@
{
"version": 1,
"base_sha": "66fd37ee6f801388a5606e374be3d17266c087cd",
"verified_at": "2026-08-24T12:13:28Z",
"base_sha": "f1aee5ccd1befb40ff2bc78940ad6a12558c4475",
"verified_at": "2026-08-24T13:54:53Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Discovery/useDiscoveryFeatureAvailability.ts",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx",
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx"
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx",
"frontend-modern/src/components/shared/DetailSectionTable.tsx",
"frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Discovery/useDiscoveryFeatureAvailability.ts": "3cc4fa39aa93c35b52272b00a3f5eb383a8e5a6aaed969e01a4a13844b2425f4",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "373489b948cb2ea38a3ca033b112d6cf2ec6368bd04b4dfde93d10b7a3184023",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "d074c6291867891ff788144e46bb59d687567c75b7b1059728fe1d355eb5155a",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "bc373c72f2d1fad9f36f6cf8b04dc97b8de9edb6c0bb5bf1d8fd3ece4aaa766b",
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "8674933ff1bd22d6c17a22802b56a2345b334c98fa6f219cc27c9525a4b01b6a",
"frontend-modern/src/components/Workloads/NodeDrawer.tsx": "bb62a66419fc658e4979a82d0ccecf7db3e5e4bc7c3bdb4792e63f312498c44e",
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "7a369ffa496fa66b260ca5f1e26b7016b82f4ded20d47aedde982a6d4f0e46b0",
"frontend-modern/src/features/docker/DockerHostDrawer.tsx": "c1d3e336b5fb128214a7899a41169bdbc5c4c1e9e19e9078fb99f6c52b46ff25"
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "5423e151cf559054d5c17292c48b8d82bc17e2b235aae7191bfd2950171f2df5",
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "5a2b06ce9abafa674dc9821b31a10f946cdd4b7f06d7a847af13ea32b1983547",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "99d6324d0b4be99d677ebd9a76cd4d5d4f86b786f6d9ff141d02d690f6d253bb",
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx": "04178af05ed8753d705f2c503e866973d9b3f35ad96d8e46cc2d57bf1a1d129c",
"frontend-modern/src/components/shared/DetailSectionTable.tsx": "18a24812a1b02af72c47edee5327c1d5e2c397cdffb57be9542ffdc89cb41595",
"frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx": "5f6e0a9d206171d2497e345c9cc3438c164126974e69323bbf16a9f21643a5ae",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "22d8fc6f6b395aadb15dc208016edd078b5adb7151e67ab10c03b7f5657b629b"
},
"routes": ["/proxmox/overview", "/truenas/overview", "/docker/overview"],
"routes": ["/proxmox/overview", "/docker/overview"],
"viewports": [
{
"width": 1440,
"height": 900
"width": 1280,
"height": 800
},
{
"width": 390,
@@ -35,15 +33,14 @@
}
],
"states": [
"AI Discovery disabled with Proxmox node and guest drawers expanded",
"AI Discovery disabled with a TrueNAS unified-resource drawer expanded",
"AI Discovery disabled with a Docker host drawer expanded"
"Proxmox node drawer with curated operator context, platform, hardware, memory, storage, and telemetry rows visible",
"Proxmox guest drawer with only the operator and inventory sections supported by the selected workload visible",
"Docker host drawer with operator context, runtime, capacity, and telemetry rows visible"
],
"interactions": [
"expanded a Proxmox node drawer at desktop width and confirmed only Overview, History, and Manage tabs",
"expanded a Proxmox guest drawer at desktop and phone widths and confirmed no Discovery badge, reason, identified-service context, or tab",
"expanded a TrueNAS resource drawer at phone width and confirmed no Discovery tab or analysis disclosure while active alerts remained visible",
"expanded a Docker host drawer at phone width and confirmed no Discovery tab or disabled-state copy",
"restored the temporary browser viewport"
"opened a Proxmox node row at desktop and phone widths and confirmed compact detail rows render immediately with no nested details disclosure",
"opened a Proxmox guest row at phone width and confirmed row-level uptime and placement are not repeated in the drawer",
"opened a Docker host row at desktop and phone widths and confirmed compact detail rows render immediately with no nested details disclosure",
"confirmed long values truncate within the value column and the drawer remains inside the phone viewport"
]
}
@@ -50,12 +50,12 @@
]
},
{
"id": "operator-first-technical-disclosure",
"id": "operator-first-technical-section",
"category": "drawer-information-hierarchy",
"summary": "Object drawers keep raw inventory behind one shared lazy technical-details disclosure while promoting additive operator facts into Overview.",
"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.",
"canonical": {
"path": "src/components/shared/TechnicalDetailsDisclosure.tsx",
"export": "TechnicalDetailsDisclosure"
"export": "TechnicalDetailsSection"
},
"requiredConsumers": [
{
@@ -71,6 +71,20 @@
"path": "src/features/docker/DockerHostDrawerOverview.tsx"
}
],
"forbiddenPatterns": [
{
"path": "src/components/Workloads/GuestDrawerOverview.tsx",
"patterns": ["const DetailCard"]
},
{
"path": "src/components/Workloads/NodeDrawerOverview.tsx",
"patterns": ["const DetailCard"]
},
{
"path": "src/features/docker/DockerHostDrawerOverview.tsx",
"patterns": ["const DetailCard"]
}
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/Workloads/GuestDrawer.test.tsx",
@@ -429,8 +443,6 @@
{ "path": "src/components/shared/cards/SystemInfoCard.tsx" },
{ "path": "src/components/shared/cards/TemperaturesCard.tsx" },
{ "path": "src/components/Workloads/DrawerDiskListCard.tsx" },
{ "path": "src/components/Workloads/GuestDrawerOverview.tsx" },
{ "path": "src/components/Workloads/NodeDrawerOverview.tsx" },
{ "path": "src/features/docker/DockerHostDrawerOverview.tsx" },
{ "path": "src/features/storageBackups/detailPresentation.ts" }
],
@@ -31,7 +31,10 @@ import { ResourceFacetSummary } from './ResourceFacetSummary';
import { InlineResourceSummaryTables } from './ResourceDetailSummary';
import { ResourceInvestigationContextTables } from './ResourceInvestigationContextTables';
import { DetailSectionTable } from '@/components/shared/DetailSectionTable';
import { TechnicalDetailsDisclosure } from '@/components/shared/TechnicalDetailsDisclosure';
import {
TechnicalDetailsDisclosure,
TechnicalDetailsSection,
} from '@/components/shared/TechnicalDetailsDisclosure';
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
import {
RESOURCE_CHANGE_KIND_ORDER,
@@ -380,27 +383,15 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
return (
<div class="space-y-3">
<DrawerAttentionSection items={attentionItems()} />
<Show when={drawer.sourceSummary() || drawer.identityIpValues()[0]}>
<TechnicalDetailsSection dataTestId="resource-technical-details">
<InlineResourceSummaryTables
resource={resource}
drawer={drawer}
showPlatformId={showPlatformId}
content="overview"
/>
</Show>
<TechnicalDetailsDisclosure
dataTestId="resource-technical-details"
subtitle="Identity, runtime, and source IDs"
>
<InlineResourceSummaryTables
resource={resource}
drawer={drawer}
showPlatformId={showPlatformId}
content="technical"
content="all"
dataTestId="resource-technical-summary-section"
/>
</TechnicalDetailsDisclosure>
</TechnicalDetailsSection>
<Show when={resource.availability || (resource.availabilityChecks?.length ?? 0) > 0}>
<div class="flex flex-wrap gap-3 [&>*]:min-w-[240px] [&>*]:flex-1">
@@ -324,7 +324,12 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
class="overflow-hidden rounded border border-border bg-surface"
>
<table class="w-full table-fixed text-[11px]">
<Show when={props.content !== 'technical'}>
<Show
when={
props.content !== 'technical' &&
Boolean(props.drawer.sourceSummary() || props.drawer.identityIpValues()[0])
}
>
<tbody data-testid="resource-current-state-section" class="divide-y divide-border">
<tr class="bg-surface-alt">
<th
@@ -366,7 +366,7 @@ describe('ResourceDetailDrawer change history section', () => {
await Promise.resolve();
expect(screen.queryByText('Loading view...')).not.toBeInTheDocument();
expect(screen.getByText('Technical details')).toBeInTheDocument();
expect(screen.getByTestId('resource-technical-details')).toBeInTheDocument();
});
it('keeps table-row presentation focused on local resource details', async () => {
@@ -655,7 +655,7 @@ describe('ResourceDetailDrawer change history section', () => {
render(() => <ResourceDetailDrawer resource={resource} />);
await screen.findByText('Technical details');
await screen.findByTestId('resource-technical-details');
expect(screen.queryByText('Context')).toBeNull();
expect(screen.queryByText('Governance')).toBeNull();
expect(screen.queryByText('Safe Summary')).toBeNull();
@@ -825,7 +825,7 @@ 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(2);
expect(inlineRender.container.querySelectorAll('details')).toHaveLength(1);
expect(inlineRender.getByText('agent-inline-1')).toBeInTheDocument();
expect(inlineRender.getAllByText('inline-host.local').length).toBeGreaterThan(0);
@@ -12,7 +12,6 @@ import type { WorkloadGuest } from '@/types/workloads';
import type { Memory, Disk, GuestNetworkInterface } from '@/types/api';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { getCanonicalWorkloadId, getWorkloadMetadataId } from '@/utils/workloads';
import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation';
import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState';
import guestDrawerSource from './GuestDrawer.tsx?raw';
import guestDrawerManageSource from './GuestDrawerManage.tsx?raw';
@@ -40,12 +39,6 @@ vi.mock('@/stores/license', () => ({
maxHistoryDays: () => 90,
}));
vi.mock('./DiskList', () => ({
DiskList: (props: { disks: Disk[] }) => (
<div data-testid="disk-list">DiskList({props.disks.length} disks)</div>
),
}));
vi.mock('../Discovery/DiscoveryTab', () => ({
DiscoveryTab: (props: {
resourceType: string;
@@ -250,11 +243,9 @@ describe('GuestDrawer', () => {
expect(screen.getByText('Needs attention')).toBeInTheDocument();
expect(screen.getByText('Memory usage has remained above 95%')).toBeInTheDocument();
expect(screen.getByText(/Debian.*12/)).toBeInTheDocument();
expect(screen.getByText('192.0.2.25')).toBeInTheDocument();
expect(container.querySelector('[data-testid="guest-technical-details"]')).not.toHaveAttribute(
'open',
);
expect(screen.getAllByText(/Debian.*12/).length).toBeGreaterThan(0);
expect(screen.getAllByText('192.0.2.25').length).toBeGreaterThan(0);
expect(container.querySelector('[data-testid="guest-technical-details"] table')).toBeTruthy();
expect(screen.queryByTestId('guest-manage-tab')).not.toBeInTheDocument();
});
@@ -363,10 +354,9 @@ describe('GuestDrawer', () => {
await waitFor(() => {
expect(screen.getByText('Identified Service')).toBeInTheDocument();
});
expect(screen.getByText('Homepage Dashboard')).toBeInTheDocument();
expect(screen.getByText('web_server')).toBeInTheDocument();
expect(screen.getByText('Homepage Dashboard · web_server')).toBeInTheDocument();
expect(screen.getByText('0.9.0')).toBeInTheDocument();
expect(screen.getByLabelText(getDiscoveryProvenanceTitle())).toBeInTheDocument();
expect(technicalDetails().getByText('Source')).toBeInTheDocument();
expect(screen.getAllByText('http://192.0.2.10:3000').length).toBeGreaterThan(0);
expect(screen.getByTestId('url-suggested')).toHaveTextContent('http://192.0.2.10:3000');
expect(screen.getByTestId('url-suggested-reason')).toHaveTextContent('Detected 3000/tcp');
@@ -565,16 +555,16 @@ describe('GuestDrawer', () => {
// ── System card ──
describe('System card', () => {
describe('System details', () => {
it('displays CPU count', () => {
render(() => <GuestDrawer guest={makeGuest({ cpus: 8 })} onClose={vi.fn()} />);
expect(screen.getByText('CPUs')).toBeInTheDocument();
expect(screen.getByText('8')).toBeInTheDocument();
});
it('displays uptime when > 0', () => {
it('does not repeat row-level uptime', () => {
render(() => <GuestDrawer guest={makeGuest({ uptime: 3600 })} onClose={vi.fn()} />);
expect(screen.getByText('Uptime')).toBeInTheDocument();
expect(screen.queryByText('Uptime')).not.toBeInTheDocument();
});
it('hides uptime when 0', () => {
@@ -582,17 +572,15 @@ describe('GuestDrawer', () => {
expect(screen.queryByText('Uptime')).not.toBeInTheDocument();
});
it('displays node name', () => {
it('does not repeat row-level node placement', () => {
render(() => <GuestDrawer guest={makeGuest({ node: 'pve-prod-01' })} onClose={vi.fn()} />);
expect(screen.getByText('Node')).toBeInTheDocument();
// Node name appears in both overview and discovery mock; use getAllByText
const nodes = screen.getAllByText('pve-prod-01');
expect(nodes.length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText('Node')).not.toBeInTheDocument();
expect(screen.queryByText('pve-prod-01')).not.toBeInTheDocument();
});
});
describe('Nested workload context card', () => {
it('shows nested Docker container count, sample rows, and canonical Docker link', () => {
describe('Nested workload context', () => {
it('shows nested Docker container count and sample rows in the compact details table', () => {
render(() => (
<GuestDrawer
guest={makeGuest({ name: 'media-lxc', type: 'lxc', workloadType: 'system-container' })}
@@ -611,15 +599,12 @@ describe('GuestDrawer', () => {
/>
));
const card = screen.getByTestId('nested-workload-context-card');
expect(within(card).getByText('Nested Docker')).toBeInTheDocument();
expect(within(card).getByText('Containers')).toBeInTheDocument();
expect(within(card).getByText('2')).toBeInTheDocument();
expect(within(card).getByText('frigate')).toBeInTheDocument();
expect(within(card).getByText('mosquitto')).toBeInTheDocument();
expect(
within(card).getByRole('link', { name: 'Open Docker page for media-lxc' }),
).toHaveAttribute('href', '/docker/overview?host=media-lxc.mist-stork.ts.net');
const technical = technicalDetails();
expect(technical.getByText('Nested Docker')).toBeInTheDocument();
expect(technical.getByText('Containers')).toBeInTheDocument();
expect(technical.getByText('2')).toBeInTheDocument();
expect(technical.getByText('frigate')).toBeInTheDocument();
expect(technical.getByText('mosquitto')).toBeInTheDocument();
});
});
@@ -658,15 +643,12 @@ describe('GuestDrawer', () => {
expect(technicalDetails().getByText('1.0.0')).toBeInTheDocument();
});
it('surfaces the install path when a running VM has no Pulse agent', () => {
it('identifies when a running VM would benefit from a Pulse agent', () => {
render(() => (
<GuestDrawer guest={makeGuest({ agentVersion: undefined })} onClose={vi.fn()} />
));
expect(screen.getByText('Actions')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Add agent for AI actions' })).toHaveAttribute(
'href',
'/settings/infrastructure?add=agent',
);
expect(screen.getByText('Pulse coverage')).toBeInTheDocument();
expect(technicalDetails().getByText('Agent recommended')).toBeInTheDocument();
});
it('shows connected node agent actions when the workload has an explicit action target', () => {
@@ -687,7 +669,7 @@ describe('GuestDrawer', () => {
/>
));
expect(screen.getByText('Actions')).toBeInTheDocument();
expect(screen.getByText('Pulse coverage')).toBeInTheDocument();
expect(technicalDetails().getByText('Node agent connected')).toHaveAttribute(
'title',
'Discovery and governed actions use the Pulse Agent connected to delly.',
@@ -722,9 +704,9 @@ describe('GuestDrawer', () => {
});
});
// ── Guest Info card (OS + IPs) ──
// ── Operator identity context (OS + IPs) ──
describe('Guest Info card', () => {
describe('Operator identity context', () => {
it('shows OS name and version', () => {
render(() => (
<GuestDrawer
@@ -732,9 +714,7 @@ describe('GuestDrawer', () => {
onClose={vi.fn()}
/>
));
expect(screen.getByText('Guest Info')).toBeInTheDocument();
expect(screen.getByText('Ubuntu')).toBeInTheDocument();
expect(screen.getByText('22.04')).toBeInTheDocument();
expect(technicalDetails().getByText('Ubuntu • 22.04')).toBeInTheDocument();
});
it('shows OS name only when version is missing', () => {
@@ -754,7 +734,6 @@ describe('GuestDrawer', () => {
onClose={vi.fn()}
/>
));
expect(screen.getByText('Guest Info')).toBeInTheDocument();
expect(technicalDetails().getByText('11.0')).toBeInTheDocument();
});
@@ -766,17 +745,17 @@ describe('GuestDrawer', () => {
/>
));
expect(technicalDetails().getByText('192.168.1.10')).toBeInTheDocument();
expect(screen.getByText('10.0.0.5')).toBeInTheDocument();
expect(technicalDetails().getByText('10.0.0.5')).toBeInTheDocument();
});
it('hides Guest Info card when no OS info and no IPs', () => {
it('hides identity rows when no OS info and no IPs', () => {
render(() => (
<GuestDrawer
guest={makeGuest({ osName: undefined, osVersion: undefined, ipAddresses: [] })}
onClose={vi.fn()}
/>
));
expect(screen.queryByText('Guest Info')).not.toBeInTheDocument();
expect(screen.queryByText('Primary IP')).not.toBeInTheDocument();
});
});
@@ -817,9 +796,8 @@ describe('GuestDrawer', () => {
usage: 0.5,
};
render(() => <GuestDrawer guest={makeGuest({ memory })} onClose={vi.fn()} />);
// The Memory card now always surfaces primary RAM usage (Usage / Total /
// Free) to match the node drawer's memory card; balloon and swap remain
// optional rows. See commit "show RAM usage in guest drawer Memory card".
// Row-level usage is not repeated; capacity and optional balloon/swap
// facts remain visible in the compact drawer section.
expect(screen.getByText('Memory')).toBeInTheDocument();
expect(screen.queryByText(/Balloon/)).not.toBeInTheDocument();
expect(screen.queryByText(/Swap/)).not.toBeInTheDocument();
@@ -849,7 +827,7 @@ describe('GuestDrawer', () => {
it('shows "Today" for a backup from today', () => {
const now = new Date('2026-03-02T10:00:00Z').getTime();
render(() => <GuestDrawer guest={makeGuest({ lastBackup: now })} onClose={vi.fn()} />);
expect(screen.getByText('Backup')).toBeInTheDocument();
expect(screen.getByText('Protection')).toBeInTheDocument();
expect(technicalDetails().getByText('Today')).toBeInTheDocument();
});
@@ -871,8 +849,8 @@ describe('GuestDrawer', () => {
const tenDaysAgo = new Date('2026-02-20T12:00:00Z').getTime();
render(() => <GuestDrawer guest={makeGuest({ lastBackup: tenDaysAgo })} onClose={vi.fn()} />);
expect(technicalDetails().getByText('10d ago')).toBeInTheDocument();
const ageEl = technicalDetails().getByText('10d ago');
expect(ageEl.className).toContain('yellow');
const ageCell = technicalDetails().getByText('10d ago').closest('td');
expect(ageCell).toHaveClass('text-amber-700');
});
it('keeps an old existing backup in the caution color rather than red', () => {
@@ -881,16 +859,16 @@ describe('GuestDrawer', () => {
<GuestDrawer guest={makeGuest({ lastBackup: fortyDaysAgo })} onClose={vi.fn()} />
));
expect(technicalDetails().getByText('40d ago')).toBeInTheDocument();
const ageEl = technicalDetails().getByText('40d ago');
expect(ageEl.className).toContain('yellow');
expect(ageEl.className).not.toContain('red');
const ageCell = technicalDetails().getByText('40d ago').closest('td');
expect(ageCell).toHaveClass('text-amber-700');
expect(ageCell).not.toHaveClass('text-rose-700');
});
it('applies green color for recent backups', () => {
const now = new Date('2026-03-02T10:00:00Z').getTime();
render(() => <GuestDrawer guest={makeGuest({ lastBackup: now })} onClose={vi.fn()} />);
const ageEl = technicalDetails().getByText('Today');
expect(ageEl.className).toContain('green');
const ageCell = technicalDetails().getByText('Today').closest('td');
expect(ageCell).toHaveClass('text-emerald-700');
});
it('hides Backup card when lastBackup is 0 (falsy)', () => {
@@ -907,24 +885,21 @@ describe('GuestDrawer', () => {
<GuestDrawer guest={makeGuest({ tags: ['production', 'web'] })} onClose={vi.fn()} />
));
expect(screen.getByText('Tags')).toBeInTheDocument();
expect(screen.getByText('production')).toBeInTheDocument();
expect(screen.getByText('web')).toBeInTheDocument();
expect(technicalDetails().getByText('production, web')).toBeInTheDocument();
});
it('renders tags from a comma-separated string', () => {
render(() => (
<GuestDrawer guest={makeGuest({ tags: 'db,critical' as any })} onClose={vi.fn()} />
));
expect(screen.getByText('db')).toBeInTheDocument();
expect(screen.getByText('critical')).toBeInTheDocument();
expect(technicalDetails().getByText('db, critical')).toBeInTheDocument();
});
it('trims whitespace from tags', () => {
render(() => (
<GuestDrawer guest={makeGuest({ tags: ' spaced , padded ' as any })} onClose={vi.fn()} />
));
expect(screen.getByText('spaced')).toBeInTheDocument();
expect(screen.getByText('padded')).toBeInTheDocument();
expect(technicalDetails().getByText('spaced, padded')).toBeInTheDocument();
});
it('hides Tags card when tags is null', () => {
@@ -941,13 +916,14 @@ describe('GuestDrawer', () => {
// ── Filesystems card ──
describe('Filesystems card', () => {
it('renders DiskList when disks are present', () => {
it('renders compact filesystem usage rows when disks are present', () => {
const disks: Disk[] = [
{ total: 10737418240, used: 5368709120, free: 5368709120, usage: 0.5, mountpoint: '/' },
];
render(() => <GuestDrawer guest={makeGuest({ disks })} onClose={vi.fn()} />);
expect(screen.getByText('Filesystems')).toBeInTheDocument();
expect(screen.getByTestId('disk-list')).toBeInTheDocument();
expect(technicalDetails().getByText('/')).toBeInTheDocument();
expect(technicalDetails().getByText(/50% · 5\.00 GB\/10\.0 GB/)).toBeInTheDocument();
});
it('hides Filesystems card when disks is empty', () => {
@@ -977,16 +953,16 @@ describe('GuestDrawer', () => {
render(() => <GuestDrawer guest={makeGuest({ networkInterfaces })} onClose={vi.fn()} />);
expect(screen.getByText('Network')).toBeInTheDocument();
expect(screen.getByText('eth0')).toBeInTheDocument();
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeInTheDocument();
expect(screen.getByText('192.168.1.5')).toBeInTheDocument();
expect(screen.getByText(/^RX /)).toBeInTheDocument();
expect(screen.getByText(/^TX /)).toBeInTheDocument();
const interfaceDetails = technicalDetails().getByText(
/192\.168\.1\.5.*MAC aa:bb:cc:dd:ee:ff/,
);
expect(interfaceDetails).toHaveTextContent('RX 1.00 KB / TX 2.00 KB');
});
it('displays "interface" as fallback when name is missing', () => {
const networkInterfaces: GuestNetworkInterface[] = [{ rxBytes: 100, txBytes: 200 }];
render(() => <GuestDrawer guest={makeGuest({ networkInterfaces })} onClose={vi.fn()} />);
expect(screen.getByText('interface')).toBeInTheDocument();
expect(screen.getByText('Interface 1')).toBeInTheDocument();
});
it('limits displayed interfaces to 4', () => {
@@ -1,35 +1,25 @@
import { For, Show } from 'solid-js';
import { TerminalSquare } from 'lucide-solid';
import BoxIcon from 'lucide-solid/icons/box';
import { Show } from 'solid-js';
import { formatDiscoveryAge } from '@/api/discovery';
import { buildInfrastructureOnboardingPath } from '@/components/Settings/infrastructureWorkspaceModel';
import { DiscoveryProvenanceMarker } from '@/components/shared/DiscoveryProvenanceMarker';
import {
DetailSectionTable,
compactDetailRows,
compactDetailSections,
makeDetailRow,
} from '@/components/shared/DetailSectionTable';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { TechnicalDetailsDisclosure } from '@/components/shared/TechnicalDetailsDisclosure';
import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDisclosure';
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
import type { Alert } from '@/types/api';
import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard';
import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
import { formatBytes, formatUptime } from '@/utils/format';
import { formatBytes } from '@/utils/format';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import { getWorkloadsGuestProtectionPresentation } from '@/utils/workloadGuestPresentation';
import { AvailabilityProbeSuggestionCard } from './AvailabilityProbeSuggestionCard';
import { DiskList } from './DiskList';
import { buildWorkloadsDiskPresentation } from './diskListModel';
import { getGuestDrawerMemoryRows, isGuestDrawerVM } from './guestDrawerModel';
import type { NestedWorkloadContext } from './nestedWorkloadContext';
import {
IN_GUEST_AGENT_INSTALL_ACTION_LABEL,
IN_GUEST_AGENT_INSTALL_TITLE,
WORKLOAD_ACTION_AGENT_LABEL,
} from './workloadAgentReadiness';
import { WORKLOAD_ACTION_AGENT_LABEL } from './workloadAgentReadiness';
import type { GuestDrawerProps } from './guestDrawerModel';
@@ -69,7 +59,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
});
const coverageLabel = () => {
if (props.hasWorkloadActionAgent) return WORKLOAD_ACTION_AGENT_LABEL;
if (props.hasAgentInfo) return props.agentLabel;
if (props.hasAgentInfo) return `${props.agentHeading} connected`;
if (props.showInGuestAgentInstallCue) return 'Agent recommended';
return null;
};
@@ -79,7 +69,13 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
label: 'Operator context',
rows: compactDetailRows([
makeDetailRow('System', props.hasOsInfo ? props.guestOsSummary : null),
makeDetailRow('Pulse coverage', coverageLabel()),
makeDetailRow('Pulse coverage', coverageLabel(), {
title: props.hasWorkloadActionAgent
? props.workloadActionAgentTitle
: props.hasAgentInfo
? props.agentTitle
: undefined,
}),
makeDetailRow('Primary IP', props.ipAddresses[0]),
makeDetailRow('Protection', protectionPresentation().label, {
tone: protectionPresentation().tone,
@@ -99,6 +95,151 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
},
]);
const technicalSections = () => {
const discovery = props.discoveryIdentifiedSummary;
const nested = props.nestedWorkloadContext;
const nestedItems = nested?.items.slice(0, 4) ?? [];
const nestedHiddenCount = nested ? Math.max(0, nested.count - nestedItems.length) : 0;
const showVmware =
(props.guest.platformScopes?.includes('vmware-vsphere') ?? false) &&
Boolean(
props.guest.vmware?.connectionName ||
props.guest.vmware?.vcenterHost ||
props.guest.vmware?.datacenterName ||
props.guest.vmware?.clusterName,
);
const diskRows = (props.guest.disks ?? []).map((disk, index) =>
buildWorkloadsDiskPresentation(disk, index, props.diskThresholds),
);
return compactDetailSections([
discovery
? {
label: 'Identified Service',
rows: compactDetailRows([
makeDetailRow('Version', discovery.serviceVersion),
makeDetailRow('Endpoint', discovery.suggestedUrl, { wrap: true }),
discovery.portCount > 0 ? makeDetailRow('Ports', `${discovery.portCount}`) : null,
makeDetailRow('Source', discovery.sourceLabel),
makeDetailRow(
'Observed',
discovery.observedAt ? formatDiscoveryAge(discovery.observedAt) : null,
),
]),
}
: null,
{
label: 'System',
rows: compactDetailRows([
props.guest.cpus ? makeDetailRow('CPUs', `${props.guest.cpus}`) : null,
props.hasAgentInfo
? makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle })
: null,
]),
},
nested
? {
label: nested.title,
rows: compactDetailRows([
makeDetailRow('Containers', `${nested.count}`),
...nestedItems.map((item) => makeDetailRow(item.name, item.status)),
nestedHiddenCount > 0
? makeDetailRow('Remaining', `${nestedHiddenCount} more`, { tone: 'muted' })
: null,
]),
}
: null,
showVmware
? {
label: 'vSphere',
rows: compactDetailRows([
makeDetailRow(
'vCenter',
props.guest.vmware?.connectionName || props.guest.vmware?.vcenterHost,
{ title: props.guest.vmware?.vcenterHost },
),
makeDetailRow('Datacenter', props.guest.vmware?.datacenterName),
makeDetailRow('Cluster', props.guest.vmware?.clusterName),
]),
}
: null,
props.ipAddresses.length > 1
? {
label: 'Network identity',
rows: compactDetailRows([
makeDetailRow('Other IPs', props.ipAddresses.slice(1).join(', '), { wrap: true }),
]),
}
: null,
{
label: 'Memory',
rows: compactDetailRows(
getGuestDrawerMemoryRows(props.guest)
.filter((row) => row.label !== 'Usage')
.map((row) => makeDetailRow(row.label, row.value)),
),
},
props.normalizedTags.length > 0
? {
label: 'Tags',
rows: compactDetailRows([
makeDetailRow('Values', props.normalizedTags.join(', '), { wrap: true }),
]),
}
: null,
props.hasFilesystemDetails && diskRows.length > 0
? {
label: 'Filesystems',
rows: compactDetailRows([
...diskRows.map((disk) =>
makeDetailRow(
disk.label,
[disk.usagePercentLabel, disk.usageText, disk.typeLabel]
.filter(Boolean)
.join(' · '),
{ title: disk.labelTitle, wrap: true },
),
),
makeDetailRow(
'Status',
isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : null,
{ wrap: true },
),
]),
}
: null,
props.hasNetworkInterfaces
? {
label: 'Network',
rows: compactDetailRows(
props.networkInterfaces.slice(0, 4).map((iface, index) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
const detail = [
addresses.join(', '),
iface.mac ? `MAC ${iface.mac}` : '',
hasTraffic
? `RX ${formatBytes(iface.rxBytes ?? 0)} / TX ${formatBytes(
iface.txBytes ?? 0,
)}`
: '',
]
.filter(Boolean)
.join(' · ');
return makeDetailRow(
iface.name || `Interface ${index + 1}`,
detail || 'No details',
{
wrap: true,
},
);
}),
),
}
: null,
]);
};
return (
<div class="space-y-3">
<DrawerAttentionSection
@@ -109,427 +250,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
acknowledged: alert.acknowledged,
}))}
/>
<DetailSectionTable sections={overviewSections()} />
<TechnicalDetailsDisclosure
<TechnicalDetailsSection
dataTestId="guest-technical-details"
subtitle="Hardware, identity, and interfaces"
>
<div class="flex flex-wrap gap-2 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.5rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden">
<Show when={props.discoveryIdentifiedSummary}>
{(summary) => (
<InfoCardFrame>
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex min-w-0 items-center gap-1.5">
<h3 class="truncate text-[11px] font-medium uppercase tracking-wide text-base-content">
Identified Service
</h3>
<DiscoveryProvenanceMarker />
</div>
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Service</span>
<span
class="font-medium text-base-content truncate ml-2"
title={summary().serviceName}
>
{summary().serviceName}
</span>
</div>
<Show when={summary().category}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Category</span>
<span class="font-medium text-base-content truncate ml-2">
{summary().category}
</span>
</div>
</Show>
<Show when={summary().serviceVersion}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Version</span>
<span
class="font-medium text-base-content truncate ml-2"
title={summary().serviceVersion}
>
{summary().serviceVersion}
</span>
</div>
</Show>
<Show when={summary().suggestedUrl}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Endpoint</span>
<span
class="font-medium text-base-content truncate ml-2"
title={summary().suggestedUrl}
>
{summary().suggestedUrl}
</span>
</div>
</Show>
<Show when={summary().portCount > 0}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Ports</span>
<span class="font-medium text-base-content">{summary().portCount}</span>
</div>
</Show>
<div class="flex flex-wrap gap-1 pt-1 text-[10px] text-muted">
<span>{summary().sourceLabel}</span>
<Show when={summary().observedAt}>
<span>· {formatDiscoveryAge(summary().observedAt!)}</span>
</Show>
</div>
</div>
</InfoCardFrame>
)}
</Show>
<InfoCardFrame>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
System
</h3>
<div class="space-y-1.5 text-[11px]">
<Show when={props.guest.cpus}>
<div class="flex items-center justify-between">
<span class="text-muted">CPUs</span>
<span class="font-medium text-base-content">{props.guest.cpus}</span>
</div>
</Show>
<Show
when={
props.guest.telemetryAvailability
? props.guest.telemetryAvailability.uptime
: props.guest.uptime > 0
}
>
<div class="flex items-center justify-between">
<span class="text-muted">Uptime</span>
<span class="font-medium text-base-content">
{formatUptime(props.guest.uptime)}
</span>
</div>
</Show>
<Show when={props.guest.node}>
<div class="flex items-center justify-between">
<span class="text-muted">Node</span>
<span class="font-medium text-base-content">{props.guest.node}</span>
</div>
</Show>
<Show when={props.hasAgentInfo}>
<div class="flex items-center justify-between">
<span class="text-muted">{props.agentHeading}</span>
<span
class="font-medium text-base-content truncate ml-2"
title={props.agentTitle}
>
{props.agentLabel}
</span>
</div>
</Show>
<Show when={props.hasWorkloadActionAgent}>
<div class="flex items-center justify-between">
<span class="text-muted">Actions</span>
<span
class="font-medium text-base-content truncate ml-2"
title={props.workloadActionAgentTitle}
>
{WORKLOAD_ACTION_AGENT_LABEL}
</span>
</div>
</Show>
<Show when={props.showInGuestAgentInstallCue}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Actions</span>
<a
href={buildInfrastructureOnboardingPath('agent')}
class="inline-flex items-center gap-1 text-right text-[11px] font-semibold text-amber-700 underline-offset-2 hover:underline dark:text-amber-300"
title={IN_GUEST_AGENT_INSTALL_TITLE}
>
<TerminalSquare class="h-3.5 w-3.5" aria-hidden="true" />
<span>{IN_GUEST_AGENT_INSTALL_ACTION_LABEL}</span>
</a>
</div>
</Show>
</div>
</InfoCardFrame>
<Show when={props.nestedWorkloadContext}>
{(context) => {
const visibleItems = () => context().items.slice(0, 4);
const hiddenCount = () => Math.max(0, context().count - visibleItems().length);
return (
<InfoCardFrame data-testid="nested-workload-context-card">
<div class="mb-2 flex items-center justify-between gap-2">
<h3 class="truncate text-[11px] font-medium uppercase tracking-wide text-base-content">
{context().title}
</h3>
<a
href={context().href}
class="inline-flex shrink-0 items-center gap-1 text-[10px] font-semibold text-blue-600 underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-blue-300"
aria-label={`Open ${context().label} page for ${props.guest.name}`}
>
<BoxIcon class="h-3 w-3" aria-hidden="true" />
<span>Open Docker</span>
</a>
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Containers</span>
<span class="font-medium text-base-content">{context().count}</span>
</div>
<For each={visibleItems()}>
{(item) => (
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<span class="truncate font-medium text-base-content" title={item.name}>
{item.name}
</span>
<span class="rounded bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted">
{item.status}
</span>
</div>
)}
</For>
<Show when={hiddenCount() > 0}>
<div class="text-[10px] font-medium text-muted">+{hiddenCount()} more</div>
</Show>
</div>
</InfoCardFrame>
);
}}
</Show>
{/* vSphere placement card: vCenter / Datacenter / Cluster live on
WorkloadGuest.vmware and aren't surfaced by System (Node already
shows the runtime host). Render only when the workload is a
vSphere VM and at least one of these fields is populated. */}
<Show
when={
(props.guest.platformScopes?.includes('vmware-vsphere') ?? false) &&
(props.guest.vmware?.connectionName ||
props.guest.vmware?.vcenterHost ||
props.guest.vmware?.datacenterName ||
props.guest.vmware?.clusterName)
}
>
<InfoCardFrame>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
vSphere
</h3>
<div class="space-y-1.5 text-[11px]">
<Show when={props.guest.vmware?.connectionName || props.guest.vmware?.vcenterHost}>
<div class="flex items-center justify-between gap-2 min-w-0">
<span class="text-muted shrink-0">vCenter</span>
<span
class="font-medium text-base-content truncate"
title={
props.guest.vmware?.vcenterHost || props.guest.vmware?.connectionName || ''
}
>
{props.guest.vmware?.connectionName || props.guest.vmware?.vcenterHost}
</span>
</div>
</Show>
<Show when={props.guest.vmware?.datacenterName}>
<div class="flex items-center justify-between gap-2 min-w-0">
<span class="text-muted shrink-0">Datacenter</span>
<span
class="font-medium text-base-content truncate"
title={props.guest.vmware?.datacenterName || ''}
>
{props.guest.vmware?.datacenterName}
</span>
</div>
</Show>
<Show when={props.guest.vmware?.clusterName}>
<div class="flex items-center justify-between gap-2 min-w-0">
<span class="text-muted shrink-0">Cluster</span>
<span
class="font-medium text-base-content truncate"
title={props.guest.vmware?.clusterName || ''}
>
{props.guest.vmware?.clusterName}
</span>
</div>
</Show>
</div>
</InfoCardFrame>
</Show>
<Show when={props.hasOsInfo || props.ipAddresses.length > 0}>
<InfoCardFrame>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Guest Info
</h3>
<div class="space-y-2">
<Show when={props.hasOsInfo}>
<div class="text-[11px] text-muted truncate" title={props.guestOsSummary}>
<Show when={(props.guest.osName?.length ?? 0) > 0}>
<span class="font-medium">{props.guest.osName}</span>
</Show>
<Show
when={
(props.guest.osName?.length ?? 0) > 0 &&
(props.guest.osVersion?.length ?? 0) > 0
}
>
<span class="text-muted mx-1"></span>
</Show>
<Show when={(props.guest.osVersion?.length ?? 0) > 0}>
<span>{props.guest.osVersion}</span>
</Show>
</div>
</Show>
<Show when={props.ipAddresses.length > 0}>
<div class="flex flex-wrap gap-1">
<For each={props.ipAddresses}>
{(ip) => (
<span
class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900 dark:text-blue-200 max-w-full truncate"
title={ip}
>
{ip}
</span>
)}
</For>
</div>
</Show>
</div>
</InfoCardFrame>
</Show>
<Show when={getGuestDrawerMemoryRows(props.guest).length > 0}>
<InfoCardFrame>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Memory
</h3>
<div class="space-y-1.5 text-[11px]">
<For each={getGuestDrawerMemoryRows(props.guest)}>
{(row) => (
<div class="flex items-center justify-between gap-2 min-w-0">
<span class="shrink-0 text-muted">{row.label}</span>
<span
class="truncate text-right font-medium text-base-content"
title={row.value}
>
{row.value}
</span>
</div>
)}
</For>
</div>
</InfoCardFrame>
</Show>
<Show when={props.guest.lastBackup}>
<InfoCardFrame>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Backup
</h3>
<div class="space-y-1 text-[11px]">
<Show when={props.backupPresentation}>
{(presentation) => (
<>
<div class="flex items-center justify-between">
<span class="text-muted">Last Backup</span>
<span class={`font-medium ${presentation().ageClass}`}>
{presentation().ageLabel}
</span>
</div>
<div class="text-[10px] text-muted">{presentation().dateLabel}</div>
</>
)}
</Show>
</div>
</InfoCardFrame>
</Show>
<Show when={props.normalizedTags.length > 0}>
<InfoCardFrame>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Tags
</h3>
<div class="flex flex-wrap gap-1">
<For each={props.normalizedTags}>
{(tag) => (
<span class="inline-block rounded bg-surface-alt px-1.5 py-0.5 text-[10px]">
{tag}
</span>
)}
</For>
</div>
</InfoCardFrame>
</Show>
<Show
when={props.hasFilesystemDetails && props.guest.disks && props.guest.disks.length > 0}
>
<InfoCardFrame>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Filesystems
</h3>
<div class="text-[11px] text-muted">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={
isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined
}
thresholds={props.diskThresholds}
/>
</div>
</InfoCardFrame>
</Show>
<Show when={props.hasNetworkInterfaces}>
<InfoCardFrame>
<h3 class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
Network
</h3>
<div class="space-y-2">
<For each={props.networkInterfaces.slice(0, 4)}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="rounded border border-dashed border-border p-2 overflow-hidden">
<div class="flex items-center gap-2 text-[11px] font-medium text-base-content min-w-0">
<span class="truncate min-w-0">{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span
class="text-[9px] text-muted font-normal truncate shrink-0 max-w-[100px]"
title={iface.mac}
>
{iface.mac}
</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1">
<For each={addresses}>
{(ip) => (
<span
class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900 dark:text-blue-200 max-w-full truncate"
title={ip}
>
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex gap-3 mt-1 text-[10px] text-muted">
<span>RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
</div>
</InfoCardFrame>
</Show>
</div>
</TechnicalDetailsDisclosure>
sections={[...overviewSections(), ...technicalSections()]}
/>
<div class="space-y-3">
<Show when={props.guest.availability || props.guest.availabilityChecks?.length}>
@@ -27,10 +27,7 @@ vi.mock('@/stores/license', () => ({
import { NodeDrawer } from './NodeDrawer';
const openTechnicalDetails = () => {
const details = screen.getByTestId('node-technical-details') as HTMLDetailsElement;
details.open = true;
fireEvent(details, new Event('toggle'));
return within(details);
return within(screen.getByTestId('node-technical-details'));
};
const makeHistoryPoints = (base: number) => [
@@ -129,27 +126,28 @@ describe('NodeDrawer', () => {
expect(screen.getByText('Overview')).toBeInTheDocument();
expect(screen.getByText('History')).toBeInTheDocument();
expect(screen.getByText('Manage')).toBeInTheDocument();
expect(technical.getByText('System')).toBeInTheDocument();
expect(technical.getByText('Platform')).toBeInTheDocument();
expect(technical.queryByText('System')).toBeNull();
expect(technical.getAllByText('Platform').length).toBeGreaterThan(0);
expect(technical.getByText('Hardware')).toBeInTheDocument();
expect(technical.getByText('Telemetry')).toBeInTheDocument();
expect(technical.getByText('Ryzen')).toBeInTheDocument();
expect(technical.getByText('6.8.0')).toBeInTheDocument();
expect(technical.getByText('8')).toBeInTheDocument();
expect(screen.getAllByText('PVE 9.1.9').length).toBeGreaterThan(0);
expect(technical.getAllByText('65°C').length).toBeGreaterThan(0);
expect(technical.getByText('CPU low')).toBeInTheDocument();
expect(technical.getByText('CPU record')).toBeInTheDocument();
expect(technical.getByText('Temp monitor')).toBeInTheDocument();
});
it('colors overview thermal rows from configured thresholds', () => {
it('colors an operator-significant thermal row from configured thresholds', () => {
render(() => (
<NodeDrawer
node={makeNode({
temperature: {
cpuPackage: 76,
cpuMax: 76,
cpuPackage: 86,
cpuMax: 86,
cpuMin: 50,
cpuMaxRecord: 76,
cpuMaxRecord: 86,
available: true,
hasCPU: true,
lastUpdate: new Date().toISOString(),
@@ -159,10 +157,12 @@ describe('NodeDrawer', () => {
/>
));
expect(openTechnicalDetails().getAllByText('76°C')[0]).toHaveClass('text-green-600');
expect(openTechnicalDetails().getAllByText('86°C')[0].closest('td')).toHaveClass(
'text-rose-700',
);
});
it('puts the active alert problem before context and collapsed inventory', () => {
it('puts the active alert problem before context and visible inventory', () => {
render(() => (
<NodeDrawer
node={makeNode()}
@@ -187,8 +187,8 @@ describe('NodeDrawer', () => {
expect(screen.getByText('Needs attention')).toBeInTheDocument();
expect(screen.getByText('Root disk usage is above 85%')).toBeInTheDocument();
expect(screen.getByText('PVE 9.1.9')).toBeInTheDocument();
expect(screen.getByTestId('node-technical-details')).not.toHaveAttribute('open');
expect(screen.getAllByText('PVE 9.1.9').length).toBeGreaterThan(0);
expect(screen.getByTestId('node-technical-details').querySelector('table')).toBeTruthy();
});
it('renders node-only thermal history without requiring a table temperature column', async () => {
@@ -1,27 +1,18 @@
import { For, Show } from 'solid-js';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { TechnicalDetailsDisclosure } from '@/components/shared/TechnicalDetailsDisclosure';
import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDisclosure';
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
import {
DetailSectionTable,
compactDetailRows,
compactDetailSections,
makeDetailRow,
type DetailRow,
type DetailValueTone,
} from '@/components/shared/DetailSectionTable';
import type { Alert, Disk, Node, Temperature } from '@/types/api';
import {
formatBytes,
formatRelativeTime,
formatSpeed,
formatUptime,
normalizeDiskArray,
} from '@/utils/format';
import { formatBytes, normalizeDiskArray } from '@/utils/format';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import { getNodeDisplayName } from '@/utils/nodes';
import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature';
import { DrawerDiskListCard, buildDrawerDiskListItems } from './DrawerDiskListCard';
import { buildDrawerDiskListItems } from './DrawerDiskListCard';
interface NodeDrawerOverviewProps {
node: Node;
@@ -53,23 +44,6 @@ const getNodeVersionLabel = (node: Node): string => {
);
};
const formatStatus = (value: string | null | undefined): string => {
const status = cleanText(value);
if (!status) return '-';
return status.charAt(0).toUpperCase() + status.slice(1);
};
const formatPercent = (value: number | null | undefined): string => {
if (typeof value !== 'number' || !Number.isFinite(value)) return '-';
const normalized = value <= 1 ? value * 100 : value;
return `${Math.round(Math.max(0, normalized))}%`;
};
const getUsedPercent = (used?: number | null, total?: number | null): string => {
if (typeof used !== 'number' || typeof total !== 'number' || total <= 0) return '-';
return formatPercent((used / total) * 100);
};
const formatLoadAverage = (loadAverage: number[] | undefined): string => {
const values = (loadAverage || []).filter((value) => Number.isFinite(value));
if (values.length === 0) return '-';
@@ -79,17 +53,6 @@ const formatLoadAverage = (loadAverage: number[] | undefined): string => {
const hasPositiveNumber = (value: number | null | undefined): value is number =>
typeof value === 'number' && Number.isFinite(value) && value > 0;
const formatLastSeen = (value: string | null | undefined): NodeOverviewRow | null => {
const lastSeen = cleanText(value);
if (!lastSeen) return null;
const parsed = new Date(lastSeen);
return {
label: 'Last seen',
value: formatRelativeTime(lastSeen, { compact: true }) || '-',
title: Number.isNaN(parsed.getTime()) ? lastSeen : parsed.toLocaleString(),
};
};
const formatTemperatureMonitoring = (value: boolean | null | undefined): string => {
if (value === true) return 'Enabled';
if (value === false) return 'Disabled';
@@ -117,10 +80,6 @@ const getThermalRows = (
if (!temperature?.available) return [];
const rows: NodeOverviewRow[] = [];
const primary = getCpuTemperature(temperature);
pushTemperature(rows, 'CPU current', primary, thresholds);
pushTemperature(rows, 'CPU package', temperature.cpuPackage, thresholds);
pushTemperature(rows, 'CPU low', temperature.cpuMin, thresholds);
pushTemperature(rows, 'CPU record', temperature.cpuMaxRecord, thresholds);
@@ -154,39 +113,28 @@ const getThermalRows = (
}
}
const lastUpdate = formatLastSeen(temperature.lastUpdate);
if (lastUpdate) rows.push({ ...lastUpdate, label: 'Updated' });
return rows;
};
const DetailCard = (props: { title: string; rows: NodeOverviewRow[] }) => (
<Show when={props.rows.length > 0}>
<InfoCardFrame>
<h3 class="mb-2 text-[11px] font-medium uppercase tracking-wide text-base-content">
{props.title}
</h3>
<div class="space-y-1.5 text-[11px]">
<For each={props.rows}>
{(row) => (
<div class="flex items-center justify-between gap-2 min-w-0">
<span class="shrink-0 text-muted">{row.label}</span>
<span
class={`truncate text-right font-medium ${row.valueClass ?? 'text-base-content'}`}
title={row.title ?? row.value}
>
{row.value}
</span>
</div>
)}
</For>
</div>
</InfoCardFrame>
</Show>
);
const getDetailTone = (valueClass: string | undefined): DetailValueTone => {
if (valueClass?.includes('red') || valueClass?.includes('rose')) return 'danger';
if (valueClass?.includes('yellow') || valueClass?.includes('amber')) return 'warning';
if (valueClass?.includes('green') || valueClass?.includes('emerald')) return 'success';
if (valueClass?.includes('blue') || valueClass?.includes('cyan')) return 'accent';
return 'default';
};
const toDetailRows = (rows: NodeOverviewRow[]): DetailRow[] =>
compactDetailRows(
rows.map((row) =>
makeDetailRow(row.label, row.value, {
title: row.title,
tone: getDetailTone(row.valueClass),
}),
),
);
export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
const displayName = () => getNodeDisplayName(props.node);
const versionLabel = () => getNodeVersionLabel(props.node);
const linkedAgentId = () => cleanText(props.node.linkedAgentId);
const clusterLabel = () =>
@@ -198,26 +146,7 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
};
const loadAverageLabel = () => formatLoadAverage(props.node.loadAverage);
const systemRows = (): NodeOverviewRow[] => [
{ label: 'Name', value: displayName(), title: props.node.name },
{ label: 'Host', value: cleanText(props.node.host) || '-', title: props.node.host },
{ label: 'Status', value: formatStatus(props.node.status) },
...(props.node.uptime > 0
? [{ label: 'Uptime', value: formatUptime(props.node.uptime) } satisfies NodeOverviewRow]
: []),
...(formatLastSeen(props.node.lastSeen) ? [formatLastSeen(props.node.lastSeen)!] : []),
];
const platformRows = (): NodeOverviewRow[] => [
...(versionLabel()
? [
{
label: 'PVE',
value: `PVE ${versionLabel()}`,
title: props.node.pveVersion,
} satisfies NodeOverviewRow,
]
: []),
...(cleanText(props.node.kernelVersion)
? [
{
@@ -231,7 +160,9 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
label: 'Cluster',
value: clusterLabel(),
},
{ label: 'Instance', value: props.node.instance || '-' },
...(props.node.instance && props.node.instance !== clusterLabel()
? [{ label: 'Instance', value: props.node.instance } satisfies NodeOverviewRow]
: []),
];
const hardwareRows = (): NodeOverviewRow[] => [
@@ -257,15 +188,6 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
];
const memoryRows = (): NodeOverviewRow[] => [
{
label: 'Usage',
value:
props.node.memory?.usageUnavailable === true
? 'Unavailable'
: `${getUsedPercent(props.node.memory?.used, props.node.memory?.total)} · ${formatBytes(
props.node.memory?.used || 0,
)}`,
},
{ label: 'Total', value: formatBytes(props.node.memory?.total || 0) },
...(props.node.memory?.cache
? [
@@ -291,39 +213,15 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
];
const storageRows = (): NodeOverviewRow[] => [
{
label: 'Root usage',
value: `${formatPercent(props.node.disk?.usage)} · ${formatBytes(props.node.disk?.used || 0)}`,
},
{ label: 'Root total', value: formatBytes(props.node.disk?.total || 0) },
{ label: 'Root free', value: formatBytes(props.node.disk?.free || 0) },
{
label: 'Disk I/O',
value: `${formatSpeed(props.node.diskRead ?? 0)} / ${formatSpeed(props.node.diskWrite ?? 0)}`,
},
];
const telemetryRows = (): NodeOverviewRow[] => [
{
label: 'Connection',
value: formatStatus(props.node.connectionHealth || props.node.status),
},
{
label: 'Agent',
value: linkedAgentId() ? stripAgentPrefix(linkedAgentId()) : 'PVE API only',
title: linkedAgentId() || undefined,
},
{
label: 'Temp monitor',
value: formatTemperatureMonitoring(props.node.temperatureMonitoringEnabled),
},
...(typeof props.node.pendingUpdates === 'number'
? [{ label: 'Updates', value: `${props.node.pendingUpdates}` } satisfies NodeOverviewRow]
: []),
{
label: 'Network I/O',
value: `${formatSpeed(props.node.networkIn ?? 0)} / ${formatSpeed(props.node.networkOut ?? 0)}`,
},
];
const perDiskItems = () => {
@@ -332,6 +230,38 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
return buildDrawerDiskListItems(disks);
};
const technicalSections = () =>
compactDetailSections([
{ label: 'Platform', rows: toDetailRows(platformRows()) },
{ label: 'Hardware', rows: toDetailRows(hardwareRows()) },
{ label: 'Memory', rows: toDetailRows(memoryRows()) },
{
label: 'Storage',
rows:
perDiskItems().length > 0
? compactDetailRows(
perDiskItems().map((disk) =>
makeDetailRow(
disk.label,
`${Math.round(disk.percent)}% · ${formatBytes(disk.used)} / ${formatBytes(
disk.total,
)}`,
{
title: disk.device ? `${disk.label} · ${disk.device}` : disk.label,
tone: getDetailTone(disk.textClass),
},
),
),
)
: toDetailRows(storageRows()),
},
{ label: 'Telemetry', rows: toDetailRows(telemetryRows()) },
{
label: 'Thermals',
rows: toDetailRows(getThermalRows(props.node.temperature, props.temperatureThresholds)),
},
]);
const overviewSections = () => {
const primaryTemperature = getCpuTemperature(props.node.temperature);
const temperatureClass =
@@ -371,28 +301,10 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
acknowledged: alert.acknowledged,
}))}
/>
<DetailSectionTable sections={overviewSections()} />
<TechnicalDetailsDisclosure
<TechnicalDetailsSection
dataTestId="node-technical-details"
subtitle="Hardware, telemetry, and storage"
contentClass="mt-2 flex flex-wrap gap-2 border-t border-border pt-2 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.5rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden"
>
<DetailCard title="System" rows={systemRows()} />
<DetailCard title="Platform" rows={platformRows()} />
<DetailCard title="Hardware" rows={hardwareRows()} />
<DetailCard title="Memory" rows={memoryRows()} />
<Show
when={perDiskItems().length > 0}
fallback={<DetailCard title="Storage" rows={storageRows()} />}
>
<DrawerDiskListCard disks={perDiskItems()} testId="node-drawer-disks" />
</Show>
<DetailCard title="Telemetry" rows={telemetryRows()} />
<DetailCard
title="Thermals"
rows={getThermalRows(props.node.temperature, props.temperatureThresholds)}
/>
</TechnicalDetailsDisclosure>
sections={[...overviewSections(), ...technicalSections()]}
/>
</div>
);
}
@@ -55,6 +55,7 @@ export const DetailSectionTable: Component<{
title={row.title ?? row.value}
>
<span
title={row.title ?? row.value}
class={
row.wrap
? 'block whitespace-normal break-words text-left leading-snug'
@@ -1143,8 +1143,6 @@ describe('shared primitive guardrails', () => {
'src/components/shared/cards/SystemInfoCard.tsx',
'src/components/shared/cards/TemperaturesCard.tsx',
'src/components/Workloads/DrawerDiskListCard.tsx',
'src/components/Workloads/GuestDrawerOverview.tsx',
'src/components/Workloads/NodeDrawerOverview.tsx',
'src/features/docker/DockerHostDrawerOverview.tsx',
'src/features/storageBackups/detailPresentation.ts',
];
@@ -8495,7 +8493,7 @@ describe('shared primitive guardrails', () => {
for (const consumerPath of overviewConsumers) {
const source = readFrontendSource(consumerPath);
expect(source).toContain('DrawerAttentionSection');
expect(source).toContain('TechnicalDetailsDisclosure');
expect(source).toContain('TechnicalDetailsSection');
expect(source).not.toContain('ResourceOperatorStateSection');
}
@@ -8533,14 +8531,30 @@ describe('shared primitive guardrails', () => {
);
expect(disclosureSource).toContain('onToggle');
expect(disclosureSource).toContain('<Show when={expanded()}>');
expect(disclosureSource).toContain('sections?: DetailSection[]');
expect(disclosureSource).toContain('<DetailSectionTable');
expect(disclosureSource).toContain('export const TechnicalDetailsSection');
for (const overviewPath of [
'src/components/Workloads/GuestDrawerOverview.tsx',
'src/components/Workloads/NodeDrawerOverview.tsx',
'src/features/docker/DockerHostDrawerOverview.tsx',
]) {
const source = readFrontendSource(overviewPath);
expect(source).toContain('<TechnicalDetailsSection');
expect(source).toContain('...technicalSections()');
expect(source).not.toContain('const DetailCard');
}
const resourceOverviewSource = readFrontendSource(
'src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx',
);
expect(resourceOverviewSource).toContain('content="all"');
const registry = JSON.parse(readFrontendSource('scripts/shared-template-registry.json')) as {
rules?: Array<{ id: string; requiredConsumers?: Array<{ path: string }> }>;
};
for (const ruleId of [
'operator-first-drawer-attention',
'operator-first-technical-disclosure',
]) {
for (const ruleId of ['operator-first-drawer-attention', 'operator-first-technical-section']) {
const rule = registry.rules?.find((candidate) => candidate.id === ruleId);
expect(rule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual(overviewConsumers);
}
@@ -1,7 +1,9 @@
import { Show, createSignal, type Component, type JSX } from 'solid-js';
import { DetailSectionTable, type DetailSection } from './DetailSectionTable';
interface TechnicalDetailsDisclosureProps {
children: JSX.Element;
children?: JSX.Element;
sections?: DetailSection[];
title?: string;
subtitle: string;
dataTestId: string;
@@ -9,6 +11,37 @@ interface TechnicalDetailsDisclosureProps {
contentClass?: string;
}
interface TechnicalDetailsSectionProps {
children?: JSX.Element;
sections?: DetailSection[];
dataTestId: string;
class?: string;
contentClass?: string;
}
const TechnicalDetailsContent: Component<
Pick<TechnicalDetailsSectionProps, 'children' | 'sections' | 'contentClass'>
> = (props) => (
<Show when={props.sections} fallback={props.children}>
{(sections) => (
<DetailSectionTable
sections={sections()}
class={props.contentClass ?? 'overflow-hidden rounded border border-border bg-surface'}
/>
)}
</Show>
);
export const TechnicalDetailsSection: Component<TechnicalDetailsSectionProps> = (props) => (
<div data-testid={props.dataTestId} class={props.class}>
<TechnicalDetailsContent
sections={props.sections}
contentClass={props.contentClass}
children={props.children}
/>
</div>
);
export const TechnicalDetailsDisclosure: Component<TechnicalDetailsDisclosureProps> = (props) => {
const [expanded, setExpanded] = createSignal(false);
@@ -23,7 +56,9 @@ export const TechnicalDetailsDisclosure: Component<TechnicalDetailsDisclosurePro
<span class="ml-2 font-normal text-muted">{props.subtitle}</span>
</summary>
<Show when={expanded()}>
<div class={props.contentClass ?? 'mt-2 border-t border-border pt-2'}>{props.children}</div>
<div class={props.contentClass ?? 'mt-2 border-t border-border pt-2'}>
<TechnicalDetailsContent sections={props.sections} children={props.children} />
</div>
</Show>
</details>
);
@@ -1,4 +1,4 @@
import { cleanup, render, screen, within } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DetailSectionTable,
@@ -12,6 +12,7 @@ import {
} from '../DetailSectionTable';
import detailSectionTableSource from '../DetailSectionTable.tsx?raw';
import detailSectionModelSource from '../detailSectionModel.ts?raw';
import { TechnicalDetailsDisclosure, TechnicalDetailsSection } from '../TechnicalDetailsDisclosure';
describe('DetailSectionTable', () => {
afterEach(() => cleanup());
@@ -77,6 +78,53 @@ describe('DetailSectionTable', () => {
expect(screen.getByText('tower').closest('td')).toHaveAttribute('title', 'tower.example.test');
});
it('lazily renders technical details with the same compact section rows', () => {
const { container } = render(() => (
<TechnicalDetailsDisclosure
dataTestId="technical-details"
subtitle="Identity and runtime"
sections={[
{
label: 'Runtime',
rows: [{ label: 'Kernel', value: '6.8.0' }],
},
]}
/>
));
expect(screen.queryByText('Runtime')).toBeNull();
const details = container.querySelector('details');
expect(details).not.toBeNull();
details!.open = true;
fireEvent(details!, new Event('toggle'));
const disclosure = screen.getByTestId('technical-details');
expect(within(disclosure).getByText('Runtime')).toBeInTheDocument();
expect(within(disclosure).getByText('Kernel')).toBeInTheDocument();
expect(within(disclosure).getByText('6.8.0')).toBeInTheDocument();
expect(disclosure.querySelector('table')).toHaveClass('table-fixed');
});
it('renders curated technical rows without an extra disclosure interaction', () => {
render(() => (
<TechnicalDetailsSection
dataTestId="technical-section"
sections={[
{
label: 'Hardware',
rows: [{ label: 'CPU', value: 'Ryzen' }],
},
]}
/>
));
const section = screen.getByTestId('technical-section');
expect(section.tagName).toBe('DIV');
expect(section.querySelector('details')).toBeNull();
expect(within(section).getByText('Hardware')).toBeInTheDocument();
expect(within(section).getByText('Ryzen')).toBeInTheDocument();
});
it('renders inline detail panels with the canonical close action', () => {
const onClose = vi.fn();
@@ -1,32 +1,26 @@
import { For, Show, createMemo } from 'solid-js';
import { Show, createMemo } from 'solid-js';
import {
DrawerDiskListCard,
buildDrawerDiskListItems,
type DrawerDiskListItem,
} from '@/components/Workloads/DrawerDiskListCard';
import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { TechnicalDetailsDisclosure } from '@/components/shared/TechnicalDetailsDisclosure';
import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDisclosure';
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
import {
DetailSectionTable,
compactDetailRows,
compactDetailSections,
makeDetailRow,
type DetailRow,
type DetailValueTone,
} from '@/components/shared/DetailSectionTable';
import { useResourceDetailDrawerDockerActionsState } from '@/components/Infrastructure/useResourceDetailDrawerDockerActionsState';
import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings';
import { useAlertsActivation } from '@/stores/alertsActivation';
import type { Resource } from '@/types/resource';
import {
formatBytes,
formatRelativeTime,
formatSpeed,
formatUptime,
normalizeDiskArray,
} from '@/utils/format';
import { formatBytes, formatRelativeTime, formatSpeed, normalizeDiskArray } from '@/utils/format';
import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature';
interface DockerHostDrawerOverviewProps {
@@ -55,74 +49,26 @@ const cleanText = (value: string | null | undefined): string => {
const stripAgentPrefix = (value: string): string =>
value.startsWith('agent:') ? value.slice('agent:'.length) : value;
const formatStatus = (value: string | null | undefined): string => {
const status = cleanText(value);
if (!status) return '-';
return status.charAt(0).toUpperCase() + status.slice(1);
};
const formatPercent = (value: number | null | undefined): string => {
if (typeof value !== 'number' || !Number.isFinite(value)) return '-';
const normalized = value <= 1 ? value * 100 : value;
return `${Math.round(Math.max(0, normalized))}%`;
};
const getUsedPercent = (used?: number | null, total?: number | null): string => {
if (typeof used !== 'number' || typeof total !== 'number' || total <= 0) return '-';
return formatPercent((used / total) * 100);
};
const getNumericField = (value: unknown, field: string): number | undefined => {
if (!value || typeof value !== 'object') return undefined;
const fieldValue = (value as Record<string, unknown>)[field];
return typeof fieldValue === 'number' ? fieldValue : undefined;
};
const getBooleanField = (value: unknown, field: string): boolean | undefined => {
if (!value || typeof value !== 'object') return undefined;
const fieldValue = (value as Record<string, unknown>)[field];
return typeof fieldValue === 'boolean' ? fieldValue : undefined;
};
const formatLastSeenRow = (value: string | number | null | undefined): DockerOverviewRow | null => {
if (value == null) return null;
const parsed = typeof value === 'number' ? new Date(value) : new Date(String(value));
if (Number.isNaN(parsed.getTime())) return null;
const iso = parsed.toISOString();
return {
label: 'Last seen',
value: formatRelativeTime(iso, { compact: true }) || '-',
title: parsed.toLocaleString(),
};
};
const titleCase = (value: string): string =>
value.length === 0 ? value : value.charAt(0).toUpperCase() + value.slice(1);
const DetailCard = (props: { title: string; rows: DockerOverviewRow[] }) => (
<Show when={props.rows.length > 0}>
<InfoCardFrame>
<h3 class="mb-2 text-[11px] font-medium uppercase tracking-wide text-base-content">
{props.title}
</h3>
<div class="space-y-1.5 text-[11px]">
<For each={props.rows}>
{(row) => (
<div class="flex items-center justify-between gap-2 min-w-0">
<span class="shrink-0 text-muted">{row.label}</span>
<span
class={`truncate text-right font-medium ${row.valueClass ?? 'text-base-content'}`}
title={row.title ?? row.value}
>
{row.value}
</span>
</div>
)}
</For>
</div>
</InfoCardFrame>
</Show>
);
const getDetailTone = (valueClass: string | undefined): DetailValueTone => {
if (valueClass?.includes('red') || valueClass?.includes('rose')) return 'danger';
if (valueClass?.includes('yellow') || valueClass?.includes('amber')) return 'warning';
if (valueClass?.includes('green') || valueClass?.includes('emerald')) return 'success';
if (valueClass?.includes('blue') || valueClass?.includes('cyan')) return 'accent';
return 'default';
};
const toDetailRows = (rows: DockerOverviewRow[]): DetailRow[] =>
compactDetailRows(
rows.map((row) =>
makeDetailRow(row.label, row.value, {
title: row.title,
tone: getDetailTone(row.valueClass),
}),
),
);
export function DockerHostDrawerManagement(props: DockerHostDrawerOverviewProps) {
const docker = () => props.host.docker;
@@ -255,44 +201,10 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
return dockerOs || agentOs || '';
};
const uptimeSeconds = (): number => {
if (typeof props.host.uptime === 'number' && props.host.uptime > 0) return props.host.uptime;
if (typeof docker()?.uptimeSeconds === 'number' && (docker()?.uptimeSeconds ?? 0) > 0) {
return docker()!.uptimeSeconds!;
}
return 0;
};
const memorySource = () => props.host.memory ?? agent()?.memory ?? docker()?.memory;
const diskSource = () => props.host.disk;
const systemRows = (): DockerOverviewRow[] => [
{
label: 'Name',
value: cleanText(props.host.name) || props.host.id,
title: props.host.name,
},
...(cleanText(docker()?.hostname)
? [
{
label: 'Hostname',
value: cleanText(docker()?.hostname),
title: docker()?.hostname,
} satisfies DockerOverviewRow,
]
: []),
{ label: 'Status', value: formatStatus(props.host.status) },
...(uptimeSeconds() > 0
? [{ label: 'Uptime', value: formatUptime(uptimeSeconds()) } satisfies DockerOverviewRow]
: []),
...(formatLastSeenRow(props.host.lastSeen) ? [formatLastSeenRow(props.host.lastSeen)!] : []),
];
const runtimeRows = (): DockerOverviewRow[] => [
{ label: 'Engine', value: runtimeLabel(), title: docker()?.runtimeVersion },
...(osLabel()
? [{ label: 'OS', value: osLabel(), title: osLabel() } satisfies DockerOverviewRow]
: []),
...(cleanText(docker()?.kernelVersion) || cleanText(agent()?.kernelVersion)
? [
{
@@ -324,24 +236,15 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
const memory = memorySource();
if (!memory) return [];
const rows: DockerOverviewRow[] = [];
if (getBooleanField(memory, 'usageUnavailable') === true) {
rows.push({ label: 'Usage', value: 'Unavailable' });
if ('usageUnavailable' in memory && memory.usageUnavailable === true) {
if (typeof memory.total === 'number' && memory.total > 0) {
rows.push({ label: 'Total', value: formatBytes(memory.total) });
}
} else if (typeof memory.total === 'number' && memory.total > 0) {
rows.push({
label: 'Usage',
value: `${getUsedPercent(memory.used, memory.total)} · ${formatBytes(memory.used || 0)}`,
});
rows.push({ label: 'Total', value: formatBytes(memory.total) });
if (typeof memory.free === 'number') {
rows.push({ label: 'Free', value: formatBytes(memory.free) });
}
} else if (typeof getNumericField(memory, 'current') === 'number') {
rows.push({ label: 'Usage', value: formatPercent(getNumericField(memory, 'current')) });
} else if (typeof getNumericField(memory, 'usage') === 'number') {
rows.push({ label: 'Usage', value: formatPercent(getNumericField(memory, 'usage')) });
}
return rows;
};
@@ -351,31 +254,16 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
if (!disk) return [];
const rows: DockerOverviewRow[] = [];
if (typeof disk.total === 'number' && disk.total > 0) {
rows.push({
label: 'Usage',
value: `${formatPercent(disk.current)} · ${formatBytes(disk.used || 0)}`,
});
rows.push({ label: 'Total', value: formatBytes(disk.total) });
if (typeof disk.free === 'number') {
rows.push({ label: 'Free', value: formatBytes(disk.free) });
}
} else if (typeof disk.current === 'number') {
rows.push({ label: 'Usage', value: formatPercent(disk.current) });
}
return rows;
};
const telemetryRows = (): DockerOverviewRow[] => {
const rows: DockerOverviewRow[] = [];
rows.push({
label: 'Connection',
value: formatStatus(props.host.status),
});
rows.push({
label: 'Agent ID',
value: linkedAgentId() ? stripAgentPrefix(linkedAgentId()) : 'Direct API',
title: linkedAgentId() || undefined,
});
const temperature = props.host.temperature ?? docker()?.temperature;
if (typeof temperature === 'number' && temperature > 0) {
rows.push({
@@ -411,6 +299,33 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
return buildDrawerDiskListItems(disks);
};
const technicalSections = () =>
compactDetailSections([
{ label: 'Runtime', rows: toDetailRows(runtimeRows()) },
{ label: 'Memory', rows: toDetailRows(memoryRows()) },
{
label: 'Storage',
rows:
perDiskItems().length > 0
? compactDetailRows(
perDiskItems().map((disk) =>
makeDetailRow(
disk.label,
`${Math.round(disk.percent)}% · ${formatBytes(disk.used)} / ${formatBytes(
disk.total,
)}`,
{
title: disk.device ? `${disk.label} · ${disk.device}` : disk.label,
tone: getDetailTone(disk.textClass),
},
),
),
)
: toDetailRows(storageRows()),
},
{ label: 'Telemetry', rows: toDetailRows(telemetryRows()) },
]);
const overviewSections = () => {
const swarm = cleanText(docker()?.swarm?.localState);
return compactDetailSections([
@@ -444,7 +359,6 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
severity: alert.level,
}))}
/>
<DetailSectionTable sections={overviewSections()} />
<Show when={props.host.availability || props.host.availabilityChecks?.length}>
<div class="max-w-sm">
<AvailabilityProbeStatusCards
@@ -453,22 +367,10 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
/>
</div>
</Show>
<TechnicalDetailsDisclosure
<TechnicalDetailsSection
dataTestId="docker-host-technical-details"
subtitle="Runtime, capacity, and storage"
contentClass="mt-2 flex flex-wrap gap-2 border-t border-border pt-2 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.5rem)] [&>*]:min-w-[200px] [&>*]:max-w-full [&>*]:overflow-hidden"
>
<DetailCard title="System" rows={systemRows()} />
<DetailCard title="Runtime" rows={runtimeRows()} />
<DetailCard title="Memory" rows={memoryRows()} />
<Show
when={perDiskItems().length > 0}
fallback={<DetailCard title="Storage" rows={storageRows()} />}
>
<DrawerDiskListCard disks={perDiskItems()} testId="docker-host-drawer-disks" />
</Show>
<DetailCard title="Telemetry" rows={telemetryRows()} />
</TechnicalDetailsDisclosure>
sections={[...overviewSections(), ...technicalSections()]}
/>
</div>
);
}