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__/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/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([ 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) =>', );