diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index dba8b60f9..c181864ac 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10419,21 +10419,7 @@ ] } ], - "work_claims": [ - { - "id": "pulse-maintainer-lane-l6", - "agent_id": "pulse-maintainer", - "summary": "Preserve non-auth system settings during quick security setup; retain authentication authorization and reviewed source identities", - "target_id": "v6-release-reliability", - "claimed_at": "2026-09-15T01:10:15Z", - "heartbeat_at": "2026-09-15T01:10:15Z", - "expires_at": "2026-09-15T03:10:15Z", - "work_item": { - "kind": "lane", - "id": "L6" - } - } - ], + "work_claims": [], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c9090d8d1..96b37c309 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -8283,3 +8283,17 @@ TLS options, non-interactive operation and cleanup on success or failure. synthetic root/sudo execution under sh and Bash, including download, preflight and install failures. A clipboard fixture proves input compatibility, not native FreeBSD installation, offline dependency availability or service persistence. + +### Agent Doctor handoff stays readable on a phone + +The outdated-agent notice routes operators to Agent Doctor with the affected +agent IDs. On a 390px viewport, the target list now allocates its visible +columns to Agent, Status and Seen so the full "Needs attention" badge fits +without horizontal scrolling or clipping. The Status column also receives +enough width in the intermediate layout, while the wide desktop layout retains +its original proportions and System, Reported and Target columns. Agent +Doctor's light-tone guidance uses opaque text colors because the current +900-level palette tokens +are translucent. The 49-agent synthetic browser pass follows the deep link, +inspects the guidance and status rows at phone and desktop widths, and keeps +update commands and credential authority unchanged. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 73929f097..77c139dd4 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7429,3 +7429,21 @@ real history hook and administration card: load a row, start a pending range read, confirm clear, then release the obsolete response at desktop and phone widths. Scripted API responses establish component behaviour, not installed backend deletion or destination delivery. + +### Large platform notices keep the inventory in view + +`PlatformOutdatedAgentNotice` previews at most three affected names and exposes +the full list through a keyboard-operable button when more hosts are outdated. +The count, update guidance and action link remain visible. This keeps a +large-estate stale-agent warning from pushing the platform inventory and +Storage search below the phone viewport while preserving every affected name +on demand. The component test pins collapsed, expanded and collapsed-again +states; 1440px, 768px and 390px browser checks verify placement and overflow. +The shared `InlineNotice` action text uses opaque 800-level colors for its four +tones. The current Tailwind configuration overrides several 900-level palette +tokens with 25%-alpha colors for translucent backgrounds, so using those +tokens for notice links made a working action look disabled. The browser proof +also follows the outdated-agent action to Agent Doctor with all 49 host IDs. +The broader palette override should be corrected in its own shared-design +slice, with background users migrated to explicit alpha utilities so other +900-level text consumers can use normal opaque color semantics. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 86b83c85c..6966db9f1 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -3929,6 +3929,12 @@ invent lifecycle state; Alerts owns signal suppression and unified resources owns persistence. `internal/monitoring/monitor_alert_intent_test.go` and the alerts intent-policy proof pin this adapter boundary. +The monitor adapter asks the registry for only the canonical resource ID when +resolving an alert reference. Exact IDs, superseded IDs, source IDs and +canonical aliases retain their precedence and ambiguous aliases remain +unresolved. This avoids cloning a full resource for every policy lookup while +keeping alert intent tied to the same identity rules as resource reads. + Alert restore precedes resource-store attachment during startup. Once the adapter attaches the persisted operator-policy resolver, monitoring asks Alerts to reconcile the restored set and refreshes shared alert state if it changed. 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 f22568a4f..b66c47ad3 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -3309,3 +3309,42 @@ a 5-second step previously reserved over a million slots per series; the preallocation is now capped at `maxQueryAllSeriesCapacity` and append still grows the slice for genuinely dense series. `pkg/metrics/store_additional_test.go` pins the cap in `TestEstimateQueryAllBatchSeriesCapacityCapsPreallocation`. + +### Large-estate browser work stays bounded by changed resources + +The Proxmox overview's canonical Workloads projection consumes the same +committed resource snapshot as its owning platform page. A consecutive delta +now carries the changed resource IDs through `useUnifiedResources` to +`useWorkloads`, so unchanged resource projections and workload rows retain +their identity. A full REST refresh, missed version, organisation change, or +unknown delta falls back to a complete projection. The all-resources cache +captures its previous generation before publication so it can take this +bounded path too. Hook tests pin both the incremental and fallback boundaries. + +The 50-node, 1,508-resource synthetic browser fixture exposed a separate +quadratic Storage navigation cost: each record row included its entire group, +and windowed row reconciliation traversed that group for every record. The +record rows now carry only their own storage record, while group headers retain +the group. At 390px with 4x CPU throttling, the measured Storage click's +longest task fell from 5.9s to 1.75s, and final route navigation recorded +0.66-1.65s Storage long tasks. The fixture uses random metric updates, so +these are bounded browser observations, not an installed-fleet latency claim. +The roughly 4MB initial resource payload remains the separately tracked +`resource-payload-static-metadata` gap and still dominates cold hydration. + +### Workload-chart benchmark signal at audit landing + +The #2259 CI Benchmarks job reported `HandleWorkloadCharts_StoreBacked-4` +at 168.7µs on its base and 189.1µs on its candidate (+12.13%, p=0.009, +ten samples). The chart handler, store query and benchmark body were unchanged +between those revisions. Two exact-base/candidate runs on `pulse-dev` +alternated ten 500ms samples each. The first, starting at load 5.63, measured +mean 426.3µs base versus 386.4µs candidate. The second, at load 1.26-1.54, +measured 391.8µs base versus 396.0µs candidate (+1.1%), with wide per-pair +variation. Both compiled benchmark binaries used pinned Go 1.26.8; the +system `go version` outside the repository reports 1.26.5 but the module's +toolchain directive selected 1.26.8 for compilation. Neither worker run +reproduced the CI magnitude or establishes an improvement. Keep the signal +open as an environment-bound observation under the existing +`performance-post-rc-headroom` follow-up; no chart-path code change is +justified by this comparison alone. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index bda171ca5..9271b4a1b 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1912,6 +1912,7 @@ "test_prefixes": [], "exact_files": [ "frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts", + "frontend-modern/src/components/Settings/__tests__/InfrastructureAgentDoctorPage.test.tsx", "frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx", "frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx", "frontend-modern/src/components/Settings/__tests__/infrastructureWorkspaceModel.test.ts" @@ -5831,6 +5832,7 @@ "frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx", "frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts", "frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts", + "frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.test.tsx", "frontend-modern/src/features/platformPage/platformSearchSuggestions.test.ts" ] }, @@ -5898,6 +5900,7 @@ "frontend-modern/src/components/shared/AnimatedNumber.test.tsx", "frontend-modern/src/components/shared/ColumnPicker.test.tsx", "frontend-modern/src/components/shared/FilterToolbar.test.tsx", + "frontend-modern/src/components/shared/InlineNotice.test.tsx", "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", "frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts" ] @@ -7979,6 +7982,7 @@ "frontend-modern/src/components/Storage/__tests__/StorageGroupRow.test.tsx", "frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx", "frontend-modern/src/components/Storage/__tests__/useDiskDetailModel.test.ts", + "frontend-modern/src/components/Storage/__tests__/useStoragePoolsTableWindowing.test.ts", "frontend-modern/src/components/Storage/code_standards.test.ts", "frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts", "frontend-modern/src/features/storageBackups/__tests__/resourceStorageMapping.test.ts", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index bd3a2eeae..26009476a 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6151,3 +6151,13 @@ This does not change token scopes, agent admission, or existing agent cleanup. Regression coverage: `TestQuickSecuritySetupForcePreservesSystemSettings` and `TestInitializeSystemSettingsPreservesExistingBytes`, `TestInitializeSystemSettingsMissing`, `TestInitializeSystemSettingsReadError`. + +### Storage pool rows do not retain their whole group + +`buildStoragePoolsTableItems` assigns each record row its stable record key and +record only. Group headers retain group metadata and expansion state. Passing +the full group through every record row made the shared windowing reconciler +walk the same group repeatedly during large-estate Storage navigation. The +windowing test pins the row shape, and the 50-node browser fixture exercises +expanded rows and History at desktop and phone widths. This is presentation +work only; it changes no storage evidence, alert state or recovery authority. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 271ef9478..720a68047 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -5356,3 +5356,31 @@ selection can be retained and recovered without manufacturing a target. The boundary, and `scripts/check-drawer-tab-retention.cjs` exercises the rendered drawer at desktop and narrow widths: selecting History, dropping the merged metrics target, and restoring it must keep the selection and recover the chart. + +### Canonical reference resolution uses a derived alias index + +After ingest and canonical identity refresh, the registry defers building its +derived simple-folded index for primary IDs, platform IDs and aliases until a +public reference lookup actually needs an alias. Rebuilding it on every ingest +made the registry's batch-ingest benchmarks regress. Distinct resources +claiming the same alias remain ambiguous. During an ingest batch, internal +reference reads use the live scan until the index is built. Exact canonical +IDs, superseded IDs, source IDs and Proxmox guest references retain their +existing precedence without paying the index build. `ResolveReferenceID` gives +identity-only monitor callers the same resolution without cloning a resource. +Registry tests cover Unicode fold classes, ambiguity, precedence and refresh. +On the 1,500-agent synthetic benchmark, canonical-alias hits moved from about +225µs and 1,518 allocations to about 5µs and 19 allocations. A server CPU +profile attributed about 0.77s of a 30s baseline window to the old alias +scan. These measurements identify this component cost, not the total +installed-fleet CPU effect. + +The first #2260 benchmark job found the eager index build on ingest: its +50-host merge benchmark rose 27.91% and the mixed ingest benchmark rose 24.88%, +with additional allocations. After deferring index construction, five +alternating exact-base/candidate worker runs at load 0.53-0.91 measured the +50-host merge at 462.4µs versus 464.8µs (+0.5%) and mixed ingest at 4.918ms +versus 4.953ms (+0.7%), with unchanged allocations. The 200-host merge was +3.210ms versus 3.192ms (-0.6%). The warmed alias-hit benchmark remained about +4.7µs and 19 allocations. These paired runs remove the observed local ingest +regression; the revised PR still needs its CI benchmark result. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 3cfaa62c8..fb410d4e7 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,23 +1,33 @@ { "version": 1, - "base_sha": "0abe518219f75512fc3f9ca0fdbd4ce549e30e90", - "verified_at": "2026-09-24T16:05:19Z", + "base_sha": "d25403b8c3e28354d0e62fc3fc35898cca3e8f1c", + "verified_at": "2026-09-24T19:54:45Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx", - "frontend-modern/src/features/platformPage/PlatformWindowedList.tsx", - "frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx" + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx", + "frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/components/shared/InlineNotice.tsx", + "frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", + "frontend-modern/src/hooks/useUnifiedResources.ts", + "frontend-modern/src/hooks/useWorkloads.ts" ], "content_sha256": { - "frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx": "5c47a4d5d5ff12b0f6cf0e495bd02ebdadccd81135ad1ff91f9f13e90b5a81af", - "frontend-modern/src/features/platformPage/PlatformWindowedList.tsx": "1842c634054e46e24d965a063719065ca1156b3f061d594c878a8866d5b33a3e", - "frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx": "6f5dbc611dfbb00bc2d770fcf06b8dbf329ebd76dc022bb63191480a221cb457" + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "c112078ff621331510ac28490371ee7ba9099f7a1bbb64d0f66ff96400d4f89a", + "frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts": "3678e4b852ae0029760d3295fdd878cec436100f85c945dd46fe760da38887e4", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "7ae6fee729328039f4d1ade064ba5d6e899f0fdf0a72e96a7df7e034b4f464de", + "frontend-modern/src/components/shared/InlineNotice.tsx": "1a239f4240c57f83383f0d936f2e90ea6cb122627299e17500c20c7236c04e9d", + "frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx": "7960f323dde72b2b2f9d961792d410db2bfd95bacd87b1ae55c1ed473737994e", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "808ba390e1978c07f08a033bc23d5cf4d62731b6cc8b388c024180b3914f6fce", + "frontend-modern/src/hooks/useUnifiedResources.ts": "c4d2f3c44b19c89d3cf0d15e033f825c37aaab985abfd963ddb5907cbab76fe2", + "frontend-modern/src/hooks/useWorkloads.ts": "453b90cfe6df925bd28a8713548c1348767f3c398d08c972b9b1f493e70986ef" }, "routes": [ "/proxmox/overview", - "/alerts", - "/docs/INSTALL", - "/docs/UPGRADE_v6" + "/proxmox/storage", + "/alerts/overview", + "/settings/infrastructure/agent-doctor" ], "viewports": [ { @@ -25,31 +35,30 @@ "height": 900 }, { - "width": 390, - "height": 844 + "width": 768, + "height": 900 }, { - "width": 900, - "height": 900 + "width": 390, + "height": 844 } ], "states": [ - "Expanded Disaster Recovery D node with Manage selected, unsaved and saved lifecycle selection, and disabled Saving controls.", - "Future maintenance scheduled for 25 September with actual local start/end dates, edited expiry, cancelled window, and active maintenance with an actual future deadline.", - "Mute, retired and active policy states, persisted maintenance and mute after a graceful same-data restart, and stable canonical resource identity.", - "Active and absent node connectivity alert, open retained incident with ordered firing, resolution and re-fire events, expanded Evidence details, and empty disabled Save Note.", - "Empty filtered History, persisted search after reload, cleared search results and intermediate-width filter controls.", - "Signed installer code and helper-ownership guidance, with internal code scrolling and narrow document layout." + "50-node, 1,508-resource synthetic snapshot with two-second random metric updates and 49 outdated agents", + "Proxmox node selection, Workloads search/filter reset, Storage record Overview and History", + "Collapsed and keyboard-expanded affected-host notice, upgrade link and Agent Doctor guidance", + "Agent Doctor first and later table rows, badge and Seen values, keyboard row expansion and collapse", + "Mobile platform switcher open and Escape dismissed; alert Timeline expanded and dismissed", + "WebSocket reconnect and reload with no extra /api/state request on reconnect" ], "interactions": [ - "Show all 50 nodes, expand Disaster Recovery D, select Manage, change lifecycle, scroll to Save, save, return Active and save. Manage and unsaved selection survive the retained-row window shift and viewport changes.", - "Create resource-only future maintenance, edit its end, cancel it, then create a one-hour active window. Inspect actual banner pixels at phone and desktop widths.", - "Save Mute all attention with active maintenance, reload, gracefully restart the same final binary with unchanged data, and reload. Confirm identical canonical agent identity and policy, with the node connectivity alert absent.", - "Cancel maintenance while keeping mute, verify the alert stays absent, then restore normal monitoring. Mute and resume the active occurrence once more and verify one open incident with its original start and ordered resolve/refire events.", - "Open Monitoring menu, close with Escape and verify focus returns, reopen and dismiss with outside click. Inspect menu placement and clipping at phone width.", - "Open Timeline and Evidence details, inspect occurrence and observation timestamps, verify empty Save Note is disabled without submitting a note.", - "Filter History to no results, reload to verify persisted search, then clear it. Follow signed-installer and ownership links to their headings and inspect code and narrow layout." + "Hydrate Proxmox Overview, expand a node, filter 929 guests to one named container, clear the filter and navigate to Alerts.", + "Switch Storage and Overview through live controls, open a storage record, select History and inspect chart placement and mobile scrolling.", + "Focus and activate Show all 49 hosts by keyboard, inspect the full list, collapse it and use the upgrade link to open Agent Doctor.", + "Inspect Agent Doctor at 1440, 768 and 390px, including guidance contrast, full badge and Seen text, first and later rows, keyboard expansion and return to Proxmox.", + "Open the mobile platform switcher, inspect stacking and clipping, dismiss with Escape. Reconnect WebSocket and reload, confirming resource hydration.", + "Navigate to Alerts and expand Timeline at desktop and phone widths; inspect dismissal and adjacent controls." ], - "command": "Playwright browser inspection against an isolated synthetic server on pulse-dev:7701 through a loopback SSH tunnel.", - "notes": "Final backend SHA-256 77d17ff9e21ccdef3f5fff9f7b91b841abe3570bf8e04e1eda501c554e3a5889. Frontend index SHA-256 b9f1288bd188c8e549ccd4cdfa7aa75b56d49df3fc0f59de9e11dac90d449172. Actual pixels and DOM state were inspected. Repeated suppressed observations, expiry, delayed lifecycle replay and native-alias startup ordering are covered by failing-baseline regression tests and final race checks. The synthetic provider does not qualify production notification destinations or installed upgrade/rollback behavior." + "command": "Playwright on the final rebuilt 50-node synthetic server at pulse-dev:7703 after the lazy registry index change: qa-agent-doctor-final-20260924.mjs, qa-final-20260924.mjs, qa-adjacent-20260924.mjs, qa-overlays-20260924.mjs, qa-notice-action-20260924.mjs.", + "notes": "Final frontend content hashes bind the receipt to every changed frontend runtime file. The browser binary used internal/unifiedresources/registry.go SHA-256 bb4fa4a0d9a635971c93d93282663bb8088e25e020c087e75de5a7b196c7222d. Pixel screenshots inspected at desktop, intermediate and phone widths, including the deepest changed states. No page errors or horizontal overflow. The synthetic fixture does not qualify installed-fleet latency or the remaining roughly 4 MB initial resource stream." } diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx index d35a80dd0..8abce4716 100644 --- a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx @@ -310,7 +310,7 @@ export const InfrastructureAgentDoctorPage: Component -
+
Structured diagnostics are temporarily unavailable

@@ -343,7 +343,7 @@ export const InfrastructureAgentDoctorPage: Component 0} fallback={ -

+
No Pulse Agent connections are currently in scope.
@@ -385,7 +385,7 @@ export const InfrastructureAgentDoctorPage: Component -
+
Update and authentication-repair commands are host-local: copy one to the affected machine to repair its Pulse Agent from this server. They do not update the Pulse server runtime and Pulse does not run them remotely. @@ -401,7 +401,7 @@ export const InfrastructureAgentDoctorPage: Component
-

+

Generate update token

@@ -415,7 +415,7 @@ export const InfrastructureAgentDoctorPage: Component setSelectedTokenTargetKey(event.currentTarget.value)} selectBaseClass="min-h-10 w-full rounded-md border border-blue-200 bg-surface px-3 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-950 dark:focus:ring-blue-900" @@ -465,7 +465,7 @@ export const InfrastructureAgentDoctorPage: Component -

+

Tokens are optional on this Pulse instance. Confirm to generate Windows update commands without embedding a token. @@ -485,13 +485,13 @@ export const InfrastructureAgentDoctorPage: Component - + Agent - + Status - + Seen Last seen @@ -583,7 +583,10 @@ export const InfrastructureAgentDoctorPage: Component {target.expectedVersion || '—'} - + {lastSeenRelative()} {lastSeen() || '—'} @@ -747,7 +750,7 @@ export const InfrastructureAgentDoctorPage: Component +

Save this credential now

This secret is held only in this page's memory and cannot be diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureAgentDoctorPage.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureAgentDoctorPage.test.tsx index 538b024a9..80e13315c 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureAgentDoctorPage.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureAgentDoctorPage.test.tsx @@ -153,4 +153,34 @@ describe('InfrastructureAgentDoctorPage action-runner enrollment', () => { screen.getByRole('button', { name: 'Rotate action-runner credential' }), ).toBeInTheDocument(); }); + + it('keeps an attention row and its host-local repair guidance available through disclosure', () => { + render(() => ( + + )); + + expect(screen.getAllByText('Needs attention')).toHaveLength(2); + expect(screen.getByText('just now')).toBeInTheDocument(); + expect(screen.getByText(/commands are host-local/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Hide details for host-1' })); + expect(screen.getByRole('button', { name: 'Show details for host-1' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + fireEvent.click(screen.getByRole('button', { name: 'Show details for host-1' })); + expect(screen.getByRole('button', { name: 'Hide details for host-1' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); }); diff --git a/frontend-modern/src/components/Storage/__tests__/useStoragePoolsTableWindowing.test.ts b/frontend-modern/src/components/Storage/__tests__/useStoragePoolsTableWindowing.test.ts index c18585839..e15aea5fd 100644 --- a/frontend-modern/src/components/Storage/__tests__/useStoragePoolsTableWindowing.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useStoragePoolsTableWindowing.test.ts @@ -53,6 +53,9 @@ describe('useStoragePoolsTableWindowing', () => { 'group:closed', 'record:storage-4', ]); + expect(items.filter((item) => item.kind === 'record').every((item) => !('group' in item))).toBe( + true, + ); }); it('keeps a large estate to a bounded 72-item DOM window and reveals expansion targets', () => { diff --git a/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts b/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts index 268499f37..f91deb44b 100644 --- a/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts +++ b/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts @@ -32,7 +32,6 @@ type StoragePoolsTableGroupItem = { type StoragePoolsTableRecordItem = { kind: 'record'; key: string; - group: StoragePoolsTableGroupModel; record: StorageRecord; }; @@ -48,7 +47,7 @@ export const buildStoragePoolsTableItems = ( } if (!group.expanded) continue; for (const record of group.items) { - items.push({ kind: 'record', key: `record:${record.id}`, group, record }); + items.push({ kind: 'record', key: `record:${record.id}`, record }); } } return items; diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index 158509812..e041d9d20 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -99,6 +99,10 @@ export interface WorkloadsSurfaceProps { // snapshot it already fetched. This avoids a second workload/infrastructure // request and keeps both surfaces on the same refresh generation. resourceSnapshot?: Accessor; + resourceSnapshotChange?: Accessor<{ + version: number; + changedIds: ReadonlySet | null; + }>; resourceSnapshotRefetch?: () => Promise; statusModeStorageScope?: string; // Platform pages that render their own hosts table above the embedded @@ -165,6 +169,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { const workloadsEnabled = createMemo(() => props.useWorkloads === true); const workloads = useWorkloads(workloadsEnabled, { resourceSnapshot: props.resourceSnapshot, + resourceSnapshotChange: props.resourceSnapshotChange, refetchSnapshot: props.resourceSnapshotRefetch, }); const infrastructureSources = useUnifiedResources({ diff --git a/frontend-modern/src/components/shared/InlineNotice.test.tsx b/frontend-modern/src/components/shared/InlineNotice.test.tsx index e2f9206bc..e27db1c06 100644 --- a/frontend-modern/src/components/shared/InlineNotice.test.tsx +++ b/frontend-modern/src/components/shared/InlineNotice.test.tsx @@ -42,7 +42,7 @@ describe('InlineNotice', () => { expect(notice?.className).toContain('border-amber-300'); expect(notice?.className).toContain('bg-amber-50'); expect(screen.getByRole('link', { name: 'Open settings' }).className).toContain( - 'text-amber-900', + 'text-amber-800', ); }); diff --git a/frontend-modern/src/components/shared/InlineNotice.tsx b/frontend-modern/src/components/shared/InlineNotice.tsx index 34bdd7a76..c7db69241 100644 --- a/frontend-modern/src/components/shared/InlineNotice.tsx +++ b/frontend-modern/src/components/shared/InlineNotice.tsx @@ -46,10 +46,10 @@ const INLINE_NOTICE_ACTION_BASE_CLASS = 'inline-flex items-center gap-1 text-xs font-semibold underline-offset-2 hover:underline'; export const INLINE_NOTICE_ACTION_TONE_CLASSES: Record = { - danger: 'text-red-900 dark:text-red-100', - info: 'text-blue-900 dark:text-blue-100', - success: 'text-emerald-900 dark:text-emerald-100', - warning: 'text-amber-900 dark:text-amber-100', + danger: 'text-red-800 dark:text-red-100', + info: 'text-blue-800 dark:text-blue-100', + success: 'text-emerald-800 dark:text-emerald-100', + warning: 'text-amber-800 dark:text-amber-100', }; const INLINE_NOTICE_ACTION_ICON_CLASS = diff --git a/frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.test.tsx b/frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.test.tsx index 447ad0546..511745482 100644 --- a/frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.test.tsx +++ b/frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it } from 'vitest'; import { syncSessionPresentationPolicy } from '@/stores/sessionPresentationPolicy'; import { PlatformOutdatedAgentNotice } from './PlatformOutdatedAgentNotice'; @@ -52,6 +52,33 @@ describe('PlatformOutdatedAgentNotice', () => { expect(notice).toHaveTextContent('Affected: tower, delly.'); }); + it('keeps a large affected-host list behind an expandable preview', () => { + render(() => ( + + )); + + const notice = screen.getByTestId('platform-outdated-agent-notice'); + expect(notice).toHaveTextContent('Affected: tower, delly, lab, and 1 more.'); + expect(notice).not.toHaveTextContent('Affected: tower, delly, lab, remote.'); + + const toggle = screen.getByRole('button', { name: 'Show all 4 hosts' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(notice).toHaveTextContent('Affected: tower, delly, lab, remote.'); + fireEvent.click(screen.getByRole('button', { name: 'Hide affected names' })); + expect(notice).not.toHaveTextContent('Affected: tower, delly, lab, remote.'); + }); + it('uses latest-detail copy for hybrid platform pages', () => { render(() => ( props.hosts.length); const names = createMemo(() => props.hosts.map((host) => host.name).join(', ')); + const [showAllHosts, setShowAllHosts] = createSignal(false); + const preview = createMemo(() => { + const firstNames = props.hosts + .slice(0, 3) + .map((host) => host.name) + .join(', '); + const remaining = count() - 3; + return remaining > 0 ? `${firstNames}, and ${remaining} more` : firstNames; + }); const actionLabel = createMemo(() => props.actionLabel || 'Open Infrastructure settings'); const subjectSingular = createMemo(() => props.subjectSingular || 'host'); const subjectPlural = createMemo(() => props.subjectPlural || 'hosts'); @@ -45,9 +54,9 @@ export function PlatformOutdatedAgentNotice(props: PlatformOutdatedAgentNoticePr return `${host.name} is running an older Pulse agent (${host.version}). Update it${target} to see ${props.missingLabel} for this ${subjectSingular()}.`; } if (copyVariant === 'latest-detail') { - return `${count()} ${subjectPlural()} are running an older Pulse agent. Update them${target} for the latest ${props.missingLabel}. Affected: ${names()}.`; + return `${count()} ${subjectPlural()} are running an older Pulse agent. Update them${target} for the latest ${props.missingLabel}. Affected: ${preview()}.`; } - return `${count()} ${subjectPlural()} are running an older Pulse agent. Update them${target} to see ${props.missingLabel}. Affected: ${names()}.`; + return `${count()} ${subjectPlural()} are running an older Pulse agent. Update them${target} to see ${props.missingLabel}. Affected: ${preview()}.`; }); return ( @@ -61,7 +70,20 @@ export function PlatformOutdatedAgentNotice(props: PlatformOutdatedAgentNoticePr actionLabel={actionLabel()} actionIcon={

Affected: {names()}.

+ + ); diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index 889c417e4..55d3bcbc3 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -364,6 +364,7 @@ export function ProxmoxPageSurface() { ? undefined : overviewModel().resources } + resourceSnapshotChange={overviewResources.resourceSnapshotChange} resourceSnapshotRefetch={() => overviewResources.refetch()} inventoryCountsVisible={inventoryCountsVisible} setInventoryCountsVisible={setInventoryCountsVisible} @@ -434,6 +435,10 @@ interface ProxmoxOverviewProps { memoryDisplayBasis: Accessor; setMemoryDisplayBasis: (value: WorkloadsMemoryDisplayBasis) => void; resourceSnapshot: Accessor; + resourceSnapshotChange: Accessor<{ + version: number; + changedIds: ReadonlySet | null; + }>; resourceSnapshotRefetch: () => Promise; inventoryCountsVisible: Accessor; setInventoryCountsVisible: (visible: boolean) => void; @@ -449,6 +454,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { layoutWidth: overviewWidth.width, useWorkloads: true, resourceSnapshot: props.resourceSnapshot, + resourceSnapshotChange: props.resourceSnapshotChange, resourceSnapshotRefetch: props.resourceSnapshotRefetch, forcedPlatform: PROXMOX_PLATFORM_FILTER, excludedWorkloadTypes: PROXMOX_WORKLOAD_EXCLUDED_TYPES, diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index ca49fd2f4..5cc8ba71a 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -18,6 +18,7 @@ const mockBackupsTableProps = vi.hoisted(() => vi.fn()); const mockWorkloadSearch = vi.hoisted(() => vi.fn(() => '')); const mockSelectedNode = vi.hoisted(() => vi.fn<() => string | null>(() => null)); const mockHandleNodeSelect = vi.hoisted(() => vi.fn()); +const mockWorkloadsOptions = vi.hoisted(() => vi.fn()); const makeResource = (resource: Partial & Pick): Resource => ({ @@ -88,17 +89,20 @@ vi.mock('@/components/Workloads/WorkloadsSurface', () => ({ })); vi.mock('@/components/Workloads/useWorkloadsState', () => ({ - useWorkloadsState: () => ({ - surfaceConnected: () => false, - surfaceInitialDataReceived: () => false, - allGuests: () => [], - selectedNode: mockSelectedNode, - handleNodeSelect: mockHandleNodeSelect, - selectedHostHint: () => null, - totalStats: mockTotalStats, - search: mockWorkloadSearch, - setSearch: vi.fn(), - }), + useWorkloadsState: (options: unknown) => { + mockWorkloadsOptions(options); + return { + surfaceConnected: () => false, + surfaceInitialDataReceived: () => false, + allGuests: () => [], + selectedNode: mockSelectedNode, + handleNodeSelect: mockHandleNodeSelect, + selectedHostHint: () => null, + totalStats: mockTotalStats, + search: mockWorkloadSearch, + setSearch: vi.fn(), + }; + }, })); vi.mock('@/features/platformPage/sharedPlatformPage', () => ({ @@ -159,6 +163,7 @@ const renderSurface = () => describe('ProxmoxPageSurface contract', () => { beforeEach(() => { + mockWorkloadsOptions.mockClear(); mockSelectedNode.mockReturnValue(null); mockHandleNodeSelect.mockClear(); mockPathname.mockReturnValue('/proxmox/overview'); @@ -176,6 +181,24 @@ describe('ProxmoxPageSurface contract', () => { mockWorkloadSearch.mockReturnValue(''); }); + it('passes the committed resource change metadata into the Workloads owner', () => { + const change = { version: 3, changedIds: new Set(['vm-1']) }; + const resourceSnapshotChange = () => change; + mockUseUnifiedResources.mockReturnValue({ + resources: () => [makeResource({ id: 'vm-1', type: 'vm' })], + resourceSnapshotChange, + loading: () => false, + error: () => null, + refetch: vi.fn(), + }); + + renderSurface(); + + expect(mockWorkloadsOptions).toHaveBeenCalledWith( + expect.objectContaining({ resourceSnapshotChange }), + ); + }); + afterEach(() => { cleanup(); vi.clearAllMocks(); diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index 761ed090b..3408c0079 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -295,6 +295,7 @@ describe('useUnifiedResources', () => { await waitForResourceCount(() => result!.resources().length); expect(result!.resources()[0]?.cpu?.current).toBe(15); + expect(result!.resourceSnapshotChange().changedIds).toBeNull(); batch(() => { setWsState('resources', 0, 'cpu', 'current', 88); @@ -303,6 +304,7 @@ describe('useUnifiedResources', () => { }); await waitForValue(() => result!.resources()[0]?.cpu?.current, 88); + expect(result!.resourceSnapshotChange().changedIds).toEqual(new Set(['vm-1'])); expect(apiFetchMock).not.toHaveBeenCalled(); dispose(); }); diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index fbd7a71d0..e3a758519 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -1,4 +1,4 @@ -import { createEffect, createRoot, createSignal } from 'solid-js'; +import { batch, createEffect, createRoot, createSignal } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import useWorkloadsSource from '../useWorkloads.ts?raw'; @@ -397,6 +397,73 @@ describe('useWorkloads', () => { dispose(); }); + it('adapts only changed canonical guests and fully refreshes when change history is unavailable', async () => { + const buildGuest = (id: string, name: string, cpu: number) => + ({ + id, + type: 'vm', + name, + status: 'running', + platformType: 'proxmox-pve', + sources: ['proxmox'], + proxmox: { sourceId: id, vmid: id === 'vm-a' ? 101 : 102, nodeName: 'pve1' }, + cpu: { current: cpu }, + }) as any; + let untouchedNameReads = 0; + const untouched = buildGuest('vm-a', 'vm-a', 10); + Object.defineProperty(untouched, 'name', { + get: () => { + untouchedNameReads += 1; + return 'vm-a'; + }, + }); + const [snapshot, setSnapshot] = createSignal([untouched, buildGuest('vm-b', 'vm-b', 20)]); + const [change, setChange] = createSignal<{ + version: number; + changedIds: ReadonlySet | null; + }>({ version: 1, changedIds: null }); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useWorkloads(() => true, { + resourceSnapshot: snapshot, + resourceSnapshotChange: change, + }); + }); + + try { + await flushAsync(); + const originalRow = result!.workloads()[0]; + untouchedNameReads = 0; + batch(() => { + setSnapshot([untouched, buildGuest('vm-b', 'vm-b', 85)]); + setChange({ version: 2, changedIds: new Set(['vm-b']) }); + }); + await flushAsync(); + expect(untouchedNameReads).toBe(0); + expect(result!.workloads()[0]).toBe(originalRow); + expect(result!.workloads()[1]?.cpu).toBeCloseTo(0.85); + + batch(() => { + setSnapshot([buildGuest('vm-a', 'renamed-a', 10), buildGuest('vm-b', 'vm-b', 85)]); + setChange({ version: 3, changedIds: null }); + }); + await flushAsync(); + expect(result!.workloads()[0]?.name).toBe('renamed-a'); + + batch(() => { + setSnapshot([buildGuest('vm-a', 'renamed-again', 10), buildGuest('vm-b', 'vm-b', 85)]); + setChange({ version: 5, changedIds: new Set(['vm-b']) }); + }); + await flushAsync(); + expect(result!.workloads()[0]?.name).toBe('renamed-again'); + } finally { + dispose(); + } + }); + it('retains the fulfilled workload snapshot when a forced refresh fails', async () => { let dispose = () => {}; let result: ReturnType | undefined; diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index a9b46a114..909309219 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -1513,6 +1513,11 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const hasCachedResources = cacheEntry.hasSnapshot; const [resources, setResources] = createStore(initialResources); + const [resourceSnapshotChange, setResourceSnapshotChange] = createSignal<{ + version: number; + changedIds: ReadonlySet | null; + }>({ version: 0, changedIds: null }); + let resourceSnapshotVersion = 0; const [policyPosture, setPolicyPosture] = createSignal( initialPolicyPosture, ); @@ -1541,6 +1546,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { return; } setResources(reconcile(next, { key: 'id' })); + setResourceSnapshotChange({ version: ++resourceSnapshotVersion, changedIds: null }); setPolicyPosture(targetEntry.policyPosture); setAggregations(targetEntry.aggregations); setFacets(targetEntry.facets); @@ -1903,6 +1909,12 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { projectedFacetResources === null ? cacheEntry.facets : buildUnifiedResourceFacets(projectedFacetResources); + // The all-resources route shares this cache object. Keep its prior state + // before publishing the new generation so it can use the same bounded + // changed-ID path as source-scoped projections. + const previousProjectedResources = cacheEntry.resources; + const previousCacheRealtimeVersion = cacheEntry.realtimeVersion; + const previousCacheHasSnapshot = cacheEntry.hasSnapshot; const now = Date.now(); clearInitialHydrationTimeout(); setUnifiedResourcesCache(allResourcesEntry, mergedWsResources, now); @@ -1935,10 +1947,10 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { } const cacheEntryCatchUp = - cacheEntry.hasSnapshot && - cacheEntry.realtimeVersion > 0 && + previousCacheHasSnapshot && + previousCacheRealtimeVersion > 0 && resolvedProjectedResources === projectedResources - ? resolveCatchUpMeta(cacheEntry.realtimeVersion) + ? resolveCatchUpMeta(previousCacheRealtimeVersion) : null; const canPatchProjectionIncrementally = cacheEntryCatchUp !== null; const changedResourceTouchesAgent = @@ -1955,9 +1967,6 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { ) : null; - // Captured before the cache write below replaces it: the fast-commit - // eligibility check needs the row the instance store currently mirrors. - const previousProjectedResources = cacheEntry.resources; setUnifiedResourcesCache( cacheEntry, resolvedProjectedResources, @@ -2008,6 +2017,10 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { } }); } + setResourceSnapshotChange({ + version: ++resourceSnapshotVersion, + changedIds: incrementalPatchIndices === null ? null : cacheEntryCatchUp!.changedIds, + }); setPolicyPosture(cacheEntry.policyPosture); setAggregations(cacheEntry.aggregations); setFacets(cacheEntry.facets); @@ -2090,6 +2103,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { batch(() => { setError(undefined); setResources(reconcile(scopedResources, { key: 'id' })); + setResourceSnapshotChange({ version: ++resourceSnapshotVersion, changedIds: null }); setPolicyPosture(scopedPolicyPosture); setAggregations(scopedAggregations); setFacets(scopedFacets); @@ -2137,6 +2151,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { return { resources: () => resources, + resourceSnapshotChange, policyPosture, aggregations, facets, diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 9d6947499..ab28ad2c6 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -1,4 +1,4 @@ -import { onCleanup, createEffect, createSignal, type Accessor } from 'solid-js'; +import { onCleanup, createEffect, createSignal, untrack, type Accessor } from 'solid-js'; import { apiFetchJSON, getOrgID } from '@/utils/apiClient'; import { normalizeOrgScope } from '@/utils/orgScope'; import { eventBus } from '@/stores/events'; @@ -258,6 +258,10 @@ const reconcileWorkloadRowIdentity = ( let reusedCount = 0; const reconciled = next.map((row) => { const previousRow = previousById.get(row.id); + if (previousRow === row) { + reusedCount += 1; + return row; + } if (previousRow && workloadSignature(previousRow) === workloadSignature(row)) { reusedCount += 1; return previousRow; @@ -829,6 +833,11 @@ export const __resetWorkloadsCacheForTests = () => { export interface UseWorkloadsOptions { /** Optional canonical snapshot owned by a platform page. */ resourceSnapshot?: Accessor; + /** Changed IDs from the same committed snapshot, or null for a full refresh. */ + resourceSnapshotChange?: Accessor<{ + version: number; + changedIds: ReadonlySet | null; + }>; /** Refetch the owner snapshot when the surface explicitly reconnects. */ refetchSnapshot?: () => Promise; } @@ -848,6 +857,9 @@ export function useWorkloads( ); const [error, setError] = createSignal(undefined); let requestVersion = 0; + let lastSnapshotVersion = 0; + let lastSnapshotOrgScope = resolveActiveOrgScope(); + let lastSnapshotRowsByResourceID = new Map(); const mutate = (value: WorkloadGuest[] | ((prev: WorkloadGuest[]) => WorkloadGuest[])) => setWorkloads((previous) => { @@ -866,7 +878,8 @@ export function useWorkloads( const applyWorkloads = (next: WorkloadGuest[], targetOrgScope = resolveActiveOrgScope()) => { const cacheEntry = getWorkloadsCacheEntry(targetOrgScope); - const current = targetOrgScope === resolveActiveOrgScope() ? workloads() : cacheEntry.workloads; + const current = + targetOrgScope === resolveActiveOrgScope() ? untrack(workloads) : cacheEntry.workloads; const reconciled = reconcileWorkloadRowIdentity(current, next); if (reconciled === current) { setWorkloadsCache(cacheEntry, current, Date.now()); @@ -929,6 +942,8 @@ export function useWorkloads( createEffect(() => { if (!enabled()) { requestVersion += 1; + lastSnapshotVersion = 0; + lastSnapshotRowsByResourceID.clear(); setLoading(false); return; } @@ -939,9 +954,27 @@ export function useWorkloads( return; } - const next = resourceSnapshot - .map(mapCanonicalResourceToWorkload) - .filter((resource): resource is WorkloadGuest => Boolean(resource)); + const change = options.resourceSnapshotChange?.(); + const currentOrgScope = resolveActiveOrgScope(); + const canReuseStableRows = + change?.changedIds !== null && + change?.changedIds !== undefined && + change.version === lastSnapshotVersion + 1 && + currentOrgScope === lastSnapshotOrgScope; + const nextRowsByResourceID = new Map(); + const next = resourceSnapshot.flatMap((resource) => { + const mapped = + canReuseStableRows && + !change!.changedIds!.has(resource.id) && + lastSnapshotRowsByResourceID.has(resource.id) + ? lastSnapshotRowsByResourceID.get(resource.id)! + : mapCanonicalResourceToWorkload(resource); + nextRowsByResourceID.set(resource.id, mapped); + return mapped ? [mapped] : []; + }); + lastSnapshotVersion = change?.version ?? 0; + lastSnapshotOrgScope = currentOrgScope; + lastSnapshotRowsByResourceID = nextRowsByResourceID; applyWorkloads(next); setLoading(false); setError(undefined); diff --git a/internal/unifiedresources/monitor_adapter.go b/internal/unifiedresources/monitor_adapter.go index f34b3ec85..28eda9d11 100644 --- a/internal/unifiedresources/monitor_adapter.go +++ b/internal/unifiedresources/monitor_adapter.go @@ -283,8 +283,7 @@ func (a *MonitorAdapter) ResolveCanonicalResourceID(ref string) (string, bool) { if registry == nil { return "", false } - _, canonicalID, ok := registry.GetByReference(ref) - return canonicalID, ok + return registry.ResolveReferenceID(ref) } // ResolveCanonicalResourceAncestors returns the live canonical parent chain, @@ -295,7 +294,7 @@ func (a *MonitorAdapter) ResolveCanonicalResourceAncestors(ref string) []string if registry == nil { return nil } - _, canonicalID, ok := registry.GetByReference(ref) + canonicalID, ok := registry.ResolveReferenceID(ref) if !ok { canonicalID = CanonicalResourceID(ref) } @@ -308,7 +307,7 @@ func (a *MonitorAdapter) ResolveCanonicalResourceAncestors(ref string) []string } parentRef := CanonicalResourceID(*resource.ParentID) parentID := parentRef - if _, resolvedParentID, resolved := registry.GetByReference(parentRef); resolved { + if resolvedParentID, resolved := registry.ResolveReferenceID(parentRef); resolved { parentID = resolvedParentID } if parentID == "" { diff --git a/internal/unifiedresources/monitor_adapter_read_state_test.go b/internal/unifiedresources/monitor_adapter_read_state_test.go index eb9eeb99f..5eb791f3c 100644 --- a/internal/unifiedresources/monitor_adapter_read_state_test.go +++ b/internal/unifiedresources/monitor_adapter_read_state_test.go @@ -134,6 +134,26 @@ func TestMonitorAdapterResolvesCanonicalOperatorIntentCapabilities(t *testing.T) } } +func TestMonitorAdapterCanonicalReferencePreservesAliasAmbiguity(t *testing.T) { + registry := NewRegistry(nil) + adapter := NewMonitorAdapter(registry) + registry.IngestResources([]Resource{ + {ID: "host-a", Type: ResourceTypeAgent, Agent: &AgentData{AgentID: "one"}}, + {ID: "host-b", Type: ResourceTypeAgent, Agent: &AgentData{AgentID: "shared"}}, + {ID: "host-c", Type: ResourceTypeAgent, Agent: &AgentData{AgentID: "shared"}}, + }) + + if id, ok := adapter.ResolveCanonicalResourceID("AGENT:ONE"); !ok || id != "host-a" { + t.Fatalf("unique alias resolved to %q, %v; want host-a", id, ok) + } + if id, ok := adapter.ResolveCanonicalResourceID("agent:shared"); ok { + t.Fatalf("ambiguous alias resolved to %q", id) + } + if id, ok := adapter.ResolveCanonicalResourceID("host-b"); !ok || id != "host-b" { + t.Fatalf("exact ID resolved to %q, %v; want host-b", id, ok) + } +} + func TestMonitorAdapterResolvesCanonicalResourceAncestorsNearestFirst(t *testing.T) { registry := NewRegistry(NewMemoryStore()) adapter := NewMonitorAdapter(registry) diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 80146cd2f..270f9ec5a 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "time" + "unicode" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust" @@ -99,6 +100,10 @@ type ResourceRegistry struct { // ID (availability links, API reads) keep resolving. An empty value marks // an ambiguous claim and never resolves. supersededIndex map[string]string + // Derived from each stored resource's canonical identity after ingest. + // nil during a batch means reference reads use the live scan until the + // final identity refresh rebuilds the index. Empty values are ambiguous. + canonicalIdentityIndex map[string]string // Cached typed view indexes. Invalidated on ingest, rebuilt lazily on // first access. Protected by mu — callers hold RLock to read, and the @@ -695,17 +700,20 @@ func (rr *ResourceRegistry) proxmoxGuestResourceIDForSourceRefLocked(ref string) if !ok { return "" } - matches := map[string]struct{}{} + uniqueID := "" for _, resourceType := range []ResourceType{ResourceTypeVM, ResourceTypeSystemContainer} { candidateID := ProxmoxGuestCanonicalID(resourceType, instance, vmid) if candidateID == "" { continue } if rr.resources[candidateID] != nil { - matches[candidateID] = struct{}{} + if uniqueID != "" && uniqueID != candidateID { + return "" + } + uniqueID = candidateID } } - return uniqueResourceIDMatch(matches) + return uniqueID } // applyRecordSuccessions re-keys operator-owned store rows from canonical IDs @@ -780,6 +788,7 @@ func (rr *ResourceRegistry) ingestResources(resources []Resource, thresholds map } rr.mu.Lock() + rr.canonicalIdentityIndex = nil rr.resources[resource.ID] = resource rr.matcher.Add(resource.ID, resource.Identity) rr.viewsDirty = true @@ -1360,35 +1369,72 @@ func (rr *ResourceRegistry) Get(id string) (*Resource, bool) { // resource ID alongside the cloned resource so callers can keep downstream // store lookups on the canonical registry identity. func (rr *ResourceRegistry) GetByReference(ref string) (*Resource, string, bool) { - rr.mu.RLock() - defer rr.mu.RUnlock() - ref = CanonicalResourceID(ref) - if ref == "" { + rr.mu.RLock() + resolvedID, needsAliasIndex := rr.resolveReferenceIDLocked(ref) + if needsAliasIndex { + rr.mu.RUnlock() + rr.mu.Lock() + if rr.canonicalIdentityIndex == nil { + rr.buildCanonicalIdentityIndexLocked() + } + resolvedID, _ = rr.resolveReferenceIDLocked(ref) + defer rr.mu.Unlock() + } else { + defer rr.mu.RUnlock() + } + if resolvedID == "" { return nil, "", false } + clone := cloneResource(rr.resources[resolvedID]) + return &clone, resolvedID, true +} - if r := rr.resources[ref]; r != nil { - clone := cloneResource(r) - return &clone, ref, true - } - - for _, resolvedID := range []string{ - rr.supersededResourceIDLocked(ref), - rr.uniqueSourceResourceIDLocked(ref), - rr.proxmoxGuestResourceIDForSourceRefLocked(ref), - rr.uniqueCanonicalIdentityResourceIDLocked(ref), - } { - if resolvedID == "" { - continue - } - if r := rr.resources[resolvedID]; r != nil { - clone := cloneResource(r) - return &clone, resolvedID, true +// ResolveReferenceID keeps identity-only consumers on the same precedence and +// ambiguity rules as GetByReference without cloning a full resource. +func (rr *ResourceRegistry) ResolveReferenceID(ref string) (string, bool) { + rr.mu.RLock() + ref = CanonicalResourceID(ref) + resolvedID, needsAliasIndex := rr.resolveReferenceIDLocked(ref) + if needsAliasIndex { + rr.mu.RUnlock() + rr.mu.Lock() + if rr.canonicalIdentityIndex == nil { + rr.buildCanonicalIdentityIndexLocked() } + resolvedID, _ = rr.resolveReferenceIDLocked(ref) + rr.mu.Unlock() + } else { + rr.mu.RUnlock() } + return resolvedID, resolvedID != "" +} - return nil, "", false +// The second result requests a one-time alias-index build after the read lock +// is released. Exact and source references never pay that cost. +func (rr *ResourceRegistry) resolveReferenceIDLocked(ref string) (string, bool) { + if ref == "" { + return "", false + } + if rr.resources[ref] != nil { + return ref, false + } + if resolvedID := rr.supersededResourceIDLocked(ref); rr.resources[resolvedID] != nil { + return resolvedID, false + } + if resolvedID := rr.uniqueSourceResourceIDLocked(ref); rr.resources[resolvedID] != nil { + return resolvedID, false + } + if resolvedID := rr.proxmoxGuestResourceIDForSourceRefLocked(ref); rr.resources[resolvedID] != nil { + return resolvedID, false + } + if rr.canonicalIdentityIndex == nil { + return "", true + } + if resolvedID := rr.uniqueCanonicalIdentityResourceIDLocked(ref); rr.resources[resolvedID] != nil { + return resolvedID, false + } + return "", false } func (rr *ResourceRegistry) uniqueSourceResourceIDLocked(sourceID string) string { @@ -1397,13 +1443,16 @@ func (rr *ResourceRegistry) uniqueSourceResourceIDLocked(sourceID string) string return "" } - matches := map[string]struct{}{} + uniqueID := "" for _, mapping := range rr.bySource { if resourceID := mapping[sourceID]; resourceID != "" { - matches[resourceID] = struct{}{} + if uniqueID != "" && uniqueID != resourceID { + return "" + } + uniqueID = resourceID } } - return uniqueResourceIDMatch(matches) + return uniqueID } func (rr *ResourceRegistry) uniqueCanonicalIdentityResourceIDLocked(ref string) string { @@ -1411,6 +1460,9 @@ func (rr *ResourceRegistry) uniqueCanonicalIdentityResourceIDLocked(ref string) if ref == "" { return "" } + if rr.canonicalIdentityIndex != nil { + return rr.canonicalIdentityIndex[canonicalIdentityFoldKey(ref)] + } matches := map[string]struct{}{} for resourceID, resource := range rr.resources { @@ -1421,6 +1473,25 @@ func (rr *ResourceRegistry) uniqueCanonicalIdentityResourceIDLocked(ref string) return uniqueResourceIDMatch(matches) } +// strings.EqualFold compares Unicode simple-fold classes, which cannot be +// keyed safely with strings.ToLower (for example, dotted I has a different +// fold class). Use the smallest rune of each class as the derived map key. +func canonicalIdentityFoldKey(ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" { + return "" + } + return strings.Map(func(r rune) rune { + minimum := r + for folded := unicode.SimpleFold(r); folded != r; folded = unicode.SimpleFold(folded) { + if folded < minimum { + minimum = folded + } + } + return minimum + }, ref) +} + func resourceMatchesCanonicalIdentityReference(resource *Resource, ref string) bool { if resource == nil || resource.Canonical == nil { return false @@ -2673,6 +2744,7 @@ func (rr *ResourceRegistry) ingest(source DataSource, sourceID string, resource func (rr *ResourceRegistry) ingestRecord(source DataSource, sourceID string, resource Resource, identity ResourceIdentity, onlyMissing bool) (ingestedID string) { rr.mu.Lock() defer rr.mu.Unlock() + rr.canonicalIdentityIndex = nil if rr.agentNodeScanIndex != nil { defer func() { rr.refreshAgentNodeScanIndexLocked(ingestedID) }() } @@ -3304,16 +3376,25 @@ func (rr *ResourceRegistry) resolveAvailabilityLinkedResource(ref string, incomi // node-scoped guest source ID follow the resource across identity eras // and live migrations. These arms resolve provider-declared persistence // keys, not display aliases, so the explicit link stays fail-closed. - for _, candidateID := range uniqueTrimmed( - rr.supersededResourceIDLocked(exactID), - rr.uniqueSourceResourceIDLocked(ref), - rr.proxmoxGuestResourceIDForSourceRefLocked(ref), - rr.uniqueCanonicalIdentityResourceIDLocked(ref), - ) { + eligible := func(candidateID string) string { + candidateID = CanonicalResourceID(candidateID) existing := rr.resources[candidateID] if existing != nil && !isAvailabilityOwnedResource(*existing) { return candidateID } + return "" + } + if candidateID := eligible(rr.supersededResourceIDLocked(exactID)); candidateID != "" { + return candidateID + } + if candidateID := eligible(rr.uniqueSourceResourceIDLocked(ref)); candidateID != "" { + return candidateID + } + if candidateID := eligible(rr.proxmoxGuestResourceIDForSourceRefLocked(ref)); candidateID != "" { + return candidateID + } + if candidateID := eligible(rr.uniqueCanonicalIdentityResourceIDLocked(ref)); candidateID != "" { + return candidateID } return "" @@ -4706,11 +4787,42 @@ func (rr *ResourceRegistry) buildChildCounts() { // policies silently fall back to factory (#1497). Runs after links and merges // settle so alias sets reflect the fully assembled resource. func (rr *ResourceRegistry) refreshCanonicalIdentitiesLocked() { + rr.canonicalIdentityIndex = nil for _, resource := range rr.resources { RefreshCanonicalIdentity(resource) } } +// buildCanonicalIdentityIndexLocked is deferred until a public reference read +// actually needs alias resolution. Ingest already walks every resource to +// refresh canonical identity, and rebuilding the map there would repeat work +// on every batch even when no alias is queried. +func (rr *ResourceRegistry) buildCanonicalIdentityIndexLocked() { + index := make(map[string]string, len(rr.resources)*2) + indexCandidate := func(candidate, resourceID string) { + key := canonicalIdentityFoldKey(candidate) + if key == "" { + return + } + if previous, exists := index[key]; !exists { + index[key] = resourceID + } else if previous != resourceID { + index[key] = "" + } + } + for resourceID, resource := range rr.resources { + if resource.Canonical == nil { + continue + } + indexCandidate(resource.Canonical.PrimaryID, resourceID) + indexCandidate(resource.Canonical.PlatformID, resourceID) + for _, alias := range resource.Canonical.Aliases { + indexCandidate(alias, resourceID) + } + } + rr.canonicalIdentityIndex = index +} + func (rr *ResourceRegistry) refreshLinkedAgentIDFromParentLocked(resource *Resource) { if resource == nil || resource.Proxmox == nil || resource.ParentID == nil { return diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index ea5104982..734f1c647 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -291,6 +291,101 @@ func TestResourceRegistry_GetByReferenceResolvesAgentRefOnMergedProxmoxHost(t *t } } +func TestResourceRegistry_CanonicalAliasIndexPreservesFoldAndAmbiguity(t *testing.T) { + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + {ID: "kelvin-host", Type: ResourceTypeAgent, Agent: &AgentData{AgentID: "Kelvin"}}, + {ID: "dotted-host", Type: ResourceTypeAgent, Agent: &AgentData{AgentID: "İmachine"}}, + {ID: "first-shared", Type: ResourceTypeAgent, Agent: &AgentData{AgentID: "shared"}}, + {ID: "second-shared", Type: ResourceTypeAgent, Agent: &AgentData{AgentID: "shared"}}, + }) + if rr.canonicalIdentityIndex != nil { + t.Fatal("ingest should defer alias index construction") + } + if id, ok := rr.ResolveReferenceID("first-shared"); !ok || id != "first-shared" { + t.Fatalf("exact ID resolved to %q, %v; want first-shared", id, ok) + } + if rr.canonicalIdentityIndex != nil { + t.Fatal("exact ID read should not build the alias index") + } + + if id, ok := rr.ResolveReferenceID(" AGENT:kelvin "); !ok || id != "kelvin-host" { + t.Fatalf("Unicode folded alias resolved to %q, %v; want kelvin-host", id, ok) + } + if rr.canonicalIdentityIndex == nil { + t.Fatal("alias lookup did not build the index") + } + if id, ok := rr.ResolveReferenceID("agent:imachine"); ok { + t.Fatalf("dotted I must not fold into ordinary i, got %q", id) + } + if id, ok := rr.ResolveReferenceID("agent:shared"); ok { + t.Fatalf("ambiguous canonical alias resolved to %q", id) + } + if id, ok := rr.ResolveReferenceID("first-shared"); !ok || id != "first-shared" { + t.Fatalf("exact ID lost priority to ambiguous alias: %q, %v", id, ok) + } + + rr.mu.Lock() + rr.canonicalIdentityIndex = nil + rr.mu.Unlock() + rr.mu.RLock() + id := rr.uniqueCanonicalIdentityResourceIDLocked("AGENT:kelvin") + sharedID := rr.uniqueCanonicalIdentityResourceIDLocked("agent:shared") + rr.mu.RUnlock() + if id != "kelvin-host" { + t.Fatalf("in-progress ingest scan resolved to %q; want kelvin-host", id) + } + if sharedID != "" { + t.Fatalf("in-progress ingest scan resolved ambiguous alias to %q", sharedID) + } +} + +func TestResourceRegistry_CanonicalAliasIndexRefreshesAfterIngest(t *testing.T) { + rr := NewRegistry(nil) + rr.IngestResources([]Resource{{ + ID: "renamed-host", Type: ResourceTypeAgent, Agent: &AgentData{AgentID: "old-machine"}, + }}) + if _, ok := rr.ResolveReferenceID("agent:old-machine"); !ok { + t.Fatal("initial alias missing") + } + rr.IngestResources([]Resource{{ + ID: "renamed-host", Type: ResourceTypeAgent, Agent: &AgentData{AgentID: "new-machine"}, + }}) + if id, ok := rr.ResolveReferenceID("agent:old-machine"); ok { + t.Fatalf("stale alias resolved after ingest to %q", id) + } + if id, ok := rr.ResolveReferenceID("agent:new-machine"); !ok || id != "renamed-host" { + t.Fatalf("new alias resolved to %q, %v; want renamed-host", id, ok) + } +} + +func BenchmarkResourceRegistry_GetByCanonicalAlias(b *testing.B) { + resources := make([]Resource, 1500) + for i := range resources { + resources[i] = Resource{ + ID: fmt.Sprintf("agent-%d", i), + Type: ResourceTypeAgent, + Agent: &AgentData{AgentID: fmt.Sprintf("machine-%d", i)}, + } + } + rr := NewRegistry(nil) + rr.IngestResources(resources) + for _, test := range []struct { + name string + ref string + }{ + {"hit", "agent:machine-750"}, + {"miss", "agent:unknown"}, + } { + b.Run(test.name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _, _ = rr.GetByReference(test.ref) + } + }) + } +} + func TestMonitorAdapterKeepsSameNamedProxmoxProvidersDistinct(t *testing.T) { adapter := NewMonitorAdapter(NewRegistry(NewMemoryStore())) seen := time.Date(2026, 7, 24, 8, 0, 0, 0, time.UTC)