Bound large-estate refresh and storage navigation work

Carry changed resource IDs into the Workloads projection so periodic deltas
reuse unchanged rows. Index canonical aliases after ingest and resolve
identity-only links without cloning resources. Remove repeated group payloads
from windowed Storage rows, and keep fleet notices and Agent Doctor usable at
narrow widths.

Record the browser proof and remaining payload and benchmark observations in
the owning subsystem contracts.

Refs #2199
This commit is contained in:
rcourtman
2026-09-24 20:22:52 +01:00
parent d25403b8c3
commit bbce69ea65
28 changed files with 704 additions and 129 deletions
+1 -15
View File
@@ -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": [
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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",
@@ -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.
@@ -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.
+39 -30
View File
@@ -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."
}
@@ -310,7 +310,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
</div>
<Show when={props.diagnosticsError}>
<section class="flex flex-col gap-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 sm:flex-row sm:items-center sm:justify-between">
<section class="flex flex-col gap-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="font-semibold">Structured diagnostics are temporarily unavailable</div>
<p class="mt-1 text-xs leading-5 text-amber-800 dark:text-amber-200">
@@ -343,7 +343,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
when={props.targets.length > 0}
fallback={
<Show when={!props.diagnosticsLoading}>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100">
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100">
No Pulse Agent connections are currently in scope.
</div>
</Show>
@@ -385,7 +385,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
</section>
<Show when={anyTargetNeedsRepair()}>
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-xs leading-5 text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100">
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-xs leading-5 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100">
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<InfrastructureAgentDoctorP
>
<section class="space-y-3 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-800 dark:bg-blue-950/30">
<div class="space-y-1">
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-100">
<h3 class="text-sm font-semibold text-blue-800 dark:text-blue-100">
Generate update token
</h3>
<p class="text-xs leading-5 text-blue-800 dark:text-blue-200">
@@ -415,7 +415,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
<FormSelect
label="Agent installation to repair"
fieldClass="flex-1"
labelClass="text-xs font-medium text-blue-900 dark:text-blue-100"
labelClass="text-xs font-medium text-blue-800 dark:text-blue-100"
value={selectedTokenTarget()?.key}
onChange={(event) => 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<InfrastructureAgentDoctorP
!operations.commandsUnlocked()
}
>
<section class="space-y-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
<section class="space-y-3 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
<p class="text-xs leading-5">
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<InfrastructureAgentDoctorP
<Table class="w-full min-w-0 table-fixed text-sm">
<TableHeader class="bg-surface-alt/60">
<TableRow>
<TableHead class="w-[26%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap">
<TableHead class="w-[40%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap sm:w-[26%]">
Agent
</TableHead>
<TableHead class="hidden w-[15%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap sm:table-cell">
System
</TableHead>
<TableHead class="w-[15%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
<TableHead class="w-[38%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap sm:w-[20%] lg:w-[15%]">
Status
</TableHead>
<TableHead class="hidden w-[12%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap sm:table-cell">
@@ -500,7 +500,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
<TableHead class="agent-doctor-target-column w-[12%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Target
</TableHead>
<TableHead class="w-[20%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
<TableHead class="w-[22%] px-2 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap sm:w-[20%] sm:px-3">
<span class="platform-table-label-compact">Seen</span>
<span class="platform-table-label-full">Last seen</span>
</TableHead>
@@ -583,7 +583,10 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
<TableCell class="agent-doctor-target-column px-3 py-1.5 text-xs text-base-content">
{target.expectedVersion || '—'}
</TableCell>
<TableCell class="px-3 py-1.5 text-xs text-muted" title={lastSeen() || ''}>
<TableCell
class="px-2 py-1.5 text-xs text-muted sm:px-3"
title={lastSeen() || ''}
>
<span class="platform-table-label-compact">{lastSeenRelative()}</span>
<span class="platform-table-label-full">{lastSeen() || '—'}</span>
</TableCell>
@@ -747,7 +750,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
customCaPath: operations.customCaPath(),
});
return (
<section class="space-y-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-xs text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100">
<section class="space-y-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100">
<div class="font-semibold">Save this credential now</div>
<p class="leading-5">
This secret is held only in this page's memory and cannot be
@@ -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(() => (
<InfrastructureAgentDoctorPage
targets={[
targetFixture({
status: 'warning',
needsUpdate: true,
expectedVersion: '6.4.0',
lastSeen: Date.now(),
}),
]}
/>
));
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',
);
});
});
@@ -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', () => {
@@ -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;
@@ -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<Resource[] | undefined>;
resourceSnapshotChange?: Accessor<{
version: number;
changedIds: ReadonlySet<string> | null;
}>;
resourceSnapshotRefetch?: () => Promise<unknown>;
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({
@@ -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',
);
});
@@ -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<InlineNoticeTone, string> = {
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 =
@@ -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(() => (
<PlatformOutdatedAgentNotice
hosts={[
{ name: 'tower', version: 'v6.0.0-rc.5' },
{ name: 'delly', version: 'v6.0.0-rc.5' },
{ name: 'lab', version: 'v6.0.0-rc.5' },
{ name: 'remote', version: 'v6.0.0-rc.5' },
]}
targetVersion="v6.0.0-rc.6"
missingLabel="images"
/>
));
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(() => (
<PlatformOutdatedAgentNotice
@@ -1,4 +1,4 @@
import { Show, createMemo } from 'solid-js';
import { Show, createMemo, createSignal } from 'solid-js';
import { AlertTriangle, ArrowRight } from 'lucide-solid';
import { InlineNotice } from '@/components/shared/InlineNotice';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
@@ -29,6 +29,15 @@ type PlatformOutdatedAgentNoticeProps = {
export function PlatformOutdatedAgentNotice(props: PlatformOutdatedAgentNoticeProps) {
const count = createMemo(() => 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={<ArrowRight aria-hidden="true" />}
>
{message()}
<span>{message()}</span>{' '}
<Show when={count() > 3}>
<button
type="button"
class="ml-1 font-medium underline underline-offset-2 hover:no-underline focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
aria-expanded={showAllHosts()}
onClick={() => setShowAllHosts((current) => !current)}
>
{showAllHosts() ? 'Hide affected names' : `Show all ${count()} ${subjectPlural()}`}
</button>
<Show when={showAllHosts()}>
<p class="mt-1 break-words">Affected: {names()}.</p>
</Show>
</Show>
</InlineNotice>
</Show>
);
@@ -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<WorkloadsMemoryDisplayBasis>;
setMemoryDisplayBasis: (value: WorkloadsMemoryDisplayBasis) => void;
resourceSnapshot: Accessor<Resource[] | undefined>;
resourceSnapshotChange: Accessor<{
version: number;
changedIds: ReadonlySet<string> | null;
}>;
resourceSnapshotRefetch: () => Promise<unknown>;
inventoryCountsVisible: Accessor<boolean>;
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,
@@ -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<Resource> & Pick<Resource, 'id' | 'type'>): 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();
@@ -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();
});
@@ -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<string> | null;
}>({ version: 1, changedIds: null });
let dispose = () => {};
let result: ReturnType<UseWorkloadsModule['useWorkloads']> | 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<UseWorkloadsModule['useWorkloads']> | undefined;
@@ -1513,6 +1513,11 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const hasCachedResources = cacheEntry.hasSnapshot;
const [resources, setResources] = createStore<Resource[]>(initialResources);
const [resourceSnapshotChange, setResourceSnapshotChange] = createSignal<{
version: number;
changedIds: ReadonlySet<string> | null;
}>({ version: 0, changedIds: null });
let resourceSnapshotVersion = 0;
const [policyPosture, setPolicyPosture] = createSignal<ResourcePolicyPostureSummary | null>(
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,
+38 -5
View File
@@ -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<Resource[] | undefined>;
/** Changed IDs from the same committed snapshot, or null for a full refresh. */
resourceSnapshotChange?: Accessor<{
version: number;
changedIds: ReadonlySet<string> | null;
}>;
/** Refetch the owner snapshot when the surface explicitly reconnects. */
refetchSnapshot?: () => Promise<unknown>;
}
@@ -848,6 +857,9 @@ export function useWorkloads(
);
const [error, setError] = createSignal<unknown>(undefined);
let requestVersion = 0;
let lastSnapshotVersion = 0;
let lastSnapshotOrgScope = resolveActiveOrgScope();
let lastSnapshotRowsByResourceID = new Map<string, WorkloadGuest | null>();
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<string, WorkloadGuest | null>();
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);
+3 -4
View File
@@ -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 == "" {
@@ -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)
+146 -34
View File
@@ -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
@@ -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)