fix(frontend): keep Proxmox workload controls in view

This commit is contained in:
rcourtman
2026-08-19 20:03:39 +01:00
parent e7de6602f7
commit e2e8b72baa
14 changed files with 273 additions and 93 deletions
@@ -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.
@@ -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
@@ -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
+28 -25
View File
@@ -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"
]
}
@@ -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');
@@ -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', () => {
@@ -73,31 +73,31 @@ export function FilterButtonGroup<T extends string | number>(props: FilterButton
>
{option.leading}
{Icon && <Icon class={iconClass()} />}
<Show
when={option.visualLabel}
fallback={
filterButtonGroup.variant() === 'prominent' ? (
<span>{option.label}</span>
) : (
<>
<span class="hidden sm:inline">{option.label}</span>
<span class="sm:hidden">{getFilterButtonGroupCompactLabel(option)}</span>
</>
)
}
>
<span class="inline-flex items-center gap-1.5 whitespace-nowrap">
{renderedLabel()}
</span>
</Show>
<Show when={option.count !== undefined}>
<span
class="inline-flex items-center self-center text-[11px] font-semibold leading-none tabular-nums text-base-content/70"
aria-hidden="true"
<span class="inline-flex items-baseline gap-1.5 whitespace-nowrap">
<Show
when={option.visualLabel}
fallback={
filterButtonGroup.variant() === 'prominent' ? (
<span>{option.label}</span>
) : (
<>
<span class="hidden sm:inline">{option.label}</span>
<span class="sm:hidden">{getFilterButtonGroupCompactLabel(option)}</span>
</>
)
}
>
{option.count!.toLocaleString()}
</span>
</Show>
<span>{renderedLabel()}</span>
</Show>
<Show when={option.count !== undefined}>
<span
class="inline-block text-[11px] font-semibold leading-4 tabular-nums text-base-content/70"
aria-hidden="true"
>
{option.count!.toLocaleString()}
</span>
</Show>
</span>
</button>
);
}}
@@ -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],
@@ -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<Row>(options: {
rows: Accessor<readonly Row[]>;
limit: Accessor<number>;
}) {
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<readonly Row[]>(() =>
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 (
<Show when={props.canExpand}>
<button
type="button"
class="inline-flex min-h-11 min-w-11 items-center justify-center gap-1 rounded px-1.5 text-[11px] font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 sm:min-h-8"
aria-expanded={props.expanded}
aria-label={label()}
onClick={props.onToggle}
>
<span>{label()}</span>
<Show
when={props.expanded}
fallback={<ChevronDownIcon class="h-3.5 w-3.5" aria-hidden="true" />}
>
<ChevronUpIcon class="h-3.5 w-3.5" aria-hidden="true" />
</Show>
</button>
</Show>
);
}
const getPlatformTableAlignClass = (align: PlatformTableCellAlign = 'left'): string => {
if (align === 'right') return 'text-right';
if (align === 'center') return 'text-center';
@@ -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<{
</span>
}
actions={
<Show
when={
inventoryCountsVisible() &&
props.topology &&
(props.topology.clusters > 0 || props.topology.standalone > 0)
}
>
<span class="text-[10px] font-medium text-muted">
<Show when={props.topology!.clusters > 0}>
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
</Show>
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
{' / '}
</Show>
<Show when={props.topology!.standalone > 0}>
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
</Show>
</span>
</Show>
<span class="inline-flex items-center gap-2">
<Show
when={
inventoryCountsVisible() &&
props.topology &&
(props.topology.clusters > 0 || props.topology.standalone > 0)
}
>
<span class="text-[10px] font-medium text-muted">
<Show when={props.topology!.clusters > 0}>
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
</Show>
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
{' / '}
</Show>
<Show when={props.topology!.standalone > 0}>
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
</Show>
</span>
</Show>
<PlatformTablePreviewToggle
expanded={nodePreview.expanded()}
canExpand={nodePreview.canExpand()}
total={props.nodes.length}
noun="nodes"
showCount={inventoryCountsVisible()}
onToggle={toggleNodePreview}
/>
</span>
}
cardClass="proxmox-nodes-card"
tableClass={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`}
@@ -313,7 +333,7 @@ export const ProxmoxNodesTable: Component<{
</For>
}
body={
<For each={sortedNodes()}>
<For each={nodePreview.visibleRows()}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const nativeNodeName = () => asTrimmedString(node.proxmox?.nodeName) ?? '';
@@ -328,6 +328,18 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
return (
<div ref={overviewWidth.setElement} class="pulse-wide-data-surface space-y-4">
<ProxmoxNodesTable
nodes={filteredNodes()}
guests={currentModel().guests}
metricDisplayMode={props.metricDisplayMode}
metricHistoryRange={props.metricHistoryRange}
layoutWidth={overviewWidth.width}
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory."
topology={estateTopology()}
inventoryCountsVisible={props.inventoryCountsVisible}
/>
<Show when={showSharedFilterToolbar()}>
<div data-summary-clear-ignore>
<WorkloadsFilter
@@ -371,18 +383,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
/>
</div>
</Show>
<ProxmoxNodesTable
nodes={filteredNodes()}
guests={currentModel().guests}
metricDisplayMode={props.metricDisplayMode}
metricHistoryRange={props.metricHistoryRange}
layoutWidth={overviewWidth.width}
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory."
topology={estateTopology()}
inventoryCountsVisible={props.inventoryCountsVisible}
/>
<WorkloadsSurface
state={workloadsState}
vms={[]}
@@ -86,6 +86,19 @@ const makeNodeResource = (overrides: Partial<Resource> = {}): 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(() => (
<ProxmoxNodesTable
nodes={makeNodeResources(10)}
guests={[]}
emptyIcon={<span />}
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(() => (
<ProxmoxNodesTable
nodes={makeNodeResources(6)}
guests={[]}
layoutWidth={() => 390}
emptyIcon={<span />}
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(() => (
<ProxmoxNodesTable
nodes={[makeNodeResource()]}
nodes={makeNodeResources(10)}
guests={[]}
topology={{ nodes: 1, clusters: 1, standalone: 0 }}
topology={{ nodes: 10, clusters: 1, standalone: 0 }}
inventoryCountsVisible={() => false}
emptyIcon={<span />}
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', () => {
@@ -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('<ProxmoxNodesTable');
const workloadFilterIndex = proxmoxPageSurfaceSource.indexOf('<WorkloadsFilter');
const workloadsSurfaceIndex = proxmoxPageSurfaceSource.indexOf('<WorkloadsSurface');
expect(nodesTableIndex).toBeGreaterThan(-1);
expect(workloadFilterIndex).toBeGreaterThan(nodesTableIndex);
expect(workloadsSurfaceIndex).toBeGreaterThan(workloadFilterIndex);
});
it('keeps Patrol coverage off the Proxmox overview', () => {
setResources([
makeResource({
-4
View File
@@ -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;