From 982465e63cc973a8619ef0209f008c4d11f59721 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:18:12 +0100 Subject: [PATCH 1/2] Show a node's guests from the Proxmox overview row Selecting a node filters its guests and reveals an off-screen guest heading without moving results that are already visible. Repeating the selection clears only the node filter. Keep node details on the chevron and preserve other guest filters when changing or clearing nodes. Build node filter options from inventory as well as guests so empty nodes retain their names and selection after reload. Do not present unrelated inventory failures as the cause of an empty filtered result. Refs lane L8. Update the owning interaction and identity contracts. --- .../subsystems/frontend-primitives.md | 14 ++- .../subsystems/performance-and-scalability.md | 7 ++ .../internal/subsystems/storage-recovery.md | 8 ++ .../internal/subsystems/unified-resources.md | 7 +- frontend-modern/browser-verification.json | 80 +++++++++++++---- .../components/Workloads/WorkloadsSurface.tsx | 3 + .../__tests__/WorkloadsSurface.k8s.test.tsx | 11 +++ .../__tests__/workloadRouteModel.test.ts | 16 ++++ .../Workloads/useWorkloadFilterOptions.ts | 16 +++- .../Workloads/useWorkloadRouteState.ts | 4 +- .../components/Workloads/useWorkloadsState.ts | 36 ++++---- .../Workloads/workloadRouteModel.ts | 29 ++++++- .../components/Workloads/workloadTopology.ts | 2 +- .../SharedPrimitives.guardrails.test.ts | 6 ++ .../contextualFocus.visibility.test.ts | 50 +++++++++++ .../src/components/shared/contextualFocus.ts | 19 ++++ .../PlatformResourceDetailTableRow.tsx | 3 +- .../features/proxmox/ProxmoxNodesTable.tsx | 37 +++++++- .../features/proxmox/ProxmoxPageSurface.tsx | 87 +++++++++++++++++-- .../__tests__/ProxmoxNodesTable.test.tsx | 11 ++- .../ProxmoxPageSurface.contract.test.tsx | 34 ++++++++ 21 files changed, 425 insertions(+), 55 deletions(-) create mode 100644 frontend-modern/src/components/shared/__tests__/contextualFocus.visibility.test.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index de97ab6f53..5c02c02598 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index de4a20b826..ef912446fc 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index af54ccd561..66d34fce95 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 91aebb51dc..a152c7a2d2 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 0a4681caa6..8bd43ae87e 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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." } diff --git a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx index cdf52103ac..448211f620 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx @@ -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; diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx index fcde82e04b..0d72951e29 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.k8s.test.tsx @@ -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', () => { diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts index 872861165f..83267b1a78 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts @@ -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({ diff --git a/frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts b/frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts index 6a9721a40e..a6d6313c79 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts @@ -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; + nodes?: Accessor; isWorkloadsRoute: Accessor; allowEmbeddedScopeFilters: Accessor; viewMode: Accessor; @@ -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()), diff --git a/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts b/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts index a62fac5a86..3eebf65645 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts @@ -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; + nodes?: Accessor; forcedPlatform?: string; forcedViewMode?: ViewMode; routeStateEnabled?: Accessor; @@ -125,6 +126,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) { const filterOptions = useWorkloadFilterOptions({ allGuests: options.allGuests, + nodes: options.nodes, isWorkloadsRoute, allowEmbeddedScopeFilters: () => true, viewMode: filterViewMode, diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index 7a4e1fdccb..1585098128 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -251,6 +251,24 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { ), ); + const infrastructureNodes = createMemo(() => { + const merged = new Map(); + 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(() => { - const merged = new Map(); - 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 = props.memoryDisplayBasis ?? (() => 'guest'); const memoryParentNodeByGuestId = createMemo(() => diff --git a/frontend-modern/src/components/Workloads/workloadRouteModel.ts b/frontend-modern/src/components/Workloads/workloadRouteModel.ts index 6543ba513e..d92ec87b2e 100644 --- a/frontend-modern/src/components/Workloads/workloadRouteModel.ts +++ b/frontend-modern/src/components/Workloads/workloadRouteModel.ts @@ -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; + +export const buildWorkloadNodeOptions = ( + guests: WorkloadGuest[], + nodes: readonly WorkloadInventoryNode[] = [], +): WorkloadNodeOption[] => { const labelsByScope = new Map(); const scopesByLabel = new Map>(); + // 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(); + 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)); diff --git a/frontend-modern/src/components/Workloads/workloadTopology.ts b/frontend-modern/src/components/Workloads/workloadTopology.ts index 0a5fb87109..11a758b508 100644 --- a/frontend-modern/src/components/Workloads/workloadTopology.ts +++ b/frontend-modern/src/components/Workloads/workloadTopology.ts @@ -30,7 +30,7 @@ const dedupeTrimmed = (values: Array): string[] => { return result; }; -export const workloadNodeScopeId = (guest: WorkloadGuest): string => +export const workloadNodeScopeId = (guest: Pick): string => `${(guest.instance || '').trim()}-${(guest.node || '').trim()}`; export const getKubernetesContextKey = (guest: WorkloadGuest): string => { diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 055920188f..62789a0ff0 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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(' { + 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' }); + }); +}); diff --git a/frontend-modern/src/components/shared/contextualFocus.ts b/frontend-modern/src/components/shared/contextualFocus.ts index f62807e681..fcb5fb9eb5 100644 --- a/frontend-modern/src/components/shared/contextualFocus.ts +++ b/frontend-modern/src/components/shared/contextualFocus.ts @@ -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, diff --git a/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx b/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx index 443602013c..2e2924eaa0 100644 --- a/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx +++ b/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx @@ -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} /> ); diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index 77df3e52a0..05796330cb 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -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; + onShowGuests?: (node: Resource) => void; + guestFilterNodeId?: Accessor; }> = (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} /> - {visibleNodeLabel()} + @@ -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} > {(column) => renderColumnCell(column)} diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index 06ba3c6f6b..889c417e4f 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -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 (
@@ -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} />
- Guests - - - {workloadsState.allGuests().length} - +
+

+ {selectedNodeLabel() ? `Guests on ${selectedNodeLabel()}` : 'Guests'} + + + {workloadsState.filteredGuests().length} + + +

+ + - +
} />
diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx index 7d37324f78..7c780b1316 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx @@ -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(() => ( } 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( diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index 4eb7701e7d..ca49fd2f4e 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -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 & Pick): 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([ From 709eeb2f7719a4e288cc6b9117d5fd708889a036 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:36:06 +0100 Subject: [PATCH 2/2] Align node filter source contracts with inventory options Keep the shared-owner and memoization assertions while allowing the platform-scoped inventory argument used to preserve nodes without guests. --- .../WorkloadsSurface.performance.contract.test.tsx | 4 ++-- .../__tests__/frontendResourceTypeBoundaries.test.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index c71532cba1..20016a56cc 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -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) =>', diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 7df9ac6186..35e2debb92 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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) =>', );