mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Unify drawer detail density
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user