From 08e501bbc88212047937ac37695b13efbcd3e902 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 24 Aug 2026 14:59:02 +0100 Subject: [PATCH] Unify drawer detail density --- .../subsystems/frontend-primitives.md | 10 +- .../subsystems/performance-and-scalability.md | 25 +- .../internal/subsystems/unified-resources.md | 8 + frontend-modern/browser-verification.json | 53 +- .../scripts/shared-template-registry.json | 22 +- .../ResourceDetailDrawerOverviewTab.tsx | 23 +- .../Infrastructure/ResourceDetailSummary.tsx | 7 +- .../ResourceDetailDrawer.history.test.tsx | 4 +- ...urceDetailDrawer.identity-runtime.test.tsx | 2 +- .../components/Workloads/GuestDrawer.test.tsx | 124 ++-- .../Workloads/GuestDrawerOverview.tsx | 598 +++++------------- .../components/Workloads/NodeDrawer.test.tsx | 30 +- .../Workloads/NodeDrawerOverview.tsx | 208 ++---- .../components/shared/DetailSectionTable.tsx | 1 + .../SharedPrimitives.guardrails.test.ts | 28 +- .../shared/TechnicalDetailsDisclosure.tsx | 39 +- .../__tests__/DetailSectionTable.test.tsx | 50 +- .../docker/DockerHostDrawerOverview.tsx | 204 ++---- 18 files changed, 537 insertions(+), 899 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 89adeb548..39c78c6b4 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index f745d6a02..a91dc9393 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 2d8fafc33..123bc5067 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 13c5280d2..78a2854ad 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index aad020490..7e129e93b 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -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" } ], diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 09efd4f7b..7cc45d1c0 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -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 - + - - - - - + 0}>
diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index 054c20764..80468f3e3 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -324,7 +324,12 @@ export const InlineResourceSummaryTables: Component - +
{ 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(() => ); - 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(); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index d28c376fe..fe2a0782d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -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); diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 4a2fd18fc..11da9f8cd 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -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[] }) => ( -
DiskList({props.disks.length} disks)
- ), -})); - 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(() => ); expect(screen.getByText('CPUs')).toBeInTheDocument(); expect(screen.getByText('8')).toBeInTheDocument(); }); - it('displays uptime when > 0', () => { + it('does not repeat row-level uptime', () => { render(() => ); - 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(() => ); - 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(() => ( { /> )); - 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(() => ( )); - 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(() => ( { 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(() => ( )); - expect(screen.queryByText('Guest Info')).not.toBeInTheDocument(); + expect(screen.queryByText('Primary IP')).not.toBeInTheDocument(); }); }); @@ -817,9 +796,8 @@ describe('GuestDrawer', () => { usage: 0.5, }; render(() => ); - // 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(() => ); - 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(() => ); 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', () => { )); 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(() => ); - 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', () => { )); 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(() => ( )); - expect(screen.getByText('db')).toBeInTheDocument(); - expect(screen.getByText('critical')).toBeInTheDocument(); + expect(technicalDetails().getByText('db, critical')).toBeInTheDocument(); }); it('trims whitespace from tags', () => { render(() => ( )); - 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(() => ); 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(() => ); 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(() => ); - expect(screen.getByText('interface')).toBeInTheDocument(); + expect(screen.getByText('Interface 1')).toBeInTheDocument(); }); it('limits displayed interfaces to 4', () => { diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 93456032f..01b6b5af6 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -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 (
- - - -
- - {(summary) => ( - -
-
-

- Identified Service -

- -
-
-
-
- Service - - {summary().serviceName} - -
- -
- Category - - {summary().category} - -
-
- -
- Version - - {summary().serviceVersion} - -
-
- -
- Endpoint - - {summary().suggestedUrl} - -
-
- 0}> -
- Ports - {summary().portCount} -
-
-
- {summary().sourceLabel} - - · {formatDiscoveryAge(summary().observedAt!)} - -
-
-
- )} -
- -

- System -

-
- -
- CPUs - {props.guest.cpus} -
-
- 0 - } - > -
- Uptime - - {formatUptime(props.guest.uptime)} - -
-
- -
- Node - {props.guest.node} -
-
- -
- {props.agentHeading} - - {props.agentLabel} - -
-
- -
- Actions - - {WORKLOAD_ACTION_AGENT_LABEL} - -
-
- - - -
-
- - - {(context) => { - const visibleItems = () => context().items.slice(0, 4); - const hiddenCount = () => Math.max(0, context().count - visibleItems().length); - return ( - -
-

- {context().title} -

- - -
-
-
- Containers - {context().count} -
- - {(item) => ( -
- - {item.name} - - - {item.status} - -
- )} -
- 0}> -
+{hiddenCount()} more
-
-
-
- ); - }} -
- - {/* 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. */} - - -

- vSphere -

-
- -
- vCenter - - {props.guest.vmware?.connectionName || props.guest.vmware?.vcenterHost} - -
-
- -
- Datacenter - - {props.guest.vmware?.datacenterName} - -
-
- -
- Cluster - - {props.guest.vmware?.clusterName} - -
-
-
-
-
- - 0}> - -

- Guest Info -

-
- -
- 0}> - {props.guest.osName} - - 0 && - (props.guest.osVersion?.length ?? 0) > 0 - } - > - - - 0}> - {props.guest.osVersion} - -
-
- 0}> -
- - {(ip) => ( - - {ip} - - )} - -
-
-
-
-
- - 0}> - -

- Memory -

-
- - {(row) => ( -
- {row.label} - - {row.value} - -
- )} -
-
-
-
- - - -

- Backup -

-
- - {(presentation) => ( - <> -
- Last Backup - - {presentation().ageLabel} - -
-
{presentation().dateLabel}
- - )} -
-
-
-
- - 0}> - -

- Tags -

-
- - {(tag) => ( - - {tag} - - )} - -
-
-
- - 0} - > - -

- Filesystems -

-
- -
-
-
- - - -

- Network -

-
- - {(iface) => { - const addresses = iface.addresses ?? []; - const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0; - return ( -
-
- {iface.name || 'interface'} - - - {iface.mac} - - -
- 0}> -
- - {(ip) => ( - - {ip} - - )} - -
-
- -
- RX {formatBytes(iface.rxBytes ?? 0)} - TX {formatBytes(iface.txBytes ?? 0)} -
-
-
- ); - }} -
-
-
-
-
-
+ sections={[...overviewSections(), ...technicalSections()]} + />
diff --git a/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx b/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx index 33d3a21d9..545414c91 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawer.test.tsx @@ -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(() => ( { /> )); - 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(() => ( { 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 () => { diff --git a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx index 0d4e79204..b95f085cc 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx @@ -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[] }) => ( - 0}> - -

- {props.title} -

-
- - {(row) => ( -
- {row.label} - - {row.value} - -
- )} -
-
-
-
-); +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, }))} /> - - - - - - - 0} - fallback={} - > - - - - - + sections={[...overviewSections(), ...technicalSections()]} + />
); } diff --git a/frontend-modern/src/components/shared/DetailSectionTable.tsx b/frontend-modern/src/components/shared/DetailSectionTable.tsx index fed96d1c0..f2b179cc3 100644 --- a/frontend-modern/src/components/shared/DetailSectionTable.tsx +++ b/frontend-modern/src/components/shared/DetailSectionTable.tsx @@ -55,6 +55,7 @@ export const DetailSectionTable: Component<{ title={row.title ?? row.value} > { '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(''); + expect(disclosureSource).toContain('sections?: DetailSection[]'); + expect(disclosureSource).toContain(' }>; }; - 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); } diff --git a/frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx b/frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx index 9d298e4d7..c0e582cdc 100644 --- a/frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx +++ b/frontend-modern/src/components/shared/TechnicalDetailsDisclosure.tsx @@ -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 +> = (props) => ( + + {(sections) => ( + + )} + +); + +export const TechnicalDetailsSection: Component = (props) => ( +
+ +
+); + export const TechnicalDetailsDisclosure: Component = (props) => { const [expanded, setExpanded] = createSignal(false); @@ -23,7 +56,9 @@ export const TechnicalDetailsDisclosure: Component{props.subtitle}
-
{props.children}
+
+ +
); diff --git a/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx b/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx index c3b6e639a..2aaa19f49 100644 --- a/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx @@ -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(() => ( + + )); + + 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(() => ( + + )); + + 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(); diff --git a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx index f91a4aea3..502e48a34 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx @@ -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)[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)[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[] }) => ( - 0}> - -

- {props.title} -

-
- - {(row) => ( -
- {row.label} - - {row.value} - -
- )} -
-
-
-
-); +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, }))} /> -
- - - - - 0} - fallback={} - > - - - - + sections={[...overviewSections(), ...technicalSections()]} + />
); }