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

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"
]
}

View file

@ -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');

View file

@ -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', () => {

View file

@ -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>
);
}}

View file

@ -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],

View file

@ -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';

View file

@ -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) ?? '';

View file

@ -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={[]}

View file

@ -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', () => {

View file

@ -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({

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;