From 549a4ca821fb9d749d269ce3530d8416bd17c46f Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:22:31 +0100 Subject: [PATCH 1/4] Keep workload identity stable across node moves Use unified resource IDs for workload rows and selection so moving a guest between Proxmox nodes retains its open drawer and History range. Resolve existing bookmarks through the registry instead of rebuilding client IDs. Migrate guest metadata before accepted inventory publication and retain its operator fields in memory if persistence fails. Native metadata and metrics coordinates remain explicit compatibility inputs. Operator policy and paid execution behavior remain intact. Register the focused publication and writer proofs once. Check proof acceptance and rejection without copying the registry catalog into tests. Refs #1669 --- .../v6/internal/subsystems/api-contracts.md | 5 + .../v6/internal/subsystems/monitoring.md | 10 ++ .../subsystems/performance-and-scalability.md | 9 +- .../v6/internal/subsystems/registry.json | 2 + .../internal/subsystems/unified-resources.md | 13 +- .../173fbf8583c15cb5.json | 74 +++++++++ .../src/api/__tests__/resources.test.ts | 12 ++ frontend-modern/src/api/resources.ts | 12 ++ .../resourceDetailDiscoveryModel.ts | 16 +- .../components/Workloads/GuestDrawer.test.tsx | 13 +- .../components/Workloads/WorkloadPanel.tsx | 2 + .../components/Workloads/WorkloadsSurface.tsx | 1 + .../components/Workloads/WorkloadsTable.tsx | 2 + ...sistantContextModel.branchcov0712b.test.ts | 22 ++- .../useWorkloadGuestMetadataState.test.ts | 73 ++++++++- .../useWorkloadSelectionState.test.ts | 145 +++++++++++++++++- .../components/Workloads/guestDrawerModel.ts | 10 ++ .../Workloads/useGuestDrawerState.ts | 24 ++- .../useWorkloadGuestMetadataState.ts | 23 ++- .../Workloads/useWorkloadSelectionState.ts | 73 +++++++-- .../components/Workloads/useWorkloadsState.ts | 15 +- .../src/hooks/__tests__/useWorkloads.test.ts | 44 +++++- frontend-modern/src/hooks/useWorkloads.ts | 22 +-- frontend-modern/src/types/workloads.ts | 2 +- .../workloads.branchcov0724pm.test.ts | 20 +-- .../src/utils/__tests__/workloads.test.ts | 90 ++++++++--- frontend-modern/src/utils/workloads.ts | 47 +++--- internal/config/guest_metadata.go | 6 +- internal/config/guest_metadata_writer_test.go | 51 ++++++ .../monitoring/guest_poll_ownership_test.go | 124 +++++++++++++++ internal/monitoring/guest_poll_publication.go | 20 +++ .../monitoring/monitor_polling_containers.go | 5 - internal/monitoring/monitor_pve_guest_poll.go | 10 -- .../canonical_completion_guard_test.py | 81 ++-------- 34 files changed, 851 insertions(+), 227 deletions(-) create mode 100644 frontend-modern/browser-verification/173fbf8583c15cb5.json diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b8f7c07e23..a18ccc1771 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3212,6 +3212,11 @@ a new API state machine, queue contract, or verification-accounting field. verification adapters, but they must remain read-only views of the canonical action audit and lifecycle trail rather than a second audit store. 6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resourceapi/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection. + Resource reference reads use `ResourceAPI.getResource()` and the existing + `GET /api/resources/{id}` resolver. The registry owns current IDs, declared + succession and unique legacy/source aliases. Frontend selection cancels a + superseded lookup and applies only its returned resource ID. A refused or + ambiguous reference cannot be translated by guessing instance, node or VMID. Compact aggregate consumers must read `GET /api/resources/stats` through `ResourceAPI.getStats()` rather than paging `/api/resources` and discarding every row. The stats client preserves the backend-owned total, type, status, diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 63a65fd906..44b4811e79 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -6517,6 +6517,11 @@ snapshot cannot resurrect a deleted or migrated entry or overwrite an operator edit. The existing tenant-local JSON shape, private file permissions and atomic temporary-file/rename path stay unchanged. A failed write is reported and not spin-retried; a later ordinary identity poll can retry the unpersisted state. +An accepted guest-key migration retains its operator fields under the new key +in memory even when persistence fails. Atomic persistence keeps the previous +successful disk snapshot, and the existing writer records and retries the +retained state on an ordinary update. A failed write cannot restore an obsolete +in-memory key after the guest's new coordinates have been admitted. `WaitForPendingWrites` reports quiescence, not durable success. `Close` seals mutation admission and observes the background writer and any admitted @@ -7285,6 +7290,11 @@ admitted publication; late inventories/details cannot recreate or erase the replacement's guest readings. An interrupted inventory does not grant fallback. One cycle per live binding owns collection until it completes, including when its caller is cancelled. Another connection can still advance independently. +Before publishing accepted VM/container coordinates, that same owner migrates +their persisted guest metadata keys. Readers cannot observe a new node ahead +of its saved web URL, tags and notes. Rejected or replaced cycles do not migrate +metadata. Collector-local post-publication callbacks own diagnostics and alerts, +while metadata migration belongs to the complete accepted publication. Deep-enrichment/caller-deadline expiry is distinct from explicit parent-cycle cancellation: already collected authoritative inventory still publishes even 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 c70867819a..f4a231634f 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1305,6 +1305,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. rows without platform metadata retain the Proxmox-compatible behavior at the migration boundary. 11. Extend workload drawer derivations and runtime wiring through `frontend-modern/src/components/Workloads/guestDrawerModel.ts` and `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Workloads/GuestDrawer.tsx` + The selected resource owns its drawer tab and History range. Regrouping the + same resource under a new parent or node retains both even if its drawer + remounts. Closing or selecting another resource restores the default view. + History continues to consume the explicit backend-owned metrics target. The guest drawer header owns the saved web-interface launch action. It reuses the shared `WebInterfaceLink` and drawer-header action class, so phone and narrow rows keep hiding the adjacent row link for name legibility @@ -1391,7 +1395,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. max-disk derivations must skip sentinel rows rather than surface them as 0% or negative percents. 13. Extend workload guest metadata cache persistence, metadata refresh, org-scope switching, and optimistic custom-URL updates through `frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts` rather than rebuilding workload-local storage caches, event listeners, or guest metadata API wiring inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts` -14. Extend workload deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Workloads/workloadSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`; canonical typed workload IDs such as `app-container::` must remain exact route/selection keys and must not be reinterpreted into synthetic node scopes + A changed set of native metadata keys refreshes that cache after accepted + inventory publication. Metric-only updates do not refetch it. Superseded + responses cannot overwrite a newer refresh, confirmed edit or tenant scope. +14. Extend workload deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Workloads/workloadSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`. Every workload row and selection key is the exact unified `Resource.id`. Historical references use the API contract's existing server resolver and never a client-reconstructed node or pod identity. 15. Extend workload route ownership, route-driven option catalogs, and toolbar filter config through `frontend-modern/src/components/Workloads/useWorkloadRouteState.ts`, `frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts`, `frontend-modern/src/components/Workloads/workloadRouteModel.ts`, `frontend-modern/src/components/Workloads/workloadFilterConfigModel.ts`, and `frontend-modern/src/components/Workloads/workloadRouteStateModel.ts`, and extend query-param synchronization plus managed workload URL semantics through `frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts` and `frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts`, rather than rebuilding route sync, alias parsing, option derivation, toolbar callback/config wiring, reset policy, node-selection compatibility rules, param precedence, or managed workload URLs inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts` Workloads route host scopes must resolve through `frontend-modern/src/components/Workloads/workloadTopology.ts`: app-container scopes use the canonical Docker/runtime host id before host labels or node fallbacks, while VM and system-container scopes keep the instance-node key. Route `agent` filters and option catalogs must consume that shared scope so Infrastructure related-workload links cannot drift from Workloads filtering. Workloads route-state writes must preserve the owning platform/runtime diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 4aa61ae657..4b8f631a2f 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -6533,12 +6533,14 @@ "exact_files": [ "internal/config/docker_metadata_test.go", "internal/config/guest_metadata_test.go", + "internal/config/guest_metadata_writer_test.go", "internal/monitoring/availability_poller_test.go", "internal/monitoring/availability_probe_agent_test.go", "internal/monitoring/availability_udp_test.go", "internal/monitoring/canonical_guardrails_test.go", "internal/monitoring/ceph_test.go", "internal/monitoring/deadman_test.go", + "internal/monitoring/guest_poll_ownership_test.go", "internal/monitoring/issue1485_unraid_lifecycle_test.go", "internal/monitoring/issue1595_collection_trust_test.go", "internal/monitoring/issue1613_contract_test.go", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 384e1e387f..16e757d4b4 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -5202,12 +5202,13 @@ Docker-only semantics when host telemetry is also present. Runtime-local filters like `agent` or cluster context may still be added as secondary scope, but the canonical platform query must remain the first-class route-state boundary for shared workload navigation. -That same shared workload-route contract also owns exact guest selection for -node-scoped workloads. Proxmox VM and system-container links must emit the -canonical workload identity (`::`) in the shared -`resource` query rather than an opaque unified-resource id, so direct route -loads and cross-surface drill-downs reopen the correct workload drawer instead -of landing on an unselected table state. +Shared workload routes, rows and selection use the exact unified `Resource.id` +for every workload kind. Moving a Proxmox guest to another node does not create +a new selection identity. Existing node-scoped bookmarks resolve through the +registry's current/declared-succession/unique-alias boundary, with ambiguity +refused. Provider coordinates remain explicit compatibility inputs for guest +metadata, native collection and History targets. They must not be rebuilt into +primary row identity or used to merge independent instance clones. Unified resources carry no recovery route-state vocabulary. The recovery query serializer (`RECOVERY_QUERY_PARAMS`, `parseRecoveryLinkSearch`, `buildRecoveryRouteSearch`) was deleted from diff --git a/frontend-modern/browser-verification/173fbf8583c15cb5.json b/frontend-modern/browser-verification/173fbf8583c15cb5.json new file mode 100644 index 0000000000..c310d3f283 --- /dev/null +++ b/frontend-modern/browser-verification/173fbf8583c15cb5.json @@ -0,0 +1,74 @@ +{ + "version": 1, + "base_sha": "acd1e4e31d666f663b1e25c7da051dee1b860af9", + "verified_at": "2026-10-12T18:16:41.185854Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/api/resources.ts", + "frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts", + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", + "frontend-modern/src/components/Workloads/WorkloadsTable.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts", + "frontend-modern/src/components/Workloads/useGuestDrawerState.ts", + "frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts", + "frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/types/workloads.ts", + "frontend-modern/src/utils/workloads.ts" + ], + "content_sha256": { + "frontend-modern/src/api/resources.ts": "7bac38bd0a3d2b0835b5bb8dbf7afcbf6d3af4e3c869120f9c72da5bd6c8e241", + "frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts": "ef0967155b47d927c7788a0529fb73810ba249261d9e1a1721e07a1db076e91a", + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "b471805bd3f7b10e0d067d13e26ed1cba4d5eea6257e74cdf55dbeed8276c402", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "c333d8f2ad4e79c8a3a15071da2bba2750aabd7d94f668af96508fa7a8828968", + "frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "e085a4a0c768c95c08f4fcb29b08f3d7d3d9b2e84e4c5837d27218ebde3bb575", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "a328b69f68e75369f89f83312a8fcb03ae2687cedd0add1ac4b269285d1ed74e", + "frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "75b81a2e8b9f5dbf398c79834c52bfddb6690ea2d444d3298c3141361f258ebc", + "frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts": "e52ab1e8f235cb8c710992837e0cb2261fcfb2a0bb77af7c8cdbb9c8ddc70e02", + "frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts": "576064a2b41efca46e1fac359f33a2bbf4385a76d69098eda698577d6be146b4", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "7b3a3e963dd70eb0bbb97e993d3de9bcdb042f677ff3e621dce8f098968819e0", + "frontend-modern/src/hooks/useWorkloads.ts": "7312dc374ad4e029802f8979c3fd1daa84a3401ff7c3ba4086f93ce30d1bf4c8", + "frontend-modern/src/types/workloads.ts": "bc596272a83bbf19fa7d21746f9b23a2eadb5f530454145f07d5a333a03801f5", + "frontend-modern/src/utils/workloads.ts": "3c72776bcee221920140010906040b77279997263a3cb8ef6265e04ddd1d4802" + }, + "routes": [ + "/proxmox/overview", + "/proxmox/overview?type=vm", + "/proxmox/overview?type=system-container" + ], + "viewports": [ + { + "width": 1280, + "height": 800 + }, + { + "width": 900, + "height": 800 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Current embedded build on private pulse-dev mock-mode server, binary SHA256 920cecf64e0a64836b118958a664a5b81bbda9e3c12eedfef1240cfb76882fff.", + "VM and LXC canonical routes and real server resolution of existing instance:node:VMID bookmarks select the exact canonical row.", + "Full resource updates move each selected guest to a new parent and node while retaining its open drawer, History tab and 12h range.", + "Normal UI web URL writes succeed. The metadata cache refetches its changed native key, retains the saved link and survives reload. The browser GET fixture mirrors accepted server migration, which backend filesystem-barrier tests verify independently.", + "Canonical operator policy retains its saved note and never-auto-remediate lock across movement and settled reload. The recurring maintenance editor remains usable and cancels without closing the resource.", + "A failed metadata PUT leaves the editable drawer open. Delayed legacy-reference replies cannot replace later manual selection or a newer route, or reopen an escaped selection.", + "Unknown and HTTP403-refused references select no unrelated workload. All browser cases report no page errors.", + "Actual screenshots inspected at desktop, intermediate and phone widths for both workload kinds, History, saved URL controls and the operator editor. Movement is a controlled publication fixture, not native Proxmox hardware qualification." + ], + "interactions": [ + "Open canonical and legacy resource links for VM and LXC", + "Save a web interface URL through Manage, focus the saved link, inspect History and select12h", + "Publish same-resource node/parent changes, inspect regrouping and retained view, then reload", + "Escape, pointer or keyboard reopen, header collapse and default-view reset", + "Save operator note and confirm remediation lock, reload, open recurring scheduler and cancel", + "Exercise metadata write refusal and delayed reference lookup followed by another selection, Escape or route change", + "Refuse a legacy lookup with HTTP403 and inspect unknown-resource selection" + ] +} diff --git a/frontend-modern/src/api/__tests__/resources.test.ts b/frontend-modern/src/api/__tests__/resources.test.ts index 2f0e3f2978..ca8bc5a33b 100644 --- a/frontend-modern/src/api/__tests__/resources.test.ts +++ b/frontend-modern/src/api/__tests__/resources.test.ts @@ -11,6 +11,18 @@ describe('ResourceAPI', () => { vi.clearAllMocks(); }); + it('resolves an encoded resource reference with cancellable authenticated lookup', async () => { + const signal = new AbortController().signal; + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ id: 'stable-resource' }); + await expect(ResourceAPI.getResource(' cluster-a:node-1:101 ', { signal })).resolves.toEqual({ + id: 'stable-resource', + }); + expect(apiFetchJSON).toHaveBeenCalledWith('/api/resources/cluster-a%3Anode-1%3A101', { + cache: 'no-store', + signal, + }); + }); + it('fetches compact resource stats without hydrating list pages', async () => { const stats = { total: 1200, diff --git a/frontend-modern/src/api/resources.ts b/frontend-modern/src/api/resources.ts index 060923f538..5a6916f217 100644 --- a/frontend-modern/src/api/resources.ts +++ b/frontend-modern/src/api/resources.ts @@ -1,5 +1,6 @@ import { apiFetchJSON } from '@/utils/apiClient'; import type { + Resource, ResourceCapability, ResourceChange, ResourceChangeKind, @@ -74,6 +75,17 @@ const fetchFacet = async (url: string): Promise => }); export class ResourceAPI { + // The backend owns alias, succession and ambiguity resolution. + static async getResource( + reference: string, + options?: { signal?: AbortSignal }, + ): Promise { + return apiFetchJSON( + `/api/resources/${encodeURIComponent(normalizeResourceId(reference))}`, + { cache: 'no-store', signal: options?.signal }, + ); + } + static async getStats(): Promise { return fetchFacet('/api/resources/stats'); } diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts index 196b97283d..e5b8190bec 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts @@ -17,7 +17,7 @@ import { import { buildAppContainerMetadataId, buildKubernetesWorkloadMetadataId, - getCanonicalWorkloadIdForResource, + getWorkloadMetadataIdForResource, } from '@/utils/workloads'; // Resource kinds that carry agent-routed metadata coordinates. @@ -191,14 +191,12 @@ const getMetadataTarget = ( } } - // Guest metadata is keyed by the canonical workload id shared with the - // workloads surfaces (`instance:node:vmid` for PVE guests, resource id - // otherwise — also the key v5 upgrades carry over). Saving under any other - // id (unified resource hash, bare vmid, discovery resource id) strands the - // URL where no table reads it. + // Guest metadata retains its provider-keyed storage/API compatibility + // boundary. Resource identity and routing use the unified resource ID. + // Keep this key aligned with the workload metadata reader during migration. return { metadataKind: 'guest', - metadataId: getCanonicalWorkloadIdForResource(resource), + metadataId: getWorkloadMetadataIdForResource(resource), }; }; @@ -403,7 +401,7 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => resourceId: vmidResourceId || resource.id, hostname, metadataKind: 'guest', - metadataId: getCanonicalWorkloadIdForResource(resource), + metadataId: getWorkloadMetadataIdForResource(resource), targetLabel: 'guest', }; case 'system-container': @@ -417,7 +415,7 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => resourceId: vmidResourceId || resource.id, hostname, metadataKind: 'guest', - metadataId: getCanonicalWorkloadIdForResource(resource), + metadataId: getWorkloadMetadataIdForResource(resource), targetLabel: 'guest', }; case 'app-container': diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 054c32aa94..e63deb0356 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -11,7 +11,7 @@ import { createSignal } from 'solid-js'; import type { WorkloadGuest } from '@/types/workloads'; import type { Alert, Memory, Disk, GuestNetworkInterface } from '@/types/api'; import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; -import { getCanonicalWorkloadId, getWorkloadMetadataId } from '@/utils/workloads'; +import { getWorkloadMetadataId } from '@/utils/workloads'; import { resetAIRuntimeState } from '@/stores/aiRuntimeState'; import guestDrawerSource from './GuestDrawer.tsx?raw'; import guestDrawerManageSource from './GuestDrawerManage.tsx?raw'; @@ -103,6 +103,7 @@ function makeGuest(overrides: Partial = {}): WorkloadGuest { instance: 'inst1', status: 'running', type: 'qemu', + metricsTarget: { resourceType: 'vm', resourceId: 'inst1:node1:100' }, cpu: 0.25, cpus: 4, memory: { total: 4294967296, used: 2147483648, free: 2147483648, usage: 0.5 }, @@ -1404,10 +1405,10 @@ describe('GuestDrawer', () => { // ── WebInterfaceUrlField ── describe('WebInterfaceUrlField', () => { - it('passes canonical metadataId using shared workload identity', () => { + it('keeps the guest metadata key separate from canonical row identity', () => { render(() => ); expect(screen.getByTestId('url-id').textContent).toBe( - getCanonicalWorkloadId(makeGuest({ id: 'my-guest-id' })), + getWorkloadMetadataId(makeGuest({ id: 'my-guest-id' })), ); }); @@ -1430,16 +1431,14 @@ describe('GuestDrawer', () => { ); }); - it('builds canonical id from instance:node:vmid when id is empty', () => { + it('does not invent a resource identity for a guest with no ID', () => { render(() => ( )); - expect(screen.getByTestId('url-id').textContent).toBe( - getCanonicalWorkloadId(makeGuest({ id: '', instance: 'pve', node: 'n1', vmid: 200 })), - ); + expect(screen.queryByTestId('url-id')).not.toBeInTheDocument(); }); it('labels app-container guests as "container"', () => { diff --git a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx index b3277dc577..ddfa83e34a 100644 --- a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx @@ -38,6 +38,7 @@ type WorkloadPanelProps = Pick< | 'nestedWorkloadContextByGuestId' | 'nodeByInstance' | 'search' + | 'selectedGuestDrawerState' | 'selectedGuestId' | 'setHoveredWorkloadId' | 'setSelectedGuestId' @@ -197,6 +198,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) { data-inline-detail-for={guestId()} > props.setSelectedGuestId(null)} metadataId={metadataIdCandidates()[0] || guestId()} diff --git a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx index 66299c985b..22d3e61cd9 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx @@ -156,6 +156,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceProps) { nestedWorkloadContextByGuestId={state.nestedWorkloadContextByGuestId} nodeByInstance={state.nodeByInstance} search={state.search} + selectedGuestDrawerState={state.selectedGuestDrawerState} selectedGuestId={state.selectedGuestId} setHoveredWorkloadId={state.setHoveredWorkloadId} setSelectedGuestId={state.setSelectedGuestId} diff --git a/frontend-modern/src/components/Workloads/WorkloadsTable.tsx b/frontend-modern/src/components/Workloads/WorkloadsTable.tsx index 82c4bb13cb..9dc87d2597 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsTable.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsTable.tsx @@ -32,6 +32,7 @@ type WorkloadsTableProps = Pick< | 'nestedWorkloadContextByGuestId' | 'nodeByInstance' | 'search' + | 'selectedGuestDrawerState' | 'selectedGuestId' | 'setHoveredWorkloadId' | 'setSelectedGuestId' @@ -137,6 +138,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) { nestedWorkloadContextByGuestId={props.nestedWorkloadContextByGuestId} nodeByInstance={props.nodeByInstance} search={props.search} + selectedGuestDrawerState={props.selectedGuestDrawerState} selectedGuestId={props.selectedGuestId} setHoveredWorkloadId={props.setHoveredWorkloadId} setSelectedGuestId={props.setSelectedGuestId} diff --git a/frontend-modern/src/components/Workloads/__tests__/guestAssistantContextModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/guestAssistantContextModel.branchcov0712b.test.ts index 11745a4bc5..dadc7a690c 100644 --- a/frontend-modern/src/components/Workloads/__tests__/guestAssistantContextModel.branchcov0712b.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/guestAssistantContextModel.branchcov0712b.test.ts @@ -188,7 +188,7 @@ describe('guestAssistantContextModel (branch coverage)', () => { expect(result.handoffResources?.[0]?.name).toBe('web-vm'); }); - it('falls back to the canonical node-scoped id when name is empty', () => { + it('falls back to the supplied resource identity when name is empty', () => { const result = buildGuestAssistantContext( makeGuest({ id: 'vm-101', @@ -201,10 +201,10 @@ describe('guestAssistantContextModel (branch coverage)', () => { }), ); - // canonicalId = buildCanonicalNodeScopedWorkloadId -> "pve-a:node-a:101" - expect(result.targetId).toBe('pve-a:node-a:101'); - expect(result.briefing?.title).toBe('pve-a:node-a:101'); - expect(result.handoffResources?.[0]?.name).toBe('pve-a:node-a:101'); + // Placement coordinates do not replace the supplied resource identity. + expect(result.targetId).toBe('vm-101'); + expect(result.briefing?.title).toBe('vm-101'); + expect(result.handoffResources?.[0]?.name).toBe('vm-101'); }); }); @@ -230,10 +230,10 @@ describe('guestAssistantContextModel (branch coverage)', () => { expect(result).toStrictEqual({ targetType: 'resource', - targetId: 'pve-a:node-a:101', + targetId: 'vm-101', context: { source: 'guest-drawer', - resourceId: 'pve-a:node-a:101', + resourceId: 'vm-101', resourceType: 'vm', resourceStatus: 'running', }, @@ -242,14 +242,10 @@ describe('guestAssistantContextModel (branch coverage)', () => { title: 'web-vm', subject: 'vm / running / proxmox-ve', statusLabel: 'Read-only context attached', - detailLines: [ - 'Resource ID: pve-a:node-a:101', - 'Primary identity: vm-101', - 'Parent: node-a', - ], + detailLines: ['Resource ID: vm-101', 'Parent: node-a'], safetyNote: 'Approval required before any action.', }, - handoffResources: [{ id: 'pve-a:node-a:101', name: 'web-vm', type: 'vm', node: 'node-a' }], + handoffResources: [{ id: 'vm-101', name: 'web-vm', type: 'vm', node: 'node-a' }], handoffMetadata: { kind: 'resource_context' }, autonomousMode: false, }); diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadGuestMetadataState.test.ts b/frontend-modern/src/components/Workloads/__tests__/useWorkloadGuestMetadataState.test.ts index b4af1b7ff6..a37edd43d9 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadGuestMetadataState.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadGuestMetadataState.test.ts @@ -1,7 +1,9 @@ -import { createRoot } from 'solid-js'; +import { createRoot, createSignal } from 'solid-js'; import { renderHook } from '@solidjs/testing-library'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GuestMetadataAPI } from '@/api/guestMetadata'; +import type { GuestMetadata } from '@/api/guestMetadata'; import { useWorkloadGuestMetadataState } from '../useWorkloadGuestMetadataState'; import { RESOURCE_METADATA_CHANGED_EVENT, @@ -89,4 +91,73 @@ describe('useWorkloadGuestMetadataState', () => { expect(result.guestMetadata()['machine-1']).toBeUndefined(); cleanup(); }); + it('reloads migrated metadata keys without losing the record contents', async () => { + const original = { + id: 'cluster-a:node-1:101', + customUrl: 'https://guest.example', + notes: ['Operator note'], + tags: ['production'], + }; + const migrated = { ...original, id: 'cluster-a:node-2:101' }; + vi.mocked(GuestMetadataAPI.getAllMetadata) + .mockReset() + .mockResolvedValueOnce({ [original.id]: original }) + .mockResolvedValueOnce({ [migrated.id]: migrated }); + const [ids, setIds] = createSignal>(new Set([original.id])); + const { result, cleanup } = renderHook(() => useWorkloadGuestMetadataState(ids)); + await Promise.resolve(); + expect(result.guestMetadata()[original.id]).toEqual(original); + setIds(new Set([migrated.id])); + await Promise.resolve(); + expect(GuestMetadataAPI.getAllMetadata).toHaveBeenCalledTimes(2); + expect(result.guestMetadata()[migrated.id]).toEqual(migrated); + expect(result.guestMetadata()[original.id]).toBeUndefined(); + cleanup(); + }); + + it('does not let a stale bulk read overwrite a confirmed metadata edit', async () => { + let finish!: (metadata: Record) => void; + vi.mocked(GuestMetadataAPI.getAllMetadata) + .mockReset() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result, cleanup } = renderHook(() => useWorkloadGuestMetadataState()); + result.handleCustomUrlUpdate('cluster-a:node-1:101', 'https://new.example'); + finish({ + 'cluster-a:node-1:101': { id: 'cluster-a:node-1:101', customUrl: 'https://old.example' }, + }); + await Promise.resolve(); + expect(result.guestMetadata()['cluster-a:node-1:101']?.customUrl).toBe('https://new.example'); + cleanup(); + }); + + it('accepts only the latest metadata-coordinate refresh', async () => { + let finishOld!: (metadata: Record) => void; + vi.mocked(GuestMetadataAPI.getAllMetadata) + .mockReset() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve; + }), + ) + .mockResolvedValueOnce({ + 'cluster-a:node-2:101': { id: 'cluster-a:node-2:101', customUrl: 'https://new.example' }, + }); + const [ids, setIds] = createSignal>(new Set(['cluster-a:node-1:101'])); + const { result, cleanup } = renderHook(() => useWorkloadGuestMetadataState(ids)); + setIds(new Set(['cluster-a:node-2:101'])); + await Promise.resolve(); + finishOld({ + 'cluster-a:node-1:101': { id: 'cluster-a:node-1:101', customUrl: 'https://old.example' }, + }); + await Promise.resolve(); + expect(result.guestMetadata()['cluster-a:node-2:101']?.customUrl).toBe('https://new.example'); + expect(result.guestMetadata()['cluster-a:node-1:101']).toBeUndefined(); + cleanup(); + }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts b/frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts index cb877cfbcd..078d7762bf 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts @@ -2,6 +2,8 @@ import { renderHook } from '@solidjs/testing-library'; import { createSignal } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ResourceAPI } from '@/api/resources'; +import type { Resource } from '@/types/resource'; import type { WorkloadGuest } from '@/types/workloads'; import { clearPendingAppShellRestoreTop, @@ -16,13 +18,15 @@ import { resolveWorkloadResourceSelection } from '../workloadSelectionModel'; import { useWorkloadSelectionState } from '../useWorkloadSelectionState'; let locationSearch = '?resource=cluster-a:node-1:101'; +let readLocationSearch = () => locationSearch; +vi.mock('@/api/resources', () => ({ ResourceAPI: { getResource: vi.fn() } })); const navigateSpy = vi.fn(); vi.mock('@solidjs/router', () => ({ useLocation: () => ({ pathname: '/proxmox/overview', get search() { - return locationSearch; + return readLocationSearch(); }, }), useNavigate: () => navigateSpy, @@ -31,6 +35,10 @@ vi.mock('@solidjs/router', () => ({ describe('useWorkloadSelectionState', () => { beforeEach(() => { locationSearch = '?resource=cluster-a:node-1:101'; + readLocationSearch = () => locationSearch; + vi.mocked(ResourceAPI.getResource) + .mockReset() + .mockImplementation(async (id) => ({ id }) as Resource); navigateSpy.mockReset(); vi.useFakeTimers(); vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { @@ -348,4 +356,139 @@ describe('useWorkloadSelectionState', () => { expect(readPendingAppShellRestoreTop()).toBeNull(); expect(navigateSpy).not.toHaveBeenCalled(); }); + it('resolves an old node-scoped link through backend identity ownership', async () => { + vi.mocked(ResourceAPI.getResource).mockResolvedValueOnce({ id: 'stable-resource' } as Resource); + const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] })); + await Promise.resolve(); + await Promise.resolve(); + expect(result.selectedGuestId()).toBe('stable-resource'); + expect(ResourceAPI.getResource).toHaveBeenCalledWith('cluster-a:node-1:101', { + signal: expect.any(AbortSignal), + }); + expect(navigateSpy).not.toHaveBeenCalled(); + }); + + it('keeps exact current identity local without an alias lookup', () => { + locationSearch = '?resource=stable-resource'; + const guest = { + id: 'stable-resource', + canonicalResourceId: 'stable-resource', + } as WorkloadGuest; + const { result } = renderHook(() => + useWorkloadSelectionState({ filteredGuests: () => [guest] }), + ); + expect(result.selectedGuestId()).toBe('stable-resource'); + expect(ResourceAPI.getResource).not.toHaveBeenCalled(); + }); + + it.each(['close', 'reselect', 'escape'] as const)( + 'does not replay a pending link after %s', + async (interaction) => { + let finish!: (resource: Resource) => void; + vi.mocked(ResourceAPI.getResource).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] })); + const signal = vi.mocked(ResourceAPI.getResource).mock.calls[0]![1]!.signal!; + if (interaction === 'close') result.clearPinnedSummaryScope(); + else if (interaction === 'reselect') result.setSelectedGuestId('independent-clone'); + else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(signal.aborted).toBe(true); + finish({ id: 'stable-resource' } as Resource); + await Promise.resolve(); + await Promise.resolve(); + expect(result.selectedGuestId()).toBe( + interaction === 'reselect' ? 'independent-clone' : null, + ); + }, + ); + + it('aborts a superseded route lookup and accepts only its current reference', async () => { + const [search, setSearch] = createSignal('?resource=old-reference'); + readLocationSearch = search; + let finishOld!: (resource: Resource) => void; + vi.mocked(ResourceAPI.getResource) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve; + }), + ) + .mockResolvedValueOnce({ id: 'new-resource' } as Resource); + const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] })); + const oldSignal = vi.mocked(ResourceAPI.getResource).mock.calls[0]![1]!.signal!; + setSearch('?resource=new-reference'); + await Promise.resolve(); + await Promise.resolve(); + finishOld({ id: 'old-resource' } as Resource); + await Promise.resolve(); + expect(oldSignal.aborted).toBe(true); + expect(result.selectedGuestId()).toBe('new-resource'); + }); + + it('does not resolve references while route state is disabled', () => { + const { result } = renderHook(() => + useWorkloadSelectionState({ filteredGuests: () => [], routeStateEnabled: () => false }), + ); + expect(result.selectedGuestId()).toBeNull(); + expect(ResourceAPI.getResource).not.toHaveBeenCalled(); + }); + + it('leaves an ambiguous or refused reference unresolved', async () => { + vi.mocked(ResourceAPI.getResource).mockRejectedValueOnce(new Error('not found')); + const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] })); + await Promise.resolve(); + await Promise.resolve(); + expect(result.selectedGuestId()).toBe('cluster-a:node-1:101'); + expect(navigateSpy).not.toHaveBeenCalled(); + }); + + it('keeps a pending reference lookup and local dismissal through unrelated filter changes', async () => { + const [search, setSearch] = createSignal('?resource=old-reference'); + readLocationSearch = search; + let finish!: (resource: Resource) => void; + vi.mocked(ResourceAPI.getResource).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] })); + const signal = vi.mocked(ResourceAPI.getResource).mock.calls[0]![1]!.signal!; + setSearch('?resource=old-reference&type=vm'); + expect(signal.aborted).toBe(false); + expect(ResourceAPI.getResource).toHaveBeenCalledTimes(1); + finish({ id: 'stable-resource' } as Resource); + await Promise.resolve(); + await Promise.resolve(); + expect(result.selectedGuestId()).toBe('stable-resource'); + result.clearPinnedSummaryScope(); + setSearch('?resource=old-reference&type=vm&node=node-2'); + expect(result.selectedGuestId()).toBeNull(); + expect(ResourceAPI.getResource).toHaveBeenCalledTimes(1); + }); + + it('keeps the selected resource drawer state through regrouping and resets it on another selection', () => { + locationSearch = ''; + const [guests, setGuests] = createSignal([ + { id: 'stable-resource', node: 'node-1' }, + ] as WorkloadGuest[]); + const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: guests })); + result.setSelectedGuestId('stable-resource'); + result.selectedGuestDrawerState.setActiveTab('history'); + result.selectedGuestDrawerState.setHistoryRange('12h'); + setGuests([{ id: 'stable-resource', node: 'node-2' }] as WorkloadGuest[]); + result.setSelectedGuestId('stable-resource'); + expect(result.selectedGuestDrawerState.activeTab()).toBe('history'); + expect(result.selectedGuestDrawerState.historyRange()).toBe('12h'); + result.setSelectedGuestId('independent-clone'); + expect(result.selectedGuestDrawerState.activeTab()).toBe('overview'); + expect(result.selectedGuestDrawerState.historyRange()).toBe('24h'); + result.selectedGuestDrawerState.setActiveTab('manage'); + result.clearPinnedSummaryScope(); + expect(result.selectedGuestDrawerState.activeTab()).toBe('overview'); + }); }); diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index cb06222121..c918fa9c74 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -1,3 +1,4 @@ +import type { Accessor, Setter } from 'solid-js'; import type { WorkloadGuest } from '@/types/workloads'; import type { Alert, VM } from '@/types/api'; import type { @@ -31,6 +32,7 @@ type Guest = WorkloadGuest; export interface GuestDrawerProps { guest: Guest; + viewState?: GuestDrawerViewState; onClose: () => void; metadataId?: string; customUrl?: string; @@ -44,6 +46,14 @@ export interface GuestDrawerProps { export type GuestDrawerTab = 'overview' | 'history' | 'manage'; +export interface GuestDrawerViewState { + activeTab: Accessor; + setActiveTab: Setter; + historyRange: Accessor; + setHistoryRange: Setter; + reset: () => void; +} + export interface GuestDrawerHistoryTarget { resourceType: HistoryResourceType; resourceId: string; diff --git a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts index 8d0a49be66..f07fcdc144 100644 --- a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts +++ b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts @@ -1,7 +1,7 @@ import { createEffect, createMemo, createSignal } from 'solid-js'; -import type { HistoryTimeRange } from '@/api/charts'; import { useAlertsActivation } from '@/stores/alertsActivation'; +import type { HistoryTimeRange } from '@/api/charts'; import type { DisplayMetricType } from '@/utils/metricThresholds'; import { getCanonicalWorkloadId, @@ -19,12 +19,13 @@ import { getGuestDrawerAgentHeading, getGuestDrawerAgentTitle, getGuestDrawerBackupPresentation, - GUEST_DRAWER_HISTORY_DEFAULT_RANGE, getGuestDrawerHistoryTarget, getGuestDrawerNetworkInterfaces, hasGuestDrawerFilesystemDetails, hasGuestDrawerOsInfo, normalizeGuestDrawerTags, + GUEST_DRAWER_HISTORY_DEFAULT_RANGE, + type GuestDrawerViewState, type GuestDrawerProps, type GuestDrawerTab, } from './guestDrawerModel'; @@ -34,12 +35,27 @@ import { shouldShowInGuestAgentInstallCue, } from './workloadAgentReadiness'; -export function useGuestDrawerState(props: GuestDrawerProps) { - const alertsActivation = useAlertsActivation(); +export function createGuestDrawerViewState(): GuestDrawerViewState { const [activeTab, setActiveTab] = createSignal('overview'); const [historyRange, setHistoryRange] = createSignal( GUEST_DRAWER_HISTORY_DEFAULT_RANGE, ); + return { + activeTab, + setActiveTab, + historyRange, + setHistoryRange, + reset: () => { + setActiveTab('overview'); + setHistoryRange(GUEST_DRAWER_HISTORY_DEFAULT_RANGE); + }, + }; +} + +export function useGuestDrawerState(props: GuestDrawerProps) { + const alertsActivation = useAlertsActivation(); + const { activeTab, setActiveTab, historyRange, setHistoryRange } = + props.viewState ?? createGuestDrawerViewState(); const guestId = createMemo(() => getCanonicalWorkloadId(props.guest)); const alertThresholdScope = createMemo(() => getWorkloadAlertThresholdScope(props.guest)); diff --git a/frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts b/frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts index 2ae4027637..7421ddbd93 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts @@ -1,4 +1,12 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import { + createEffect, + createMemo, + createSignal, + on, + onCleanup, + onMount, + type Accessor, +} from 'solid-js'; import { GuestMetadataAPI } from '@/api/guestMetadata'; import type { GuestMetadata } from '@/api/guestMetadata'; import { getOrgID } from '@/utils/apiClient'; @@ -207,7 +215,8 @@ const queueGuestMetadataPersist = (storageKey: string, metadata: GuestMetadataRe } }; -export function useWorkloadGuestMetadataState() { +export function useWorkloadGuestMetadataState(metadataIds?: Accessor>) { + let refreshRevision = 0; const [orgScope, setOrgScope] = createSignal(normalizeOrgScope(getOrgID())); const guestMetadataStorageKey = createMemo(() => guestMetadataStorageKeyForOrg(orgScope())); const [guestMetadata, setGuestMetadata] = createSignal( @@ -225,8 +234,11 @@ export function useWorkloadGuestMetadataState() { }); const refreshGuestMetadata = async () => { + const revision = ++refreshRevision; + const storageKey = guestMetadataStorageKey(); try { const metadata = await GuestMetadataAPI.getAllMetadata(); + if (revision !== refreshRevision || storageKey !== guestMetadataStorageKey()) return; updateGuestMetadataState(() => metadata || {}); logger.debug('Guest metadata refreshed'); } catch (error) { @@ -244,6 +256,7 @@ export function useWorkloadGuestMetadataState() { return; } + refreshRevision += 1; updateGuestMetadataState((prev) => { const previousEntry = prev[guestId]; @@ -296,6 +309,12 @@ export function useWorkloadGuestMetadataState() { }); }; + // The server migrates provider metadata keys before publishing a node move. + // Refetch when that boundary coordinate changes, rather than retaining only + // the initial map under the previous node key. Metric-only ticks keep it. + if (metadataIds) + createEffect(on(metadataIds, () => void refreshGuestMetadata(), { defer: true })); + onMount(() => { void refreshGuestMetadata(); diff --git a/frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts b/frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts index 0840dc6949..40971c93dc 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts @@ -1,5 +1,16 @@ import { useLocation } from '@solidjs/router'; -import { createEffect, createSignal, type Accessor } from 'solid-js'; +import { + createEffect, + createMemo, + createSignal, + on, + onCleanup, + untrack, + type Accessor, +} from 'solid-js'; + +import { ResourceAPI } from '@/api/resources'; +import { getCanonicalWorkloadId } from '@/utils/workloads'; import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus'; import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus'; @@ -9,6 +20,8 @@ import { resolveWorkloadResourceSelection, } from './workloadSelectionModel'; +import { createGuestDrawerViewState } from './useGuestDrawerState'; + interface UseWorkloadsSelectionStateOptions { clearAdditionalPageStateOnEscape?: () => void; filteredGuests: Accessor; @@ -18,9 +31,10 @@ interface UseWorkloadsSelectionStateOptions { export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOptions) { const location = useLocation(); + const selectedGuestDrawerState = createGuestDrawerViewState(); const [selectedGuestId, setSelectedGuestIdRaw] = createSignal(null); const [hoveredWorkloadId, setHoveredWorkloadId] = createSignal(null); - const [handledResourceId, setHandledResourceId] = createSignal(null); + let referenceLookup: AbortController | undefined; const [revealedGuestId, setRevealedGuestId] = createSignal(null); const [tableWrapperRef, setTableWrapperRefSignal] = createSignal( @@ -29,7 +43,9 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt const [tableBodyRef, setTableBodyRef] = createSignal(null); const clearPinnedSummaryScope = () => { + referenceLookup?.abort(); preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => { + selectedGuestDrawerState.reset(); setSelectedGuestIdRaw(null); }); }; @@ -60,27 +76,49 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt // Row focus is local: it never navigates, so it must not stage an app-shell // restore that the next unrelated route change would replay. const setSelectedGuestId = (id: string | null) => { + referenceLookup?.abort(); preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => { + if (id !== selectedGuestId()) selectedGuestDrawerState.reset(); setSelectedGuestIdRaw(id); }); }; - createEffect(() => { - if (options.routeStateEnabled && !options.routeStateEnabled()) return; - const resourceId = resolveWorkloadResourceSelection(location.search); - if (!resourceId) { - if (handledResourceId() !== null) { - setSelectedGuestId(null); - setHandledResourceId(null); - } - return; - } + const routeReference = createMemo(() => resolveWorkloadResourceSelection(location.search)); + createEffect( + on( + [() => location.pathname, routeReference, () => options.routeStateEnabled?.() ?? true], + ([, reference, enabled]) => { + if (!enabled) return; + setSelectedGuestId(reference); + if (!reference) return; - if (resourceId !== handledResourceId()) { - setSelectedGuestId(resourceId); - setHandledResourceId(resourceId); - } - }); + // Exact current IDs need no translation. Historical/source references + // are resolved by the registry, which owns succession and ambiguity. + if ( + untrack(options.filteredGuests).some( + (guest) => getCanonicalWorkloadId(guest) === reference, + ) + ) + return; + const controller = new AbortController(); + referenceLookup = controller; + onCleanup(() => controller.abort()); + void ResourceAPI.getResource(reference, { signal: controller.signal }) + .then((resource) => { + if ( + !controller.signal.aborted && + selectedGuestId() === reference && + resource.id?.trim() + ) { + setSelectedGuestId(resource.id); + } + }) + .catch(() => { + // An unresolved or refused reference must not select another row. + }); + }, + ), + ); createEffect(() => { const hoveredId = hoveredWorkloadId(); @@ -103,6 +141,7 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt clearPinnedSummaryScope, hoveredWorkloadId, revealedGuestId, + selectedGuestDrawerState, selectedGuestId, setClearSurfaceRootRef, setHoveredWorkloadId, diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index 143fc138ce..d7a352d379 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -22,7 +22,7 @@ import { getWorkloadsLoadingState, getWorkloadsNoInventoryState, } from '@/utils/workloadEmptyStatePresentation'; -import { getCanonicalWorkloadId } from '@/utils/workloads'; +import { getCanonicalWorkloadId, getWorkloadMetadataId } from '@/utils/workloads'; import { nodeFromResource } from '@/utils/resourceStateAdapters'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { @@ -136,13 +136,20 @@ export function useWorkloadsState(props: WorkloadsStateOptions) { const alertsActivation = useAlertsActivation(); const alertsEnabled = alertsActivation.detectionEnabled; - const { guestMetadata, handleCustomUrlUpdate } = useWorkloadGuestMetadataState(); - const workloads = useWorkloads(undefined, { resourceSnapshot: props.resourceSnapshot, resourceSnapshotChange: props.resourceSnapshotChange, refetchSnapshot: props.resourceSnapshotRefetch, }); + const metadataIds = createMemo>( + () => new Set(workloads.workloads().map(getWorkloadMetadataId)), + new Set(), + { + equals: (previous, next) => + previous.size === next.size && [...next].every((id) => previous.has(id)), + }, + ); + const { guestMetadata, handleCustomUrlUpdate } = useWorkloadGuestMetadataState(metadataIds); const infrastructureSources = useUnifiedResources({ query: WORKLOADS_INFRASTRUCTURE_SOURCES_QUERY, cacheKey: 'workloads-infrastructure-sources', @@ -447,6 +454,7 @@ export function useWorkloadsState(props: WorkloadsStateOptions) { clearPinnedSummaryScope, hoveredWorkloadId, revealedGuestId, + selectedGuestDrawerState, selectedGuestId, setClearSurfaceRootRef, setHoveredWorkloadId, @@ -599,6 +607,7 @@ export function useWorkloadsState(props: WorkloadsStateOptions) { search, scrollToTop, selectedCluster, + selectedGuestDrawerState, selectedGuestId, selectedHostHint, selectedPlatform, diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index 8f31eabe06..420ad6cdae 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -1609,7 +1609,7 @@ describe('useWorkloads', () => { expect(result!.workloads()[0]?.clusterName).toBe('cluster-b'); expect(result!.workloads()[0]?.contextLabel).toBe('pve1 (cluster-b)'); expect(result!.workloads()[0]?.instance).toBe('cluster-b'); - expect(result!.workloads()[0]?.id).toBe('cluster-b:pve1:101'); + expect(result!.workloads()[0]?.id).toBe('vm-1'); dispose(); }); @@ -1688,7 +1688,7 @@ describe('useWorkloads', () => { await flushAsync(); await waitForWorkloadCount(() => result!.workloads().length); expect(apiFetchJSONMock).toHaveBeenCalledTimes(1); - expect(result!.workloads()[0]?.id).toBe('cluster-a:pve1:101'); + expect(result!.workloads()[0]?.id).toBe(sampleResource.id); apiFetchJSONMock.mockResolvedValueOnce({ data: [ @@ -1709,13 +1709,13 @@ describe('useWorkloads', () => { await waitForWorkloadCount(() => result!.workloads().length); expect(apiFetchJSONMock).toHaveBeenCalledTimes(2); - expect(result!.workloads()[0]?.id).toBe('cluster-b:pve2:202'); + expect(result!.workloads()[0]?.id).toBe('cluster-b-pve2-202'); eventBus.emit('org_switched', 'default'); await flushAsync(); expect(apiFetchJSONMock).toHaveBeenCalledTimes(2); - expect(result!.workloads()[0]?.id).toBe('cluster-a:pve1:101'); + expect(result!.workloads()[0]?.id).toBe(sampleResource.id); dispose(); }); @@ -1726,3 +1726,39 @@ describe('useWorkloads', () => { expect(useWorkloadsSource).not.toContain('const normalizeOrgScope ='); }); }); + +describe('resource-owned workload identity', () => { + it.each(['vm', 'system-container', 'pod'] as const)( + 'preserves %s row identity when placement changes', + async (type) => { + const { useWorkloads, __resetWorkloadsCacheForTests } = await import('@/hooks/useWorkloads'); + __resetWorkloadsCacheForTests(); + const original = { + ...sampleResource, + id: 'stable-resource', + type, + proxmox: { instance: 'cluster-a', node: 'node-1', vmid: 101 }, + kubernetes: { clusterId: 'cluster-a', podUid: 'native-pod-uid' }, + }; + const [snapshot, setSnapshot] = createSignal([original] as any); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useWorkloads(() => true, { resourceSnapshot: snapshot }); + }); + try { + await flushAsync(); + expect(result!.workloads()[0]?.id).toBe(original.id); + setSnapshot([ + { ...original, node: 'node-2', proxmox: { ...original.proxmox, node: 'node-2' } }, + ]); + await flushAsync(); + expect(result!.workloads()[0]?.id).toBe(original.id); + expect(result!.workloads()[0]?.canonicalResourceId).toBe(original.id); + } finally { + dispose(); + } + }, + ); +}); diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 13ab9ed8d4..28a3bcfe25 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -11,7 +11,6 @@ import { import { canonicalDiscoveryResourceType } from '@/utils/discoveryTarget'; import { normalizeDiskArray } from '@/utils/format'; import { - buildCanonicalNodeScopedWorkloadId, getWorkloadCPUFraction, isDockerManagedAppContainer, resolveWorkloadTypeFromString, @@ -518,26 +517,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { // runtime ids such as Docker container ids must stay separate so workload // routing, discovery, anomalies, and drawer selection align with the shared // resource model. - const guestId = (() => { - if (workloadType === 'vm' || workloadType === 'system-container') { - const canonicalId = buildCanonicalNodeScopedWorkloadId({ - instance, - node, - vmid, - }); - if (canonicalId) { - return canonicalId; - } - } - if (workloadType === 'pod') { - const clusterId = resource.kubernetes?.clusterId; - const podUid = resource.kubernetes?.podUid; - if (clusterId && podUid) { - return `k8s:${clusterId}:pod:${podUid}`; - } - } - return resource.id; - })(); + const guestId = resource.id; return { id: guestId, diff --git a/frontend-modern/src/types/workloads.ts b/frontend-modern/src/types/workloads.ts index adc5fcc45d..c2dfff6acc 100644 --- a/frontend-modern/src/types/workloads.ts +++ b/frontend-modern/src/types/workloads.ts @@ -23,7 +23,7 @@ export type WorkloadGuest = (VM | Container) & { workloadType?: WorkloadType; /** Backend identities that may own active alerts for this canonical workload. */ alertResourceIds?: string[]; - /** Exact unified-resource ID; a Proxmox row ID can be a different VMID-based display key. */ + /** Exact unified-resource identity, also used as the canonical workload row ID. */ canonicalResourceId?: string; /** Exact unified parent identity, independent of provider display labels. */ parentResourceId?: string; diff --git a/frontend-modern/src/utils/__tests__/workloads.branchcov0724pm.test.ts b/frontend-modern/src/utils/__tests__/workloads.branchcov0724pm.test.ts index a1d32ec9d7..a504efa9b2 100644 --- a/frontend-modern/src/utils/__tests__/workloads.branchcov0724pm.test.ts +++ b/frontend-modern/src/utils/__tests__/workloads.branchcov0724pm.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; import { - buildCanonicalNodeScopedWorkloadId, + buildProxmoxGuestMetadataId, buildKubernetesWorkloadMetadataId, - getCanonicalWorkloadIdForResource, + getWorkloadMetadataIdForResource, getWorkloadMetadataId, getWorkloadMetadataIdCandidates, isDockerManagedAppContainer, @@ -171,23 +171,23 @@ describe('getWorkloadMetadataId empty-candidates fallback', () => { }); // Covers the non-finite vmid arm (`Number.isFinite(vmid) ? Number(vmid) : 0`) -// in buildCanonicalNodeScopedWorkloadId. The happy-path specs only pass numeric +// in buildProxmoxGuestMetadataId. The happy-path specs only pass numeric // vmids, so the `: 0` coercion (which then short-circuits to null) is cold. -describe('buildCanonicalNodeScopedWorkloadId non-finite vmid coercion', () => { +describe('buildProxmoxGuestMetadataId non-finite vmid coercion', () => { it('coerces a null vmid to 0 and returns null', () => { expect( - buildCanonicalNodeScopedWorkloadId({ instance: 'homelab', node: 'pve1', vmid: null }), + buildProxmoxGuestMetadataId({ instance: 'homelab', node: 'pve1', vmid: null }), ).toBeNull(); }); }); -// Covers the proxmox.nodeName fallback in getCanonicalWorkloadIdForResource. +// Covers the proxmox.nodeName fallback in getWorkloadMetadataIdForResource. // The happy-path specs populate proxmox.node, so the `node || nodeName` fallback // to nodeName is never exercised. -describe('getCanonicalWorkloadIdForResource proxmox nodeName fallback', () => { +describe('getWorkloadMetadataIdForResource proxmox nodeName fallback', () => { it('uses proxmox.nodeName when proxmox.node is absent', () => { expect( - getCanonicalWorkloadIdForResource({ + getWorkloadMetadataIdForResource({ id: 'vm-fallback', type: 'vm', clusterId: 'Core Fabric', @@ -198,7 +198,7 @@ describe('getCanonicalWorkloadIdForResource proxmox nodeName fallback', () => { it('prefers proxmox.node over proxmox.nodeName when both are present', () => { expect( - getCanonicalWorkloadIdForResource({ + getWorkloadMetadataIdForResource({ id: 'lxc-fallback', type: 'system-container', clusterId: 'Core Fabric', @@ -209,7 +209,7 @@ describe('getCanonicalWorkloadIdForResource proxmox nodeName fallback', () => { it('falls back to the resource id when only nodeName is present but it is blank', () => { expect( - getCanonicalWorkloadIdForResource({ + getWorkloadMetadataIdForResource({ id: 'vm-orphan', type: 'vm', clusterId: 'Core Fabric', diff --git a/frontend-modern/src/utils/__tests__/workloads.test.ts b/frontend-modern/src/utils/__tests__/workloads.test.ts index 12869f8464..2960f7ab45 100644 --- a/frontend-modern/src/utils/__tests__/workloads.test.ts +++ b/frontend-modern/src/utils/__tests__/workloads.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildKubernetesWorkloadMetadataId, - buildCanonicalNodeScopedWorkloadId, + buildProxmoxGuestMetadataId, canonicalizeWorkloadFilterType, getWorkloadCPUFraction, getWorkloadCPUPercent, @@ -10,7 +10,7 @@ import { resolveWorkloadTypeFromString, getWorkloadMetricsKind, getCanonicalWorkloadId, - getCanonicalWorkloadIdForResource, + getWorkloadMetadataIdForResource, getWorkloadMetadataId, getWorkloadMetadataIdCandidates, isDockerManagedAppContainer, @@ -177,12 +177,12 @@ describe('getWorkloadMetricsKind', () => { }); describe('getCanonicalWorkloadId', () => { - it('returns composite id for vm with instance, node, vmid', () => { + it('preserves the supplied VM identity when provider coordinates exist', () => { const guest = { id: 'orig', type: 'vm', instance: 'homelab', node: 'node1', vmid: 100 }; - expect(getCanonicalWorkloadId(guest)).toBe('homelab:node1:100'); + expect(getCanonicalWorkloadId(guest)).toBe('orig'); }); - it('returns composite id for system-container with instance, node, vmid', () => { + it('preserves the supplied container identity when provider coordinates exist', () => { const guest = { id: 'orig', type: 'system-container', @@ -190,7 +190,7 @@ describe('getCanonicalWorkloadId', () => { node: 'node2', vmid: 200, }; - expect(getCanonicalWorkloadId(guest)).toBe('homelab:node2:200'); + expect(getCanonicalWorkloadId(guest)).toBe('orig'); }); it('returns id for app-container workloads (no instance/node/vmid)', () => { @@ -233,10 +233,10 @@ describe('getCanonicalWorkloadId', () => { }); }); -describe('buildCanonicalNodeScopedWorkloadId', () => { - it('builds canonical ids from instance, node, and vmid', () => { +describe('buildProxmoxGuestMetadataId', () => { + it('builds legacy metadata keys from instance, node, and vmid', () => { expect( - buildCanonicalNodeScopedWorkloadId({ + buildProxmoxGuestMetadataId({ instance: 'Core Fabric', node: 'pve2', vmid: 112, @@ -244,15 +244,13 @@ describe('buildCanonicalNodeScopedWorkloadId', () => { ).toBe('Core Fabric:pve2:112'); }); - it('returns null when any canonical part is missing', () => { + it('returns null when any provider coordinate is missing', () => { expect( - buildCanonicalNodeScopedWorkloadId({ instance: 'Core Fabric', node: '', vmid: 112 }), + buildProxmoxGuestMetadataId({ instance: 'Core Fabric', node: '', vmid: 112 }), ).toBeNull(); + expect(buildProxmoxGuestMetadataId({ instance: '', node: 'pve2', vmid: 112 })).toBeNull(); expect( - buildCanonicalNodeScopedWorkloadId({ instance: '', node: 'pve2', vmid: 112 }), - ).toBeNull(); - expect( - buildCanonicalNodeScopedWorkloadId({ instance: 'Core Fabric', node: 'pve2', vmid: 0 }), + buildProxmoxGuestMetadataId({ instance: 'Core Fabric', node: 'pve2', vmid: 0 }), ).toBeNull(); }); }); @@ -349,10 +347,10 @@ describe('getWorkloadMetadataId', () => { }); }); -describe('getCanonicalWorkloadIdForResource', () => { +describe('getWorkloadMetadataIdForResource', () => { it('uses proxmox guest identity for vm and system-container resources', () => { expect( - getCanonicalWorkloadIdForResource({ + getWorkloadMetadataIdForResource({ id: 'system-container-45cf61f16a6e2c16', type: 'system-container', clusterId: 'Core Fabric', @@ -364,9 +362,9 @@ describe('getCanonicalWorkloadIdForResource', () => { ).toBe('Core Fabric:pve2:112'); }); - it('falls back to the resource id when canonical guest identity is incomplete', () => { + it('falls back to the resource id when provider metadata coordinates are incomplete', () => { expect( - getCanonicalWorkloadIdForResource({ + getWorkloadMetadataIdForResource({ id: 'vm-123', type: 'vm', clusterId: '', @@ -438,3 +436,57 @@ describe('normalizeWorkloadViewModeParam', () => { expect(normalizeWorkloadViewModeParam('')).toBeNull(); }); }); + +describe('canonical workload identity across placement changes', () => { + it.each(['vm', 'system-container'] as const)( + 'keeps the %s resource identity across node migration', + (workloadType) => { + const guest = { + id: 'legacy-row-key', + canonicalResourceId: 'stable-resource', + workloadType, + type: workloadType, + instance: 'cluster-a', + node: 'node-1', + vmid: 101, + }; + expect(getCanonicalWorkloadId(guest)).toBe('stable-resource'); + expect(getCanonicalWorkloadId({ ...guest, node: 'node-2' })).toBe('stable-resource'); + expect(getCanonicalWorkloadId({ ...guest, canonicalResourceId: 'independent-clone' })).toBe( + 'independent-clone', + ); + }, + ); + + it('uses the supplied resource ID rather than deriving an identity from provider coordinates', () => { + const guest = { + id: 'stable-resource', + type: 'vm', + instance: 'cluster-a', + node: 'node-1', + vmid: 101, + }; + expect(getCanonicalWorkloadId(guest)).toBe('stable-resource'); + }); +}); + +describe('Proxmox metadata compatibility boundary', () => { + it('retains provider metadata keys while the row uses stable resource identity', () => { + const guest = { + id: 'stable-resource', + canonicalResourceId: 'stable-resource', + name: 'guest-101', + type: 'vm', + instance: 'cluster-a', + node: 'node-1', + vmid: 101, + }; + expect(getWorkloadMetadataId(guest)).toBe('cluster-a:node-1:101'); + expect(getWorkloadMetadataIdCandidates(guest)).toEqual([ + 'cluster-a:node-1:101', + 'stable-resource', + ]); + expect(getWorkloadMetadataId({ ...guest, node: 'node-2' })).toBe('cluster-a:node-2:101'); + expect(getCanonicalWorkloadId({ ...guest, node: 'node-2' })).toBe('stable-resource'); + }); +}); diff --git a/frontend-modern/src/utils/workloads.ts b/frontend-modern/src/utils/workloads.ts index 478b244516..cbcca27559 100644 --- a/frontend-modern/src/utils/workloads.ts +++ b/frontend-modern/src/utils/workloads.ts @@ -148,22 +148,9 @@ export const isDockerManagedAppContainer = ( return runtime === 'docker' || runtime === 'podman'; }; -export const getCanonicalWorkloadId = ( - guest: Pick, -): string => { - const type = resolveWorkloadType(guest); - if (type === 'vm' || type === 'system-container') { - const canonicalId = buildCanonicalNodeScopedWorkloadId({ - instance: guest.instance, - node: guest.node, - vmid: guest.vmid, - }); - if (canonicalId) { - return canonicalId; - } - } - return guest.id; -}; +export const getCanonicalWorkloadId = >( + guest: T, +): string => guest.canonicalResourceId?.trim() || guest.id; export const buildAppContainerMetadataId = ({ dockerHostId, @@ -222,6 +209,12 @@ export const getWorkloadMetadataIdCandidates = ( const canonicalId = getCanonicalWorkloadId(guest); const candidates: string[] = []; + const workloadType = resolveWorkloadType(guest); + if (workloadType === 'vm' || workloadType === 'system-container') { + const metadataId = buildProxmoxGuestMetadataId(guest); + if (metadataId) candidates.push(metadataId); + } + if (isDockerManagedAppContainer(guest)) { const appContainerId = buildAppContainerMetadataId(guest); if (appContainerId) candidates.push(appContainerId); @@ -252,21 +245,23 @@ export const getWorkloadMetadataId = ( >, ): string => getWorkloadMetadataIdCandidates(guest)[0] || getCanonicalWorkloadId(guest); -type NodeScopedWorkloadIdentity = { +type ProxmoxGuestMetadataIdentity = { instance?: string | null; node?: string | null; vmid?: number | null; }; -const normalizeNodeScopedWorkloadKeyPart = (value?: string | null): string => (value || '').trim(); +const normalizeProxmoxMetadataKeyPart = (value?: string | null): string => (value || '').trim(); -export const buildCanonicalNodeScopedWorkloadId = ({ +// Guest-metadata storage and legacy API clients use provider keys. Keep that +// compatibility coordinate separate from resource identity and row selection. +export const buildProxmoxGuestMetadataId = ({ instance, node, vmid, -}: NodeScopedWorkloadIdentity): string | null => { - const normalizedInstance = normalizeNodeScopedWorkloadKeyPart(instance); - const normalizedNode = normalizeNodeScopedWorkloadKeyPart(node); +}: ProxmoxGuestMetadataIdentity): string | null => { + const normalizedInstance = normalizeProxmoxMetadataKeyPart(instance); + const normalizedNode = normalizeProxmoxMetadataKeyPart(node); const normalizedVmid = Number.isFinite(vmid) ? Number(vmid) : 0; if (!normalizedInstance || !normalizedNode || normalizedVmid <= 0) { return null; @@ -274,18 +269,18 @@ export const buildCanonicalNodeScopedWorkloadId = ({ return `${normalizedInstance}:${normalizedNode}:${normalizedVmid}`; }; -export const getCanonicalWorkloadIdForResource = ( +export const getWorkloadMetadataIdForResource = ( resource: Pick, ): string => { const workloadType = resolveWorkloadTypeFromString(resource.type); if (workloadType === 'vm' || workloadType === 'system-container') { - const canonicalId = buildCanonicalNodeScopedWorkloadId({ + const metadataId = buildProxmoxGuestMetadataId({ instance: resource.proxmox?.instance || resource.clusterId, node: resource.proxmox?.node || resource.proxmox?.nodeName, vmid: resource.proxmox?.vmid, }); - if (canonicalId) { - return canonicalId; + if (metadataId) { + return metadataId; } } return resource.id; diff --git a/internal/config/guest_metadata.go b/internal/config/guest_metadata.go index cba3f71834..700d3012f0 100644 --- a/internal/config/guest_metadata.go +++ b/internal/config/guest_metadata.go @@ -370,8 +370,10 @@ func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node stri // Persist while holding the store lock. save() assumes locked access. if err := s.save(); err != nil { log.Error().Err(err).Msg("Failed to save guest metadata after migration") - s.metadata[legacyID] = legacyMeta - delete(s.metadata, guestID) + // The admitted guest now uses the new node coordinates. Keep its + // operator fields available in memory while atomic persistence + // retains the last good disk snapshot. save records the failure; + // ordinary identity updates retry through the existing writer. } return cloneGuestMetadata(migrated) diff --git a/internal/config/guest_metadata_writer_test.go b/internal/config/guest_metadata_writer_test.go index f4c78ce497..9d59da97e0 100644 --- a/internal/config/guest_metadata_writer_test.go +++ b/internal/config/guest_metadata_writer_test.go @@ -13,6 +13,57 @@ import ( "time" ) +func TestGuestMetadataMigrationPersistenceFailureRetainsAcceptedRecord(t *testing.T) { + for _, failure := range []string{"write", "rename"} { + t.Run(failure, func(t *testing.T) { + fs := &guestWriterFaultFS{FileSystem: defaultFileSystem{}} + path := t.TempDir() + store := NewGuestMetadataStore(path, fs) + t.Cleanup(func() { _ = store.Close(time.Second) }) + oldID, newID := "pve:old-node:100", "pve:new-node:100" + operator := &GuestMetadata{ + CustomURL: "https://guest.example", Tags: []string{"production"}, + Notes: []string{"operator note"}, LastKnownName: "guest-100", LastKnownType: "qemu", + } + if err := store.Set(oldID, operator); err != nil { + t.Fatal(err) + } + writes := fs.writes.Load() + fs.failWrite.Store(failure == "write") + fs.failRename.Store(failure == "rename") + store.GetWithLegacyMigration(newID, "pve", "new-node", 100) + migrated := store.Get(newID) + if migrated == nil || migrated.ID != newID || migrated.CustomURL != operator.CustomURL || + !reflect.DeepEqual(migrated.Tags, operator.Tags) || !reflect.DeepEqual(migrated.Notes, operator.Notes) || + store.Get(oldID) != nil { + t.Fatal("persistence failure discarded the accepted in-memory metadata migration") + } + if !store.WaitForPendingWrites(time.Second) || fs.writes.Load() != writes+1 { + t.Fatal("failed synchronous migration started a retry loop") + } + lastGood := NewGuestMetadataStore(path, nil) + if lastGood.Get(oldID) == nil || lastGood.Get(newID) != nil { + t.Fatal("failed migration damaged the last successful persisted snapshot") + } + _ = lastGood.Close(time.Second) + fs.failWrite.Store(false) + fs.failRename.Store(false) + store.RememberIdentity(newID, operator.LastKnownName, operator.LastKnownType) + if err := store.Close(time.Second); err != nil { + t.Fatal(err) + } + reloaded := NewGuestMetadataStore(path, nil) + t.Cleanup(func() { _ = reloaded.Close(time.Second) }) + got := reloaded.Get(newID) + if got == nil || got.CustomURL != operator.CustomURL || + !reflect.DeepEqual(got.Tags, operator.Tags) || !reflect.DeepEqual(got.Notes, operator.Notes) || + reloaded.Get(oldID) != nil { + t.Fatal("ordinary identity refresh did not persist the retained migration") + } + }) + } +} + func guestWriterTestStore(t *testing.T) (*GuestMetadataStore, *controlledGuestWriterFS) { t.Helper() fs := &controlledGuestWriterFS{FileSystem: defaultFileSystem{}, entered: make(chan struct{}), release: make(chan struct{})} diff --git a/internal/monitoring/guest_poll_ownership_test.go b/internal/monitoring/guest_poll_ownership_test.go index 7345d494a0..25310359d6 100644 --- a/internal/monitoring/guest_poll_ownership_test.go +++ b/internal/monitoring/guest_poll_ownership_test.go @@ -3,6 +3,7 @@ package monitoring import ( "context" "fmt" + "os" "reflect" "sync" "sync/atomic" @@ -15,6 +16,129 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) +type guestPublicationMetadataFS struct { + blocked atomic.Bool + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (fs *guestPublicationMetadataFS) ReadFile(path string) ([]byte, error) { + return os.ReadFile(path) +} +func (fs *guestPublicationMetadataFS) WriteFile(path string, data []byte, mode os.FileMode) error { + if fs.blocked.Load() { + fs.once.Do(func() { close(fs.entered) }) + <-fs.release + } + return os.WriteFile(path, data, mode) +} +func (fs *guestPublicationMetadataFS) Rename(oldPath, newPath string) error { + return os.Rename(oldPath, newPath) +} +func (fs *guestPublicationMetadataFS) Remove(path string) error { return os.Remove(path) } +func (fs *guestPublicationMetadataFS) Stat(path string) (os.FileInfo, error) { + return os.Stat(path) +} +func (fs *guestPublicationMetadataFS) MkdirAll(path string, mode os.FileMode) error { + return os.MkdirAll(path, mode) +} + +func TestGuestPollPublicationMigratesMetadataBeforeNewCoordinates(t *testing.T) { + client := &guestPollOwnershipClient{} + m := guestPollOwnershipMonitor(t, client) + fs := &guestPublicationMetadataFS{entered: make(chan struct{}), release: make(chan struct{})} + store := config.NewGuestMetadataStore(t.TempDir(), fs) + m.guestMetadataStore = store + for _, id := range []string{"site-a:old-node:105", "site-a:old-node:106"} { + if err := store.Set(id, &config.GuestMetadata{ + CustomURL: "https://guest.example", Tags: []string{"production"}, Notes: []string{"operator note"}, + }); err != nil { + t.Fatal(err) + } + } + m.state.UpdateGuestsForInstance("site-a", + []models.VM{{ID: "site-a:old-node:105", Instance: "site-a", Node: "old-node", VMID: 105}}, + []models.Container{{ID: "site-a:old-node:106", Instance: "site-a", Node: "old-node", VMID: 106}}) + ctx, owner := m.beginPVEGuestPoll(context.Background(), "site-a", client) + if owner == nil { + t.Fatal("registered cycle not admitted") + } + defer m.finishPVEGuestPoll("site-a", owner) + // Exercise the former collector effect as well as the publication owner. + // Both versions reach the same persistence barrier, so the observation + // proves ordering rather than merely requiring a new helper to be called. + stagePVEGuestEffect(ctx, func() { + store.GetWithLegacyMigration("site-a:new-node:105", "site-a", "new-node", 105) + }) + fs.blocked.Store(true) + done := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(fs.release) }) } + t.Cleanup(func() { + unblock() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("publication did not exit") + } + if err := store.Close(time.Second); err != nil { + t.Error(err) + } + }) + go func() { + m.publishPVEGuests(ctx, "site-a", + []models.VM{{ID: "site-a:new-node:105", Instance: "site-a", Node: "new-node", VMID: 105}}, + []models.Container{{ID: "site-a:new-node:106", Instance: "site-a", Node: "new-node", VMID: 106}}) + close(done) + }() + select { + case <-fs.entered: + case <-time.After(5 * time.Second): + t.Fatal("metadata persistence did not start") + } + snapshot := m.state.GetSnapshot() + if len(snapshot.VMs) != 1 || snapshot.VMs[0].Node != "old-node" || + len(snapshot.Containers) != 1 || snapshot.Containers[0].Node != "old-node" { + t.Error("new guest coordinates became visible before metadata migration completed") + } + unblock() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("publication did not complete") + } + for _, id := range []string{"site-a:new-node:105", "site-a:new-node:106"} { + meta := store.Get(id) + if meta == nil || meta.CustomURL != "https://guest.example" || + !reflect.DeepEqual(meta.Tags, []string{"production"}) || !reflect.DeepEqual(meta.Notes, []string{"operator note"}) { + t.Errorf("published guest lost operator metadata: %s %+v", id, meta) + } + } +} + +func TestGuestPollRejectedPublicationDoesNotMigrateMetadata(t *testing.T) { + client := &guestPollOwnershipClient{} + m := guestPollOwnershipMonitor(t, client) + store := config.NewGuestMetadataStore(t.TempDir(), nil) + m.guestMetadataStore = store + if err := store.Set("site-a:old-node:105", &config.GuestMetadata{CustomURL: "https://guest.example"}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close(time.Second) }) + ctx, owner := m.beginPVEGuestPoll(context.Background(), "site-a", client) + if owner == nil { + t.Fatal("registered cycle not admitted") + } + defer m.finishPVEGuestPoll("site-a", owner) + m.replacePVEClient("site-a", &guestPollOwnershipClient{}) + m.publishPVEGuests(ctx, "site-a", + []models.VM{{ID: "site-a:new-node:105", Instance: "site-a", Node: "new-node", VMID: 105}}, nil) + if store.Get("site-a:old-node:105") == nil || store.Get("site-a:new-node:105") != nil { + t.Fatal("replaced poll owner migrated metadata") + } +} + // Ignore cancellation deliberately: a late inventory reply cannot prove that // its connection/runtime still owns publication. No native guest is involved. type guestPollOwnershipClient struct { diff --git a/internal/monitoring/guest_poll_publication.go b/internal/monitoring/guest_poll_publication.go index 7bb6713ec4..a4bee87850 100644 --- a/internal/monitoring/guest_poll_publication.go +++ b/internal/monitoring/guest_poll_publication.go @@ -80,6 +80,7 @@ func stagePVEGuestEffect(ctx context.Context, effect func()) { func (m *Monitor) publishPVEGuests(ctx context.Context, instance string, vms []models.VM, containers []models.Container) { owner, _ := ctx.Value(pveGuestPollContextKey{}).(*pveGuestPoll) if owner == nil { + m.migratePVEGuestMetadata(instance, vms, containers) m.state.UpdateGuestsForInstance(instance, vms, containers) return } @@ -99,6 +100,9 @@ func (m *Monitor) publishPVEGuests(ctx context.Context, instance string, vms []m if !current { return } + // Readers can observe the new node as soon as state is published. Move + // operator metadata first, under the same accepted connection ownership. + m.migratePVEGuestMetadata(instance, vms, containers) m.state.UpdateGuestsForInstance(instance, vms, containers) owner.mu.Lock() effects := owner.effects @@ -109,6 +113,22 @@ func (m *Monitor) publishPVEGuests(ctx context.Context, instance string, vms []m } } +func (m *Monitor) migratePVEGuestMetadata(instance string, vms []models.VM, containers []models.Container) { + if m.guestMetadataStore == nil { + return + } + for _, vm := range vms { + if vm.ID != "" && vm.Node != "" && vm.VMID > 0 { + m.guestMetadataStore.GetWithLegacyMigration(vm.ID, instance, vm.Node, vm.VMID) + } + } + for _, container := range containers { + if container.ID != "" && container.Node != "" && container.VMID > 0 { + m.guestMetadataStore.GetWithLegacyMigration(container.ID, instance, container.Node, container.VMID) + } + } +} + // Called with m.mu and the publication writer held. Replacing a transport is // not a guest reboot: accepted cache ages and OS-command safety stay intact. func (m *Monitor) cancelPVEGuestPollLocked(instance string) { diff --git a/internal/monitoring/monitor_polling_containers.go b/internal/monitoring/monitor_polling_containers.go index 7fc4bff26b..24505ed5ba 100644 --- a/internal/monitoring/monitor_polling_containers.go +++ b/internal/monitoring/monitor_polling_containers.go @@ -297,12 +297,7 @@ func (m *Monitor) collectContainersWithNodes(ctx context.Context, instanceName s } } - // Trigger guest metadata migration if old format exists stagePVEGuestEffect(ctx, func() { - if m.guestMetadataStore != nil { - m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, n.Node, int(container.VMID)) - } - m.recordGuestSnapshot(instanceName, modelContainer.Type, n.Node, int(container.VMID), GuestMemorySnapshot{ Name: modelContainer.Name, Status: modelContainer.Status, diff --git a/internal/monitoring/monitor_pve_guest_poll.go b/internal/monitoring/monitor_pve_guest_poll.go index 5263d5b121..d7ea1c5275 100644 --- a/internal/monitoring/monitor_pve_guest_poll.go +++ b/internal/monitoring/monitor_pve_guest_poll.go @@ -385,12 +385,7 @@ func (m *Monitor) handleClusterVMResource( return models.VM{}, false } - // Trigger guest metadata migration if old format exists stagePVEGuestEffect(ctx, func() { - if m.guestMetadataStore != nil { - m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, res.Node, res.VMID) - } - m.recordGuestSnapshot(instanceName, vm.Type, res.Node, res.VMID, GuestMemorySnapshot{ Name: vm.Name, Status: vm.Status, @@ -422,12 +417,7 @@ func (m *Monitor) handleClusterContainerResource( return models.Container{}, false } - // Trigger guest metadata migration if old format exists stagePVEGuestEffect(ctx, func() { - if m.guestMetadataStore != nil { - m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, res.Node, res.VMID) - } - m.recordGuestSnapshot(instanceName, container.Type, res.Node, res.VMID, GuestMemorySnapshot{ Name: container.Name, Status: container.Status, diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 59d7125d12..eaaff38630 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -249,39 +249,13 @@ class CanonicalCompletionGuardTest(unittest.TestCase): ) self.assertEqual(monitoring["verification"]["exact_files"], []) policy_ids = [policy["id"] for policy in monitoring["verification"]["path_policies"]] + self.assertIn("monitoring-runtime", policy_ids) + requirements = monitoring["verification_requirements"] self.assertEqual( - policy_ids, [ - "maintenance-verification-runtime", - "availability-certificate-runtime", - "discovery-provider-runtime", - "host-agent-ingest-runtime", - "truenas-runtime", - "metrics-hot-path", - "metrics-history-runtime", - "storage-risk-runtime", - "memory-source-runtime", - "docker-collect-runtime", - "docker-swarm-runtime", - "kubernetes-native-agent-runtime", - "runtime-report-model", - "proxmox-guest-counter-runtime", - "proxmox-zfs-runtime", - "proxmox-cluster-client-runtime", - "proxmox-ceph-runtime", - "proxmox-backup-identity-monitoring", - "container-entrypoint-runtime", - "mock-runtime-fixtures", - "pbs-client-runtime", - "pbs-protection-evidence-runtime", - "diskinventory-collection-trust", - "agent-fleet-diagnostics-runtime", - "monitoring-runtime", - "filesystem-observations", + {key: value for key, value in requirement.items() if key != "exact_files"} + for requirement in requirements ], - ) - self.assertEqual( - monitoring["verification_requirements"], [ { "id": "monitoring-runtime", @@ -289,44 +263,21 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "touched_runtime_files": ["internal/monitoring/monitor.go"], "allow_same_subsystem_tests": False, "test_prefixes": [], - "exact_files": [ - "internal/config/docker_metadata_test.go", - "internal/config/guest_metadata_test.go", - "internal/monitoring/availability_poller_test.go", - "internal/monitoring/availability_probe_agent_test.go", - "internal/monitoring/availability_udp_test.go", - "internal/monitoring/canonical_guardrails_test.go", - "internal/monitoring/ceph_test.go", - "internal/monitoring/deadman_test.go", - "internal/monitoring/issue1485_unraid_lifecycle_test.go", - "internal/monitoring/issue1595_collection_trust_test.go", - "internal/monitoring/issue1613_contract_test.go", - "internal/monitoring/issue1638_dns_cache_test.go", - "internal/monitoring/issue1913_host_continuity_test.go", - "internal/monitoring/metric_window_provider_test.go", - "internal/monitoring/monitor_additional_test.go", - "internal/monitoring/monitor_alert_handling_test.go", - "internal/monitoring/monitor_alert_intent_test.go", - "internal/monitoring/monitor_alert_override_migration_test.go", - "internal/monitoring/monitor_backups_readstate_test.go", - "internal/monitoring/monitor_docker_test.go", - "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", - "internal/monitoring/monitor_host_agents_test.go", - "internal/monitoring/monitor_mock_alerts_test.go", - "internal/monitoring/monitor_notification_startup_test.go", - "internal/monitoring/monitor_pve_cluster_refresh_test.go", - "internal/monitoring/monitor_pve_guest_lxc_test.go", - "internal/monitoring/physical_disk_roundtrip_test.go", - "internal/monitoring/proxmox_large_cluster_poll_budget_test.go", - "internal/monitoring/pve_protection_observation_test.go", - "internal/monitoring/ratetracker_test.go", - "internal/monitoring/reload_test.go", - "internal/monitoring/truenas_poller_test.go", - "internal/unifiedresources/code_standards_test.go", - ], } ], ) + proofs = [ + "internal/config/guest_metadata_writer_test.go", + "internal/monitoring/guest_poll_ownership_test.go", + ] + self.assertEqual( + staged_verification_files_for_requirement( + monitoring, + requirements[0], + proofs + ["internal/api/action_runner_credentials_test.go"], + ), + proofs, + ) def test_docker_collect_runtime_change_requires_monitoring_contract(self): required = infer_impacted_subsystems(["internal/dockeragent/collect.go"]) From ecf69f5a19d9b4b6e532046e8999128b7c2f9356 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:36:01 +0100 Subject: [PATCH 2/4] Complete workload identity and History compatibility The legacy History fallback read a canonical row ID as a native metric key. Use the shared provider key independently, with backend targets taking priority. Keep topology and duplicate-resource proofs on canonical row IDs. Route selection owns initial positioning. Measure selected-row geometry without replacing its reveal window, then let real scroll and resize events follow the viewport. Local row clicks keep their existing scroll position. Refs #1669 --- .../subsystems/performance-and-scalability.md | 6 +- .../internal/subsystems/unified-resources.md | 6 ++ .../1658a6606f30b890.json | 62 ++++++++++++++++ ...loadsSurface.performance.contract.test.tsx | 24 ++++-- .../guestDrawerModel.branchcov2.test.ts | 37 +++++++++- .../nestedWorkloadContext.branchcov2.test.ts | 36 ++++----- .../__tests__/nestedWorkloadContext.test.ts | 4 +- .../useWorkloadSelectionState.test.ts | 54 ++++++++++++++ .../useWorkloadViewportSync.test.tsx | 74 +++++++++++++++++++ ...rkloadMetricHistoryModel.branchcov.test.ts | 2 +- .../workloadTopology.attribution.test.ts | 12 +-- .../__tests__/workloadTopology.scope.test.ts | 26 +++---- .../__tests__/workloadTopology.test.ts | 8 +- .../Workloads/useWorkloadSelectionState.ts | 17 +++-- .../Workloads/useWorkloadViewportSync.ts | 15 +++- .../Workloads/useWorkloadsDerivedState.ts | 2 +- .../frontendResourceTypeBoundaries.test.ts | 4 +- .../workloads.branchcov0724pm.test.ts | 10 +-- .../src/utils/__tests__/workloads.test.ts | 14 ++-- .../src/utils/workloadMetricHistoryTarget.ts | 17 ++++- frontend-modern/src/utils/workloads.ts | 20 ++--- 21 files changed, 356 insertions(+), 94 deletions(-) create mode 100644 frontend-modern/browser-verification/1658a6606f30b890.json 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 f4a231634f..c1dc8f4065 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1309,6 +1309,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. same resource under a new parent or node retains both even if its drawer remounts. Closing or selecting another resource restores the default view. History continues to consume the explicit backend-owned metrics target. + Legacy VM and system-container inputs without that target use the shared + native provider-key formatter for History. This compatibility fallback + follows the current node and never changes canonical selection identity. + Backend targets take precedence over every client-derived fallback. The guest drawer header owns the saved web-interface launch action. It reuses the shared `WebInterfaceLink` and drawer-header action class, so phone and narrow rows keep hiding the adjacent row link for name legibility @@ -1398,7 +1402,7 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. A changed set of native metadata keys refreshes that cache after accepted inventory publication. Metric-only updates do not refetch it. Superseded responses cannot overwrite a newer refresh, confirmed edit or tenant scope. -14. Extend workload deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Workloads/workloadSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`. Every workload row and selection key is the exact unified `Resource.id`. Historical references use the API contract's existing server resolver and never a client-reconstructed node or pod identity. +14. Extend workload deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Workloads/workloadSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`. Every workload row and selection key is the exact unified `Resource.id`. Route-driven selection lets the shared focus bridge reveal its destination. Initial viewport measurement retains the selection window and still measures row and detail geometry. Scroll and resize events remain authoritative for subsequent window movement. Local row clicks preserve the existing viewport, and that preservation must not undo a route reveal. Historical references use the API contract's existing server resolver and never a client-reconstructed node or pod identity. 15. Extend workload route ownership, route-driven option catalogs, and toolbar filter config through `frontend-modern/src/components/Workloads/useWorkloadRouteState.ts`, `frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts`, `frontend-modern/src/components/Workloads/workloadRouteModel.ts`, `frontend-modern/src/components/Workloads/workloadFilterConfigModel.ts`, and `frontend-modern/src/components/Workloads/workloadRouteStateModel.ts`, and extend query-param synchronization plus managed workload URL semantics through `frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts` and `frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts`, rather than rebuilding route sync, alias parsing, option derivation, toolbar callback/config wiring, reset policy, node-selection compatibility rules, param precedence, or managed workload URLs inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts` Workloads route host scopes must resolve through `frontend-modern/src/components/Workloads/workloadTopology.ts`: app-container scopes use the canonical Docker/runtime host id before host labels or node fallbacks, while VM and system-container scopes keep the instance-node key. Route `agent` filters and option catalogs must consume that shared scope so Infrastructure related-workload links cannot drift from Workloads filtering. Workloads route-state writes must preserve the owning platform/runtime diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 16e757d4b4..c30bdc88fb 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -5209,6 +5209,12 @@ registry's current/declared-succession/unique-alias boundary, with ambiguity refused. Provider coordinates remain explicit compatibility inputs for guest metadata, native collection and History targets. They must not be rebuilt into primary row identity or used to merge independent instance clones. +History uses an explicit backend metrics target when present. Its legacy +Proxmox fallback formats the current instance, node and VMID through the shared +native guest formatter, independently of the canonical selection ID. A guest +move changes that native target while retaining the selected canonical row. +The workload target tests and current-source desktop, intermediate and phone +browser matrix cover both supplied targets and missing-target fallback. Unified resources carry no recovery route-state vocabulary. The recovery query serializer (`RECOVERY_QUERY_PARAMS`, `parseRecoveryLinkSearch`, `buildRecoveryRouteSearch`) was deleted from diff --git a/frontend-modern/browser-verification/1658a6606f30b890.json b/frontend-modern/browser-verification/1658a6606f30b890.json new file mode 100644 index 0000000000..05346ab8b0 --- /dev/null +++ b/frontend-modern/browser-verification/1658a6606f30b890.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "base_sha": "549a4ca821fb9d749d269ce3530d8416bd17c46f", + "verified_at": "2026-10-12T21:28:49Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts", + "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts", + "frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts", + "frontend-modern/src/utils/workloadMetricHistoryTarget.ts", + "frontend-modern/src/utils/workloads.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts": "b9897665cbb498c843d4e1e4c69c2f776c1cb6711308726937918588e05cd5af", + "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts": "436d5cdbcd730ba5bc971f462b274cd240c6db39401901abe2f859c15162bdd7", + "frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts": "53015e33663b2fea41dd39a1b4c5137220d1c645a395fb32feecc24a1e5aa4a5", + "frontend-modern/src/utils/workloadMetricHistoryTarget.ts": "24bc9eff087e426cf08cdb4c11e48ff3c70d89c0a6c09b0530dbd07e3e056ef0", + "frontend-modern/src/utils/workloads.ts": "779b5fec276546bb499df800f0eeb4709169dfe725aa3f5727231da981c62179" + }, + "routes": [ + "/proxmox/overview?type=vm&resource=vm-b62864e1d6f16ad4", + "/proxmox/overview?type=lxc&resource=system-container-5f29a7a3f4732690", + "/proxmox/overview?type=vm&resource=ProductionWest%3Apve1%3A100", + "/proxmox/overview?type=lxc&resource=ProductionWest%3Apve1%3A113", + "/proxmox/overview?resource=missing-identity-fixture" + ], + "viewports": [ + { + "width": 1280, + "height": 800 + }, + { + "width": 900, + "height": 800 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "VM and LXC Overview, History and Manage at all three widths", + "Canonical and legacy links on cold load and reload", + "Selected resource after controlled node and parent move", + "History with backend targets and native fallback before and after node move", + "Persisted manual link, operator note and never-auto-remediate policy", + "Recurring maintenance editor, cancelled editor and metadata write failure", + "Pending legacy lookup, superseded lookup and denied lookup", + "Unknown reference with no unrelated drawer" + ], + "interactions": [ + "Pointer and keyboard row expansion and re-expansion", + "History tab and 12-hour range retained through node move", + "Manage manual URL save, reload and metadata refetch", + "Escape close and filter reset, search, reselect and focus return", + "Collapse and reopen, then different resource resets to Overview", + "Policy save and reload, recurring maintenance editor open and cancel", + "Manual selection, Escape and newer route each cancel held legacy lookup", + "403 lookup refusal and 503 metadata write failure", + "Scroll through the full phone drawer and inspect desktop, intermediate and phone pixels" + ] +} 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 13c587700e..c505e6d157 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 @@ -1095,7 +1095,13 @@ describe('Workloads performance contract', () => { mockLocationSearch = '?type=all'; mockWorkloads = [ makeGuest(1, { id: 'raw-a', instance: 'shared', node: 'node-x', vmid: 42 }), - makeGuest(2, { id: 'raw-b', instance: 'shared', node: 'node-x', vmid: 42 }), + makeGuest(2, { + id: 'raw-b', + canonicalResourceId: 'raw-a', + instance: 'shared', + node: 'node-x', + vmid: 42, + }), ]; const canonicalId = getCanonicalWorkloadId( makeGuest(1, { id: 'raw-a', instance: 'shared', node: 'node-x', vmid: 42 }) as any, @@ -1109,7 +1115,17 @@ describe('Workloads performance contract', () => { await waitFor(() => { expect(getGuestRowCount(container)).toBe(1); }); - expect(canonicalId).toBe('shared:node-x:42'); + expect(canonicalId).toBe('raw-a'); + }); + + it('keeps distinct canonical resources even when their native coordinates coincide', async () => { + mockLocationSearch = '?type=all'; + mockWorkloads = [ + makeGuest(1, { id: 'vm-distinct-a', instance: 'shared', node: 'node-x', vmid: 42 }), + makeGuest(2, { id: 'vm-distinct-b', instance: 'shared', node: 'node-x', vmid: 42 }), + ]; + const { container } = render(() => ); + await waitFor(() => expect(getGuestRowCount(container)).toBe(2)); }); it('applies page-owned workload type exclusions before derived row state', async () => { @@ -1350,7 +1366,6 @@ describe('Workloads performance contract', () => { expect(workloadsSelectionStateSource).toContain( 'const [hoveredWorkloadId, setHoveredWorkloadId]', ); - expect(workloadsSelectionStateSource).toContain('setHandledResourceId(null)'); expect(workloadsSelectionStateSource).toContain("from './workloadSelectionModel'"); expect(workloadsSelectionStateSource).not.toContain('useNavigate'); expect(workloadsSelectionStateSource).not.toContain('createRouteStateNavigateScheduler'); @@ -1358,7 +1373,6 @@ describe('Workloads performance contract', () => { 'resolveWorkloadsSelectionNavigateTarget', ); expect(workloadsSelectionStateSource).not.toContain('parseWorkloadsLinkSearch'); - expect(workloadsSelectionStateSource).not.toContain('getCanonicalWorkloadId'); expect(workloadSelectionModelSource).toContain('parseWorkloadsLinkSearch(search)'); expect(workloadSelectionModelSource).toContain('getCanonicalWorkloadId'); expect(workloadSelectionModelSource).toContain('resolveWorkloadResourceSelection'); @@ -1749,7 +1763,7 @@ describe('Workloads performance contract', () => { expect(workloadsWorkloadTableSource).toContain(''); expect(workloadsWorkloadTableSource).not.toContain(' { expect( getGuestDrawerHistoryTarget( makeGuest({ - id: 'cluster-a:pve1:101', + id: 'vm-stable-id', + instance: 'cluster-a', + node: 'pve2', + vmid: 101, type: 'qemu', workloadType: 'vm', metricsTarget: { resourceType: 'vm', resourceId: 'mock-cluster-a-pve1-101' }, @@ -386,7 +389,7 @@ describe('guestDrawerModel (branch coverage)', () => { ).toBeNull(); }); - it('maps a vm to the canonical node-scoped id', () => { + it('maps a vm to its native metric key', () => { expect( getGuestDrawerHistoryTarget( makeGuest({ type: 'qemu', workloadType: 'vm', instance: 'c', node: 'pve', vmid: 101 }), @@ -394,7 +397,7 @@ describe('guestDrawerModel (branch coverage)', () => { ).toStrictEqual({ resourceType: 'vm', resourceId: 'c:pve:101' }); }); - it('maps a system-container to the canonical node-scoped id', () => { + it('maps a system-container to its native metric key', () => { expect( getGuestDrawerHistoryTarget( makeGuest({ @@ -408,6 +411,34 @@ describe('guestDrawerModel (branch coverage)', () => { ).toStrictEqual({ resourceType: 'system-container', resourceId: 'c:pve:102' }); }); + it.each([ + ['vm', 'qemu', 101], + ['system-container', 'lxc', 102], + ] as const)( + 'keeps %s selection stable while legacy History follows a node move', + (workloadType, type, vmid) => { + const original = makeGuest({ + id: 'opaque-resource-id', + canonicalResourceId: 'opaque-resource-id', + workloadType, + type, + vmid, + instance: 'cluster-a', + node: 'pve1', + }); + const moved = { ...original, node: 'pve2' }; + expect(original.id).toBe(moved.id); + expect(getGuestDrawerHistoryTarget(original)).toStrictEqual({ + resourceType: workloadType, + resourceId: 'cluster-a:pve1:' + vmid, + }); + expect(getGuestDrawerHistoryTarget(moved)).toStrictEqual({ + resourceType: workloadType, + resourceId: 'cluster-a:pve2:' + vmid, + }); + }, + ); + it('maps an app-container to its plain id', () => { expect( getGuestDrawerHistoryTarget( diff --git a/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.branchcov2.test.ts b/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.branchcov2.test.ts index 5bc1da2670..8d30945120 100644 --- a/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.branchcov2.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.branchcov2.test.ts @@ -89,7 +89,7 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [container], visibleGuests: [parent] }); - expect(result['pve-a:node-a:141']).toStrictEqual({ + expect(result['lxc-141']).toStrictEqual({ type: 'app-container', label: 'Podman', title: 'Nested Podman', @@ -113,8 +113,8 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [container], visibleGuests: [parent] }); - expect(result['pve-a:node-a:141']?.items[0]?.runtimeLabel).toBe('Docker'); - expect(result['pve-a:node-a:141']?.label).toBe('Docker'); + expect(result['lxc-141']?.items[0]?.runtimeLabel).toBe('Docker'); + expect(result['lxc-141']?.label).toBe('Docker'); }); }); @@ -145,7 +145,7 @@ describe('nestedWorkloadContext (branch coverage)', () => { visibleGuests: [parent], }); - expect(result['pve-a:node-a:141']?.items.map((item) => item.status)).toEqual([ + expect(result['lxc-141']?.items.map((item) => item.status)).toEqual([ 'exited', 'unknown', 'unknown', @@ -165,7 +165,7 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [container], visibleGuests: [parent] }); - expect(result['pve-a:node-a:141']?.items[0]?.name).toBe('cid-7'); + expect(result['lxc-141']?.items[0]?.name).toBe('cid-7'); }); it('falls back to the canonical workload id when both name and containerId are empty', () => { @@ -179,7 +179,7 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [container], visibleGuests: [parent] }); - expect(result['pve-a:node-a:141']?.items[0]?.name).toBe('app-container:orphan'); + expect(result['lxc-141']?.items[0]?.name).toBe('app-container:orphan'); }); it('uses contextLabel for the host facet when dockerHostName is empty', () => { @@ -194,7 +194,7 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [container], visibleGuests: [parent] }); - expect(result['pve-a:node-a:141']?.href).toBe('/docker/overview?host=ctx-host'); + expect(result['lxc-141']?.href).toBe('/docker/overview?host=ctx-host'); }); }); @@ -218,9 +218,9 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [dockerOne, podmanOne], visibleGuests: [parent] }); - expect(result['pve-a:node-a:141']?.label).toBe('Containers'); - expect(result['pve-a:node-a:141']?.title).toBe('Nested containers'); - expect(result['pve-a:node-a:141']?.count).toBe(2); + expect(result['lxc-141']?.label).toBe('Containers'); + expect(result['lxc-141']?.title).toBe('Nested containers'); + expect(result['lxc-141']?.count).toBe(2); }); }); @@ -235,8 +235,8 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [container], visibleGuests: [parent] }); - expect(Object.keys(result)).toEqual(['pve-a:node-a:141']); - expect(result['pve-a:node-a:141']?.items[0]?.name).toBe('two'); + expect(Object.keys(result)).toEqual(['lxc-141']); + expect(result['lxc-141']?.items[0]?.name).toBe('two'); }); it('matches a parent via an unprefixed 1-segment host id (>=3/>=2 false, >=1 true)', () => { @@ -249,7 +249,7 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [container], visibleGuests: [parent] }); - expect(Object.keys(result)).toEqual(['pve-a:node-a:141']); + expect(Object.keys(result)).toEqual(['lxc-141']); }); it('yields no parent match when only the lxc docker prefix is present (all segment arms false)', () => { @@ -276,8 +276,8 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [container], visibleGuests: [parent] }); - expect(Object.keys(result)).toEqual(['pve-a:node-a:141']); - expect(result['pve-a:node-a:141']?.items[0]?.name).toBe('echo'); + expect(Object.keys(result)).toEqual(['lxc-141']); + expect(result['lxc-141']?.items[0]?.name).toBe('echo'); }); }); @@ -383,7 +383,7 @@ describe('nestedWorkloadContext (branch coverage)', () => { const result = run({ guests: [container], visibleGuests: [parent] }); expect(result).toStrictEqual({ - 'pve-a:node-a:141': { + 'lxc-141': { type: 'app-container', label: 'Docker', title: 'Nested Docker', @@ -437,8 +437,8 @@ describe('nestedWorkloadContext (branch coverage)', () => { visibleGuests: [vmParent, strayContainer, pod], }); - expect(Object.keys(result)).toEqual(['pve-f:node-f:500']); - expect(result['pve-f:node-f:500']?.items.map((item) => item.name)).toEqual(['svc']); + expect(Object.keys(result)).toEqual(['vm-500']); + expect(result['vm-500']?.items.map((item) => item.name)).toEqual(['svc']); }); it('skips app containers that do not match the requested platform scope', () => { diff --git a/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.test.ts b/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.test.ts index 1a5af4a2c9..fe16a4be22 100644 --- a/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/nestedWorkloadContext.test.ts @@ -60,7 +60,7 @@ describe('nested workload context', () => { platformScope: 'proxmox-pve', }); - expect(result['pve-a:node-a:141']).toMatchObject({ + expect(result['lxc-141']).toMatchObject({ label: 'Docker', count: 1, href: '/docker/overview?host=media-lxc.mist-stork.ts.net', @@ -109,7 +109,7 @@ describe('nested workload context', () => { platformScope: 'proxmox-pve', }); - expect(result['pve-a:node-a:141']?.href).toBe('/docker/overview'); + expect(result['lxc-141']?.href).toBe('/docker/overview'); }); it('does not guess when Docker host identity only matches ambiguous VMIDs', () => { diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts b/frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts index 078d7762bf..23645edf1f 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts @@ -163,6 +163,60 @@ describe('useWorkloadSelectionState', () => { expect(navigateSpy).not.toHaveBeenCalled(); }); + it('does not restore the old viewport when a route selects a known canonical resource', () => { + const [search, setSearch] = createSignal('?resource=vm-first'); + readLocationSearch = search; + const [filteredGuests] = createSignal([ + { id: 'vm-first' } as WorkloadGuest, + { id: 'vm-second' } as WorkloadGuest, + ]); + const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests })); + const scroller = document.createElement('div'); + scroller.style.overflowY = 'auto'; + Object.defineProperty(scroller, 'scrollHeight', { value: 800 }); + Object.defineProperty(scroller, 'clientHeight', { value: 200 }); + const writes: number[] = []; + Object.defineProperty(scroller, 'scrollTop', { + get: () => 175, + set: (value) => writes.push(value), + }); + const wrapper = document.createElement('div'); + scroller.append(wrapper); + document.body.append(scroller); + result.setTableWrapperRef(wrapper); + setSearch('?resource=vm-second'); + expect(result.selectedGuestId()).toBe('vm-second'); + expect(ResourceAPI.getResource).not.toHaveBeenCalled(); + expect(writes).toEqual([]); + }); + + it('does not restore the old viewport when a legacy route resolves to its canonical resource', async () => { + let resolve!: (resource: Resource) => void; + vi.mocked(ResourceAPI.getResource).mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const [filteredGuests] = createSignal([{ id: 'vm-current' } as WorkloadGuest]); + const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests })); + const scroller = document.createElement('div'); + scroller.style.overflowY = 'auto'; + Object.defineProperty(scroller, 'scrollHeight', { value: 800 }); + Object.defineProperty(scroller, 'clientHeight', { value: 200 }); + const writes: number[] = []; + Object.defineProperty(scroller, 'scrollTop', { + get: () => 175, + set: (value) => writes.push(value), + }); + const wrapper = document.createElement('div'); + scroller.append(wrapper); + document.body.append(scroller); + result.setTableWrapperRef(wrapper); + resolve({ id: 'vm-current' } as Resource); + await Promise.resolve(); + expect(result.selectedGuestId()).toBe('vm-current'); + expect(writes).toEqual([]); + }); it('preserves the nearest scrollable ancestor when row focus changes locally', () => { locationSearch = '?type=app-container&platform=truenas&agent=truenas-main'; const [filteredGuests] = createSignal([]); diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx b/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx index 05415a2987..b2a14ef547 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx @@ -89,6 +89,80 @@ describe('useWorkloadViewportSync', () => { expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); }); + it('measures an active selection without replacing its initial window, then follows scroll and resize', async () => { + const onScroll = vi.fn(); + const onRowGeometryChange = vi.fn(); + const groupedWindowing: UseGroupedTableWindowingResult = { + startIndex: () => 330, + endIndex: () => 366, + mountedCount: () => 36, + getVisibleSlice: (_groupKey, guests) => guests, + isWindowed: () => true, + onScroll, + revealIndex: vi.fn(), + }; + const rect = (height: number, top = 0): DOMRect => ({ + bottom: top + height, + height, + left: 0, + right: 800, + top, + width: 800, + x: 0, + y: top, + toJSON: () => ({}), + }); + const Harness = () => { + const [bodyRef, setBodyRef] = createSignal(null); + useWorkloadViewportSync({ + filteredGuestCount: () => 640, + expandedDetailActive: () => true, + groupedWindowing, + onRowGeometryChange, + rowHeight: () => 32, + tableBodyRef: bodyRef, + }); + return ( + + { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect(320, -96)); + setBodyRef(element); + }} + > + { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect(24)); + }} + > + + + { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect(32)); + }} + > + + + +
Group
Selected workload
+ ); + }; + render(() => ); + await waitFor(() => + expect(onRowGeometryChange).toHaveBeenCalledWith({ + groupHeaderHeight: 24, + rowHeight: 32, + }), + ); + expect(onScroll).not.toHaveBeenCalled(); + window.dispatchEvent(new Event('scroll')); + await waitFor(() => expect(onScroll).toHaveBeenCalledTimes(1)); + window.dispatchEvent(new Event('resize')); + await waitFor(() => expect(onScroll).toHaveBeenCalledTimes(2)); + }); it('captures expanded detail geometry when the drawer mounts before scrolling', async () => { const onExpandedDetailHeightChange = vi.fn(); const groupedWindowing: UseGroupedTableWindowingResult = { diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.branchcov.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.branchcov.test.ts index e5f6d1832c..14bd737e11 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.branchcov.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.branchcov.test.ts @@ -98,8 +98,8 @@ describe('workloadMetricHistoryModel (branch coverage)', () => { type: 'qemu', }); expect(getWorkloadChartKeyCandidates(guest)).toEqual([ - 'cluster-a:pve:101', 'cluster-a-pve-101', + 'cluster-a:pve:101', 'pve-101', 'pve:pve:101', ]); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadTopology.attribution.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadTopology.attribution.test.ts index 40e98737e8..e5f17a04a0 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadTopology.attribution.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadTopology.attribution.test.ts @@ -7,7 +7,7 @@ import { buildWorkloadNodeOptions } from '../workloadRouteModel'; const guests = [ { - id: 'lab-east-pve1-101', + id: 'vm-instance-a', vmid: 101, node: 'pve1', instance: 'lab-east', @@ -16,7 +16,7 @@ const guests = [ platformScopes: ['proxmox-pve'], }, { - id: 'lab-east-pve1-101', + id: 'vm-instance-b', vmid: 101, node: 'east-pve1', instance: 'lab', @@ -55,17 +55,17 @@ describe('Supplied node attribution (existing API regression controls)', () => { 'preserves each supplied parent regardless of inventory order', (...inventory) => { const parents = buildGuestParentNodeMapFromNodes(guests, inventory); - expect(parents['lab-east:pve1:101']).toBe(nodes[0]); - expect(parents['lab:east-pve1:101']).toBe(nodes[1]); + expect(parents[guests[0].id]).toBe(nodes[0]); + expect(parents[guests[1].id]).toBe(nodes[1]); }, ); it('does not substitute an inferred guest-ID parent when its explicit source fields disagree', () => { const contradictory = { ...guests[1], id: 'native-a-101' }; const parents = buildGuestParentNodeMapFromNodes([contradictory], nodes); - expect(parents['lab:east-pve1:101']).toBe(nodes[1]); + expect(parents[contradictory.id]).toBe(nodes[1]); }); it('cannot give a vSphere VM Proxmox parent state from matching labels', () => { const vm = { ...guests[0], platformScopes: ['vmware-vsphere'] }; - expect(buildGuestParentNodeMapFromNodes([vm], nodes)['lab-east:pve1:101']).toBeUndefined(); + expect(buildGuestParentNodeMapFromNodes([vm], nodes)[vm.id]).toBeUndefined(); }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadTopology.scope.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadTopology.scope.test.ts index 6308bf3f4d..a05385423d 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadTopology.scope.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadTopology.scope.test.ts @@ -35,7 +35,10 @@ const node = (id: string, instance: string, name: string, total: number, status uptime: status === 'online' ? 100 : 0, memory: { total, used: 0, free: total, usage: 0 }, }) as Node; -const guests = [guest('lab-east', 'pve1'), guest('lab', 'east-pve1')]; +const guests = [ + guest('lab-east', 'pve1', { id: 'vm-instance-a' }), + guest('lab', 'east-pve1', { id: 'vm-instance-b' }), +]; const nodes = [ node('native-a', 'lab-east', 'pve1', 8192, 'offline'), node('native-b', 'lab', 'east-pve1', 32768), @@ -61,8 +64,7 @@ describe('Workloads node attribution', () => { const scope = workloadNodeScopeId(original); expect(groups[scope]).toEqual([original]); expect(filtered(inventory, scope)).toEqual([original]); - expect(original.id).toBe('lab-east-pve1-101'); - expect(getCanonicalWorkloadId(original)).toBe(`${original.instance}:${original.node}:101`); + expect(getCanonicalWorkloadId(original)).toBe(original.id); } expect(filtered(inventory, 'lab-east-pve1')).toEqual([]); expect(filtered([inventory[0]], 'lab-east-pve1')).toEqual([inventory[0]]); @@ -73,20 +75,18 @@ describe('Workloads node attribution', () => { 'assigns status and host memory to the exact tuple, not the first legacy alias', (...inventory) => { const parents = buildGuestParentNodeMapFromNodes(guests, inventory); - expect(parents['lab-east:pve1:101']).toBe(nodes[0]); - expect(parents['lab:east-pve1:101']).toBe(nodes[1]); - expect(parents['lab-east:pve1:101']?.status).toBe('offline'); - expect(parents['lab:east-pve1:101']?.memory.total).toBe(32768); + expect(parents[guests[0].id]).toBe(nodes[0]); + expect(parents[guests[1].id]).toBe(nodes[1]); + expect(parents[guests[0].id]?.status).toBe('offline'); + expect(parents[guests[1].id]?.memory.total).toBe(32768); }, ); it('does not let a contradictory inferred ID override the supplied parent fields', () => { const wrongPrefix = guest('lab', 'east-pve1', { id: 'native-a-101' }); - expect(buildGuestParentNodeMapFromNodes([wrongPrefix], nodes)['lab:east-pve1:101']).toBe( - nodes[1], - ); + expect(buildGuestParentNodeMapFromNodes([wrongPrefix], nodes)[wrongPrefix.id]).toBe(nodes[1]); const missing = guest('other', 'absent', { id: 'native-a-101' }); - expect(buildGuestParentNodeMapFromNodes([missing], nodes)['other:absent:101']).toBeUndefined(); + expect(buildGuestParentNodeMapFromNodes([missing], nodes)[missing.id]).toBeUndefined(); }); it('retains a unique consistent legacy-ID fallback for missing parent fields', () => { @@ -107,11 +107,11 @@ describe('Workloads node attribution', () => { const map = buildNodeByInstance([...nodes, duplicate]); expect(map[workloadNodeScopeId(guests[0])]).toBeUndefined(); expect( - buildGuestParentNodeMapFromNodes([guests[0]], [...nodes, duplicate])['lab-east:pve1:101'], + buildGuestParentNodeMapFromNodes([guests[0]], [...nodes, duplicate])[guests[0].id], ).toBeUndefined(); const shared = nodes.map((n) => ({ ...n, id: 'same-native-id' })); expect(buildNodeByInstance(shared)['same-native-id']).toBeUndefined(); - expect(buildGuestParentNodeMapFromNodes(guests, shared)['lab:east-pve1:101']).toBe(shared[1]); + expect(buildGuestParentNodeMapFromNodes(guests, shared)[guests[1].id]).toBe(shared[1]); }); it('cannot assign a Proxmox node to an unrelated platform or container by matching labels', () => { diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts index 351a15bf31..3c22f3675d 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts @@ -72,7 +72,7 @@ describe('workloadTopology', () => { expect(map['cluster-b-node-b']).toBe(nodeB); }); - it('builds guest parent node mapping using id lookup first, then composite fallback', () => { + it('maps each supplied guest ID to its current provider parent', () => { const nodeA = makeNode('cluster-a-node-a', 'cluster-a', 'node-a'); const nodeB = makeNode('cluster-b-node-b', 'cluster-b', 'node-b'); const nodeMap = buildNodeByInstance([nodeA, nodeB]); @@ -107,9 +107,9 @@ describe('workloadTopology', () => { nodeMap, ); - expect(mapping['cluster-a:node-a:101']).toBe(nodeA); - expect(mapping['cluster-b:node-b:102']).toBe(nodeB); - expect(mapping['cluster-c:node-c:103']).toBeUndefined(); + expect(mapping[guestWithIdLookup.id]).toBe(nodeA); + expect(mapping[guestWithFallback.id]).toBe(nodeB); + expect(mapping[guestWithoutParent.id]).toBeUndefined(); }); }); diff --git a/frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts b/frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts index 40971c93dc..7ddc68de19 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts @@ -75,12 +75,14 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt // Row focus is local: it never navigates, so it must not stage an app-shell // restore that the next unrelated route change would replay. + const applySelectedGuestId = (id: string | null) => { + if (id !== selectedGuestId()) selectedGuestDrawerState.reset(); + setSelectedGuestIdRaw(id); + }; + const setSelectedGuestId = (id: string | null) => { referenceLookup?.abort(); - preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => { - if (id !== selectedGuestId()) selectedGuestDrawerState.reset(); - setSelectedGuestIdRaw(id); - }); + preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => applySelectedGuestId(id)); }; const routeReference = createMemo(() => resolveWorkloadResourceSelection(location.search)); @@ -89,7 +91,10 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt [() => location.pathname, routeReference, () => options.routeStateEnabled?.() ?? true], ([, reference, enabled]) => { if (!enabled) return; - setSelectedGuestId(reference); + referenceLookup?.abort(); + // A route selects a destination. The shared focus bridge reveals it; + // retaining the previous scroll offset would undo that reveal. + applySelectedGuestId(reference); if (!reference) return; // Exact current IDs need no translation. Historical/source references @@ -110,7 +115,7 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt selectedGuestId() === reference && resource.id?.trim() ) { - setSelectedGuestId(resource.id); + applySelectedGuestId(resource.id); } }) .catch(() => { diff --git a/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts b/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts index 9831d5f432..b768822c1e 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts @@ -33,7 +33,11 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp return detail; }; - const syncGuestWindowToViewport = (measureRows = false, projectedScrollDelta = 0) => { + const syncGuestWindowToViewport = ( + measureRows = false, + projectedScrollDelta = 0, + alignWindowToViewport = true, + ) => { if (typeof window === 'undefined') return; const body = options.tableBodyRef(); if (!body || isWindowedSurfaceHidden(body)) return; @@ -58,7 +62,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp const scrollContainer = findWindowedPageScrollContainer(body); if (scrollContainer) { setIsScrollToTopVisible(scrollContainer.scrollTop > SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX); - if (!options.groupedWindowing.isWindowed()) return; + if (!alignWindowToViewport || !options.groupedWindowing.isWindowed()) return; const containerRect = scrollContainer.getBoundingClientRect(); const scrollTop = Math.max(0, containerRect.top - rect.top); options.groupedWindowing.onScroll( @@ -70,7 +74,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp } setIsScrollToTopVisible(window.scrollY > SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX); - if (!options.groupedWindowing.isWindowed()) return; + if (!alignWindowToViewport || !options.groupedWindowing.isWindowed()) return; options.groupedWindowing.onScroll( Math.max(0, -rect.top + projectedScrollDelta), window.innerHeight, @@ -134,7 +138,10 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp // groupedWindowing.onScroll) that the pass itself moves. Tracking them // would re-run this effect — remeasuring rows with forced reflows and // rebinding listeners — on every runway top-up during scrolling. - untrack(() => handleViewportResize()); + // Selection owns initial positioning. Measure its geometry without + // replacing the reveal window before the shared focus bridge scrolls. + // Actual scroll and resize events continue to follow the viewport. + untrack(() => syncGuestWindowToViewport(true, 0, !options.expandedDetailActive?.())); const scrollContainer = findWindowedPageScrollContainer(options.tableBodyRef()!); const scrollTarget = scrollContainer ?? window; onCleanup( diff --git a/frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts b/frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts index 68ee47b759..6f49068dd0 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts @@ -290,7 +290,7 @@ export function useWorkloadsDerivedState(options: WorkloadsWorkloadDerivedStateO }); const workloadViewport = useWorkloadViewportSync({ - expandedDetailActive: () => selectedGuestIndex() !== null && selectedDetailHeight() > 0, + expandedDetailActive: () => selectedGuestIndex() !== null, filteredGuestCount: () => filteredGuests().length, groupedWindowing, onExpandedDetailHeightChange: (height) => { diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index a1701f1a33..b8db6bb4d2 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -762,10 +762,8 @@ describe('frontend resource type boundaries', () => { expect(workloadsSelectionStateSource).toContain( 'const [hoveredWorkloadId, setHoveredWorkloadId]', ); - expect(workloadsSelectionStateSource).toContain('setHandledResourceId(null)'); expect(workloadsSelectionStateSource).toContain("from './workloadSelectionModel'"); expect(workloadsSelectionStateSource).not.toContain('parseWorkloadsLinkSearch'); - expect(workloadsSelectionStateSource).not.toContain('getCanonicalWorkloadId'); expect(workloadSelectionModelSource).toContain('parseWorkloadsLinkSearch(search)'); expect(workloadSelectionModelSource).toContain('getCanonicalWorkloadId'); expect(workloadSelectionModelSource).toContain('resolveWorkloadResourceSelection'); @@ -951,7 +949,7 @@ describe('frontend resource type boundaries', () => { expect(workloadsWorkloadTableSource).toContain('WorkloadPanel'); expect(workloadsWorkloadTableSource).not.toContain(' { }); // Covers the non-finite vmid arm (`Number.isFinite(vmid) ? Number(vmid) : 0`) -// in buildProxmoxGuestMetadataId. The happy-path specs only pass numeric +// in buildProxmoxGuestNativeId. The happy-path specs only pass numeric // vmids, so the `: 0` coercion (which then short-circuits to null) is cold. -describe('buildProxmoxGuestMetadataId non-finite vmid coercion', () => { +describe('buildProxmoxGuestNativeId non-finite vmid coercion', () => { it('coerces a null vmid to 0 and returns null', () => { - expect( - buildProxmoxGuestMetadataId({ instance: 'homelab', node: 'pve1', vmid: null }), - ).toBeNull(); + expect(buildProxmoxGuestNativeId({ instance: 'homelab', node: 'pve1', vmid: null })).toBeNull(); }); }); diff --git a/frontend-modern/src/utils/__tests__/workloads.test.ts b/frontend-modern/src/utils/__tests__/workloads.test.ts index 2960f7ab45..f45367ff50 100644 --- a/frontend-modern/src/utils/__tests__/workloads.test.ts +++ b/frontend-modern/src/utils/__tests__/workloads.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildKubernetesWorkloadMetadataId, - buildProxmoxGuestMetadataId, + buildProxmoxGuestNativeId, canonicalizeWorkloadFilterType, getWorkloadCPUFraction, getWorkloadCPUPercent, @@ -233,10 +233,10 @@ describe('getCanonicalWorkloadId', () => { }); }); -describe('buildProxmoxGuestMetadataId', () => { +describe('buildProxmoxGuestNativeId', () => { it('builds legacy metadata keys from instance, node, and vmid', () => { expect( - buildProxmoxGuestMetadataId({ + buildProxmoxGuestNativeId({ instance: 'Core Fabric', node: 'pve2', vmid: 112, @@ -245,12 +245,10 @@ describe('buildProxmoxGuestMetadataId', () => { }); it('returns null when any provider coordinate is missing', () => { + expect(buildProxmoxGuestNativeId({ instance: 'Core Fabric', node: '', vmid: 112 })).toBeNull(); + expect(buildProxmoxGuestNativeId({ instance: '', node: 'pve2', vmid: 112 })).toBeNull(); expect( - buildProxmoxGuestMetadataId({ instance: 'Core Fabric', node: '', vmid: 112 }), - ).toBeNull(); - expect(buildProxmoxGuestMetadataId({ instance: '', node: 'pve2', vmid: 112 })).toBeNull(); - expect( - buildProxmoxGuestMetadataId({ instance: 'Core Fabric', node: 'pve2', vmid: 0 }), + buildProxmoxGuestNativeId({ instance: 'Core Fabric', node: 'pve2', vmid: 0 }), ).toBeNull(); }); }); diff --git a/frontend-modern/src/utils/workloadMetricHistoryTarget.ts b/frontend-modern/src/utils/workloadMetricHistoryTarget.ts index fdc752e475..b05eab74da 100644 --- a/frontend-modern/src/utils/workloadMetricHistoryTarget.ts +++ b/frontend-modern/src/utils/workloadMetricHistoryTarget.ts @@ -1,6 +1,10 @@ import type { ResourceType } from '@/api/charts'; import type { WorkloadGuest } from '@/types/workloads'; -import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads'; +import { + buildProxmoxGuestNativeId, + getCanonicalWorkloadId, + resolveWorkloadType, +} from '@/utils/workloads'; export interface WorkloadMetricHistoryTarget { resourceType: ResourceType; @@ -16,10 +20,17 @@ export const getWorkloadMetricHistoryTarget = ( return { resourceType: explicitResourceType, resourceId: explicitResourceId }; } - const resourceId = getCanonicalWorkloadId(guest).trim(); + const workloadType = resolveWorkloadType(guest); + // Legacy guest inputs have native coordinates but no backend metrics target. + // The History API reads native metric keys, independently of row identity. + const nativeGuestId = + workloadType === 'vm' || workloadType === 'system-container' + ? buildProxmoxGuestNativeId(guest) + : null; + const resourceId = (nativeGuestId || getCanonicalWorkloadId(guest)).trim(); if (!resourceId) return null; - switch (resolveWorkloadType(guest)) { + switch (workloadType) { case 'vm': return { resourceType: 'vm', resourceId }; case 'system-container': diff --git a/frontend-modern/src/utils/workloads.ts b/frontend-modern/src/utils/workloads.ts index cbcca27559..a34594aa29 100644 --- a/frontend-modern/src/utils/workloads.ts +++ b/frontend-modern/src/utils/workloads.ts @@ -211,7 +211,7 @@ export const getWorkloadMetadataIdCandidates = ( const workloadType = resolveWorkloadType(guest); if (workloadType === 'vm' || workloadType === 'system-container') { - const metadataId = buildProxmoxGuestMetadataId(guest); + const metadataId = buildProxmoxGuestNativeId(guest); if (metadataId) candidates.push(metadataId); } @@ -245,23 +245,23 @@ export const getWorkloadMetadataId = ( >, ): string => getWorkloadMetadataIdCandidates(guest)[0] || getCanonicalWorkloadId(guest); -type ProxmoxGuestMetadataIdentity = { +type ProxmoxGuestNativeIdentity = { instance?: string | null; node?: string | null; vmid?: number | null; }; -const normalizeProxmoxMetadataKeyPart = (value?: string | null): string => (value || '').trim(); +const normalizeProxmoxNativeKeyPart = (value?: string | null): string => (value || '').trim(); -// Guest-metadata storage and legacy API clients use provider keys. Keep that -// compatibility coordinate separate from resource identity and row selection. -export const buildProxmoxGuestMetadataId = ({ +// Guest metadata and legacy History requests use this native provider key. +// Backend metrics targets take precedence. This key is never row identity. +export const buildProxmoxGuestNativeId = ({ instance, node, vmid, -}: ProxmoxGuestMetadataIdentity): string | null => { - const normalizedInstance = normalizeProxmoxMetadataKeyPart(instance); - const normalizedNode = normalizeProxmoxMetadataKeyPart(node); +}: ProxmoxGuestNativeIdentity): string | null => { + const normalizedInstance = normalizeProxmoxNativeKeyPart(instance); + const normalizedNode = normalizeProxmoxNativeKeyPart(node); const normalizedVmid = Number.isFinite(vmid) ? Number(vmid) : 0; if (!normalizedInstance || !normalizedNode || normalizedVmid <= 0) { return null; @@ -274,7 +274,7 @@ export const getWorkloadMetadataIdForResource = ( ): string => { const workloadType = resolveWorkloadTypeFromString(resource.type); if (workloadType === 'vm' || workloadType === 'system-container') { - const metadataId = buildProxmoxGuestMetadataId({ + const metadataId = buildProxmoxGuestNativeId({ instance: resource.proxmox?.instance || resource.clusterId, node: resource.proxmox?.node || resource.proxmox?.nodeName, vmid: resource.proxmox?.vmid, From 98713106f7e5335858c3c30b78776bb46d5e60bc Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:15:02 +0100 Subject: [PATCH 3/4] Verify merged workload drawer behavior Bind the combined drawer model and state to a current-source browser matrix covering canonical and legacy selection, History, saved metadata, node moves and lookup refusal on desktop, intermediate and phone viewports. Refs #1669 --- .../1658a6606f30b890.json | 10 ++-- .../f611c55a17d9ca1e.json | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 frontend-modern/browser-verification/f611c55a17d9ca1e.json diff --git a/frontend-modern/browser-verification/1658a6606f30b890.json b/frontend-modern/browser-verification/1658a6606f30b890.json index 05346ab8b0..8ff1d02e3a 100644 --- a/frontend-modern/browser-verification/1658a6606f30b890.json +++ b/frontend-modern/browser-verification/1658a6606f30b890.json @@ -1,7 +1,7 @@ { "version": 1, - "base_sha": "549a4ca821fb9d749d269ce3530d8416bd17c46f", - "verified_at": "2026-10-12T21:28:49Z", + "base_sha": "a4d2cdc2b8b6f8e667d86501473b14fc0e1844ca", + "verified_at": "2026-10-12T21:56:37Z", "result": "passed", "changed_paths": [ "frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts", @@ -19,9 +19,9 @@ }, "routes": [ "/proxmox/overview?type=vm&resource=vm-b62864e1d6f16ad4", - "/proxmox/overview?type=lxc&resource=system-container-5f29a7a3f4732690", - "/proxmox/overview?type=vm&resource=ProductionWest%3Apve1%3A100", - "/proxmox/overview?type=lxc&resource=ProductionWest%3Apve1%3A113", + "/proxmox/overview?type=vm&resource=Production%20West%3Apve1%3A100", + "/proxmox/overview?type=system-container&resource=system-container-5f29a7a3f4732690", + "/proxmox/overview?type=system-container&resource=Production%20West%3Apve1%3A113", "/proxmox/overview?resource=missing-identity-fixture" ], "viewports": [ diff --git a/frontend-modern/browser-verification/f611c55a17d9ca1e.json b/frontend-modern/browser-verification/f611c55a17d9ca1e.json new file mode 100644 index 0000000000..79e453a8e6 --- /dev/null +++ b/frontend-modern/browser-verification/f611c55a17d9ca1e.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "base_sha": "a4d2cdc2b8b6f8e667d86501473b14fc0e1844ca", + "verified_at": "2026-10-12T21:56:37Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/guestDrawerModel.ts", + "frontend-modern/src/components/Workloads/useGuestDrawerState.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "dc00da43c6b96591334bd3a6210d681226e14d5b78f51cb40de26ba44aa5fa04", + "frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "c2b903e04453a24ab0163f64e8b973a6ab6e80dd23afba6a63f7fb8236f6181b" + }, + "routes": [ + "/proxmox/overview?type=vm&resource=vm-b62864e1d6f16ad4", + "/proxmox/overview?type=vm&resource=Production%20West%3Apve1%3A100", + "/proxmox/overview?type=system-container&resource=system-container-5f29a7a3f4732690", + "/proxmox/overview?type=system-container&resource=Production%20West%3Apve1%3A113", + "/proxmox/overview?resource=missing-identity-fixture" + ], + "viewports": [ + { + "width": 1280, + "height": 800 + }, + { + "width": 900, + "height": 800 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "VM and LXC Overview, History and Manage at all three widths", + "Canonical and legacy links on cold load and reload", + "Selected resource after controlled node and parent move", + "History with backend targets and native fallback before and after node move", + "Persisted manual link, operator note and never-auto-remediate policy", + "Recurring maintenance editor, cancelled editor and metadata write failure", + "Pending legacy lookup, superseded lookup and denied lookup", + "Unknown reference with no unrelated drawer" + ], + "interactions": [ + "Pointer and keyboard row expansion and re-expansion", + "History tab and 12-hour range retained through node move", + "Manage manual URL save, reload and metadata refetch", + "Escape close and filter reset, search, reselect and focus return", + "Collapse and reopen, then different resource resets to Overview", + "Policy save and reload, recurring maintenance editor open and cancel", + "Manual selection, Escape and newer route each cancel held legacy lookup", + "403 lookup refusal and 503 metadata write failure", + "Scroll through the full phone drawer and inspect desktop, intermediate and phone pixels" + ] +} From 7c781789e4c3a18e584101a472ecf0b334252cba Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:44:52 +0100 Subject: [PATCH 4/4] Include shared Git helpers in the prepush browser fixture The browser gate now uses the shared repository I/O module. Exercise its real dependency in the prepush fixture so smoke tests reach qualification checks instead of failing during import. Contract-Neutral: Test fixture dependency only, no application or contract behavior changes. --- scripts/tests/test_dev_prepush_frontend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/test_dev_prepush_frontend.py b/scripts/tests/test_dev_prepush_frontend.py index 554be45e51..b1a3ba2bcc 100644 --- a/scripts/tests/test_dev_prepush_frontend.py +++ b/scripts/tests/test_dev_prepush_frontend.py @@ -272,7 +272,7 @@ class DevPrepushBrowserTest(DevPrepushFixture): def setUp(self): super().setUp() - for name in ("browser_verification_guard.py", "format_staged_frontend.py"): + for name in ("browser_verification_guard.py", "format_staged_frontend.py", "repo_file_io.py"): self.write( "scripts/release_control/" + name, (ROOT / "scripts/release_control" / name).read_text(),