Move Proxmox host drawers to the host table

This commit is contained in:
rcourtman
2026-05-18 17:19:32 +01:00
parent 5a405f7f07
commit 84f627e22a
12 changed files with 282 additions and 39 deletions
@@ -309,7 +309,10 @@ regression protection.
`WorkloadPanel` owns the mutually exclusive host/guest drawer handoff:
clicking a grouped host row while a guest drawer is open must clear the
selected guest and keep/focus the host summary group so the node drawer can
replace the guest drawer.
replace the guest drawer. Platform pages that render a dedicated host
table above an embedded Workloads table may disable the grouped host drawer
through the explicit Workloads surface option, but standalone Workloads
must keep the inline grouped-row drawer as the default.
Compact icon headers inside `WorkloadTableHeader.tsx` may stay visually
dense for responsive workload tables, but the icon must be decorative and
the column label must remain present through an `sr-only` label so the
@@ -14,6 +14,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
if (!Number.isFinite(parsed)) return '0';
return String(Math.max(0, Math.min(parsed, 100)));
};
const invertPercent = (value: number) => String(100 - Math.max(0, Math.min(value, 100)));
return (
<div ref={state.setContainerRef} class={presentation().containerClass}>
@@ -204,9 +205,25 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
>
<div
data-stacked-disk-fill="vertical"
class="absolute bottom-0 left-0 right-0 rounded-sm brightness-100 transition-[filter] duration-100 group-hover/disk:brightness-125"
style={{ height: `${bar.fillPercent}%`, background: bar.color }}
/>
class="absolute inset-0 rounded-sm brightness-100 transition-[filter] duration-100 group-hover/disk:brightness-125"
>
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<rect
class="metric-fill-geometry"
x="0"
y={invertPercent(bar.fillPercent)}
width="100"
height={clampPercent(bar.fillPercent)}
rx="2"
fill={bar.color}
/>
</svg>
</div>
</div>
)}
</For>
@@ -43,6 +43,7 @@ type WorkloadPanelProps = Pick<
| 'groupedWindowing'
| 'guestMetadata'
| 'guestParentNodeMap'
| 'groupNodeDrawerMode'
| 'groupingMode'
| 'handleCustomUrlUpdate'
| 'handleTagClick'
@@ -380,14 +381,17 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
);
const shouldShowNodeDrawer = createMemo(
() =>
props.groupNodeDrawerMode() === 'inline' &&
Boolean(node()) &&
props.focusedSummaryWorkloadGroupId() === groupKey() &&
props.selectedGuestId() === null,
);
const canOpenNodeDrawer = () => props.groupNodeDrawerMode() === 'inline';
const handleGroupHoverChange = (next: SummarySeriesGroupScope | null) => {
props.setHoveredWorkloadGroupScope(next);
};
const handleGroupFocusToggle = () => {
if (!canOpenNodeDrawer()) return;
const scope = groupSummaryScope();
const selectedGuestId = props.selectedGuestId();
const nextFocusedScope =
@@ -445,7 +449,9 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
node={node()!}
renderAs="tr"
colspan={props.totalColumns()}
columns={props.compactGroupHeaders() ? undefined : props.workloadTableVisibleColumns()}
columns={
props.compactGroupHeaders() ? undefined : props.workloadTableVisibleColumns()
}
columnCellClass={
props.compactGroupHeaders() ? undefined : getGroupNodeColumnCellClass
}
@@ -456,15 +462,23 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
!props.compactGroupHeaders() &&
!props.workloadTableVisibleColumnIds().includes('info')
}
trClass="cursor-pointer select-none duration-150"
trClass={
canOpenNodeDrawer()
? 'cursor-pointer select-none duration-150'
: 'select-none duration-150'
}
trProps={{
'aria-expanded': shouldShowNodeDrawer() ? 'true' : 'false',
'aria-expanded': canOpenNodeDrawer()
? shouldShowNodeDrawer()
? 'true'
: 'false'
: undefined,
'data-summary-group-id': groupKey(),
'data-summary-group-series-count': String(
groupSummaryScope()?.seriesIds.length ?? 0,
),
'data-summary-row-active': isSummaryGroupHighlighted() ? 'true' : 'false',
onClick: handleGroupFocusToggle,
onClick: canOpenNodeDrawer() ? handleGroupFocusToggle : undefined,
...groupRowInteraction,
}}
/>
@@ -147,6 +147,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceProps) {
groupedWindowing={state.groupedWindowing}
guestMetadata={state.guestMetadata}
guestParentNodeMap={state.guestParentNodeMap}
groupNodeDrawerMode={state.groupNodeDrawerMode}
groupingMode={state.groupingMode}
handleCustomUrlUpdate={state.handleCustomUrlUpdate}
handleSort={state.handleSort}
@@ -20,6 +20,7 @@ type WorkloadsTableProps = Pick<
| 'groupedWindowing'
| 'guestMetadata'
| 'guestParentNodeMap'
| 'groupNodeDrawerMode'
| 'groupingMode'
| 'handleCustomUrlUpdate'
| 'handleSort'
@@ -103,6 +104,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
groupedWindowing={props.groupedWindowing}
guestMetadata={props.guestMetadata}
guestParentNodeMap={props.guestParentNodeMap}
groupNodeDrawerMode={props.groupNodeDrawerMode}
groupingMode={props.groupingMode}
handleCustomUrlUpdate={props.handleCustomUrlUpdate}
handleTagClick={props.handleTagClick}
@@ -370,7 +370,9 @@ describe('Workloads platform-page embed contract', () => {
expect(stateSource).toContain(
'onMetricDisplayModeChange?: (value: WorkloadsMetricDisplayMode) => void;',
);
expect(stateSource).toContain('metricHistoryRange?: Accessor<WorkloadTableMetricHistoryRange>;');
expect(stateSource).toContain(
'metricHistoryRange?: Accessor<WorkloadTableMetricHistoryRange>;',
);
expect(stateSource).toContain(
'onMetricHistoryRangeChange?: (value: WorkloadTableMetricHistoryRange) => void;',
);
@@ -381,17 +383,12 @@ describe('Workloads platform-page embed contract', () => {
// The controls layer must short-circuit to the page-provided accessor +
// change handler when supplied; the internal persistent signal stays as
// the fallback so standalone usage keeps working.
expect(controlsSource).toContain(
'options.metricDisplayMode ?? internalMetricDisplayMode',
);
expect(controlsSource).toContain('options.metricDisplayMode ?? internalMetricDisplayMode');
expect(controlsSource).toContain('options.onMetricDisplayModeChange');
expect(controlsSource).toContain(
'options.metricHistoryRange ?? internalMetricHistoryRange',
);
expect(controlsSource).toContain('options.metricHistoryRange ?? internalMetricHistoryRange');
const proxmoxSource = (
await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw')
).default;
const proxmoxSource = (await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw'))
.default;
expect(proxmoxSource).toContain('STORAGE_KEYS.WORKLOADS_METRIC_DISPLAY_MODE');
expect(proxmoxSource).toContain('STORAGE_KEYS.WORKLOADS_METRIC_HISTORY_RANGE');
expect(proxmoxSource).toContain('metricDisplayMode={metricDisplayMode}');
@@ -399,9 +396,8 @@ describe('Workloads platform-page embed contract', () => {
expect(proxmoxSource).toContain('metricHistoryRange={metricHistoryRange}');
expect(proxmoxSource).toContain('onMetricHistoryRangeChange={setMetricHistoryRange}');
const nodesTableSource = (
await import('../../../features/proxmox/ProxmoxNodesTable.tsx?raw')
).default;
const nodesTableSource = (await import('../../../features/proxmox/ProxmoxNodesTable.tsx?raw'))
.default;
expect(nodesTableSource).toContain('useWorkloadTableMetricHistory');
expect(nodesTableSource).toContain('MetricMiniSparkline');
expect(nodesTableSource).toContain('isSparklineMode');
@@ -410,9 +406,7 @@ describe('Workloads platform-page embed contract', () => {
it('exposes compactGroupHeaders so platform pages can strip duplicate host stats from group rows', async () => {
const stateSource = (await import('../useWorkloadsState.ts?raw')).default;
expect(stateSource).toContain('compactGroupHeaders?: boolean;');
expect(stateSource).toContain(
'compactGroupHeaders: () => props.compactGroupHeaders === true,',
);
expect(stateSource).toContain('compactGroupHeaders: () => props.compactGroupHeaders === true,');
const tableSource = (await import('../WorkloadsTable.tsx?raw')).default;
expect(tableSource).toContain(`| 'compactGroupHeaders'`);
@@ -430,11 +424,36 @@ describe('Workloads platform-page embed contract', () => {
);
expect(panelSource).toContain('!props.compactGroupHeaders() &&');
const proxmoxSource = (
await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw')
).default;
const proxmoxSource = (await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw'))
.default;
expect(proxmoxSource).toContain('compactGroupHeaders');
});
it('lets platform pages move grouped host drawers to a dedicated host table owner', async () => {
const stateSource = (await import('../useWorkloadsState.ts?raw')).default;
expect(stateSource).toContain("groupNodeDrawerMode?: 'inline' | 'disabled';");
expect(stateSource).toContain(
"groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',",
);
const tableSource = (await import('../WorkloadsTable.tsx?raw')).default;
expect(tableSource).toContain(`| 'groupNodeDrawerMode'`);
expect(tableSource).toContain('groupNodeDrawerMode={props.groupNodeDrawerMode}');
const panelSource = (await import('../WorkloadPanel.tsx?raw')).default;
expect(panelSource).toContain("props.groupNodeDrawerMode() === 'inline'");
expect(panelSource).toContain(
'onClick: canOpenNodeDrawer() ? handleGroupFocusToggle : undefined',
);
const proxmoxPageSource = (await import('../../../features/proxmox/ProxmoxPageSurface.tsx?raw'))
.default;
const proxmoxNodesSource = (await import('../../../features/proxmox/ProxmoxNodesTable.tsx?raw'))
.default;
expect(proxmoxPageSource).toContain('groupNodeDrawerMode="disabled"');
expect(proxmoxNodesSource).toContain('NodeDrawer');
expect(proxmoxNodesSource).toContain('data-inline-node-detail-for={node.id}');
});
});
describe('Workloads performance contract', () => {
@@ -79,6 +79,10 @@ export interface WorkloadsSurfaceProps {
// `compactGroupHeaders` strips those stats from the NodeGroupHeader rows
// in grouped mode so the section dividers don't duplicate the info.
compactGroupHeaders?: boolean;
// Default Workloads behavior owns grouped host row drawers inline. Platform
// pages with a dedicated host table can disable that drawer so host details
// open from the host-owned table instead of the embedded guest table.
groupNodeDrawerMode?: 'inline' | 'disabled';
// When a platform page owns the metric display mode + sparkline range
// (so the same toggle drives both the page's hosts table and this
// embedded workloads surface), pass the accessors + change handlers.
@@ -454,9 +458,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
navigate,
nodeByInstance,
namespaceFilterConfig,
platformFilterConfig: props.suppressPlatformFilter
? () => undefined
: platformFilterConfig,
platformFilterConfig: props.suppressPlatformFilter ? () => undefined : platformFilterConfig,
platformOptions,
reconnect,
reconnectSurface,
@@ -522,6 +524,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
ws,
groupingMode,
compactGroupHeaders: () => props.compactGroupHeaders === true,
groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',
} as const;
}
@@ -1,7 +1,16 @@
import { For, Show, createMemo, type Accessor, type Component, type JSX } from 'solid-js';
import {
For,
Show,
createMemo,
createSignal,
type Accessor,
type Component,
type JSX,
} from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { TableCard } from '@/components/shared/TableCard';
import { NodeDrawer } from '@/components/Workloads/NodeDrawer';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline';
@@ -33,6 +42,7 @@ import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workload
import { type WorkloadTableMetricHistoryRange } from '@/components/Workloads/workloadMetricHistoryModel';
import type { Disk, Node as LegacyNode } from '@/types/api';
import type { Resource } from '@/types/resource';
import { nodeFromResource } from '@/utils/resourceStateAdapters';
import {
getResourceClusterLabel,
getResourceNodeName,
@@ -128,6 +138,7 @@ export const ProxmoxNodesTable: Component<{
emptyDescription: string;
}> = (props) => {
const breakpoint = useBreakpoint();
const [selectedNodeId, setSelectedNodeId] = createSignal<string | null>(null);
const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
const visibleColumns = createMemo(() => getProxmoxHostVisibleColumnsForLayout(layoutMode()));
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
@@ -183,6 +194,18 @@ export const ProxmoxNodesTable: Component<{
<For each={props.nodes}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const drawerNode = () => nodeFromResource(node);
const detailRowId = () => `proxmox-host-drawer-${node.id}`;
const isSelected = () => selectedNodeId() === node.id;
const toggleNodeDrawer = () =>
setSelectedNodeId((current) => (current === node.id ? null : node.id));
const handleActivationKey: JSX.EventHandler<HTMLTableRowElement, KeyboardEvent> = (
event,
) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleNodeDrawer();
};
const version = () => asTrimmedString(getResourceVersion(node));
const cluster = () => getResourceClusterLabel(node);
const counts = () => countGuestsForNode(props.guests, getResourceNodeName(node));
@@ -397,9 +420,42 @@ export const ProxmoxNodesTable: Component<{
};
return (
<TableRow class="text-[11px] sm:text-xs">
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
</TableRow>
<>
<TableRow
class={`cursor-pointer text-[11px] outline-none sm:text-xs ${
isSelected() ? 'bg-surface-hover' : ''
} focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface`}
aria-controls={isSelected() ? detailRowId() : undefined}
aria-expanded={isSelected() ? 'true' : 'false'}
data-proxmox-host-row={node.id}
onClick={toggleNodeDrawer}
onKeyDown={handleActivationKey}
tabIndex={0}
>
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
</TableRow>
<Show when={isSelected() && drawerNode()}>
{(selectedNode) => (
<TableRow data-inline-node-detail-for={node.id}>
<TableCell
id={detailRowId()}
colspan={visibleColumns().length}
class="p-0 border-b border-border bg-surface-alt"
>
<div
class="px-2 py-3 sm:px-4 sm:py-4"
onClick={(event) => event.stopPropagation()}
>
<NodeDrawer
node={selectedNode()}
disks={normalizeDiskArray(node.agent?.disks)}
/>
</div>
</TableCell>
</TableRow>
)}
</Show>
</>
);
}}
</For>
@@ -145,6 +145,7 @@ export function ProxmoxPageSurface() {
suppressPlatformFilter
forcedPlatform={PROXMOX_PLATFORM_FILTER}
compactGroupHeaders
groupNodeDrawerMode="disabled"
metricDisplayMode={metricDisplayMode}
onMetricDisplayModeChange={setMetricDisplayMode}
metricHistoryRange={metricHistoryRange}
@@ -0,0 +1,102 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { ProxmoxNodesTable } from '../ProxmoxNodesTable';
const nodeDrawerMock = vi.hoisted(() => vi.fn());
vi.mock('@/hooks/useBreakpoint', () => ({
useBreakpoint: () => ({
width: () => 1280,
}),
}));
vi.mock('@/components/shared/responsive', () => ({
ResponsiveMetricCell: () => <div data-testid="responsive-metric-cell" />,
}));
vi.mock('@/components/Workloads/StackedMemoryBar', () => ({
StackedMemoryBar: () => <div data-testid="stacked-memory-bar" />,
}));
vi.mock('@/components/Workloads/StackedDiskBar', () => ({
StackedDiskBar: () => <div data-testid="stacked-disk-bar" />,
}));
vi.mock('@/components/Workloads/MetricMiniSparkline', () => ({
MetricMiniSparkline: () => <div data-testid="metric-mini-sparkline" />,
}));
vi.mock('@/components/shared/TemperatureGauge', () => ({
TemperatureGauge: () => <div data-testid="temperature-gauge" />,
}));
vi.mock('@/components/Workloads/useWorkloadTableMetricHistory', () => ({
useWorkloadTableMetricHistory: () => ({
getNodeMetricSeries: () => [],
}),
}));
vi.mock('@/components/Workloads/NodeDrawer', () => ({
NodeDrawer: (props: { node: { name: string } }) => {
nodeDrawerMock(props.node);
return <div data-testid="node-drawer">{props.node.name}</div>;
},
}));
const makeNodeResource = (overrides: Partial<Resource> = {}): Resource => ({
id: 'agent:pve-node-1',
name: 'pve-node-1',
displayName: 'pve-node-1',
platformId: 'homelab',
platformType: 'proxmox-pve',
sourceType: 'hybrid',
status: 'online',
type: 'agent',
lastSeen: 1_700_000_000_000,
cpu: { current: 42 },
memory: { total: 8_000, used: 3_200, free: 4_800, current: 40 },
disk: { total: 10_000, used: 4_500, free: 5_500, current: 45 },
proxmox: {
clusterName: 'homelab',
nodeName: 'pve-node-1',
pveVersion: 'pve-manager/9.1.9/ee7bad0a3d1546c9',
},
...overrides,
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ProxmoxNodesTable', () => {
it('opens the host details drawer from the host-owned top table row', async () => {
render(() => (
<ProxmoxNodesTable
nodes={[makeNodeResource()]}
guests={[]}
emptyIcon={<span />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="No nodes"
/>
));
const row = screen.getByText('pve-node-1').closest('tr');
expect(row).toBeTruthy();
expect(row).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument();
await fireEvent.click(row!);
expect(row).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('node-drawer')).toHaveTextContent('pve-node-1');
expect(nodeDrawerMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'pve-node-1' }));
await fireEvent.click(row!);
expect(row).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import workloadsStateSource from '@/components/Workloads/useWorkloadsState.ts?raw';
import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw';
import workloadsTableSource from '@/components/Workloads/WorkloadsTable.tsx?raw';
import proxmoxNodesTableSource from '../ProxmoxNodesTable.tsx?raw';
import proxmoxPageSurfaceSource from '../ProxmoxPageSurface.tsx?raw';
describe('Proxmox drawer placement contract', () => {
it('keeps host details owned by the Proxmox host table instead of the embedded guest table', () => {
expect(workloadsStateSource).toContain("groupNodeDrawerMode?: 'inline' | 'disabled';");
expect(workloadsStateSource).toContain(
"groupNodeDrawerMode: () => props.groupNodeDrawerMode ?? 'inline',",
);
expect(workloadsTableSource).toContain(`| 'groupNodeDrawerMode'`);
expect(workloadsTableSource).toContain('groupNodeDrawerMode={props.groupNodeDrawerMode}');
expect(workloadPanelSource).toContain("props.groupNodeDrawerMode() === 'inline'");
expect(workloadPanelSource).toContain(
'onClick: canOpenNodeDrawer() ? handleGroupFocusToggle : undefined',
);
expect(proxmoxPageSurfaceSource).toContain('groupNodeDrawerMode="disabled"');
expect(proxmoxNodesTableSource).toContain('NodeDrawer');
expect(proxmoxNodesTableSource).toContain('data-inline-node-detail-for={node.id}');
});
});
@@ -13,10 +13,10 @@ describe('proxmoxHostTableModel', () => {
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk']);
expect(getProxmoxHostTableMinWidthClass('mobile')).toBe('min-w-full');
expect(getProxmoxHostColumnWidthStyle('node', 'mobile', ids)).toEqual({ width: '38%' });
expect(getProxmoxHostColumnWidthStyle('cpu', 'mobile', ids)).toEqual({ width: '20%' });
expect(getProxmoxHostColumnWidthStyle('memory', 'mobile', ids)).toEqual({ width: '21%' });
expect(getProxmoxHostColumnWidthStyle('disk', 'mobile', ids)).toEqual({ width: '21%' });
expect(getProxmoxHostColumnWidthStyle('node', 'mobile', ids)).toEqual({ width: '44%' });
expect(getProxmoxHostColumnWidthStyle('cpu', 'mobile', ids)).toEqual({ width: '23%' });
expect(getProxmoxHostColumnWidthStyle('memory', 'mobile', ids)).toEqual({ width: '23%' });
expect(getProxmoxHostColumnWidthStyle('disk', 'mobile', ids)).toEqual({ width: '10%' });
});
it('adds temperature and guest counts before slower-changing metadata on tablet', () => {
@@ -50,7 +50,7 @@ describe('proxmoxHostTableModel', () => {
expect(getProxmoxHostTableMinWidthClass('compact')).toBe('min-w-[1080px]');
expect(getProxmoxHostTableMinWidthClass('wide')).toBe('min-w-[1080px]');
expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({
width: '12%',
width: '13.0435%',
});
});
});