Merge pull request #2065 from rcourtman/fix/proxmox-node-guest-navigation

Show a node's guests from the Proxmox overview row
This commit is contained in:
pulse-triage[bot]
2026-09-12 22:55:38 +01:00
committed by GitHub
23 changed files with 433 additions and 59 deletions
@@ -360,8 +360,18 @@ whole-row pointer activation, Enter/Space keyboard activation, focus treatment,
`aria-expanded` / `aria-controls`, and exclusion of embedded links and controls.
`PlatformResourceDetailToggleButton` is the desktop disclosure affordance and
is visually removed on phone layouts where the complete row is the touch target;
provider tables must not add a second mobile chevron. Operator overrides remain
in the shared `Manage` tab and use the explicit compact density of `FormSelect`
provider tables must not add a second mobile chevron. When row activation
performs a different primary action, such as selecting a node's guests on
Proxmox, the shared toggle remains visible on phones through its
`hideWhenRowTappableOnMobile={false}` option. The node name supplies native
keyboard activation for guest selection, and embedded controls retain their
separate actions. Explicit guest selection focuses the guest heading with
scroll prevention. The shared `revealElementInViewport` helper leaves a
visible heading anchored and reveals an off-screen heading only far enough
to show the start of its results. It uses the actual scroll container and
marks deliberate movement so route-state restoration cannot compete with it. Activating the selected node again clears its node scope without
moving scroll or focus, while hover leaves the guest inventory unchanged.
Operator overrides remain in the shared `Manage` tab and use the explicit compact density of `FormSelect`
and `FormTextarea`, keeping form labels, help relationships, touch targets, and
control chrome canonical without expanding the low-frequency management surface.
When the summary row already owns a canonical unified `Resource`, its expanded
@@ -1850,6 +1850,13 @@ The Workloads selector path and the Workloads runtime that consumes it
are now part of the protected performance surface rather than proof-only
context. Future hot-path filter/group/sort/windowing changes must route through
the explicit Workloads performance proof policy in the subsystem registry.
Node filter options combine the existing canonical Proxmox node snapshot
with workload-derived hosts. Empty nodes retain instance-scoped identities
and labels without an extra inventory request. Only matching platform scopes
include these Proxmox nodes. A filtered-empty guest result cannot present an
unrelated source outage as its cause when the unfiltered guest inventory is
populated. Verify node option disambiguation and filtered-empty handling in
`workloadRouteModel.test.ts` and `WorkloadsSurface.k8s.test.tsx`.
Route-backed workload `resource` focus on that hot path is contextual state
only, not inferred filter state: opening or closing an inline drawer must not
invent, retain, or clear `agent` or node-scope filters unless those filters
@@ -646,6 +646,14 @@ the page supplies it through the Workloads `tableTitle` slot and the shared
states. This heading alignment changes neither workload backup scope nor the
storage/recovery evidence carried by the adjacent Backup column and Backups
tab.
The overview's node-row action selects the shared workload node scope and
reveals the guest section. The title names the selected node and reports the
filtered guest count, including zero. Activating the selected node again or
using its Show all nodes control clears only
the node scope, preserving search, status and type filters. These navigation
actions do not change backup ownership or recovery evidence. Verify selection,
clear and independent node disclosure in the Proxmox table tests and the
current `frontend-modern/browser-verification.json` interaction matrix.
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.
@@ -788,7 +788,12 @@ tables own which row opens, which resource label is exposed, and which
source-specific detail payload or drawer is rendered, but the disclosure
affordance itself must compose
`PlatformResourceDetailToggleButton` from the frontend-primitives-owned
`PlatformResourceDetailTableRow.tsx` contract. Future platform tables must not
`PlatformResourceDetailTableRow.tsx` contract. When whole-row activation selects workload scope instead of opening details,
the shared toggle stays visible on phones and continues to own disclosure
semantics and event containment. Proxmox guest selection derives its node
scope through `nodeFromResource` and `workloadNodeScopeId`, including empty
nodes, rather than inferring identity from the visible guest collection.
Future platform tables must not
add page-local chevron buttons, bespoke `aria-expanded` handling, or local
event-propagation variants for row detail expansion.
Inline detail section content follows the same ownership split.
+63 -17
View File
@@ -1,37 +1,83 @@
{
"version": 1,
"base_sha": "72809bc1cf71e2493d1f0c492b926110a969bdb4",
"verified_at": "2026-09-11T10:26:20.316799Z",
"base_sha": "5a4163ff83890005142ed81b025b8dea2a755b56",
"verified_at": "2026-09-12T16:22:19.408045Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/cards/DisksCard.tsx"
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts",
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/Workloads/workloadRouteModel.ts",
"frontend-modern/src/components/Workloads/workloadTopology.ts",
"frontend-modern/src/components/shared/contextualFocus.ts",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
],
"content_sha256": {
"frontend-modern/src/components/shared/cards/DisksCard.tsx": "cdda554f93b7aecb925d803b0192260ef1a445065c501468a0412b824dc08028"
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "8f0befdd56d332c9fb9e74319b8a7cb3db6fcb5518cd6dbc560f0710d83bd471",
"frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts": "d4e44b1410578876832ae8ba205b5e212154b8b940baa7f42bf72c4b5611ccde",
"frontend-modern/src/components/Workloads/useWorkloadRouteState.ts": "b669aed8d88287230a72d98d6916ed77c51455ea0c68fbda3d0623a66859944f",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "1d515d140099a05219f97773b988d065bc5784d95910626f0e797ff3acd1e283",
"frontend-modern/src/components/Workloads/workloadRouteModel.ts": "fb8d8d45ee9acce9359efefb2778a7a246946c84da25f502cbddb398d0ff02a6",
"frontend-modern/src/components/Workloads/workloadTopology.ts": "0917dfb1d9e7b1d65e3ff3afebc9a725b68d40188177fe7181547aebc9b1c3c7",
"frontend-modern/src/components/shared/contextualFocus.ts": "2c83a1ab5ca16c45d0c8a90bbdc0a7d75aedb1c1ec46b5513e31fbc26dfcab2e",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx": "76a2ccdbfa460492f4eb3b6cae91bda72d15915c6b172056c987e18a11116c94",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "33c6804b64cbacc0e770aabdd162a3e5121e0db5947d91fde95297c621772459",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "d4c7072f66a8a8ed50d920a18982ffef2ffca1fd972daa6471d3c0fe5857ef24"
},
"routes": [
"/qualification/disks/?theme=light&count=2",
"/qualification/disks/?theme=dark&count=24"
"/proxmox/overview"
],
"viewports": [
{
"width": 600,
"height": 500
"width": 1440,
"height": 1000
},
{
"width": 1200,
"height": 500
"width": 1440,
"height": 700
},
{
"width": 1024,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"2 and 24 mounts in light and dark themes",
"No nested clipping before scrolling; all mounts remain in outer flow"
"Default inventory and inert node hover",
"Selected and cleared node with matching guest count",
"Empty node and type-filtered empty result",
"Node and guest details expanded independently",
"Restored node selection after reload",
"Mobile filters expanded and collapsed",
"Node name default and hover without link decoration",
"Keyboard focus ring without underline",
"Selected and unselected guest scope",
"Separate desktop external website link"
],
"interactions": [
"Focus Before disks, keyboard End then Tab",
"Verify final mount in viewport and After disks focused",
"Inspect full-page Firefox dark narrow and Chromium light desktop screenshots"
"Click node name, count and plain row cell to filter guests",
"Activate the node name with Enter and Space, and select with touch",
"Reveal and focus the guest heading when it starts below the viewport",
"Expand and collapse node details with the chevron without changing guest scope",
"Keep external links independent of row selection",
"Switch nodes and activate the selected node again to clear its scope with pointer, keyboard and touch",
"Preserve search, status and type filters while selecting and clearing nodes",
"Open guest details and dismiss with Escape while retaining node scope",
"Inspect desktop, intermediate and mobile pixels, including touch controls and empty results",
"Assert the selected node keeps its screen position and keyboard focus when deselected, including with a long open details panel",
"Select and switch minipc, delly and delly2 with an already visible guest heading and assert unchanged node screen position at all four viewports",
"Hover node name and inspect computed decoration and actual pixels",
"Tab and Shift+Tab to node name and inspect visible keyboard focus ring",
"Enter selects guests and Space clears node scope",
"Touch selects and clears the node on phone",
"Verify external website icon remains an independent link"
],
"command": "pulse-heavy-run -- node tests/qualification/disk-mounts/check.mjs",
"notes": "16 synthetic production-card/CSS cases passed in Firefox142.0.1 and Chromium141.0.7390.37. Screenshots retained in lane outcome screenshots directory. No full installed drawer or reporter Firefox140 ESR reproduction claimed. Production runtime unchanged during completion repair."
"command": "Playwright scripts check.cjs, adjacent.cjs, reveal.cjs, deselect.cjs and selection-scroll.cjs against the current local Vite server",
"notes": "Live inventory. Final source hashes match the browser-verified implementation. Screenshots and interaction scripts retained locally in the proxmox-node-row-review receipt directory. Rebased onto current main with byte-identical verified runtime files. Final presentation follow-up removes the node-name hover underline. The affected hover, focus, selection and touch matrix passed at1440x1000 and390x844, with other runtime content unchanged."
}
@@ -76,6 +76,9 @@ function WorkloadInventoryIssueList(props: { issues: readonly WorkloadInventoryS
export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
const state = props.state ?? useWorkloadsState(props);
const visibleInventoryIssues = createMemo(() => {
// A filtered-empty table is not an inventory outage. Once inventory is
// present, unrelated source failures cannot explain an empty selection.
if (state.allGuests().length > 0) return [];
const issues = state.workloadInventoryIssues?.() ?? [];
const forcedPlatform = props.forcedPlatform?.trim().toLowerCase();
const platformIssueTypes = forcedPlatform ? PLATFORM_ISSUE_TYPES[forcedPlatform] : undefined;
@@ -301,6 +301,16 @@ describe('Workloads pod workloads integration', () => {
surfaceInitialDataReceived: () => true,
allGuests: () => [{ id: 'vm-1' }],
filteredGuests: () => [],
workloadInventoryIssues: () => [
{
id: 'other-node',
type: 'pve',
name: 'other-node',
stateLabel: 'Source unreachable',
coverageLabel: 'VMs and containers',
description: 'Unrelated connection failure.',
},
],
search: () => '',
viewMode: () => 'vm',
statusMode: () => 'stopped',
@@ -320,6 +330,7 @@ describe('Workloads pod workloads integration', () => {
expect(screen.getByText('No guests found')).toBeInTheDocument();
expect(screen.getByText('No guests match your current filters')).toBeInTheDocument();
expect(screen.queryByText('No vSphere VMs')).not.toBeInTheDocument();
expect(screen.queryByTestId('workload-inventory-source-issues')).not.toBeInTheDocument();
});
it('keeps page-owned table-only empty copy when no operator filters are active', () => {
@@ -1227,8 +1227,8 @@ describe('Workloads performance contract', () => {
expect(workloadsWorkloadRouteStateSource).toContain('WORKLOADS_WORKLOAD_ROUTE_RESET_STATE');
expect(workloadsWorkloadRouteStateSource).toContain('isWorkloadsRoute,');
expect(workloadsWorkloadFilterOptionsSource).toContain("from './workloadFilterConfigModel'");
expect(workloadsWorkloadFilterOptionsSource).toContain(
'buildWorkloadNodeOptions(platformScopedGuests())',
expect(workloadsWorkloadFilterOptionsSource).toMatch(
/const workloadNodeOptions = createMemo\(\(\) =>\s*buildWorkloadNodeOptions\(\s*platformScopedGuests\(\),/,
);
expect(workloadsWorkloadFilterOptionsSource).not.toContain(
'const onContextChange = (value: string) =>',
@@ -62,6 +62,22 @@ describe('workloadRouteModel', () => {
]);
});
it('includes empty inventory nodes and disambiguates them against occupied nodes', () => {
const options = buildWorkloadNodeOptions(
[makeGuest({ node: 'node-a', instance: 'cluster-a' })],
[
{ name: 'node-a', instance: 'cluster-a' },
{ name: 'node-a', instance: 'cluster-b' },
{ name: 'empty', instance: 'cluster-a' },
],
);
expect(options).toEqual([
{ value: 'cluster-a-empty', label: 'empty' },
{ value: 'cluster-a-node-a', label: 'node-a (cluster-a)' },
{ value: 'cluster-b-node-a', label: 'node-a (cluster-b)' },
]);
});
it('builds app-container host options from Docker host ids and host labels', () => {
const options = buildWorkloadNodeOptions([
makeGuest({
@@ -1,7 +1,10 @@
import { createMemo, type Accessor } from 'solid-js';
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
import {
normalizeSourcePlatformQueryValue,
sourcePlatformScopeMatchesFilter,
} from '@/utils/sourcePlatforms';
import { workloadMatchesPlatformScope } from '@/utils/workloads';
import type { WorkloadsToolbarFilterConfig } from './workloadsFilterModel';
import {
@@ -11,6 +14,7 @@ import {
buildWorkloadsKubernetesNamespaceOptions,
buildWorkloadsVmwareClusterOptions,
buildWorkloadNodeOptions,
type WorkloadInventoryNode,
} from './workloadRouteModel';
import {
buildWorkloadsContainerRuntimeFilterConfig,
@@ -22,6 +26,7 @@ import {
interface WorkloadsWorkloadFilterOptionsOptions {
allGuests: Accessor<WorkloadGuest[]>;
nodes?: Accessor<readonly WorkloadInventoryNode[]>;
isWorkloadsRoute: Accessor<boolean>;
allowEmbeddedScopeFilters: Accessor<boolean>;
viewMode: Accessor<ViewMode>;
@@ -51,7 +56,14 @@ export function useWorkloadFilterOptions(options: WorkloadsWorkloadFilterOptions
.filter((guest) => workloadMatchesPlatformScope(guest, normalizedScope));
});
const workloadNodeOptions = createMemo(() => buildWorkloadNodeOptions(platformScopedGuests()));
const workloadNodeOptions = createMemo(() =>
buildWorkloadNodeOptions(
platformScopedGuests(),
sourcePlatformScopeMatchesFilter('proxmox-pve', options.platformScope?.())
? (options.nodes?.() ?? [])
: [],
),
);
const kubernetesContextOptions = createMemo(() =>
buildWorkloadsKubernetesContextOptions(platformScopedGuests()),
@@ -1,7 +1,7 @@
import { createSignal, onMount, type Accessor, type Setter } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
import { deserializeWorkloadViewMode } from './workloadRouteModel';
import { deserializeWorkloadViewMode, type WorkloadInventoryNode } from './workloadRouteModel';
import {
WORKLOADS_WORKLOAD_ROUTE_RESET_STATE,
deserializeWorkloadsContainerRuntime,
@@ -13,6 +13,7 @@ import { WORKLOADS_QUERY_PARAMS } from '@/routing/resourceLinks';
export interface WorkloadRouteStateOptions {
allGuests: Accessor<WorkloadGuest[]>;
nodes?: Accessor<readonly WorkloadInventoryNode[]>;
forcedPlatform?: string;
forcedViewMode?: ViewMode;
routeStateEnabled?: Accessor<boolean>;
@@ -125,6 +126,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) {
const filterOptions = useWorkloadFilterOptions({
allGuests: options.allGuests,
nodes: options.nodes,
isWorkloadsRoute,
allowEmbeddedScopeFilters: () => true,
viewMode: filterViewMode,
@@ -251,6 +251,24 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
),
);
const infrastructureNodes = createMemo<Node[]>(() => {
const merged = new Map<string, Node>();
props.nodes.forEach((node) => merged.set(node.id, node));
if (workloadsEnabled()) {
infrastructureResources()
.filter(isProxmoxNodeResource)
.map(nodeFromResource)
.filter((node): node is Node => Boolean(node))
.forEach((node) => {
const existing = merged.get(node.id);
merged.set(node.id, existing ? { ...existing, ...node } : node);
});
}
return Array.from(merged.values());
});
const {
clusterFilterConfig,
clusterOptions,
@@ -282,6 +300,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
containerRuntimeOptions,
} = useWorkloadRouteState({
allGuests,
nodes: infrastructureNodes,
forcedPlatform: props.forcedPlatform,
forcedViewMode: props.forcedViewMode,
showFilters,
@@ -356,23 +375,6 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
routeStateEnabled: props.routeStateEnabled,
});
const infrastructureNodes = createMemo<Node[]>(() => {
const merged = new Map<string, Node>();
props.nodes.forEach((node) => merged.set(node.id, node));
if (workloadsEnabled()) {
infrastructureResources()
.filter(isProxmoxNodeResource)
.map(nodeFromResource)
.filter((node): node is Node => Boolean(node))
.forEach((node) => {
const existing = merged.get(node.id);
merged.set(node.id, existing ? { ...existing, ...node } : node);
});
}
return Array.from(merged.values());
});
const workloadMemoryDisplayBasis: Accessor<WorkloadsMemoryDisplayBasis> =
props.memoryDisplayBasis ?? (() => 'guest');
const memoryParentNodeByGuestId = createMemo(() =>
@@ -1,3 +1,4 @@
import type { Node } from '@/types/api';
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
import {
getWorkloadPlatformScopes,
@@ -11,6 +12,7 @@ import {
getKubernetesContextKey,
getWorkloadHostLabel,
workloadHostScopeId,
workloadNodeScopeId,
} from './workloadTopology';
export type WorkloadNodeOption = WorkloadsFilterSelectOption;
@@ -20,10 +22,24 @@ export const deserializeWorkloadViewMode = (raw: unknown): ViewMode => {
return normalizeWorkloadViewModeParam(raw) ?? 'all';
};
export const buildWorkloadNodeOptions = (guests: WorkloadGuest[]): WorkloadNodeOption[] => {
export type WorkloadInventoryNode = Pick<Node, 'name' | 'instance'>;
export const buildWorkloadNodeOptions = (
guests: WorkloadGuest[],
nodes: readonly WorkloadInventoryNode[] = [],
): WorkloadNodeOption[] => {
const labelsByScope = new Map<string, string>();
const scopesByLabel = new Map<string, Set<string>>();
// Inventory owns node identity even when a node currently has no guests.
for (const node of nodes) {
const name = node.name.trim();
if (!name) continue;
const scopes = scopesByLabel.get(name) ?? new Set<string>();
scopes.add(workloadNodeScopeId({ node: name, instance: node.instance }));
scopesByLabel.set(name, scopes);
}
for (const guest of guests) {
const type = resolveWorkloadType(guest);
if (type === 'pod') continue;
@@ -62,6 +78,17 @@ export const buildWorkloadNodeOptions = (guests: WorkloadGuest[]): WorkloadNodeO
labelsByScope.set(scope, label);
}
for (const node of nodes) {
const name = node.name.trim();
if (!name) continue;
const instance = (node.instance || '').trim();
const scope = workloadNodeScopeId({ node: name, instance });
if (labelsByScope.has(scope)) continue;
const label =
(scopesByLabel.get(name)?.size ?? 0) > 1 && instance ? `${name} (${instance})` : name;
labelsByScope.set(scope, label);
}
return Array.from(labelsByScope.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
@@ -30,7 +30,7 @@ const dedupeTrimmed = (values: Array<string | null | undefined>): string[] => {
return result;
};
export const workloadNodeScopeId = (guest: WorkloadGuest): string =>
export const workloadNodeScopeId = (guest: Pick<WorkloadGuest, 'instance' | 'node'>): string =>
`${(guest.instance || '').trim()}-${(guest.node || '').trim()}`;
export const getKubernetesContextKey = (guest: WorkloadGuest): string => {
@@ -7700,6 +7700,12 @@ describe('shared primitive guardrails', () => {
]) {
expect(source).toContain('PlatformResourceDetailToggleButton');
}
// A row with a different primary action must retain the shared disclosure.
expect(proxmoxNodesTableSource).toContain('hideWhenRowTappableOnMobile={false}');
expect(proxmoxNodesTableSource).not.toContain('<SummaryRowActionButton');
expect(platformResourceDetailTableRowSource).toContain(
'hideWhenRowTappableOnMobile={props.hideWhenRowTappableOnMobile ?? true}',
);
expect(agentsMachinesTableSource).not.toContain('data-agent-machine-expand-icon');
});
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { revealElementInViewport } from '../contextualFocus';
const target = (top: number, bottom: number) => {
const element = document.createElement('h2');
element.getBoundingClientRect = () => ({ top, bottom }) as DOMRect;
return element;
};
afterEach(() => vi.restoreAllMocks());
describe('conditional contextual reveal', () => {
it('does not move a visible heading even when extra result space is requested', () => {
const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
expect(
revealElementInViewport({
element: target(window.innerHeight - 30, window.innerHeight - 10),
bottomPadding: 96,
}),
).toBe(false);
expect(scroll).not.toHaveBeenCalled();
});
it('reveals an off-screen heading with room for its first results', () => {
const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
expect(
revealElementInViewport({
element: target(window.innerHeight + 40, window.innerHeight + 60),
bottomPadding: 96,
}),
).toBe(true);
expect(scroll).toHaveBeenCalledWith({ top: window.scrollY + 156, behavior: 'instant' });
});
it('uses the clipped scroll-container viewport instead of moving the document', () => {
const scroller = document.createElement('div');
scroller.style.overflowY = 'auto';
Object.defineProperties(scroller, {
clientHeight: { value: 300 },
scrollHeight: { value: 1000 },
});
scroller.getBoundingClientRect = () => ({ top: 100, bottom: 400 }) as DOMRect;
scroller.scrollTop = 25;
scroller.scrollTo = vi.fn();
const element = target(450, 470);
scroller.append(element);
expect(revealElementInViewport({ element })).toBe(true);
expect(scroller.scrollTo).toHaveBeenCalledWith({ top: 95, behavior: 'instant' });
});
});
@@ -130,6 +130,25 @@ const scrollVerticalScroller = (
}
};
// Leave a visible target anchored. Only off-screen navigation needs a reveal.
export const revealElementInViewport = (options: {
element: HTMLElement;
bottomPadding?: number;
behavior?: ScrollBehavior;
}): boolean => {
if (typeof window === 'undefined') return false;
const scroller = resolveVerticalScroller(options.element);
const metrics = getScrollerMetrics(scroller);
const rect = options.element.getBoundingClientRect();
const top = Math.max(0, metrics.top);
const bottom = Math.min(window.innerHeight, metrics.bottom);
if (rect.top >= top && rect.bottom <= bottom) return false;
const offset =
rect.top < top ? rect.top - top : rect.bottom - bottom + (options.bottomPadding ?? 0);
scrollVerticalScroller(scroller, metrics.scrollTop + offset, options.behavior ?? 'instant');
return true;
};
export const findInlineDetailElement = (
root: ParentNode | null | undefined,
seriesId: string | null | undefined,
@@ -59,6 +59,7 @@ export function getPlatformResourceDetailRowInteractionProps(
export const PlatformResourceDetailToggleButton: Component<{
expanded: boolean;
resourceLabel: string;
hideWhenRowTappableOnMobile?: boolean;
controlsId?: string;
class?: string;
onToggle: () => void;
@@ -69,7 +70,7 @@ export const PlatformResourceDetailToggleButton: Component<{
subjectLabel={`details for ${props.resourceLabel}`}
controlsId={props.controlsId}
class={props.class}
hideWhenRowTappableOnMobile
hideWhenRowTappableOnMobile={props.hideWhenRowTappableOnMobile ?? true}
onAction={props.onToggle}
/>
);
@@ -23,6 +23,7 @@ import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { TableCell, TableRow } from '@/components/shared/Table';
import { SUMMARY_ROW_ACTION_BUTTON_FOCUS_CLASS } from '@/components/shared/summaryInteractionA11y';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getNodeExternalUrl } from '@/utils/nodes';
import { asTrimmedString } from '@/utils/stringUtils';
@@ -46,7 +47,10 @@ import {
getPlatformTableCellClassForKind,
type PlatformTableSortValue,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
import {
getPlatformResourceDetailRowInteractionProps,
PlatformResourceDetailToggleButton,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import { type ProxmoxEstateTopology } from '@/features/platformPage/platformEstateOverviewModel';
import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workloadsFilterModel';
import { type WorkloadTableMetricHistoryRange } from '@/components/Workloads/workloadMetricHistoryModel';
@@ -204,6 +208,8 @@ export const ProxmoxNodesTable: Component<{
emptyDescription: string;
topology?: ProxmoxEstateTopology;
inventoryCountsVisible?: Accessor<boolean>;
onShowGuests?: (node: Resource) => void;
guestFilterNodeId?: Accessor<string | null>;
}> = (props) => {
const breakpoint = useBreakpoint();
const inventoryCountsVisible = () => props.inventoryCountsVisible?.() ?? true;
@@ -375,6 +381,12 @@ export const ProxmoxNodesTable: Component<{
const version = () => asTrimmedString(getResourceVersion(node));
const cluster = () => getResourceClusterLabel(node);
const counts = () => countGuestsForNode(props.guests, node);
const isGuestFilterSelected = () => props.guestFilterNodeId?.() === node.id;
const showGuests = () => props.onShowGuests?.(node);
const rowInteraction = getPlatformResourceDetailRowInteractionProps({
expanded: false,
onToggle: showGuests,
});
const indicator = () => getSimpleStatusIndicator(node.status);
const connectionHealth = createMemo(() =>
(asTrimmedString(node.proxmox?.connectionHealth) ?? '').toLowerCase(),
@@ -484,6 +496,7 @@ export const ProxmoxNodesTable: Component<{
expanded={isSelected()}
resourceLabel={name()}
controlsId={detailRowId()}
hideWhenRowTappableOnMobile={false}
onToggle={toggleNodeDrawer}
/>
<StatusDot
@@ -504,7 +517,24 @@ export const ProxmoxNodesTable: Component<{
: `Open ${name()} web interface`
}
>
{visibleNodeLabel()}
<button
type="button"
class={`block max-w-full truncate rounded-sm text-left ${SUMMARY_ROW_ACTION_BUTTON_FOCUS_CLASS}`}
aria-label={`Show guests on ${name()}`}
aria-controls="proxmox-guests-section"
aria-pressed={isGuestFilterSelected()}
title={
isGuestFilterSelected()
? `Clear guest filter for ${name()}`
: `Show guests on ${name()}`
}
onClick={(event) => {
event.stopPropagation();
showGuests();
}}
>
{visibleNodeLabel()}
</button>
</ResourceNameWithWebInterfaceLink>
</div>
<Show when={!isOnline()}>
@@ -721,7 +751,8 @@ export const ProxmoxNodesTable: Component<{
} ${isOnline() ? '' : 'opacity-60'}`}
data-proxmox-host-row={node.id}
data-workload-alert-accent={alertAccentTone()}
onClick={toggleNodeDrawer}
data-summary-row-active={isGuestFilterSelected() ? 'true' : undefined}
onClick={rowInteraction.onClick}
>
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
</TableRow>
@@ -11,6 +11,10 @@ import StorageSurface from '@/components/Storage/Storage';
import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import { useWorkloadsState } from '@/components/Workloads/useWorkloadsState';
import { workloadNodeScopeId } from '@/components/Workloads/workloadTopology';
import { revealElementInViewport } from '@/components/shared/contextualFocus';
import { nodeFromResource } from '@/utils/resourceStateAdapters';
import { TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS } from '@/components/shared/TableCardHeader';
import {
DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE,
getWorkloadsMetricFilterProps,
@@ -470,6 +474,57 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
workloadsState.allGuests().length > 0,
);
const estateTopology = createMemo(() => buildProxmoxEstateTopology(currentModel().resources));
// Use the same node identity as the workload table, including empty nodes.
const nodeScopes = createMemo(
() =>
new Map(
currentModel().pveNodes.map((resource) => {
const node = nodeFromResource(resource);
return [
resource.id,
workloadNodeScopeId({
node: node?.name ?? resource.name,
instance: node?.instance ?? '',
}),
];
}),
),
);
const selectedScope = () => workloadsState.selectedNode() || workloadsState.selectedHostHint();
const selectedNode = createMemo(() =>
currentModel().pveNodes.find((node) => nodeScopes().get(node.id) === selectedScope()),
);
const selectedNodeLabel = createMemo(() => {
const scope = selectedScope();
if (!scope) return null;
return (
selectedNode()?.name ||
workloadsState.hostFilterConfig()?.options.find((option) => option.value === scope)?.label ||
scope
);
});
let guestsHeading: HTMLHeadingElement | undefined;
const focusGuests = () => {
guestsHeading?.focus({ preventScroll: true });
if (guestsHeading) {
revealElementInViewport({ element: guestsHeading, bottomPadding: 96 });
}
};
const showNodeGuests = (node: Resource) => {
const scope = nodeScopes().get(node.id);
if (!scope) return;
const nextScope = selectedScope() === scope ? null : scope;
workloadsState.handleNodeSelect(nextScope, nextScope ? 'pve' : null);
if (nextScope) {
requestAnimationFrame(() => {
if (selectedScope() === nextScope) focusGuests();
});
}
};
const clearNodeFilter = () => {
workloadsState.handleNodeSelect(null, null);
guestsHeading?.focus({ preventScroll: true });
};
return (
<div ref={overviewWidth.setElement} class="pulse-wide-data-surface flex flex-col gap-4">
@@ -486,6 +541,8 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory."
topology={estateTopology()}
inventoryCountsVisible={props.inventoryCountsVisible}
onShowGuests={showNodeGuests}
guestFilterNodeId={() => selectedNode()?.id ?? null}
/>
</section>
<section
@@ -550,14 +607,30 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
emptyStateTitle="No Proxmox workloads"
emptyStateDescription="Proxmox VMs and LXCs appear here when inventory is available."
tableTitle={
<h2 id="proxmox-guests-heading" class="inline-flex items-center gap-1.5">
Guests
<Show when={showSharedFilterToolbar()}>
<span class="font-semibold tabular-nums text-base-content">
{workloadsState.allGuests().length}
</span>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<h2
ref={guestsHeading}
id="proxmox-guests-heading"
tabIndex={-1}
class="inline-flex flex-wrap items-center gap-1.5 rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
>
{selectedNodeLabel() ? `Guests on ${selectedNodeLabel()}` : 'Guests'}
<Show when={showSharedFilterToolbar()}>
<span class="font-semibold tabular-nums text-base-content">
{workloadsState.filteredGuests().length}
</span>
</Show>
</h2>
<Show when={selectedNodeLabel()}>
<button
type="button"
class={`${TABLE_CARD_HEADER_CLEAR_BUTTON_CLASS} min-h-6`}
onClick={clearNodeFilter}
>
Show all nodes
</button>
</Show>
</h2>
</div>
}
/>
</section>
@@ -618,11 +618,13 @@ describe('ProxmoxNodesTable', () => {
expect(rowNames()).toEqual(['pve-node-1', 'pve-node-2']);
});
it('opens the host details drawer from the host-owned top table row', async () => {
it('selects guests from the row and reserves the chevron for node details', async () => {
const onShowGuests = vi.fn();
render(() => (
<ProxmoxNodesTable
nodes={[makeNodeResource()]}
guests={[]}
onShowGuests={onShowGuests}
emptyIcon={<span />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="No nodes"
@@ -639,6 +641,10 @@ describe('ProxmoxNodesTable', () => {
expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument();
await fireEvent.click(row!);
expect(onShowGuests).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('node-drawer')).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Expand details for pve-node-1' }));
expect(onShowGuests).toHaveBeenCalledTimes(1);
expect(row).not.toHaveAttribute('aria-expanded');
expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true');
@@ -650,7 +656,8 @@ describe('ProxmoxNodesTable', () => {
}),
);
await fireEvent.click(row!);
await fireEvent.click(screen.getByRole('button', { name: 'Collapse details for pve-node-1' }));
expect(onShowGuests).toHaveBeenCalledTimes(1);
expect(row).not.toHaveAttribute('aria-expanded');
expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute(
@@ -16,6 +16,8 @@ const mockTotalStats = vi.hoisted(() => vi.fn());
const mockNodesTableProps = vi.hoisted(() => vi.fn());
const mockBackupsTableProps = vi.hoisted(() => vi.fn());
const mockWorkloadSearch = vi.hoisted(() => vi.fn(() => ''));
const mockSelectedNode = vi.hoisted(() => vi.fn<() => string | null>(() => null));
const mockHandleNodeSelect = vi.hoisted(() => vi.fn());
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource =>
({
@@ -90,6 +92,9 @@ vi.mock('@/components/Workloads/useWorkloadsState', () => ({
surfaceConnected: () => false,
surfaceInitialDataReceived: () => false,
allGuests: () => [],
selectedNode: mockSelectedNode,
handleNodeSelect: mockHandleNodeSelect,
selectedHostHint: () => null,
totalStats: mockTotalStats,
search: mockWorkloadSearch,
setSearch: vi.fn(),
@@ -154,6 +159,8 @@ const renderSurface = () =>
describe('ProxmoxPageSurface contract', () => {
beforeEach(() => {
mockSelectedNode.mockReturnValue(null);
mockHandleNodeSelect.mockClear();
mockPathname.mockReturnValue('/proxmox/overview');
mockVersionInfo.mockReturnValue(null);
mockTotalStats.mockReturnValue({
@@ -224,6 +231,33 @@ describe('ProxmoxPageSurface contract', () => {
);
});
it('toggles only the node scope when the selected node is activated again', () => {
const node = makeResource({
id: 'agent:pve1',
name: 'pve1',
type: 'agent',
proxmox: { nodeName: 'pve1', instance: 'lab' },
});
setResources([node]);
renderSurface();
const { onShowGuests } = mockNodesTableProps.mock.lastCall![0];
const revealFrame = vi.spyOn(window, 'requestAnimationFrame');
onShowGuests(node);
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith('lab-pve1', 'pve');
expect(revealFrame).toHaveBeenCalledTimes(1);
revealFrame.mockClear();
mockSelectedNode.mockReturnValue('lab-pve1');
onShowGuests(node);
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith(null, null);
expect(revealFrame).not.toHaveBeenCalled();
mockSelectedNode.mockReturnValue('lab-other');
onShowGuests(node);
expect(mockHandleNodeSelect).toHaveBeenLastCalledWith('lab-pve1', 'pve');
expect(revealFrame).toHaveBeenCalledTimes(1);
revealFrame.mockRestore();
});
it('passes committed workload search terms to the node table', () => {
mockWorkloadSearch.mockReturnValue('reporting-api-01, wireguard-edge-01');
setResources([
@@ -665,9 +665,13 @@ describe('frontend resource type boundaries', () => {
expect(workloadsControlsStateSource).toContain('DEFAULT_WORKLOADS_SORT_KEY');
expect(workloadsWorkloadRouteStateSource).toContain('useWorkloadFilterOptions');
expect(workloadsWorkloadFilterOptionsSource).toContain("from './workloadFilterConfigModel'");
expect(workloadsWorkloadFilterOptionsSource).toContain(
'buildWorkloadNodeOptions(platformScopedGuests())',
expect(workloadsWorkloadFilterOptionsSource).toMatch(
/buildWorkloadNodeOptions\(\s*platformScopedGuests\(\),/,
);
expect(workloadsWorkloadFilterOptionsSource).toContain(
"sourcePlatformScopeMatchesFilter('proxmox-pve', options.platformScope?.())",
);
expect(workloadsWorkloadFilterOptionsSource).toContain('? (options.nodes?.() ?? [])');
expect(workloadsWorkloadFilterOptionsSource).not.toContain(
'const onContextChange = (value: string) =>',
);