diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8e5881e4e..d8e70700a 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2072,8 +2072,18 @@ default` instead of fusing provider and badge text such as across platform workload surfaces. A page with adjacent totals, such as Proxmox's Nodes header, must own one visibility signal and pass it to both the shared filter and table instead of creating independently persisted - signals that only synchronize after a reload. Docker / Podman, Kubernetes, TrueNAS, - VMware vSphere, and Standalone Machines must continue using their shared + signals that only synchronize after a reload. A provider host or node table + must render before the workload filter, and the workload filter must sit + immediately before the `WorkloadsSurface` it controls. It must never precede + a provider table whose rows it does not filter; Proxmox and VMware vSphere + use the same host-or-node, workload-controls, workload-table reading order. + Large provider inventories use the shared `createPlatformTablePreview` and + `PlatformTablePreviewToggle` boundary to keep the controlled workload table + in the initial reading flow: Proxmox shows eight node rows by default on + larger layouts and four on phone-sized layouts, with an accessible, + reversible show-all control in the Nodes header. Expansion is deliberate + session state and must not persist a page-burying expanded default. + Docker / Podman, Kubernetes, TrueNAS, VMware vSphere, and Standalone Machines must continue using their shared `PlatformTableToolbar` counters and table headers; none may add a parallel estate card grid or provider-only spotlight surface. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index e0b5404f4..e07ae5ab7 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -408,7 +408,13 @@ and unified-resource projection, not backup or recovery evidence. `ProxmoxPageSurface` must flow the already-loaded workload inventory into the shared workload filter counts and derive cluster/standalone topology through `platformEstateOverviewModel.ts` for the existing Nodes header rather than -creating a Proxmox-only metric or spotlight panel. Those counts must not imply +creating a Proxmox-only metric or spotlight panel. The node inventory may use +the shared bounded table-preview disclosure so workload controls remain in the +initial reading flow, but it must retain canonical node ordering and reveal the +same already-loaded collection rather than changing backup or recovery scope. +The workload filter belongs immediately before the workload table it controls, +after the node preview; it must not appear to filter the node table. Those +counts and layout controls must not imply protection, verification, or restore readiness beyond the evidence held by the workflow-owned Storage, Backups, Ceph, and Mail surfaces. Proxmox backup inventory loading and load-failure chrome is likewise a diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 6f4aba8f8..1c803c03b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -382,6 +382,13 @@ touch- and keyboard-operable inline detail disclosure. Future platform tables must keep that split: row data and platform semantics stay in the unified-resource consumer, and the repeated table shell stays in the shared frontend primitive. +Large platform inventories may bound their initial row projection through the +shared platform-table preview primitive so the next canonical surface remains +in the initial reading flow. Proxmox node consumers retain the page model's +canonical ordering, render eight rows on larger layouts and four rows on phone +layouts by default, and expose all rows only through the reversible shared +header disclosure. The preview must slice the already-sorted unified-resource +collection; it must not fetch, re-rank, omit, or derive a second node truth. Alert decoration on those platform rows consumes the canonical active-alert read model and the detector-enabled accessor. External notification activation is not a resource-health field and must never suppress row alerts, change diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 3ba3687b1..689a99ee8 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,46 +1,49 @@ { "version": 1, - "base_sha": "66c87fb6e8595af47c360573bb8efa51d46492d1", - "verified_at": "2026-08-19T18:57:18Z", + "base_sha": "e7de6602f7d43052950fb72d9591ecf154090f10", + "verified_at": "2026-08-19T19:07:24Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/AI/Chat/index.tsx", - "frontend-modern/src/components/GitHubStarBanner.tsx", - "frontend-modern/src/components/shared/MobileNavBar.tsx", + "frontend-modern/src/components/shared/FilterButtonGroup.tsx", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", "frontend-modern/src/index.css" ], "content_sha256": { - "frontend-modern/src/components/AI/Chat/index.tsx": "67d3dc20ea1100c3047f27052c35aaa4b1e15a8d2ebeda1022fecae9c772835d", - "frontend-modern/src/components/GitHubStarBanner.tsx": "ea65775ad40dc08d54d6e08f6f3c736c545917c11e8e7fbddb71d856afc11d79", - "frontend-modern/src/components/shared/MobileNavBar.tsx": "16a6917b7fdc25d77bd9cd6760f132f08a0f13f833ede6d278237e37378549e6", - "frontend-modern/src/index.css": "1b195e474bdf5cfa33af971d1a01ed6b7e7e58eb998d823616a292c0ecc6fb1f" + "frontend-modern/src/components/shared/FilterButtonGroup.tsx": "4f7c115ad13ad614f5ebb3f73d2c0f7561342e2a6b58b2bde36cf11d828d54ca", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "c946582423cf0f95e3e8f92d676c175a51c2f474be70ed383f882b7ef30e8973", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "01ea9d0ec46b0cb97870aa145b17fdd1fcc4fd8b3576ffdd21dfd2f4ad50e5c7", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "66fd5ef686cd052d37fa59ceacdae524a9274fd12e1f22f4c2be0e1ca80f5ce4", + "frontend-modern/src/index.css": "d8fe7b2c49b50003ab493cd37e37bbdd5c637764c1efd23fa20b966550c7ba5f" }, "routes": [ - "/", - "/proxmox" + "/proxmox/overview", + "/vmware/overview" ], "viewports": [ { - "width": 375, - "height": 812 + "width": 1440, + "height": 900 }, { - "width": 1280, - "height": 800 + "width": 390, + "height": 844 } ], "states": [ - "authenticated mobile 375x812, bottom navigation mounted and measuring 45px, --pulse-mobile-nav-height published as 45px", - "authenticated mobile with the Assistant overlay open, backdrop and panel both ending at the bar's top edge", - "authenticated desktop 1280x800, bar display:none and the variable published as 0px", - "re-verified after rebase onto 66c87fb6e, which reworked the FilterBar and Proxmox estate surfaces on the same route" + "Proxmox desktop default bounded node preview with eight rows, visible workload controls, and the workload table in the initial viewport", + "Proxmox desktop expanded 32-node estate and restored eight-row collapsed preview", + "Proxmox phone default bounded node preview with four rows and the reveal control visible", + "Proxmox phone expanded 32-node estate and restored four-row collapsed preview without horizontal overflow", + "Proxmox inventory totals shown and hidden across filter counts, Nodes topology, and the node reveal label", + "Proxmox and vSphere 12px labels with 11px counts sharing identical 16px line boxes and baselines" ], "interactions": [ - "opened the Assistant overlay and measured backdrop bottom 767 against bar top 767, gap 0px, previously a 35px un-dimmed clickable band", - "hit-tested 5px above the bar and found the Assistant panel covering it rather than page content", - "probed .filter-bottom-nav-aware-panel computed bottom, resolving to 45px on mobile from the shared variable", - "probed the star banner class combination at 800px and confirmed md:bottom-4 still wins at 16px", - "dispatched a resize event and confirmed the published value republishes from 0px to 45px", - "reloaded at 1280x800 and confirmed the bar is display:none with the variable published as 0px" + "Expanded and collapsed Show all 32 nodes on desktop and phone", + "Switched Proxmox workload status from All to Attention and observed the adjacent workload table change from 150 rendered rows to 21", + "Hid and restored Inventory totals from the View menu and confirmed the node reveal label stopped exposing the total while hidden", + "Compared the final shared inline count geometry on Proxmox and vSphere", + "Cleared the temporary Proxmox status filter and left /proxmox/overview at 1440x900" ] } diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 12112db69..e2c62a048 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -546,7 +546,7 @@ describe('App architecture', () => { expect(appStylesSource).toContain('.pulse-shell--full-width'); expect(appStylesSource).toContain('.pulse-wide-data-surface.space-y-3'); expect(appStylesSource).toContain('.filter-bar > div > div:first-child button'); - expect(appStylesSource).toContain('.proxmox-nodes-card > :first-child'); + expect(appStylesSource).not.toContain('.proxmox-nodes-card > :first-child'); expect(appStylesSource).toContain('.pulse-wide-data-surface .host-row'); expect(appStylesSource).toContain('.pulse-footer > div'); expect(appLayoutSource).toContain('tabs mb-2 hidden xl:flex'); diff --git a/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx b/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx index 74603ddfc..76655066c 100644 --- a/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx +++ b/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx @@ -80,7 +80,9 @@ describe('FilterButtonGroup', () => { const button = screen.getByRole('button', { name: 'VMs, 1,234' }); expect(button).toHaveTextContent('VMs1,234'); - expect(button.querySelector('[aria-hidden="true"]')).toHaveClass('tabular-nums'); + const count = button.querySelector('[aria-hidden="true"]'); + expect(count).toHaveClass('tabular-nums', 'leading-4'); + expect(count?.parentElement).toHaveClass('items-baseline'); }); it('blocks disabled option changes in the runtime owner', () => { diff --git a/frontend-modern/src/components/shared/FilterButtonGroup.tsx b/frontend-modern/src/components/shared/FilterButtonGroup.tsx index af3bf6c77..19f3ea62f 100644 --- a/frontend-modern/src/components/shared/FilterButtonGroup.tsx +++ b/frontend-modern/src/components/shared/FilterButtonGroup.tsx @@ -73,31 +73,31 @@ export function FilterButtonGroup(props: FilterButton > {option.leading} {Icon && } - {option.label} - ) : ( - <> - - {getFilterButtonGroupCompactLabel(option)} - - ) - } - > - - {renderedLabel()} - - - - + ) : ( + <> + + {getFilterButtonGroupCompactLabel(option)} + + ) + } > - {option.count!.toLocaleString()} - - + {renderedLabel()} + + + + + ); }} diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 57bce11dc..22640ab7b 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -500,6 +500,8 @@ describe('shared primitive guardrails', () => { expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupCompactLabel'); expect(filterButtonGroupModelSource).toContain("option.label.startsWith('All ')"); expect(filterButtonGroupModelSource).toContain('count?: number'); + expect(filterButtonGroupSource).toContain('items-baseline'); + expect(filterButtonGroupSource).toContain('text-[11px] font-semibold leading-4 tabular-nums'); expect(workloadsFilterSource).toContain('inventoryStats'); expect(workloadsFilterSource).toContain('PLATFORM_ESTATE_COUNTS_STORAGE_KEY'); expect(generalSettingsPanelSource).toContain('FilterButtonGroup'); @@ -5264,6 +5266,9 @@ describe('shared primitive guardrails', () => { expect(sharedPlatformPageSource).toContain('export function getPlatformTableClass'); expect(sharedPlatformPageSource).toContain("className !== 'min-w-full'"); expect(sharedPlatformPageSource).toContain('class={getPlatformTableClass(props.tableClass)}'); + expect(sharedPlatformPageSource).toContain('export function createPlatformTablePreview'); + expect(sharedPlatformPageSource).toContain('export function PlatformTablePreviewToggle'); + expect(sharedPlatformPageSource).toContain('aria-expanded={props.expanded}'); for (const [path, source] of [ ['src/features/docker/DockerHostsTable.tsx', dockerHostsTableSource], diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index de3d96489..02dacde9f 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -1,6 +1,8 @@ import { A } from '@solidjs/router'; +import ChevronDownIcon from 'lucide-solid/icons/chevron-down'; import ChevronLeftIcon from 'lucide-solid/icons/chevron-left'; import ChevronRightIcon from 'lucide-solid/icons/chevron-right'; +import ChevronUpIcon from 'lucide-solid/icons/chevron-up'; import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw'; import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert'; import { @@ -11,6 +13,7 @@ import { createSignal, onCleanup, onMount, + type Accessor, type Component, type JSX, } from 'solid-js'; @@ -306,6 +309,66 @@ export function PlatformTableShell(props: PlatformTableShellProps) { ); } +export function createPlatformTablePreview(options: { + rows: Accessor; + limit: Accessor; +}) { + const [expanded, setExpanded] = createSignal(false); + const limit = createMemo(() => Math.max(1, Math.trunc(options.limit()))); + const canExpand = createMemo(() => options.rows().length > limit()); + const visibleRows = createMemo(() => + expanded() && canExpand() ? options.rows() : options.rows().slice(0, limit()), + ); + + createEffect(() => { + if (!canExpand()) setExpanded(false); + }); + + return { + expanded, + canExpand, + limit, + visibleRows, + setExpanded, + toggle: () => { + if (canExpand()) setExpanded((current) => !current); + }, + }; +} + +export function PlatformTablePreviewToggle(props: { + expanded: boolean; + canExpand: boolean; + total: number; + noun: string; + showCount?: boolean; + onToggle: () => void; +}) { + const collapsedLabel = () => + props.showCount === false ? `Show all ${props.noun}` : `Show all ${props.total} ${props.noun}`; + const label = () => (props.expanded ? `Show fewer ${props.noun}` : collapsedLabel()); + + return ( + + + + ); +} + const getPlatformTableAlignClass = (align: PlatformTableCellAlign = 'left'): string => { if (align === 'right') return 'text-right'; if (align === 'center') return 'text-center'; diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index f8f36f3c2..9607f7fcb 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -34,7 +34,9 @@ import { PlatformSortableTableHead, PlatformTableEmptyState, PlatformTableMetricFallback, + PlatformTablePreviewToggle, PlatformTableShell, + createPlatformTablePreview, createPlatformTableSortState, formatPlatformTableIntegerValue, formatPlatformTablePercentValue, @@ -223,6 +225,14 @@ export const ProxmoxNodesTable: Component<{ const sortedNodes = createMemo(() => sort.sortRows(props.nodes, (node, key) => getHostSortValue(node, props.guests, key)), ); + const previewLimit = createMemo(() => + layoutMode() === 'narrow' || layoutMode() === 'phone' || layoutMode() === 'mobile' ? 4 : 8, + ); + const nodePreview = createPlatformTablePreview({ rows: sortedNodes, limit: previewLimit }); + const toggleNodePreview = () => { + if (nodePreview.expanded()) setSelectedNodeId(null); + nodePreview.toggle(); + }; // Use the same canonical history reader the workloads table uses; cache // keys collide so the two readers dedupe their fetches. @@ -255,26 +265,36 @@ export const ProxmoxNodesTable: Component<{ } actions={ - 0 || props.topology.standalone > 0) - } - > - - 0}> - {formatPlatformTableIntegerValue(props.topology!.clusters)}{' '} - {props.topology!.clusters === 1 ? 'cluster' : 'clusters'} - - 0 && props.topology!.standalone > 0}> - {' / '} - - 0}> - {formatPlatformTableIntegerValue(props.topology!.standalone)} standalone - - - + + 0 || props.topology.standalone > 0) + } + > + + 0}> + {formatPlatformTableIntegerValue(props.topology!.clusters)}{' '} + {props.topology!.clusters === 1 ? 'cluster' : 'clusters'} + + 0 && props.topology!.standalone > 0}> + {' / '} + + 0}> + {formatPlatformTableIntegerValue(props.topology!.standalone)} standalone + + + + + } cardClass="proxmox-nodes-card" tableClass={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`} @@ -313,7 +333,7 @@ export const ProxmoxNodesTable: Component<{ } body={ - + {(node) => { const name = () => asTrimmedString(node.name) || node.id; const nativeNodeName = () => asTrimmedString(node.proxmox?.nodeName) ?? ''; diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index c562d6839..8b7d88909 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -328,6 +328,18 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { return (
+ } + emptyTitle="No Proxmox VE nodes" + emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory." + topology={estateTopology()} + inventoryCountsVisible={props.inventoryCountsVisible} + />
- } - emptyTitle="No Proxmox VE nodes" - emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory." - topology={estateTopology()} - inventoryCountsVisible={props.inventoryCountsVisible} - /> = {}): Resource => ({ ...overrides, }); +const makeNodeResources = (count: number): Resource[] => + Array.from({ length: count }, (_, index) => + makeNodeResource({ + id: `agent:pve-node-${index + 1}`, + name: `pve-node-${index + 1}`, + displayName: `pve-node-${index + 1}`, + proxmox: { + clusterName: 'homelab', + nodeName: `pve-node-${index + 1}`, + }, + }), + ); + beforeEach(() => { vi.clearAllMocks(); getMetricThresholdsMock.mockReturnValue({ warning: 80, critical: 85 }); @@ -100,12 +113,54 @@ afterEach(() => { }); describe('ProxmoxNodesTable', () => { + it('keeps large estates bounded until the operator expands the node preview', () => { + render(() => ( + } + emptyTitle="No Proxmox VE nodes" + emptyDescription="No nodes" + /> + )); + + expect(screen.getAllByRole('row')).toHaveLength(9); + const showAll = screen.getByRole('button', { name: 'Show all 10 nodes' }); + expect(showAll).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(showAll); + expect(screen.getAllByRole('row')).toHaveLength(11); + expect(screen.getByRole('button', { name: 'Show fewer nodes' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + + fireEvent.click(screen.getByRole('button', { name: 'Show fewer nodes' })); + expect(screen.getAllByRole('row')).toHaveLength(9); + }); + + it('keeps the phone preview to four rows with its reveal control available', () => { + render(() => ( + 390} + emptyIcon={} + emptyTitle="No Proxmox VE nodes" + emptyDescription="No nodes" + /> + )); + + expect(screen.getAllByRole('row')).toHaveLength(5); + expect(screen.getByRole('button', { name: 'Show all 6 nodes' })).toBeVisible(); + }); + it('hides node and topology totals through the page-owned inventory preference', () => { render(() => ( false} emptyIcon={} emptyTitle="No Proxmox VE nodes" @@ -115,6 +170,8 @@ describe('ProxmoxNodesTable', () => { expect(screen.getByText('Nodes').parentElement).toHaveTextContent(/^Nodes$/); expect(screen.queryByText('1 cluster')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show all nodes' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Show all 10 nodes' })).not.toBeInTheDocument(); }); it('links each node to its PVE web interface without hijacking the row click', () => { diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index 5b7c8e836..d77fa6a44 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -3,6 +3,7 @@ import { Route, Router } from '@solidjs/router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Resource } from '@/types/resource'; import { ProxmoxPageSurface } from '../ProxmoxPageSurface'; +import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw'; const mockUseUnifiedResources = vi.fn(); const mockPathname = vi.hoisted(() => vi.fn(() => '/proxmox/overview')); @@ -271,6 +272,16 @@ describe('ProxmoxPageSurface contract', () => { ); }); + it('places workload controls beside the workload table they affect', () => { + const nodesTableIndex = proxmoxPageSurfaceSource.indexOf(' { setResources([ makeResource({ diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index 87adece23..9334fb792 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -514,10 +514,6 @@ min-height: 2.25rem; } - .proxmox-nodes-card > :first-child { - display: none; - } - .pulse-wide-data-surface .table-scroll-shell > .table-fixed tbody td, .pulse-wide-data-surface .table-scroll-shell > .table-fixed tbody .workload-row > td { padding-top: 0;