mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
Merge published workload identity recovery into reviewed maintenance
Retain every reviewed starting commit and the refreshed exact published workload identity source additively. PR2888 remains unchanged pending its completed qualification defects; this unproposed composition requires exact final-source checks before the later publication batch. Change-source: pulse-maintainer
This commit is contained in:
@@ -730,3 +730,12 @@ Pulse v6 should be:
|
||||
2. extension-oriented instead of patch-oriented
|
||||
3. enforced by tests and guardrails
|
||||
4. obvious for both humans and agents to continue correctly
|
||||
|
||||
### Inherited merge content
|
||||
|
||||
Staged qualification compares each file’s Git mode and object ID with the
|
||||
merged parents. Exact inherited states, including deletions, keep their parent
|
||||
qualification. New resolutions and mode changes remain current work and must
|
||||
satisfy their owning contract and proof requirements. Range checks still
|
||||
qualify the complete proposed change. Combined frontend content needs a fresh
|
||||
browser receipt in a subsequent non-merge commit.
|
||||
|
||||
@@ -3239,6 +3239,11 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
verification adapters, but they must remain read-only views of the canonical
|
||||
action audit and lifecycle trail rather than a second audit store.
|
||||
6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resourceapi/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection.
|
||||
Resource reference reads use `ResourceAPI.getResource()` and the existing
|
||||
`GET /api/resources/{id}` resolver. The registry owns current IDs, declared
|
||||
succession and unique legacy/source aliases. Frontend selection cancels a
|
||||
superseded lookup and applies only its returned resource ID. A refused or
|
||||
ambiguous reference cannot be translated by guessing instance, node or VMID.
|
||||
Compact aggregate consumers must read `GET /api/resources/stats` through
|
||||
`ResourceAPI.getStats()` rather than paging `/api/resources` and discarding
|
||||
every row. The stats client preserves the backend-owned total, type, status,
|
||||
|
||||
@@ -6554,6 +6554,11 @@ snapshot cannot resurrect a deleted or migrated entry or overwrite an operator
|
||||
edit. The existing tenant-local JSON shape, private file permissions and atomic
|
||||
temporary-file/rename path stay unchanged. A failed write is reported and not
|
||||
spin-retried; a later ordinary identity poll can retry the unpersisted state.
|
||||
An accepted guest-key migration retains its operator fields under the new key
|
||||
in memory even when persistence fails. Atomic persistence keeps the previous
|
||||
successful disk snapshot, and the existing writer records and retries the
|
||||
retained state on an ordinary update. A failed write cannot restore an obsolete
|
||||
in-memory key after the guest's new coordinates have been admitted.
|
||||
|
||||
`WaitForPendingWrites` reports quiescence, not durable success. `Close` seals
|
||||
mutation admission and observes the background writer and any admitted
|
||||
@@ -7322,6 +7327,11 @@ admitted publication; late inventories/details cannot recreate or erase the
|
||||
replacement's guest readings. An interrupted inventory does not grant fallback.
|
||||
One cycle per live binding owns collection until it completes, including when
|
||||
its caller is cancelled. Another connection can still advance independently.
|
||||
Before publishing accepted VM/container coordinates, that same owner migrates
|
||||
their persisted guest metadata keys. Readers cannot observe a new node ahead
|
||||
of its saved web URL, tags and notes. Rejected or replaced cycles do not migrate
|
||||
metadata. Collector-local post-publication callbacks own diagnostics and alerts,
|
||||
while metadata migration belongs to the complete accepted publication.
|
||||
|
||||
Deep-enrichment/caller-deadline expiry is distinct from explicit parent-cycle
|
||||
cancellation: already collected authoritative inventory still publishes even
|
||||
|
||||
@@ -1330,6 +1330,14 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
rows without platform metadata retain the Proxmox-compatible behavior at
|
||||
the migration boundary.
|
||||
11. Extend workload drawer derivations and runtime wiring through `frontend-modern/src/components/Workloads/guestDrawerModel.ts` and `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Workloads/GuestDrawer.tsx`
|
||||
The selected resource owns its drawer tab and History range. Regrouping the
|
||||
same resource under a new parent or node retains both even if its drawer
|
||||
remounts. Closing or selecting another resource restores the default view.
|
||||
History continues to consume the explicit backend-owned metrics target.
|
||||
Legacy VM and system-container inputs without that target use the shared
|
||||
native provider-key formatter for History. This compatibility fallback
|
||||
follows the current node and never changes canonical selection identity.
|
||||
Backend targets take precedence over every client-derived fallback.
|
||||
The guest drawer header owns the saved web-interface launch action. It
|
||||
reuses the shared `WebInterfaceLink` and drawer-header action class, so
|
||||
phone and narrow rows keep hiding the adjacent row link for name legibility
|
||||
@@ -1416,7 +1424,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
max-disk derivations must skip sentinel rows rather than surface them as
|
||||
0% or negative percents.
|
||||
13. Extend workload guest metadata cache persistence, metadata refresh, org-scope switching, and optimistic custom-URL updates through `frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts` rather than rebuilding workload-local storage caches, event listeners, or guest metadata API wiring inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
|
||||
14. Extend workload deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Workloads/workloadSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`; canonical typed workload IDs such as `app-container:<host>:<provider-id>` must remain exact route/selection keys and must not be reinterpreted into synthetic node scopes
|
||||
A changed set of native metadata keys refreshes that cache after accepted
|
||||
inventory publication. Metric-only updates do not refetch it. Superseded
|
||||
responses cannot overwrite a newer refresh, confirmed edit or tenant scope.
|
||||
14. Extend workload deep-link selection and hovered-row continuity semantics through `frontend-modern/src/components/Workloads/workloadSelectionModel.ts`, and extend table scroll preservation plus reactive selection state through `frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts`, rather than rebuilding resource-query parsing, selected-row scroll pinning, or hovered-row invalidation inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`. Every workload row and selection key is the exact unified `Resource.id`. Route-driven selection lets the shared focus bridge reveal its destination. Initial viewport measurement retains the selection window and still measures row and detail geometry. Scroll and resize events remain authoritative for subsequent window movement. Local row clicks preserve the existing viewport, and that preservation must not undo a route reveal. Historical references use the API contract's existing server resolver and never a client-reconstructed node or pod identity.
|
||||
15. Extend workload route ownership, route-driven option catalogs, and toolbar filter config through `frontend-modern/src/components/Workloads/useWorkloadRouteState.ts`, `frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts`, `frontend-modern/src/components/Workloads/workloadRouteModel.ts`, `frontend-modern/src/components/Workloads/workloadFilterConfigModel.ts`, and `frontend-modern/src/components/Workloads/workloadRouteStateModel.ts`, and extend query-param synchronization plus managed workload URL semantics through `frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts` and `frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts`, rather than rebuilding route sync, alias parsing, option derivation, toolbar callback/config wiring, reset policy, node-selection compatibility rules, param precedence, or managed workload URLs inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
|
||||
Workloads route host scopes must resolve through `frontend-modern/src/components/Workloads/workloadTopology.ts`: app-container scopes use the canonical Docker/runtime host id before host labels or node fallbacks, while VM and system-container scopes keep the instance-node key. Route `agent` filters and option catalogs must consume that shared scope so Infrastructure related-workload links cannot drift from Workloads filtering.
|
||||
Workloads route-state writes must preserve the owning platform/runtime
|
||||
|
||||
@@ -6533,12 +6533,14 @@
|
||||
"exact_files": [
|
||||
"internal/config/docker_metadata_test.go",
|
||||
"internal/config/guest_metadata_test.go",
|
||||
"internal/config/guest_metadata_writer_test.go",
|
||||
"internal/monitoring/availability_poller_test.go",
|
||||
"internal/monitoring/availability_probe_agent_test.go",
|
||||
"internal/monitoring/availability_udp_test.go",
|
||||
"internal/monitoring/canonical_guardrails_test.go",
|
||||
"internal/monitoring/ceph_test.go",
|
||||
"internal/monitoring/deadman_test.go",
|
||||
"internal/monitoring/guest_poll_ownership_test.go",
|
||||
"internal/monitoring/issue1485_unraid_lifecycle_test.go",
|
||||
"internal/monitoring/issue1595_collection_trust_test.go",
|
||||
"internal/monitoring/issue1613_contract_test.go",
|
||||
|
||||
@@ -5202,12 +5202,19 @@ Docker-only semantics when host telemetry is also present. Runtime-local
|
||||
filters like `agent` or cluster context may still be added as secondary scope,
|
||||
but the canonical platform query must remain the first-class route-state
|
||||
boundary for shared workload navigation.
|
||||
That same shared workload-route contract also owns exact guest selection for
|
||||
node-scoped workloads. Proxmox VM and system-container links must emit the
|
||||
canonical workload identity (`<instance>:<node>:<vmid>`) in the shared
|
||||
`resource` query rather than an opaque unified-resource id, so direct route
|
||||
loads and cross-surface drill-downs reopen the correct workload drawer instead
|
||||
of landing on an unselected table state.
|
||||
Shared workload routes, rows and selection use the exact unified `Resource.id`
|
||||
for every workload kind. Moving a Proxmox guest to another node does not create
|
||||
a new selection identity. Existing node-scoped bookmarks resolve through the
|
||||
registry's current/declared-succession/unique-alias boundary, with ambiguity
|
||||
refused. Provider coordinates remain explicit compatibility inputs for guest
|
||||
metadata, native collection and History targets. They must not be rebuilt into
|
||||
primary row identity or used to merge independent instance clones.
|
||||
History uses an explicit backend metrics target when present. Its legacy
|
||||
Proxmox fallback formats the current instance, node and VMID through the shared
|
||||
native guest formatter, independently of the canonical selection ID. A guest
|
||||
move changes that native target while retaining the selected canonical row.
|
||||
The workload target tests and current-source desktop, intermediate and phone
|
||||
browser matrix cover both supplied targets and missing-target fallback.
|
||||
Unified resources carry no recovery route-state vocabulary. The recovery
|
||||
query serializer (`RECOVERY_QUERY_PARAMS`, `parseRecoveryLinkSearch`,
|
||||
`buildRecoveryRouteSearch`) was deleted from
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "a4d2cdc2b8b6f8e667d86501473b14fc0e1844ca",
|
||||
"verified_at": "2026-10-12T21:56:37Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts",
|
||||
"frontend-modern/src/utils/workloadMetricHistoryTarget.ts",
|
||||
"frontend-modern/src/utils/workloads.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts": "b9897665cbb498c843d4e1e4c69c2f776c1cb6711308726937918588e05cd5af",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts": "436d5cdbcd730ba5bc971f462b274cd240c6db39401901abe2f859c15162bdd7",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts": "53015e33663b2fea41dd39a1b4c5137220d1c645a395fb32feecc24a1e5aa4a5",
|
||||
"frontend-modern/src/utils/workloadMetricHistoryTarget.ts": "24bc9eff087e426cf08cdb4c11e48ff3c70d89c0a6c09b0530dbd07e3e056ef0",
|
||||
"frontend-modern/src/utils/workloads.ts": "779b5fec276546bb499df800f0eeb4709169dfe725aa3f5727231da981c62179"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview?type=vm&resource=vm-b62864e1d6f16ad4",
|
||||
"/proxmox/overview?type=vm&resource=Production%20West%3Apve1%3A100",
|
||||
"/proxmox/overview?type=system-container&resource=system-container-5f29a7a3f4732690",
|
||||
"/proxmox/overview?type=system-container&resource=Production%20West%3Apve1%3A113",
|
||||
"/proxmox/overview?resource=missing-identity-fixture"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 900,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"VM and LXC Overview, History and Manage at all three widths",
|
||||
"Canonical and legacy links on cold load and reload",
|
||||
"Selected resource after controlled node and parent move",
|
||||
"History with backend targets and native fallback before and after node move",
|
||||
"Persisted manual link, operator note and never-auto-remediate policy",
|
||||
"Recurring maintenance editor, cancelled editor and metadata write failure",
|
||||
"Pending legacy lookup, superseded lookup and denied lookup",
|
||||
"Unknown reference with no unrelated drawer"
|
||||
],
|
||||
"interactions": [
|
||||
"Pointer and keyboard row expansion and re-expansion",
|
||||
"History tab and 12-hour range retained through node move",
|
||||
"Manage manual URL save, reload and metadata refetch",
|
||||
"Escape close and filter reset, search, reselect and focus return",
|
||||
"Collapse and reopen, then different resource resets to Overview",
|
||||
"Policy save and reload, recurring maintenance editor open and cancel",
|
||||
"Manual selection, Escape and newer route each cancel held legacy lookup",
|
||||
"403 lookup refusal and 503 metadata write failure",
|
||||
"Scroll through the full phone drawer and inspect desktop, intermediate and phone pixels"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "acd1e4e31d666f663b1e25c7da051dee1b860af9",
|
||||
"verified_at": "2026-10-12T18:16:41.185854Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/resources.ts",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts",
|
||||
"frontend-modern/src/types/workloads.ts",
|
||||
"frontend-modern/src/utils/workloads.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/resources.ts": "7bac38bd0a3d2b0835b5bb8dbf7afcbf6d3af4e3c869120f9c72da5bd6c8e241",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts": "ef0967155b47d927c7788a0529fb73810ba249261d9e1a1721e07a1db076e91a",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "b471805bd3f7b10e0d067d13e26ed1cba4d5eea6257e74cdf55dbeed8276c402",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "c333d8f2ad4e79c8a3a15071da2bba2750aabd7d94f668af96508fa7a8828968",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "e085a4a0c768c95c08f4fcb29b08f3d7d3d9b2e84e4c5837d27218ebde3bb575",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "a328b69f68e75369f89f83312a8fcb03ae2687cedd0add1ac4b269285d1ed74e",
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "75b81a2e8b9f5dbf398c79834c52bfddb6690ea2d444d3298c3141361f258ebc",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts": "e52ab1e8f235cb8c710992837e0cb2261fcfb2a0bb77af7c8cdbb9c8ddc70e02",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts": "576064a2b41efca46e1fac359f33a2bbf4385a76d69098eda698577d6be146b4",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "7b3a3e963dd70eb0bbb97e993d3de9bcdb042f677ff3e621dce8f098968819e0",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts": "7312dc374ad4e029802f8979c3fd1daa84a3401ff7c3ba4086f93ce30d1bf4c8",
|
||||
"frontend-modern/src/types/workloads.ts": "bc596272a83bbf19fa7d21746f9b23a2eadb5f530454145f07d5a333a03801f5",
|
||||
"frontend-modern/src/utils/workloads.ts": "3c72776bcee221920140010906040b77279997263a3cb8ef6265e04ddd1d4802"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview",
|
||||
"/proxmox/overview?type=vm",
|
||||
"/proxmox/overview?type=system-container"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 900,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Current embedded build on private pulse-dev mock-mode server, binary SHA256 920cecf64e0a64836b118958a664a5b81bbda9e3c12eedfef1240cfb76882fff.",
|
||||
"VM and LXC canonical routes and real server resolution of existing instance:node:VMID bookmarks select the exact canonical row.",
|
||||
"Full resource updates move each selected guest to a new parent and node while retaining its open drawer, History tab and 12h range.",
|
||||
"Normal UI web URL writes succeed. The metadata cache refetches its changed native key, retains the saved link and survives reload. The browser GET fixture mirrors accepted server migration, which backend filesystem-barrier tests verify independently.",
|
||||
"Canonical operator policy retains its saved note and never-auto-remediate lock across movement and settled reload. The recurring maintenance editor remains usable and cancels without closing the resource.",
|
||||
"A failed metadata PUT leaves the editable drawer open. Delayed legacy-reference replies cannot replace later manual selection or a newer route, or reopen an escaped selection.",
|
||||
"Unknown and HTTP403-refused references select no unrelated workload. All browser cases report no page errors.",
|
||||
"Actual screenshots inspected at desktop, intermediate and phone widths for both workload kinds, History, saved URL controls and the operator editor. Movement is a controlled publication fixture, not native Proxmox hardware qualification."
|
||||
],
|
||||
"interactions": [
|
||||
"Open canonical and legacy resource links for VM and LXC",
|
||||
"Save a web interface URL through Manage, focus the saved link, inspect History and select12h",
|
||||
"Publish same-resource node/parent changes, inspect regrouping and retained view, then reload",
|
||||
"Escape, pointer or keyboard reopen, header collapse and default-view reset",
|
||||
"Save operator note and confirm remediation lock, reload, open recurring scheduler and cancel",
|
||||
"Exercise metadata write refusal and delayed reference lookup followed by another selection, Escape or route change",
|
||||
"Refuse a legacy lookup with HTTP403 and inspect unknown-resource selection"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "a4d2cdc2b8b6f8e667d86501473b14fc0e1844ca",
|
||||
"verified_at": "2026-10-12T21:56:37Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "dc00da43c6b96591334bd3a6210d681226e14d5b78f51cb40de26ba44aa5fa04",
|
||||
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "c2b903e04453a24ab0163f64e8b973a6ab6e80dd23afba6a63f7fb8236f6181b"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview?type=vm&resource=vm-b62864e1d6f16ad4",
|
||||
"/proxmox/overview?type=vm&resource=Production%20West%3Apve1%3A100",
|
||||
"/proxmox/overview?type=system-container&resource=system-container-5f29a7a3f4732690",
|
||||
"/proxmox/overview?type=system-container&resource=Production%20West%3Apve1%3A113",
|
||||
"/proxmox/overview?resource=missing-identity-fixture"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 900,
|
||||
"height": 800
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"VM and LXC Overview, History and Manage at all three widths",
|
||||
"Canonical and legacy links on cold load and reload",
|
||||
"Selected resource after controlled node and parent move",
|
||||
"History with backend targets and native fallback before and after node move",
|
||||
"Persisted manual link, operator note and never-auto-remediate policy",
|
||||
"Recurring maintenance editor, cancelled editor and metadata write failure",
|
||||
"Pending legacy lookup, superseded lookup and denied lookup",
|
||||
"Unknown reference with no unrelated drawer"
|
||||
],
|
||||
"interactions": [
|
||||
"Pointer and keyboard row expansion and re-expansion",
|
||||
"History tab and 12-hour range retained through node move",
|
||||
"Manage manual URL save, reload and metadata refetch",
|
||||
"Escape close and filter reset, search, reselect and focus return",
|
||||
"Collapse and reopen, then different resource resets to Overview",
|
||||
"Policy save and reload, recurring maintenance editor open and cancel",
|
||||
"Manual selection, Escape and newer route each cancel held legacy lookup",
|
||||
"403 lookup refusal and 503 metadata write failure",
|
||||
"Scroll through the full phone drawer and inspect desktop, intermediate and phone pixels"
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,18 @@ describe('ResourceAPI', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('resolves an encoded resource reference with cancellable authenticated lookup', async () => {
|
||||
const signal = new AbortController().signal;
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce({ id: 'stable-resource' });
|
||||
await expect(ResourceAPI.getResource(' cluster-a:node-1:101 ', { signal })).resolves.toEqual({
|
||||
id: 'stable-resource',
|
||||
});
|
||||
expect(apiFetchJSON).toHaveBeenCalledWith('/api/resources/cluster-a%3Anode-1%3A101', {
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches compact resource stats without hydrating list pages', async () => {
|
||||
const stats = {
|
||||
total: 1200,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import type {
|
||||
Resource,
|
||||
ResourceCapability,
|
||||
ResourceChange,
|
||||
ResourceChangeKind,
|
||||
@@ -74,6 +75,17 @@ const fetchFacet = async <T>(url: string): Promise<T> =>
|
||||
});
|
||||
|
||||
export class ResourceAPI {
|
||||
// The backend owns alias, succession and ambiguity resolution.
|
||||
static async getResource(
|
||||
reference: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<Resource> {
|
||||
return apiFetchJSON<Resource>(
|
||||
`/api/resources/${encodeURIComponent(normalizeResourceId(reference))}`,
|
||||
{ cache: 'no-store', signal: options?.signal },
|
||||
);
|
||||
}
|
||||
|
||||
static async getStats(): Promise<ResourceStatsResponse> {
|
||||
return fetchFacet<ResourceStatsResponse>('/api/resources/stats');
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import {
|
||||
buildAppContainerMetadataId,
|
||||
buildKubernetesWorkloadMetadataId,
|
||||
getCanonicalWorkloadIdForResource,
|
||||
getWorkloadMetadataIdForResource,
|
||||
} from '@/utils/workloads';
|
||||
|
||||
// Resource kinds that carry agent-routed metadata coordinates.
|
||||
@@ -191,14 +191,12 @@ const getMetadataTarget = (
|
||||
}
|
||||
}
|
||||
|
||||
// Guest metadata is keyed by the canonical workload id shared with the
|
||||
// workloads surfaces (`instance:node:vmid` for PVE guests, resource id
|
||||
// otherwise — also the key v5 upgrades carry over). Saving under any other
|
||||
// id (unified resource hash, bare vmid, discovery resource id) strands the
|
||||
// URL where no table reads it.
|
||||
// Guest metadata retains its provider-keyed storage/API compatibility
|
||||
// boundary. Resource identity and routing use the unified resource ID.
|
||||
// Keep this key aligned with the workload metadata reader during migration.
|
||||
return {
|
||||
metadataKind: 'guest',
|
||||
metadataId: getCanonicalWorkloadIdForResource(resource),
|
||||
metadataId: getWorkloadMetadataIdForResource(resource),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -403,7 +401,7 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null =>
|
||||
resourceId: vmidResourceId || resource.id,
|
||||
hostname,
|
||||
metadataKind: 'guest',
|
||||
metadataId: getCanonicalWorkloadIdForResource(resource),
|
||||
metadataId: getWorkloadMetadataIdForResource(resource),
|
||||
targetLabel: 'guest',
|
||||
};
|
||||
case 'system-container':
|
||||
@@ -417,7 +415,7 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null =>
|
||||
resourceId: vmidResourceId || resource.id,
|
||||
hostname,
|
||||
metadataKind: 'guest',
|
||||
metadataId: getCanonicalWorkloadIdForResource(resource),
|
||||
metadataId: getWorkloadMetadataIdForResource(resource),
|
||||
targetLabel: 'guest',
|
||||
};
|
||||
case 'app-container':
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createSignal } from 'solid-js';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import type { Alert, Memory, Disk, GuestNetworkInterface } from '@/types/api';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { getCanonicalWorkloadId, getWorkloadMetadataId } from '@/utils/workloads';
|
||||
import { getWorkloadMetadataId } from '@/utils/workloads';
|
||||
import { resetAIRuntimeState } from '@/stores/aiRuntimeState';
|
||||
import guestDrawerSource from './GuestDrawer.tsx?raw';
|
||||
import guestDrawerManageSource from './GuestDrawerManage.tsx?raw';
|
||||
@@ -103,6 +103,7 @@ function makeGuest(overrides: Partial<WorkloadGuest> = {}): WorkloadGuest {
|
||||
instance: 'inst1',
|
||||
status: 'running',
|
||||
type: 'qemu',
|
||||
metricsTarget: { resourceType: 'vm', resourceId: 'inst1:node1:100' },
|
||||
cpu: 0.25,
|
||||
cpus: 4,
|
||||
memory: { total: 4294967296, used: 2147483648, free: 2147483648, usage: 0.5 },
|
||||
@@ -1408,10 +1409,10 @@ describe('GuestDrawer', () => {
|
||||
// ── WebInterfaceUrlField ──
|
||||
|
||||
describe('WebInterfaceUrlField', () => {
|
||||
it('passes canonical metadataId using shared workload identity', () => {
|
||||
it('keeps the guest metadata key separate from canonical row identity', () => {
|
||||
render(() => <GuestDrawer guest={makeGuest({ id: 'my-guest-id' })} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('url-id').textContent).toBe(
|
||||
getCanonicalWorkloadId(makeGuest({ id: 'my-guest-id' })),
|
||||
getWorkloadMetadataId(makeGuest({ id: 'my-guest-id' })),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1434,16 +1435,14 @@ describe('GuestDrawer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('builds canonical id from instance:node:vmid when id is empty', () => {
|
||||
it('does not invent a resource identity for a guest with no ID', () => {
|
||||
render(() => (
|
||||
<GuestDrawer
|
||||
guest={makeGuest({ id: '', instance: 'pve', node: 'n1', vmid: 200 })}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
));
|
||||
expect(screen.getByTestId('url-id').textContent).toBe(
|
||||
getCanonicalWorkloadId(makeGuest({ id: '', instance: 'pve', node: 'n1', vmid: 200 })),
|
||||
);
|
||||
expect(screen.queryByTestId('url-id')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels app-container guests as "container"', () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ type WorkloadPanelProps = Pick<
|
||||
| 'nestedWorkloadContextByGuestId'
|
||||
| 'nodeByInstance'
|
||||
| 'search'
|
||||
| 'selectedGuestDrawerState'
|
||||
| 'selectedGuestId'
|
||||
| 'setHoveredWorkloadId'
|
||||
| 'setSelectedGuestId'
|
||||
@@ -197,6 +198,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
data-inline-detail-for={guestId()}
|
||||
>
|
||||
<GuestDrawer
|
||||
viewState={props.selectedGuestDrawerState}
|
||||
guest={guest()}
|
||||
onClose={() => props.setSelectedGuestId(null)}
|
||||
metadataId={metadataIdCandidates()[0] || guestId()}
|
||||
|
||||
@@ -156,6 +156,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceProps) {
|
||||
nestedWorkloadContextByGuestId={state.nestedWorkloadContextByGuestId}
|
||||
nodeByInstance={state.nodeByInstance}
|
||||
search={state.search}
|
||||
selectedGuestDrawerState={state.selectedGuestDrawerState}
|
||||
selectedGuestId={state.selectedGuestId}
|
||||
setHoveredWorkloadId={state.setHoveredWorkloadId}
|
||||
setSelectedGuestId={state.setSelectedGuestId}
|
||||
|
||||
@@ -32,6 +32,7 @@ type WorkloadsTableProps = Pick<
|
||||
| 'nestedWorkloadContextByGuestId'
|
||||
| 'nodeByInstance'
|
||||
| 'search'
|
||||
| 'selectedGuestDrawerState'
|
||||
| 'selectedGuestId'
|
||||
| 'setHoveredWorkloadId'
|
||||
| 'setSelectedGuestId'
|
||||
@@ -137,6 +138,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
|
||||
nestedWorkloadContextByGuestId={props.nestedWorkloadContextByGuestId}
|
||||
nodeByInstance={props.nodeByInstance}
|
||||
search={props.search}
|
||||
selectedGuestDrawerState={props.selectedGuestDrawerState}
|
||||
selectedGuestId={props.selectedGuestId}
|
||||
setHoveredWorkloadId={props.setHoveredWorkloadId}
|
||||
setSelectedGuestId={props.setSelectedGuestId}
|
||||
|
||||
+19
-5
@@ -1095,7 +1095,13 @@ describe('Workloads performance contract', () => {
|
||||
mockLocationSearch = '?type=all';
|
||||
mockWorkloads = [
|
||||
makeGuest(1, { id: 'raw-a', instance: 'shared', node: 'node-x', vmid: 42 }),
|
||||
makeGuest(2, { id: 'raw-b', instance: 'shared', node: 'node-x', vmid: 42 }),
|
||||
makeGuest(2, {
|
||||
id: 'raw-b',
|
||||
canonicalResourceId: 'raw-a',
|
||||
instance: 'shared',
|
||||
node: 'node-x',
|
||||
vmid: 42,
|
||||
}),
|
||||
];
|
||||
const canonicalId = getCanonicalWorkloadId(
|
||||
makeGuest(1, { id: 'raw-a', instance: 'shared', node: 'node-x', vmid: 42 }) as any,
|
||||
@@ -1109,7 +1115,17 @@ describe('Workloads performance contract', () => {
|
||||
await waitFor(() => {
|
||||
expect(getGuestRowCount(container)).toBe(1);
|
||||
});
|
||||
expect(canonicalId).toBe('shared:node-x:42');
|
||||
expect(canonicalId).toBe('raw-a');
|
||||
});
|
||||
|
||||
it('keeps distinct canonical resources even when their native coordinates coincide', async () => {
|
||||
mockLocationSearch = '?type=all';
|
||||
mockWorkloads = [
|
||||
makeGuest(1, { id: 'vm-distinct-a', instance: 'shared', node: 'node-x', vmid: 42 }),
|
||||
makeGuest(2, { id: 'vm-distinct-b', instance: 'shared', node: 'node-x', vmid: 42 }),
|
||||
];
|
||||
const { container } = render(() => <PageOwnedWorkloadsSurface />);
|
||||
await waitFor(() => expect(getGuestRowCount(container)).toBe(2));
|
||||
});
|
||||
|
||||
it('applies page-owned workload type exclusions before derived row state', async () => {
|
||||
@@ -1350,7 +1366,6 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadsSelectionStateSource).toContain(
|
||||
'const [hoveredWorkloadId, setHoveredWorkloadId]',
|
||||
);
|
||||
expect(workloadsSelectionStateSource).toContain('setHandledResourceId(null)');
|
||||
expect(workloadsSelectionStateSource).toContain("from './workloadSelectionModel'");
|
||||
expect(workloadsSelectionStateSource).not.toContain('useNavigate');
|
||||
expect(workloadsSelectionStateSource).not.toContain('createRouteStateNavigateScheduler');
|
||||
@@ -1358,7 +1373,6 @@ describe('Workloads performance contract', () => {
|
||||
'resolveWorkloadsSelectionNavigateTarget',
|
||||
);
|
||||
expect(workloadsSelectionStateSource).not.toContain('parseWorkloadsLinkSearch');
|
||||
expect(workloadsSelectionStateSource).not.toContain('getCanonicalWorkloadId');
|
||||
expect(workloadSelectionModelSource).toContain('parseWorkloadsLinkSearch(search)');
|
||||
expect(workloadSelectionModelSource).toContain('getCanonicalWorkloadId');
|
||||
expect(workloadSelectionModelSource).toContain('resolveWorkloadResourceSelection');
|
||||
@@ -1749,7 +1763,7 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadsWorkloadTableSource).toContain('<colgroup>');
|
||||
expect(workloadsWorkloadTableSource).not.toContain('<TableHead');
|
||||
expect(workloadsWorkloadTableSource).not.toContain('NodeGroupHeader');
|
||||
expect(workloadsWorkloadTableSource).not.toContain('GuestDrawer');
|
||||
expect(workloadsWorkloadTableSource).not.toMatch(/from\s+['"]\.\/GuestDrawer['"]/);
|
||||
expect(workloadTableHeaderSource).toContain('TableHead');
|
||||
expect(workloadTableHeaderSource).toContain('col.sortKey as WorkloadSortKey');
|
||||
expect(workloadTableHeaderSource).toContain('getTableSortIndicator');
|
||||
|
||||
+9
-13
@@ -188,7 +188,7 @@ describe('guestAssistantContextModel (branch coverage)', () => {
|
||||
expect(result.handoffResources?.[0]?.name).toBe('web-vm');
|
||||
});
|
||||
|
||||
it('falls back to the canonical node-scoped id when name is empty', () => {
|
||||
it('falls back to the supplied resource identity when name is empty', () => {
|
||||
const result = buildGuestAssistantContext(
|
||||
makeGuest({
|
||||
id: 'vm-101',
|
||||
@@ -201,10 +201,10 @@ describe('guestAssistantContextModel (branch coverage)', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// canonicalId = buildCanonicalNodeScopedWorkloadId -> "pve-a:node-a:101"
|
||||
expect(result.targetId).toBe('pve-a:node-a:101');
|
||||
expect(result.briefing?.title).toBe('pve-a:node-a:101');
|
||||
expect(result.handoffResources?.[0]?.name).toBe('pve-a:node-a:101');
|
||||
// Placement coordinates do not replace the supplied resource identity.
|
||||
expect(result.targetId).toBe('vm-101');
|
||||
expect(result.briefing?.title).toBe('vm-101');
|
||||
expect(result.handoffResources?.[0]?.name).toBe('vm-101');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,10 +230,10 @@ describe('guestAssistantContextModel (branch coverage)', () => {
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
targetType: 'resource',
|
||||
targetId: 'pve-a:node-a:101',
|
||||
targetId: 'vm-101',
|
||||
context: {
|
||||
source: 'guest-drawer',
|
||||
resourceId: 'pve-a:node-a:101',
|
||||
resourceId: 'vm-101',
|
||||
resourceType: 'vm',
|
||||
resourceStatus: 'running',
|
||||
},
|
||||
@@ -242,14 +242,10 @@ describe('guestAssistantContextModel (branch coverage)', () => {
|
||||
title: 'web-vm',
|
||||
subject: 'vm / running / proxmox-ve',
|
||||
statusLabel: 'Read-only context attached',
|
||||
detailLines: [
|
||||
'Resource ID: pve-a:node-a:101',
|
||||
'Primary identity: vm-101',
|
||||
'Parent: node-a',
|
||||
],
|
||||
detailLines: ['Resource ID: vm-101', 'Parent: node-a'],
|
||||
safetyNote: 'Approval required before any action.',
|
||||
},
|
||||
handoffResources: [{ id: 'pve-a:node-a:101', name: 'web-vm', type: 'vm', node: 'node-a' }],
|
||||
handoffResources: [{ id: 'vm-101', name: 'web-vm', type: 'vm', node: 'node-a' }],
|
||||
handoffMetadata: { kind: 'resource_context' },
|
||||
autonomousMode: false,
|
||||
});
|
||||
|
||||
+34
-3
@@ -369,7 +369,10 @@ describe('guestDrawerModel (branch coverage)', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryTarget(
|
||||
makeGuest({
|
||||
id: 'cluster-a:pve1:101',
|
||||
id: 'vm-stable-id',
|
||||
instance: 'cluster-a',
|
||||
node: 'pve2',
|
||||
vmid: 101,
|
||||
type: 'qemu',
|
||||
workloadType: 'vm',
|
||||
metricsTarget: { resourceType: 'vm', resourceId: 'mock-cluster-a-pve1-101' },
|
||||
@@ -386,7 +389,7 @@ describe('guestDrawerModel (branch coverage)', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a vm to the canonical node-scoped id', () => {
|
||||
it('maps a vm to its native metric key', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryTarget(
|
||||
makeGuest({ type: 'qemu', workloadType: 'vm', instance: 'c', node: 'pve', vmid: 101 }),
|
||||
@@ -394,7 +397,7 @@ describe('guestDrawerModel (branch coverage)', () => {
|
||||
).toStrictEqual({ resourceType: 'vm', resourceId: 'c:pve:101' });
|
||||
});
|
||||
|
||||
it('maps a system-container to the canonical node-scoped id', () => {
|
||||
it('maps a system-container to its native metric key', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryTarget(
|
||||
makeGuest({
|
||||
@@ -408,6 +411,34 @@ describe('guestDrawerModel (branch coverage)', () => {
|
||||
).toStrictEqual({ resourceType: 'system-container', resourceId: 'c:pve:102' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['vm', 'qemu', 101],
|
||||
['system-container', 'lxc', 102],
|
||||
] as const)(
|
||||
'keeps %s selection stable while legacy History follows a node move',
|
||||
(workloadType, type, vmid) => {
|
||||
const original = makeGuest({
|
||||
id: 'opaque-resource-id',
|
||||
canonicalResourceId: 'opaque-resource-id',
|
||||
workloadType,
|
||||
type,
|
||||
vmid,
|
||||
instance: 'cluster-a',
|
||||
node: 'pve1',
|
||||
});
|
||||
const moved = { ...original, node: 'pve2' };
|
||||
expect(original.id).toBe(moved.id);
|
||||
expect(getGuestDrawerHistoryTarget(original)).toStrictEqual({
|
||||
resourceType: workloadType,
|
||||
resourceId: 'cluster-a:pve1:' + vmid,
|
||||
});
|
||||
expect(getGuestDrawerHistoryTarget(moved)).toStrictEqual({
|
||||
resourceType: workloadType,
|
||||
resourceId: 'cluster-a:pve2:' + vmid,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('maps an app-container to its plain id', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryTarget(
|
||||
|
||||
+18
-18
@@ -89,7 +89,7 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
|
||||
const result = run({ guests: [container], visibleGuests: [parent] });
|
||||
|
||||
expect(result['pve-a:node-a:141']).toStrictEqual({
|
||||
expect(result['lxc-141']).toStrictEqual({
|
||||
type: 'app-container',
|
||||
label: 'Podman',
|
||||
title: 'Nested Podman',
|
||||
@@ -113,8 +113,8 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
|
||||
const result = run({ guests: [container], visibleGuests: [parent] });
|
||||
|
||||
expect(result['pve-a:node-a:141']?.items[0]?.runtimeLabel).toBe('Docker');
|
||||
expect(result['pve-a:node-a:141']?.label).toBe('Docker');
|
||||
expect(result['lxc-141']?.items[0]?.runtimeLabel).toBe('Docker');
|
||||
expect(result['lxc-141']?.label).toBe('Docker');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
visibleGuests: [parent],
|
||||
});
|
||||
|
||||
expect(result['pve-a:node-a:141']?.items.map((item) => item.status)).toEqual([
|
||||
expect(result['lxc-141']?.items.map((item) => item.status)).toEqual([
|
||||
'exited',
|
||||
'unknown',
|
||||
'unknown',
|
||||
@@ -165,7 +165,7 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
|
||||
const result = run({ guests: [container], visibleGuests: [parent] });
|
||||
|
||||
expect(result['pve-a:node-a:141']?.items[0]?.name).toBe('cid-7');
|
||||
expect(result['lxc-141']?.items[0]?.name).toBe('cid-7');
|
||||
});
|
||||
|
||||
it('falls back to the canonical workload id when both name and containerId are empty', () => {
|
||||
@@ -179,7 +179,7 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
|
||||
const result = run({ guests: [container], visibleGuests: [parent] });
|
||||
|
||||
expect(result['pve-a:node-a:141']?.items[0]?.name).toBe('app-container:orphan');
|
||||
expect(result['lxc-141']?.items[0]?.name).toBe('app-container:orphan');
|
||||
});
|
||||
|
||||
it('uses contextLabel for the host facet when dockerHostName is empty', () => {
|
||||
@@ -194,7 +194,7 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
|
||||
const result = run({ guests: [container], visibleGuests: [parent] });
|
||||
|
||||
expect(result['pve-a:node-a:141']?.href).toBe('/docker/overview?host=ctx-host');
|
||||
expect(result['lxc-141']?.href).toBe('/docker/overview?host=ctx-host');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,9 +218,9 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
|
||||
const result = run({ guests: [dockerOne, podmanOne], visibleGuests: [parent] });
|
||||
|
||||
expect(result['pve-a:node-a:141']?.label).toBe('Containers');
|
||||
expect(result['pve-a:node-a:141']?.title).toBe('Nested containers');
|
||||
expect(result['pve-a:node-a:141']?.count).toBe(2);
|
||||
expect(result['lxc-141']?.label).toBe('Containers');
|
||||
expect(result['lxc-141']?.title).toBe('Nested containers');
|
||||
expect(result['lxc-141']?.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,8 +235,8 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
|
||||
const result = run({ guests: [container], visibleGuests: [parent] });
|
||||
|
||||
expect(Object.keys(result)).toEqual(['pve-a:node-a:141']);
|
||||
expect(result['pve-a:node-a:141']?.items[0]?.name).toBe('two');
|
||||
expect(Object.keys(result)).toEqual(['lxc-141']);
|
||||
expect(result['lxc-141']?.items[0]?.name).toBe('two');
|
||||
});
|
||||
|
||||
it('matches a parent via an unprefixed 1-segment host id (>=3/>=2 false, >=1 true)', () => {
|
||||
@@ -249,7 +249,7 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
|
||||
const result = run({ guests: [container], visibleGuests: [parent] });
|
||||
|
||||
expect(Object.keys(result)).toEqual(['pve-a:node-a:141']);
|
||||
expect(Object.keys(result)).toEqual(['lxc-141']);
|
||||
});
|
||||
|
||||
it('yields no parent match when only the lxc docker prefix is present (all segment arms false)', () => {
|
||||
@@ -276,8 +276,8 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
|
||||
const result = run({ guests: [container], visibleGuests: [parent] });
|
||||
|
||||
expect(Object.keys(result)).toEqual(['pve-a:node-a:141']);
|
||||
expect(result['pve-a:node-a:141']?.items[0]?.name).toBe('echo');
|
||||
expect(Object.keys(result)).toEqual(['lxc-141']);
|
||||
expect(result['lxc-141']?.items[0]?.name).toBe('echo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
const result = run({ guests: [container], visibleGuests: [parent] });
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
'pve-a:node-a:141': {
|
||||
'lxc-141': {
|
||||
type: 'app-container',
|
||||
label: 'Docker',
|
||||
title: 'Nested Docker',
|
||||
@@ -437,8 +437,8 @@ describe('nestedWorkloadContext (branch coverage)', () => {
|
||||
visibleGuests: [vmParent, strayContainer, pod],
|
||||
});
|
||||
|
||||
expect(Object.keys(result)).toEqual(['pve-f:node-f:500']);
|
||||
expect(result['pve-f:node-f:500']?.items.map((item) => item.name)).toEqual(['svc']);
|
||||
expect(Object.keys(result)).toEqual(['vm-500']);
|
||||
expect(result['vm-500']?.items.map((item) => item.name)).toEqual(['svc']);
|
||||
});
|
||||
|
||||
it('skips app containers that do not match the requested platform scope', () => {
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('nested workload context', () => {
|
||||
platformScope: 'proxmox-pve',
|
||||
});
|
||||
|
||||
expect(result['pve-a:node-a:141']).toMatchObject({
|
||||
expect(result['lxc-141']).toMatchObject({
|
||||
label: 'Docker',
|
||||
count: 1,
|
||||
href: '/docker/overview?host=media-lxc.mist-stork.ts.net',
|
||||
@@ -109,7 +109,7 @@ describe('nested workload context', () => {
|
||||
platformScope: 'proxmox-pve',
|
||||
});
|
||||
|
||||
expect(result['pve-a:node-a:141']?.href).toBe('/docker/overview');
|
||||
expect(result['lxc-141']?.href).toBe('/docker/overview');
|
||||
});
|
||||
|
||||
it('does not guess when Docker host identity only matches ambiguous VMIDs', () => {
|
||||
|
||||
+72
-1
@@ -1,7 +1,9 @@
|
||||
import { createRoot } from 'solid-js';
|
||||
import { createRoot, createSignal } from 'solid-js';
|
||||
import { renderHook } from '@solidjs/testing-library';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GuestMetadataAPI } from '@/api/guestMetadata';
|
||||
import type { GuestMetadata } from '@/api/guestMetadata';
|
||||
import { useWorkloadGuestMetadataState } from '../useWorkloadGuestMetadataState';
|
||||
import {
|
||||
RESOURCE_METADATA_CHANGED_EVENT,
|
||||
@@ -89,4 +91,73 @@ describe('useWorkloadGuestMetadataState', () => {
|
||||
expect(result.guestMetadata()['machine-1']).toBeUndefined();
|
||||
cleanup();
|
||||
});
|
||||
it('reloads migrated metadata keys without losing the record contents', async () => {
|
||||
const original = {
|
||||
id: 'cluster-a:node-1:101',
|
||||
customUrl: 'https://guest.example',
|
||||
notes: ['Operator note'],
|
||||
tags: ['production'],
|
||||
};
|
||||
const migrated = { ...original, id: 'cluster-a:node-2:101' };
|
||||
vi.mocked(GuestMetadataAPI.getAllMetadata)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ [original.id]: original })
|
||||
.mockResolvedValueOnce({ [migrated.id]: migrated });
|
||||
const [ids, setIds] = createSignal<ReadonlySet<string>>(new Set([original.id]));
|
||||
const { result, cleanup } = renderHook(() => useWorkloadGuestMetadataState(ids));
|
||||
await Promise.resolve();
|
||||
expect(result.guestMetadata()[original.id]).toEqual(original);
|
||||
setIds(new Set([migrated.id]));
|
||||
await Promise.resolve();
|
||||
expect(GuestMetadataAPI.getAllMetadata).toHaveBeenCalledTimes(2);
|
||||
expect(result.guestMetadata()[migrated.id]).toEqual(migrated);
|
||||
expect(result.guestMetadata()[original.id]).toBeUndefined();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not let a stale bulk read overwrite a confirmed metadata edit', async () => {
|
||||
let finish!: (metadata: Record<string, GuestMetadata>) => void;
|
||||
vi.mocked(GuestMetadataAPI.getAllMetadata)
|
||||
.mockReset()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const { result, cleanup } = renderHook(() => useWorkloadGuestMetadataState());
|
||||
result.handleCustomUrlUpdate('cluster-a:node-1:101', 'https://new.example');
|
||||
finish({
|
||||
'cluster-a:node-1:101': { id: 'cluster-a:node-1:101', customUrl: 'https://old.example' },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(result.guestMetadata()['cluster-a:node-1:101']?.customUrl).toBe('https://new.example');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('accepts only the latest metadata-coordinate refresh', async () => {
|
||||
let finishOld!: (metadata: Record<string, GuestMetadata>) => void;
|
||||
vi.mocked(GuestMetadataAPI.getAllMetadata)
|
||||
.mockReset()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
'cluster-a:node-2:101': { id: 'cluster-a:node-2:101', customUrl: 'https://new.example' },
|
||||
});
|
||||
const [ids, setIds] = createSignal<ReadonlySet<string>>(new Set(['cluster-a:node-1:101']));
|
||||
const { result, cleanup } = renderHook(() => useWorkloadGuestMetadataState(ids));
|
||||
setIds(new Set(['cluster-a:node-2:101']));
|
||||
await Promise.resolve();
|
||||
finishOld({
|
||||
'cluster-a:node-1:101': { id: 'cluster-a:node-1:101', customUrl: 'https://old.example' },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(result.guestMetadata()['cluster-a:node-2:101']?.customUrl).toBe('https://new.example');
|
||||
expect(result.guestMetadata()['cluster-a:node-1:101']).toBeUndefined();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
+198
-1
@@ -2,6 +2,8 @@ import { renderHook } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ResourceAPI } from '@/api/resources';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import {
|
||||
clearPendingAppShellRestoreTop,
|
||||
@@ -16,13 +18,15 @@ import { resolveWorkloadResourceSelection } from '../workloadSelectionModel';
|
||||
import { useWorkloadSelectionState } from '../useWorkloadSelectionState';
|
||||
|
||||
let locationSearch = '?resource=cluster-a:node-1:101';
|
||||
let readLocationSearch = () => locationSearch;
|
||||
vi.mock('@/api/resources', () => ({ ResourceAPI: { getResource: vi.fn() } }));
|
||||
const navigateSpy = vi.fn();
|
||||
|
||||
vi.mock('@solidjs/router', () => ({
|
||||
useLocation: () => ({
|
||||
pathname: '/proxmox/overview',
|
||||
get search() {
|
||||
return locationSearch;
|
||||
return readLocationSearch();
|
||||
},
|
||||
}),
|
||||
useNavigate: () => navigateSpy,
|
||||
@@ -31,6 +35,10 @@ vi.mock('@solidjs/router', () => ({
|
||||
describe('useWorkloadSelectionState', () => {
|
||||
beforeEach(() => {
|
||||
locationSearch = '?resource=cluster-a:node-1:101';
|
||||
readLocationSearch = () => locationSearch;
|
||||
vi.mocked(ResourceAPI.getResource)
|
||||
.mockReset()
|
||||
.mockImplementation(async (id) => ({ id }) as Resource);
|
||||
navigateSpy.mockReset();
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
@@ -155,6 +163,60 @@ describe('useWorkloadSelectionState', () => {
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not restore the old viewport when a route selects a known canonical resource', () => {
|
||||
const [search, setSearch] = createSignal('?resource=vm-first');
|
||||
readLocationSearch = search;
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([
|
||||
{ id: 'vm-first' } as WorkloadGuest,
|
||||
{ id: 'vm-second' } as WorkloadGuest,
|
||||
]);
|
||||
const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests }));
|
||||
const scroller = document.createElement('div');
|
||||
scroller.style.overflowY = 'auto';
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800 });
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200 });
|
||||
const writes: number[] = [];
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
get: () => 175,
|
||||
set: (value) => writes.push(value),
|
||||
});
|
||||
const wrapper = document.createElement('div');
|
||||
scroller.append(wrapper);
|
||||
document.body.append(scroller);
|
||||
result.setTableWrapperRef(wrapper);
|
||||
setSearch('?resource=vm-second');
|
||||
expect(result.selectedGuestId()).toBe('vm-second');
|
||||
expect(ResourceAPI.getResource).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not restore the old viewport when a legacy route resolves to its canonical resource', async () => {
|
||||
let resolve!: (resource: Resource) => void;
|
||||
vi.mocked(ResourceAPI.getResource).mockReturnValue(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([{ id: 'vm-current' } as WorkloadGuest]);
|
||||
const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests }));
|
||||
const scroller = document.createElement('div');
|
||||
scroller.style.overflowY = 'auto';
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800 });
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200 });
|
||||
const writes: number[] = [];
|
||||
Object.defineProperty(scroller, 'scrollTop', {
|
||||
get: () => 175,
|
||||
set: (value) => writes.push(value),
|
||||
});
|
||||
const wrapper = document.createElement('div');
|
||||
scroller.append(wrapper);
|
||||
document.body.append(scroller);
|
||||
result.setTableWrapperRef(wrapper);
|
||||
resolve({ id: 'vm-current' } as Resource);
|
||||
await Promise.resolve();
|
||||
expect(result.selectedGuestId()).toBe('vm-current');
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
it('preserves the nearest scrollable ancestor when row focus changes locally', () => {
|
||||
locationSearch = '?type=app-container&platform=truenas&agent=truenas-main';
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
|
||||
@@ -348,4 +410,139 @@ describe('useWorkloadSelectionState', () => {
|
||||
expect(readPendingAppShellRestoreTop()).toBeNull();
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
it('resolves an old node-scoped link through backend identity ownership', async () => {
|
||||
vi.mocked(ResourceAPI.getResource).mockResolvedValueOnce({ id: 'stable-resource' } as Resource);
|
||||
const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(result.selectedGuestId()).toBe('stable-resource');
|
||||
expect(ResourceAPI.getResource).toHaveBeenCalledWith('cluster-a:node-1:101', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps exact current identity local without an alias lookup', () => {
|
||||
locationSearch = '?resource=stable-resource';
|
||||
const guest = {
|
||||
id: 'stable-resource',
|
||||
canonicalResourceId: 'stable-resource',
|
||||
} as WorkloadGuest;
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({ filteredGuests: () => [guest] }),
|
||||
);
|
||||
expect(result.selectedGuestId()).toBe('stable-resource');
|
||||
expect(ResourceAPI.getResource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['close', 'reselect', 'escape'] as const)(
|
||||
'does not replay a pending link after %s',
|
||||
async (interaction) => {
|
||||
let finish!: (resource: Resource) => void;
|
||||
vi.mocked(ResourceAPI.getResource).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] }));
|
||||
const signal = vi.mocked(ResourceAPI.getResource).mock.calls[0]![1]!.signal!;
|
||||
if (interaction === 'close') result.clearPinnedSummaryScope();
|
||||
else if (interaction === 'reselect') result.setSelectedGuestId('independent-clone');
|
||||
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(signal.aborted).toBe(true);
|
||||
finish({ id: 'stable-resource' } as Resource);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(result.selectedGuestId()).toBe(
|
||||
interaction === 'reselect' ? 'independent-clone' : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('aborts a superseded route lookup and accepts only its current reference', async () => {
|
||||
const [search, setSearch] = createSignal('?resource=old-reference');
|
||||
readLocationSearch = search;
|
||||
let finishOld!: (resource: Resource) => void;
|
||||
vi.mocked(ResourceAPI.getResource)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ id: 'new-resource' } as Resource);
|
||||
const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] }));
|
||||
const oldSignal = vi.mocked(ResourceAPI.getResource).mock.calls[0]![1]!.signal!;
|
||||
setSearch('?resource=new-reference');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
finishOld({ id: 'old-resource' } as Resource);
|
||||
await Promise.resolve();
|
||||
expect(oldSignal.aborted).toBe(true);
|
||||
expect(result.selectedGuestId()).toBe('new-resource');
|
||||
});
|
||||
|
||||
it('does not resolve references while route state is disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useWorkloadSelectionState({ filteredGuests: () => [], routeStateEnabled: () => false }),
|
||||
);
|
||||
expect(result.selectedGuestId()).toBeNull();
|
||||
expect(ResourceAPI.getResource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an ambiguous or refused reference unresolved', async () => {
|
||||
vi.mocked(ResourceAPI.getResource).mockRejectedValueOnce(new Error('not found'));
|
||||
const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(result.selectedGuestId()).toBe('cluster-a:node-1:101');
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a pending reference lookup and local dismissal through unrelated filter changes', async () => {
|
||||
const [search, setSearch] = createSignal('?resource=old-reference');
|
||||
readLocationSearch = search;
|
||||
let finish!: (resource: Resource) => void;
|
||||
vi.mocked(ResourceAPI.getResource).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: () => [] }));
|
||||
const signal = vi.mocked(ResourceAPI.getResource).mock.calls[0]![1]!.signal!;
|
||||
setSearch('?resource=old-reference&type=vm');
|
||||
expect(signal.aborted).toBe(false);
|
||||
expect(ResourceAPI.getResource).toHaveBeenCalledTimes(1);
|
||||
finish({ id: 'stable-resource' } as Resource);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(result.selectedGuestId()).toBe('stable-resource');
|
||||
result.clearPinnedSummaryScope();
|
||||
setSearch('?resource=old-reference&type=vm&node=node-2');
|
||||
expect(result.selectedGuestId()).toBeNull();
|
||||
expect(ResourceAPI.getResource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the selected resource drawer state through regrouping and resets it on another selection', () => {
|
||||
locationSearch = '';
|
||||
const [guests, setGuests] = createSignal([
|
||||
{ id: 'stable-resource', node: 'node-1' },
|
||||
] as WorkloadGuest[]);
|
||||
const { result } = renderHook(() => useWorkloadSelectionState({ filteredGuests: guests }));
|
||||
result.setSelectedGuestId('stable-resource');
|
||||
result.selectedGuestDrawerState.setActiveTab('history');
|
||||
result.selectedGuestDrawerState.setHistoryRange('12h');
|
||||
setGuests([{ id: 'stable-resource', node: 'node-2' }] as WorkloadGuest[]);
|
||||
result.setSelectedGuestId('stable-resource');
|
||||
expect(result.selectedGuestDrawerState.activeTab()).toBe('history');
|
||||
expect(result.selectedGuestDrawerState.historyRange()).toBe('12h');
|
||||
result.setSelectedGuestId('independent-clone');
|
||||
expect(result.selectedGuestDrawerState.activeTab()).toBe('overview');
|
||||
expect(result.selectedGuestDrawerState.historyRange()).toBe('24h');
|
||||
result.selectedGuestDrawerState.setActiveTab('manage');
|
||||
result.clearPinnedSummaryScope();
|
||||
expect(result.selectedGuestDrawerState.activeTab()).toBe('overview');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +89,80 @@ describe('useWorkloadViewportSync', () => {
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||
});
|
||||
|
||||
it('measures an active selection without replacing its initial window, then follows scroll and resize', async () => {
|
||||
const onScroll = vi.fn();
|
||||
const onRowGeometryChange = vi.fn();
|
||||
const groupedWindowing: UseGroupedTableWindowingResult = {
|
||||
startIndex: () => 330,
|
||||
endIndex: () => 366,
|
||||
mountedCount: () => 36,
|
||||
getVisibleSlice: (_groupKey, guests) => guests,
|
||||
isWindowed: () => true,
|
||||
onScroll,
|
||||
revealIndex: vi.fn(),
|
||||
};
|
||||
const rect = (height: number, top = 0): DOMRect => ({
|
||||
bottom: top + height,
|
||||
height,
|
||||
left: 0,
|
||||
right: 800,
|
||||
top,
|
||||
width: 800,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const Harness = () => {
|
||||
const [bodyRef, setBodyRef] = createSignal<HTMLTableSectionElement | null>(null);
|
||||
useWorkloadViewportSync({
|
||||
filteredGuestCount: () => 640,
|
||||
expandedDetailActive: () => true,
|
||||
groupedWindowing,
|
||||
onRowGeometryChange,
|
||||
rowHeight: () => 32,
|
||||
tableBodyRef: bodyRef,
|
||||
});
|
||||
return (
|
||||
<table>
|
||||
<tbody
|
||||
ref={(element) => {
|
||||
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect(320, -96));
|
||||
setBodyRef(element);
|
||||
}}
|
||||
>
|
||||
<tr
|
||||
data-summary-group-id="group"
|
||||
ref={(element) => {
|
||||
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect(24));
|
||||
}}
|
||||
>
|
||||
<td>Group</td>
|
||||
</tr>
|
||||
<tr
|
||||
class="workload-row"
|
||||
ref={(element) => {
|
||||
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect(32));
|
||||
}}
|
||||
>
|
||||
<td>Selected workload</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
render(() => <Harness />);
|
||||
await waitFor(() =>
|
||||
expect(onRowGeometryChange).toHaveBeenCalledWith({
|
||||
groupHeaderHeight: 24,
|
||||
rowHeight: 32,
|
||||
}),
|
||||
);
|
||||
expect(onScroll).not.toHaveBeenCalled();
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
await waitFor(() => expect(onScroll).toHaveBeenCalledTimes(1));
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await waitFor(() => expect(onScroll).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
it('captures expanded detail geometry when the drawer mounts before scrolling', async () => {
|
||||
const onExpandedDetailHeightChange = vi.fn();
|
||||
const groupedWindowing: UseGroupedTableWindowingResult = {
|
||||
|
||||
+1
-1
@@ -98,8 +98,8 @@ describe('workloadMetricHistoryModel (branch coverage)', () => {
|
||||
type: 'qemu',
|
||||
});
|
||||
expect(getWorkloadChartKeyCandidates(guest)).toEqual([
|
||||
'cluster-a:pve:101',
|
||||
'cluster-a-pve-101',
|
||||
'cluster-a:pve:101',
|
||||
'pve-101',
|
||||
'pve:pve:101',
|
||||
]);
|
||||
|
||||
+6
-6
@@ -7,7 +7,7 @@ import { buildWorkloadNodeOptions } from '../workloadRouteModel';
|
||||
|
||||
const guests = [
|
||||
{
|
||||
id: 'lab-east-pve1-101',
|
||||
id: 'vm-instance-a',
|
||||
vmid: 101,
|
||||
node: 'pve1',
|
||||
instance: 'lab-east',
|
||||
@@ -16,7 +16,7 @@ const guests = [
|
||||
platformScopes: ['proxmox-pve'],
|
||||
},
|
||||
{
|
||||
id: 'lab-east-pve1-101',
|
||||
id: 'vm-instance-b',
|
||||
vmid: 101,
|
||||
node: 'east-pve1',
|
||||
instance: 'lab',
|
||||
@@ -55,17 +55,17 @@ describe('Supplied node attribution (existing API regression controls)', () => {
|
||||
'preserves each supplied parent regardless of inventory order',
|
||||
(...inventory) => {
|
||||
const parents = buildGuestParentNodeMapFromNodes(guests, inventory);
|
||||
expect(parents['lab-east:pve1:101']).toBe(nodes[0]);
|
||||
expect(parents['lab:east-pve1:101']).toBe(nodes[1]);
|
||||
expect(parents[guests[0].id]).toBe(nodes[0]);
|
||||
expect(parents[guests[1].id]).toBe(nodes[1]);
|
||||
},
|
||||
);
|
||||
it('does not substitute an inferred guest-ID parent when its explicit source fields disagree', () => {
|
||||
const contradictory = { ...guests[1], id: 'native-a-101' };
|
||||
const parents = buildGuestParentNodeMapFromNodes([contradictory], nodes);
|
||||
expect(parents['lab:east-pve1:101']).toBe(nodes[1]);
|
||||
expect(parents[contradictory.id]).toBe(nodes[1]);
|
||||
});
|
||||
it('cannot give a vSphere VM Proxmox parent state from matching labels', () => {
|
||||
const vm = { ...guests[0], platformScopes: ['vmware-vsphere'] };
|
||||
expect(buildGuestParentNodeMapFromNodes([vm], nodes)['lab-east:pve1:101']).toBeUndefined();
|
||||
expect(buildGuestParentNodeMapFromNodes([vm], nodes)[vm.id]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,10 @@ const node = (id: string, instance: string, name: string, total: number, status
|
||||
uptime: status === 'online' ? 100 : 0,
|
||||
memory: { total, used: 0, free: total, usage: 0 },
|
||||
}) as Node;
|
||||
const guests = [guest('lab-east', 'pve1'), guest('lab', 'east-pve1')];
|
||||
const guests = [
|
||||
guest('lab-east', 'pve1', { id: 'vm-instance-a' }),
|
||||
guest('lab', 'east-pve1', { id: 'vm-instance-b' }),
|
||||
];
|
||||
const nodes = [
|
||||
node('native-a', 'lab-east', 'pve1', 8192, 'offline'),
|
||||
node('native-b', 'lab', 'east-pve1', 32768),
|
||||
@@ -61,8 +64,7 @@ describe('Workloads node attribution', () => {
|
||||
const scope = workloadNodeScopeId(original);
|
||||
expect(groups[scope]).toEqual([original]);
|
||||
expect(filtered(inventory, scope)).toEqual([original]);
|
||||
expect(original.id).toBe('lab-east-pve1-101');
|
||||
expect(getCanonicalWorkloadId(original)).toBe(`${original.instance}:${original.node}:101`);
|
||||
expect(getCanonicalWorkloadId(original)).toBe(original.id);
|
||||
}
|
||||
expect(filtered(inventory, 'lab-east-pve1')).toEqual([]);
|
||||
expect(filtered([inventory[0]], 'lab-east-pve1')).toEqual([inventory[0]]);
|
||||
@@ -73,20 +75,18 @@ describe('Workloads node attribution', () => {
|
||||
'assigns status and host memory to the exact tuple, not the first legacy alias',
|
||||
(...inventory) => {
|
||||
const parents = buildGuestParentNodeMapFromNodes(guests, inventory);
|
||||
expect(parents['lab-east:pve1:101']).toBe(nodes[0]);
|
||||
expect(parents['lab:east-pve1:101']).toBe(nodes[1]);
|
||||
expect(parents['lab-east:pve1:101']?.status).toBe('offline');
|
||||
expect(parents['lab:east-pve1:101']?.memory.total).toBe(32768);
|
||||
expect(parents[guests[0].id]).toBe(nodes[0]);
|
||||
expect(parents[guests[1].id]).toBe(nodes[1]);
|
||||
expect(parents[guests[0].id]?.status).toBe('offline');
|
||||
expect(parents[guests[1].id]?.memory.total).toBe(32768);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not let a contradictory inferred ID override the supplied parent fields', () => {
|
||||
const wrongPrefix = guest('lab', 'east-pve1', { id: 'native-a-101' });
|
||||
expect(buildGuestParentNodeMapFromNodes([wrongPrefix], nodes)['lab:east-pve1:101']).toBe(
|
||||
nodes[1],
|
||||
);
|
||||
expect(buildGuestParentNodeMapFromNodes([wrongPrefix], nodes)[wrongPrefix.id]).toBe(nodes[1]);
|
||||
const missing = guest('other', 'absent', { id: 'native-a-101' });
|
||||
expect(buildGuestParentNodeMapFromNodes([missing], nodes)['other:absent:101']).toBeUndefined();
|
||||
expect(buildGuestParentNodeMapFromNodes([missing], nodes)[missing.id]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains a unique consistent legacy-ID fallback for missing parent fields', () => {
|
||||
@@ -107,11 +107,11 @@ describe('Workloads node attribution', () => {
|
||||
const map = buildNodeByInstance([...nodes, duplicate]);
|
||||
expect(map[workloadNodeScopeId(guests[0])]).toBeUndefined();
|
||||
expect(
|
||||
buildGuestParentNodeMapFromNodes([guests[0]], [...nodes, duplicate])['lab-east:pve1:101'],
|
||||
buildGuestParentNodeMapFromNodes([guests[0]], [...nodes, duplicate])[guests[0].id],
|
||||
).toBeUndefined();
|
||||
const shared = nodes.map((n) => ({ ...n, id: 'same-native-id' }));
|
||||
expect(buildNodeByInstance(shared)['same-native-id']).toBeUndefined();
|
||||
expect(buildGuestParentNodeMapFromNodes(guests, shared)['lab:east-pve1:101']).toBe(shared[1]);
|
||||
expect(buildGuestParentNodeMapFromNodes(guests, shared)[guests[1].id]).toBe(shared[1]);
|
||||
});
|
||||
|
||||
it('cannot assign a Proxmox node to an unrelated platform or container by matching labels', () => {
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('workloadTopology', () => {
|
||||
expect(map['cluster-b-node-b']).toBe(nodeB);
|
||||
});
|
||||
|
||||
it('builds guest parent node mapping using id lookup first, then composite fallback', () => {
|
||||
it('maps each supplied guest ID to its current provider parent', () => {
|
||||
const nodeA = makeNode('cluster-a-node-a', 'cluster-a', 'node-a');
|
||||
const nodeB = makeNode('cluster-b-node-b', 'cluster-b', 'node-b');
|
||||
const nodeMap = buildNodeByInstance([nodeA, nodeB]);
|
||||
@@ -107,9 +107,9 @@ describe('workloadTopology', () => {
|
||||
nodeMap,
|
||||
);
|
||||
|
||||
expect(mapping['cluster-a:node-a:101']).toBe(nodeA);
|
||||
expect(mapping['cluster-b:node-b:102']).toBe(nodeB);
|
||||
expect(mapping['cluster-c:node-c:103']).toBeUndefined();
|
||||
expect(mapping[guestWithIdLookup.id]).toBe(nodeA);
|
||||
expect(mapping[guestWithFallback.id]).toBe(nodeB);
|
||||
expect(mapping[guestWithoutParent.id]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Accessor, Setter } from 'solid-js';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import type { Alert, VM } from '@/types/api';
|
||||
import type {
|
||||
@@ -37,6 +38,7 @@ type Guest = WorkloadGuest;
|
||||
|
||||
export interface GuestDrawerProps {
|
||||
guest: Guest;
|
||||
viewState?: GuestDrawerViewState;
|
||||
onClose: () => void;
|
||||
metadataId?: string;
|
||||
customUrl?: string;
|
||||
@@ -50,6 +52,14 @@ export interface GuestDrawerProps {
|
||||
|
||||
export type GuestDrawerTab = 'overview' | 'history' | 'manage';
|
||||
|
||||
export interface GuestDrawerViewState {
|
||||
activeTab: Accessor<GuestDrawerTab>;
|
||||
setActiveTab: Setter<GuestDrawerTab>;
|
||||
historyRange: Accessor<HistoryTimeRange>;
|
||||
setHistoryRange: Setter<HistoryTimeRange>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export interface GuestDrawerHistoryTarget {
|
||||
resourceType: HistoryResourceType;
|
||||
resourceId: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createEffect, createMemo, createSignal } from 'solid-js';
|
||||
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import type { DisplayMetricType } from '@/utils/metricThresholds';
|
||||
import {
|
||||
@@ -20,12 +20,13 @@ import {
|
||||
getGuestDrawerAgentHeading,
|
||||
getGuestDrawerAgentTitle,
|
||||
getGuestDrawerBackupPresentation,
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
getGuestDrawerHistoryTarget,
|
||||
getGuestDrawerNetworkInterfaces,
|
||||
hasGuestDrawerFilesystemDetails,
|
||||
hasGuestDrawerOsInfo,
|
||||
normalizeGuestDrawerTags,
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
type GuestDrawerViewState,
|
||||
type GuestDrawerProps,
|
||||
type GuestDrawerTab,
|
||||
} from './guestDrawerModel';
|
||||
@@ -35,13 +36,28 @@ import {
|
||||
shouldShowInGuestAgentInstallCue,
|
||||
} from './workloadAgentReadiness';
|
||||
|
||||
export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const now = useRelativeTimeNow();
|
||||
export function createGuestDrawerViewState(): GuestDrawerViewState {
|
||||
const [activeTab, setActiveTab] = createSignal<GuestDrawerTab>('overview');
|
||||
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
);
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
historyRange,
|
||||
setHistoryRange,
|
||||
reset: () => {
|
||||
setActiveTab('overview');
|
||||
setHistoryRange(GUEST_DRAWER_HISTORY_DEFAULT_RANGE);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useGuestDrawerState(props: GuestDrawerProps) {
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const now = useRelativeTimeNow();
|
||||
const { activeTab, setActiveTab, historyRange, setHistoryRange } =
|
||||
props.viewState ?? createGuestDrawerViewState();
|
||||
|
||||
const guestId = createMemo(() => getCanonicalWorkloadId(props.guest));
|
||||
const alertThresholdScope = createMemo(() => getWorkloadAlertThresholdScope(props.guest));
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type Accessor,
|
||||
} from 'solid-js';
|
||||
import { GuestMetadataAPI } from '@/api/guestMetadata';
|
||||
import type { GuestMetadata } from '@/api/guestMetadata';
|
||||
import { getOrgID } from '@/utils/apiClient';
|
||||
@@ -207,7 +215,8 @@ const queueGuestMetadataPersist = (storageKey: string, metadata: GuestMetadataRe
|
||||
}
|
||||
};
|
||||
|
||||
export function useWorkloadGuestMetadataState() {
|
||||
export function useWorkloadGuestMetadataState(metadataIds?: Accessor<ReadonlySet<string>>) {
|
||||
let refreshRevision = 0;
|
||||
const [orgScope, setOrgScope] = createSignal(normalizeOrgScope(getOrgID()));
|
||||
const guestMetadataStorageKey = createMemo(() => guestMetadataStorageKeyForOrg(orgScope()));
|
||||
const [guestMetadata, setGuestMetadata] = createSignal<GuestMetadataRecord>(
|
||||
@@ -225,8 +234,11 @@ export function useWorkloadGuestMetadataState() {
|
||||
});
|
||||
|
||||
const refreshGuestMetadata = async () => {
|
||||
const revision = ++refreshRevision;
|
||||
const storageKey = guestMetadataStorageKey();
|
||||
try {
|
||||
const metadata = await GuestMetadataAPI.getAllMetadata();
|
||||
if (revision !== refreshRevision || storageKey !== guestMetadataStorageKey()) return;
|
||||
updateGuestMetadataState(() => metadata || {});
|
||||
logger.debug('Guest metadata refreshed');
|
||||
} catch (error) {
|
||||
@@ -244,6 +256,7 @@ export function useWorkloadGuestMetadataState() {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshRevision += 1;
|
||||
updateGuestMetadataState((prev) => {
|
||||
const previousEntry = prev[guestId];
|
||||
|
||||
@@ -296,6 +309,12 @@ export function useWorkloadGuestMetadataState() {
|
||||
});
|
||||
};
|
||||
|
||||
// The server migrates provider metadata keys before publishing a node move.
|
||||
// Refetch when that boundary coordinate changes, rather than retaining only
|
||||
// the initial map under the previous node key. Metric-only ticks keep it.
|
||||
if (metadataIds)
|
||||
createEffect(on(metadataIds, () => void refreshGuestMetadata(), { defer: true }));
|
||||
|
||||
onMount(() => {
|
||||
void refreshGuestMetadata();
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { useLocation } from '@solidjs/router';
|
||||
import { createEffect, createSignal, type Accessor } from 'solid-js';
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
untrack,
|
||||
type Accessor,
|
||||
} from 'solid-js';
|
||||
|
||||
import { ResourceAPI } from '@/api/resources';
|
||||
import { getCanonicalWorkloadId } from '@/utils/workloads';
|
||||
|
||||
import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus';
|
||||
import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus';
|
||||
@@ -9,6 +20,8 @@ import {
|
||||
resolveWorkloadResourceSelection,
|
||||
} from './workloadSelectionModel';
|
||||
|
||||
import { createGuestDrawerViewState } from './useGuestDrawerState';
|
||||
|
||||
interface UseWorkloadsSelectionStateOptions {
|
||||
clearAdditionalPageStateOnEscape?: () => void;
|
||||
filteredGuests: Accessor<WorkloadGuest[]>;
|
||||
@@ -18,9 +31,10 @@ interface UseWorkloadsSelectionStateOptions {
|
||||
export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOptions) {
|
||||
const location = useLocation();
|
||||
|
||||
const selectedGuestDrawerState = createGuestDrawerViewState();
|
||||
const [selectedGuestId, setSelectedGuestIdRaw] = createSignal<string | null>(null);
|
||||
const [hoveredWorkloadId, setHoveredWorkloadId] = createSignal<string | null>(null);
|
||||
const [handledResourceId, setHandledResourceId] = createSignal<string | null>(null);
|
||||
let referenceLookup: AbortController | undefined;
|
||||
const [revealedGuestId, setRevealedGuestId] = createSignal<string | null>(null);
|
||||
|
||||
const [tableWrapperRef, setTableWrapperRefSignal] = createSignal<HTMLDivElement | undefined>(
|
||||
@@ -29,7 +43,9 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
|
||||
const [tableBodyRef, setTableBodyRef] = createSignal<HTMLTableSectionElement | null>(null);
|
||||
|
||||
const clearPinnedSummaryScope = () => {
|
||||
referenceLookup?.abort();
|
||||
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => {
|
||||
selectedGuestDrawerState.reset();
|
||||
setSelectedGuestIdRaw(null);
|
||||
});
|
||||
};
|
||||
@@ -59,28 +75,55 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
|
||||
|
||||
// Row focus is local: it never navigates, so it must not stage an app-shell
|
||||
// restore that the next unrelated route change would replay.
|
||||
const setSelectedGuestId = (id: string | null) => {
|
||||
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => {
|
||||
setSelectedGuestIdRaw(id);
|
||||
});
|
||||
const applySelectedGuestId = (id: string | null) => {
|
||||
if (id !== selectedGuestId()) selectedGuestDrawerState.reset();
|
||||
setSelectedGuestIdRaw(id);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (options.routeStateEnabled && !options.routeStateEnabled()) return;
|
||||
const resourceId = resolveWorkloadResourceSelection(location.search);
|
||||
if (!resourceId) {
|
||||
if (handledResourceId() !== null) {
|
||||
setSelectedGuestId(null);
|
||||
setHandledResourceId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const setSelectedGuestId = (id: string | null) => {
|
||||
referenceLookup?.abort();
|
||||
preserveScrollableAncestorVerticalOffset(tableWrapperRef(), () => applySelectedGuestId(id));
|
||||
};
|
||||
|
||||
if (resourceId !== handledResourceId()) {
|
||||
setSelectedGuestId(resourceId);
|
||||
setHandledResourceId(resourceId);
|
||||
}
|
||||
});
|
||||
const routeReference = createMemo(() => resolveWorkloadResourceSelection(location.search));
|
||||
createEffect(
|
||||
on(
|
||||
[() => location.pathname, routeReference, () => options.routeStateEnabled?.() ?? true],
|
||||
([, reference, enabled]) => {
|
||||
if (!enabled) return;
|
||||
referenceLookup?.abort();
|
||||
// A route selects a destination. The shared focus bridge reveals it;
|
||||
// retaining the previous scroll offset would undo that reveal.
|
||||
applySelectedGuestId(reference);
|
||||
if (!reference) return;
|
||||
|
||||
// Exact current IDs need no translation. Historical/source references
|
||||
// are resolved by the registry, which owns succession and ambiguity.
|
||||
if (
|
||||
untrack(options.filteredGuests).some(
|
||||
(guest) => getCanonicalWorkloadId(guest) === reference,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const controller = new AbortController();
|
||||
referenceLookup = controller;
|
||||
onCleanup(() => controller.abort());
|
||||
void ResourceAPI.getResource(reference, { signal: controller.signal })
|
||||
.then((resource) => {
|
||||
if (
|
||||
!controller.signal.aborted &&
|
||||
selectedGuestId() === reference &&
|
||||
resource.id?.trim()
|
||||
) {
|
||||
applySelectedGuestId(resource.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// An unresolved or refused reference must not select another row.
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const hoveredId = hoveredWorkloadId();
|
||||
@@ -103,6 +146,7 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
|
||||
clearPinnedSummaryScope,
|
||||
hoveredWorkloadId,
|
||||
revealedGuestId,
|
||||
selectedGuestDrawerState,
|
||||
selectedGuestId,
|
||||
setClearSurfaceRootRef,
|
||||
setHoveredWorkloadId,
|
||||
|
||||
@@ -33,7 +33,11 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
return detail;
|
||||
};
|
||||
|
||||
const syncGuestWindowToViewport = (measureRows = false, projectedScrollDelta = 0) => {
|
||||
const syncGuestWindowToViewport = (
|
||||
measureRows = false,
|
||||
projectedScrollDelta = 0,
|
||||
alignWindowToViewport = true,
|
||||
) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const body = options.tableBodyRef();
|
||||
if (!body || isWindowedSurfaceHidden(body)) return;
|
||||
@@ -58,7 +62,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
const scrollContainer = findWindowedPageScrollContainer(body);
|
||||
if (scrollContainer) {
|
||||
setIsScrollToTopVisible(scrollContainer.scrollTop > SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX);
|
||||
if (!options.groupedWindowing.isWindowed()) return;
|
||||
if (!alignWindowToViewport || !options.groupedWindowing.isWindowed()) return;
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const scrollTop = Math.max(0, containerRect.top - rect.top);
|
||||
options.groupedWindowing.onScroll(
|
||||
@@ -70,7 +74,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
}
|
||||
|
||||
setIsScrollToTopVisible(window.scrollY > SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX);
|
||||
if (!options.groupedWindowing.isWindowed()) return;
|
||||
if (!alignWindowToViewport || !options.groupedWindowing.isWindowed()) return;
|
||||
options.groupedWindowing.onScroll(
|
||||
Math.max(0, -rect.top + projectedScrollDelta),
|
||||
window.innerHeight,
|
||||
@@ -134,7 +138,10 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
// groupedWindowing.onScroll) that the pass itself moves. Tracking them
|
||||
// would re-run this effect — remeasuring rows with forced reflows and
|
||||
// rebinding listeners — on every runway top-up during scrolling.
|
||||
untrack(() => handleViewportResize());
|
||||
// Selection owns initial positioning. Measure its geometry without
|
||||
// replacing the reveal window before the shared focus bridge scrolls.
|
||||
// Actual scroll and resize events continue to follow the viewport.
|
||||
untrack(() => syncGuestWindowToViewport(true, 0, !options.expandedDetailActive?.()));
|
||||
const scrollContainer = findWindowedPageScrollContainer(options.tableBodyRef()!);
|
||||
const scrollTarget = scrollContainer ?? window;
|
||||
onCleanup(
|
||||
|
||||
@@ -290,7 +290,7 @@ export function useWorkloadsDerivedState(options: WorkloadsWorkloadDerivedStateO
|
||||
});
|
||||
|
||||
const workloadViewport = useWorkloadViewportSync({
|
||||
expandedDetailActive: () => selectedGuestIndex() !== null && selectedDetailHeight() > 0,
|
||||
expandedDetailActive: () => selectedGuestIndex() !== null,
|
||||
filteredGuestCount: () => filteredGuests().length,
|
||||
groupedWindowing,
|
||||
onExpandedDetailHeightChange: (height) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
getWorkloadsLoadingState,
|
||||
getWorkloadsNoInventoryState,
|
||||
} from '@/utils/workloadEmptyStatePresentation';
|
||||
import { getCanonicalWorkloadId } from '@/utils/workloads';
|
||||
import { getCanonicalWorkloadId, getWorkloadMetadataId } from '@/utils/workloads';
|
||||
import { nodeFromResource } from '@/utils/resourceStateAdapters';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import {
|
||||
@@ -136,13 +136,20 @@ export function useWorkloadsState(props: WorkloadsStateOptions) {
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = alertsActivation.detectionEnabled;
|
||||
|
||||
const { guestMetadata, handleCustomUrlUpdate } = useWorkloadGuestMetadataState();
|
||||
|
||||
const workloads = useWorkloads(undefined, {
|
||||
resourceSnapshot: props.resourceSnapshot,
|
||||
resourceSnapshotChange: props.resourceSnapshotChange,
|
||||
refetchSnapshot: props.resourceSnapshotRefetch,
|
||||
});
|
||||
const metadataIds = createMemo<ReadonlySet<string>>(
|
||||
() => new Set(workloads.workloads().map(getWorkloadMetadataId)),
|
||||
new Set<string>(),
|
||||
{
|
||||
equals: (previous, next) =>
|
||||
previous.size === next.size && [...next].every((id) => previous.has(id)),
|
||||
},
|
||||
);
|
||||
const { guestMetadata, handleCustomUrlUpdate } = useWorkloadGuestMetadataState(metadataIds);
|
||||
const infrastructureSources = useUnifiedResources({
|
||||
query: WORKLOADS_INFRASTRUCTURE_SOURCES_QUERY,
|
||||
cacheKey: 'workloads-infrastructure-sources',
|
||||
@@ -447,6 +454,7 @@ export function useWorkloadsState(props: WorkloadsStateOptions) {
|
||||
clearPinnedSummaryScope,
|
||||
hoveredWorkloadId,
|
||||
revealedGuestId,
|
||||
selectedGuestDrawerState,
|
||||
selectedGuestId,
|
||||
setClearSurfaceRootRef,
|
||||
setHoveredWorkloadId,
|
||||
@@ -599,6 +607,7 @@ export function useWorkloadsState(props: WorkloadsStateOptions) {
|
||||
search,
|
||||
scrollToTop,
|
||||
selectedCluster,
|
||||
selectedGuestDrawerState,
|
||||
selectedGuestId,
|
||||
selectedHostHint,
|
||||
selectedPlatform,
|
||||
|
||||
@@ -1609,7 +1609,7 @@ describe('useWorkloads', () => {
|
||||
expect(result!.workloads()[0]?.clusterName).toBe('cluster-b');
|
||||
expect(result!.workloads()[0]?.contextLabel).toBe('pve1 (cluster-b)');
|
||||
expect(result!.workloads()[0]?.instance).toBe('cluster-b');
|
||||
expect(result!.workloads()[0]?.id).toBe('cluster-b:pve1:101');
|
||||
expect(result!.workloads()[0]?.id).toBe('vm-1');
|
||||
|
||||
dispose();
|
||||
});
|
||||
@@ -1688,7 +1688,7 @@ describe('useWorkloads', () => {
|
||||
await flushAsync();
|
||||
await waitForWorkloadCount(() => result!.workloads().length);
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledTimes(1);
|
||||
expect(result!.workloads()[0]?.id).toBe('cluster-a:pve1:101');
|
||||
expect(result!.workloads()[0]?.id).toBe(sampleResource.id);
|
||||
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
data: [
|
||||
@@ -1709,13 +1709,13 @@ describe('useWorkloads', () => {
|
||||
await waitForWorkloadCount(() => result!.workloads().length);
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledTimes(2);
|
||||
expect(result!.workloads()[0]?.id).toBe('cluster-b:pve2:202');
|
||||
expect(result!.workloads()[0]?.id).toBe('cluster-b-pve2-202');
|
||||
|
||||
eventBus.emit('org_switched', 'default');
|
||||
await flushAsync();
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledTimes(2);
|
||||
expect(result!.workloads()[0]?.id).toBe('cluster-a:pve1:101');
|
||||
expect(result!.workloads()[0]?.id).toBe(sampleResource.id);
|
||||
|
||||
dispose();
|
||||
});
|
||||
@@ -1726,3 +1726,39 @@ describe('useWorkloads', () => {
|
||||
expect(useWorkloadsSource).not.toContain('const normalizeOrgScope =');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource-owned workload identity', () => {
|
||||
it.each(['vm', 'system-container', 'pod'] as const)(
|
||||
'preserves %s row identity when placement changes',
|
||||
async (type) => {
|
||||
const { useWorkloads, __resetWorkloadsCacheForTests } = await import('@/hooks/useWorkloads');
|
||||
__resetWorkloadsCacheForTests();
|
||||
const original = {
|
||||
...sampleResource,
|
||||
id: 'stable-resource',
|
||||
type,
|
||||
proxmox: { instance: 'cluster-a', node: 'node-1', vmid: 101 },
|
||||
kubernetes: { clusterId: 'cluster-a', podUid: 'native-pod-uid' },
|
||||
};
|
||||
const [snapshot, setSnapshot] = createSignal([original] as any);
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<typeof useWorkloads> | undefined;
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
result = useWorkloads(() => true, { resourceSnapshot: snapshot });
|
||||
});
|
||||
try {
|
||||
await flushAsync();
|
||||
expect(result!.workloads()[0]?.id).toBe(original.id);
|
||||
setSnapshot([
|
||||
{ ...original, node: 'node-2', proxmox: { ...original.proxmox, node: 'node-2' } },
|
||||
]);
|
||||
await flushAsync();
|
||||
expect(result!.workloads()[0]?.id).toBe(original.id);
|
||||
expect(result!.workloads()[0]?.canonicalResourceId).toBe(original.id);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { canonicalDiscoveryResourceType } from '@/utils/discoveryTarget';
|
||||
import { normalizeDiskArray } from '@/utils/format';
|
||||
import {
|
||||
buildCanonicalNodeScopedWorkloadId,
|
||||
getWorkloadCPUFraction,
|
||||
isDockerManagedAppContainer,
|
||||
resolveWorkloadTypeFromString,
|
||||
@@ -518,26 +517,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
|
||||
// runtime ids such as Docker container ids must stay separate so workload
|
||||
// routing, discovery, anomalies, and drawer selection align with the shared
|
||||
// resource model.
|
||||
const guestId = (() => {
|
||||
if (workloadType === 'vm' || workloadType === 'system-container') {
|
||||
const canonicalId = buildCanonicalNodeScopedWorkloadId({
|
||||
instance,
|
||||
node,
|
||||
vmid,
|
||||
});
|
||||
if (canonicalId) {
|
||||
return canonicalId;
|
||||
}
|
||||
}
|
||||
if (workloadType === 'pod') {
|
||||
const clusterId = resource.kubernetes?.clusterId;
|
||||
const podUid = resource.kubernetes?.podUid;
|
||||
if (clusterId && podUid) {
|
||||
return `k8s:${clusterId}:pod:${podUid}`;
|
||||
}
|
||||
}
|
||||
return resource.id;
|
||||
})();
|
||||
const guestId = resource.id;
|
||||
|
||||
return {
|
||||
id: guestId,
|
||||
|
||||
@@ -23,7 +23,7 @@ export type WorkloadGuest = (VM | Container) & {
|
||||
workloadType?: WorkloadType;
|
||||
/** Backend identities that may own active alerts for this canonical workload. */
|
||||
alertResourceIds?: string[];
|
||||
/** Exact unified-resource ID; a Proxmox row ID can be a different VMID-based display key. */
|
||||
/** Exact unified-resource identity, also used as the canonical workload row ID. */
|
||||
canonicalResourceId?: string;
|
||||
/** Exact unified parent identity, independent of provider display labels. */
|
||||
parentResourceId?: string;
|
||||
|
||||
@@ -762,10 +762,8 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(workloadsSelectionStateSource).toContain(
|
||||
'const [hoveredWorkloadId, setHoveredWorkloadId]',
|
||||
);
|
||||
expect(workloadsSelectionStateSource).toContain('setHandledResourceId(null)');
|
||||
expect(workloadsSelectionStateSource).toContain("from './workloadSelectionModel'");
|
||||
expect(workloadsSelectionStateSource).not.toContain('parseWorkloadsLinkSearch');
|
||||
expect(workloadsSelectionStateSource).not.toContain('getCanonicalWorkloadId');
|
||||
expect(workloadSelectionModelSource).toContain('parseWorkloadsLinkSearch(search)');
|
||||
expect(workloadSelectionModelSource).toContain('getCanonicalWorkloadId');
|
||||
expect(workloadSelectionModelSource).toContain('resolveWorkloadResourceSelection');
|
||||
@@ -951,7 +949,7 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(workloadsWorkloadTableSource).toContain('WorkloadPanel');
|
||||
expect(workloadsWorkloadTableSource).not.toContain('<TableHead');
|
||||
expect(workloadsWorkloadTableSource).not.toContain('NodeGroupHeader');
|
||||
expect(workloadsWorkloadTableSource).not.toContain('GuestDrawer');
|
||||
expect(workloadsWorkloadTableSource).not.toMatch(/from\s+['"]\.\/GuestDrawer['"]/);
|
||||
expect(workloadTableHeaderSource).toContain('TableHead');
|
||||
expect(workloadTableHeaderSource).toContain('col.sortKey as WorkloadSortKey');
|
||||
expect(workloadTableHeaderSource).not.toContain('NodeGroupHeader');
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCanonicalNodeScopedWorkloadId,
|
||||
buildProxmoxGuestNativeId,
|
||||
buildKubernetesWorkloadMetadataId,
|
||||
getCanonicalWorkloadIdForResource,
|
||||
getWorkloadMetadataIdForResource,
|
||||
getWorkloadMetadataId,
|
||||
getWorkloadMetadataIdCandidates,
|
||||
isDockerManagedAppContainer,
|
||||
@@ -171,23 +171,21 @@ describe('getWorkloadMetadataId empty-candidates fallback', () => {
|
||||
});
|
||||
|
||||
// Covers the non-finite vmid arm (`Number.isFinite(vmid) ? Number(vmid) : 0`)
|
||||
// in buildCanonicalNodeScopedWorkloadId. The happy-path specs only pass numeric
|
||||
// in buildProxmoxGuestNativeId. The happy-path specs only pass numeric
|
||||
// vmids, so the `: 0` coercion (which then short-circuits to null) is cold.
|
||||
describe('buildCanonicalNodeScopedWorkloadId non-finite vmid coercion', () => {
|
||||
describe('buildProxmoxGuestNativeId non-finite vmid coercion', () => {
|
||||
it('coerces a null vmid to 0 and returns null', () => {
|
||||
expect(
|
||||
buildCanonicalNodeScopedWorkloadId({ instance: 'homelab', node: 'pve1', vmid: null }),
|
||||
).toBeNull();
|
||||
expect(buildProxmoxGuestNativeId({ instance: 'homelab', node: 'pve1', vmid: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Covers the proxmox.nodeName fallback in getCanonicalWorkloadIdForResource.
|
||||
// Covers the proxmox.nodeName fallback in getWorkloadMetadataIdForResource.
|
||||
// The happy-path specs populate proxmox.node, so the `node || nodeName` fallback
|
||||
// to nodeName is never exercised.
|
||||
describe('getCanonicalWorkloadIdForResource proxmox nodeName fallback', () => {
|
||||
describe('getWorkloadMetadataIdForResource proxmox nodeName fallback', () => {
|
||||
it('uses proxmox.nodeName when proxmox.node is absent', () => {
|
||||
expect(
|
||||
getCanonicalWorkloadIdForResource({
|
||||
getWorkloadMetadataIdForResource({
|
||||
id: 'vm-fallback',
|
||||
type: 'vm',
|
||||
clusterId: 'Core Fabric',
|
||||
@@ -198,7 +196,7 @@ describe('getCanonicalWorkloadIdForResource proxmox nodeName fallback', () => {
|
||||
|
||||
it('prefers proxmox.node over proxmox.nodeName when both are present', () => {
|
||||
expect(
|
||||
getCanonicalWorkloadIdForResource({
|
||||
getWorkloadMetadataIdForResource({
|
||||
id: 'lxc-fallback',
|
||||
type: 'system-container',
|
||||
clusterId: 'Core Fabric',
|
||||
@@ -209,7 +207,7 @@ describe('getCanonicalWorkloadIdForResource proxmox nodeName fallback', () => {
|
||||
|
||||
it('falls back to the resource id when only nodeName is present but it is blank', () => {
|
||||
expect(
|
||||
getCanonicalWorkloadIdForResource({
|
||||
getWorkloadMetadataIdForResource({
|
||||
id: 'vm-orphan',
|
||||
type: 'vm',
|
||||
clusterId: 'Core Fabric',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildKubernetesWorkloadMetadataId,
|
||||
buildCanonicalNodeScopedWorkloadId,
|
||||
buildProxmoxGuestNativeId,
|
||||
canonicalizeWorkloadFilterType,
|
||||
getWorkloadCPUFraction,
|
||||
getWorkloadCPUPercent,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolveWorkloadTypeFromString,
|
||||
getWorkloadMetricsKind,
|
||||
getCanonicalWorkloadId,
|
||||
getCanonicalWorkloadIdForResource,
|
||||
getWorkloadMetadataIdForResource,
|
||||
getWorkloadMetadataId,
|
||||
getWorkloadMetadataIdCandidates,
|
||||
isDockerManagedAppContainer,
|
||||
@@ -177,12 +177,12 @@ describe('getWorkloadMetricsKind', () => {
|
||||
});
|
||||
|
||||
describe('getCanonicalWorkloadId', () => {
|
||||
it('returns composite id for vm with instance, node, vmid', () => {
|
||||
it('preserves the supplied VM identity when provider coordinates exist', () => {
|
||||
const guest = { id: 'orig', type: 'vm', instance: 'homelab', node: 'node1', vmid: 100 };
|
||||
expect(getCanonicalWorkloadId(guest)).toBe('homelab:node1:100');
|
||||
expect(getCanonicalWorkloadId(guest)).toBe('orig');
|
||||
});
|
||||
|
||||
it('returns composite id for system-container with instance, node, vmid', () => {
|
||||
it('preserves the supplied container identity when provider coordinates exist', () => {
|
||||
const guest = {
|
||||
id: 'orig',
|
||||
type: 'system-container',
|
||||
@@ -190,7 +190,7 @@ describe('getCanonicalWorkloadId', () => {
|
||||
node: 'node2',
|
||||
vmid: 200,
|
||||
};
|
||||
expect(getCanonicalWorkloadId(guest)).toBe('homelab:node2:200');
|
||||
expect(getCanonicalWorkloadId(guest)).toBe('orig');
|
||||
});
|
||||
|
||||
it('returns id for app-container workloads (no instance/node/vmid)', () => {
|
||||
@@ -233,10 +233,10 @@ describe('getCanonicalWorkloadId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCanonicalNodeScopedWorkloadId', () => {
|
||||
it('builds canonical ids from instance, node, and vmid', () => {
|
||||
describe('buildProxmoxGuestNativeId', () => {
|
||||
it('builds legacy metadata keys from instance, node, and vmid', () => {
|
||||
expect(
|
||||
buildCanonicalNodeScopedWorkloadId({
|
||||
buildProxmoxGuestNativeId({
|
||||
instance: 'Core Fabric',
|
||||
node: 'pve2',
|
||||
vmid: 112,
|
||||
@@ -244,15 +244,11 @@ describe('buildCanonicalNodeScopedWorkloadId', () => {
|
||||
).toBe('Core Fabric:pve2:112');
|
||||
});
|
||||
|
||||
it('returns null when any canonical part is missing', () => {
|
||||
it('returns null when any provider coordinate is missing', () => {
|
||||
expect(buildProxmoxGuestNativeId({ instance: 'Core Fabric', node: '', vmid: 112 })).toBeNull();
|
||||
expect(buildProxmoxGuestNativeId({ instance: '', node: 'pve2', vmid: 112 })).toBeNull();
|
||||
expect(
|
||||
buildCanonicalNodeScopedWorkloadId({ instance: 'Core Fabric', node: '', vmid: 112 }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildCanonicalNodeScopedWorkloadId({ instance: '', node: 'pve2', vmid: 112 }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildCanonicalNodeScopedWorkloadId({ instance: 'Core Fabric', node: 'pve2', vmid: 0 }),
|
||||
buildProxmoxGuestNativeId({ instance: 'Core Fabric', node: 'pve2', vmid: 0 }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -349,10 +345,10 @@ describe('getWorkloadMetadataId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCanonicalWorkloadIdForResource', () => {
|
||||
describe('getWorkloadMetadataIdForResource', () => {
|
||||
it('uses proxmox guest identity for vm and system-container resources', () => {
|
||||
expect(
|
||||
getCanonicalWorkloadIdForResource({
|
||||
getWorkloadMetadataIdForResource({
|
||||
id: 'system-container-45cf61f16a6e2c16',
|
||||
type: 'system-container',
|
||||
clusterId: 'Core Fabric',
|
||||
@@ -364,9 +360,9 @@ describe('getCanonicalWorkloadIdForResource', () => {
|
||||
).toBe('Core Fabric:pve2:112');
|
||||
});
|
||||
|
||||
it('falls back to the resource id when canonical guest identity is incomplete', () => {
|
||||
it('falls back to the resource id when provider metadata coordinates are incomplete', () => {
|
||||
expect(
|
||||
getCanonicalWorkloadIdForResource({
|
||||
getWorkloadMetadataIdForResource({
|
||||
id: 'vm-123',
|
||||
type: 'vm',
|
||||
clusterId: '',
|
||||
@@ -438,3 +434,57 @@ describe('normalizeWorkloadViewModeParam', () => {
|
||||
expect(normalizeWorkloadViewModeParam('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical workload identity across placement changes', () => {
|
||||
it.each(['vm', 'system-container'] as const)(
|
||||
'keeps the %s resource identity across node migration',
|
||||
(workloadType) => {
|
||||
const guest = {
|
||||
id: 'legacy-row-key',
|
||||
canonicalResourceId: 'stable-resource',
|
||||
workloadType,
|
||||
type: workloadType,
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 101,
|
||||
};
|
||||
expect(getCanonicalWorkloadId(guest)).toBe('stable-resource');
|
||||
expect(getCanonicalWorkloadId({ ...guest, node: 'node-2' })).toBe('stable-resource');
|
||||
expect(getCanonicalWorkloadId({ ...guest, canonicalResourceId: 'independent-clone' })).toBe(
|
||||
'independent-clone',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the supplied resource ID rather than deriving an identity from provider coordinates', () => {
|
||||
const guest = {
|
||||
id: 'stable-resource',
|
||||
type: 'vm',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 101,
|
||||
};
|
||||
expect(getCanonicalWorkloadId(guest)).toBe('stable-resource');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proxmox metadata compatibility boundary', () => {
|
||||
it('retains provider metadata keys while the row uses stable resource identity', () => {
|
||||
const guest = {
|
||||
id: 'stable-resource',
|
||||
canonicalResourceId: 'stable-resource',
|
||||
name: 'guest-101',
|
||||
type: 'vm',
|
||||
instance: 'cluster-a',
|
||||
node: 'node-1',
|
||||
vmid: 101,
|
||||
};
|
||||
expect(getWorkloadMetadataId(guest)).toBe('cluster-a:node-1:101');
|
||||
expect(getWorkloadMetadataIdCandidates(guest)).toEqual([
|
||||
'cluster-a:node-1:101',
|
||||
'stable-resource',
|
||||
]);
|
||||
expect(getWorkloadMetadataId({ ...guest, node: 'node-2' })).toBe('cluster-a:node-2:101');
|
||||
expect(getCanonicalWorkloadId({ ...guest, node: 'node-2' })).toBe('stable-resource');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ResourceType } from '@/api/charts';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads';
|
||||
import {
|
||||
buildProxmoxGuestNativeId,
|
||||
getCanonicalWorkloadId,
|
||||
resolveWorkloadType,
|
||||
} from '@/utils/workloads';
|
||||
|
||||
export interface WorkloadMetricHistoryTarget {
|
||||
resourceType: ResourceType;
|
||||
@@ -16,10 +20,17 @@ export const getWorkloadMetricHistoryTarget = (
|
||||
return { resourceType: explicitResourceType, resourceId: explicitResourceId };
|
||||
}
|
||||
|
||||
const resourceId = getCanonicalWorkloadId(guest).trim();
|
||||
const workloadType = resolveWorkloadType(guest);
|
||||
// Legacy guest inputs have native coordinates but no backend metrics target.
|
||||
// The History API reads native metric keys, independently of row identity.
|
||||
const nativeGuestId =
|
||||
workloadType === 'vm' || workloadType === 'system-container'
|
||||
? buildProxmoxGuestNativeId(guest)
|
||||
: null;
|
||||
const resourceId = (nativeGuestId || getCanonicalWorkloadId(guest)).trim();
|
||||
if (!resourceId) return null;
|
||||
|
||||
switch (resolveWorkloadType(guest)) {
|
||||
switch (workloadType) {
|
||||
case 'vm':
|
||||
return { resourceType: 'vm', resourceId };
|
||||
case 'system-container':
|
||||
|
||||
@@ -148,22 +148,9 @@ export const isDockerManagedAppContainer = (
|
||||
return runtime === 'docker' || runtime === 'podman';
|
||||
};
|
||||
|
||||
export const getCanonicalWorkloadId = (
|
||||
guest: Pick<WorkloadGuest, 'id' | 'workloadType' | 'type' | 'instance' | 'node' | 'vmid'>,
|
||||
): string => {
|
||||
const type = resolveWorkloadType(guest);
|
||||
if (type === 'vm' || type === 'system-container') {
|
||||
const canonicalId = buildCanonicalNodeScopedWorkloadId({
|
||||
instance: guest.instance,
|
||||
node: guest.node,
|
||||
vmid: guest.vmid,
|
||||
});
|
||||
if (canonicalId) {
|
||||
return canonicalId;
|
||||
}
|
||||
}
|
||||
return guest.id;
|
||||
};
|
||||
export const getCanonicalWorkloadId = <T extends Pick<WorkloadGuest, 'id' | 'canonicalResourceId'>>(
|
||||
guest: T,
|
||||
): string => guest.canonicalResourceId?.trim() || guest.id;
|
||||
|
||||
export const buildAppContainerMetadataId = ({
|
||||
dockerHostId,
|
||||
@@ -222,6 +209,12 @@ export const getWorkloadMetadataIdCandidates = (
|
||||
const canonicalId = getCanonicalWorkloadId(guest);
|
||||
const candidates: string[] = [];
|
||||
|
||||
const workloadType = resolveWorkloadType(guest);
|
||||
if (workloadType === 'vm' || workloadType === 'system-container') {
|
||||
const metadataId = buildProxmoxGuestNativeId(guest);
|
||||
if (metadataId) candidates.push(metadataId);
|
||||
}
|
||||
|
||||
if (isDockerManagedAppContainer(guest)) {
|
||||
const appContainerId = buildAppContainerMetadataId(guest);
|
||||
if (appContainerId) candidates.push(appContainerId);
|
||||
@@ -252,21 +245,23 @@ export const getWorkloadMetadataId = (
|
||||
>,
|
||||
): string => getWorkloadMetadataIdCandidates(guest)[0] || getCanonicalWorkloadId(guest);
|
||||
|
||||
type NodeScopedWorkloadIdentity = {
|
||||
type ProxmoxGuestNativeIdentity = {
|
||||
instance?: string | null;
|
||||
node?: string | null;
|
||||
vmid?: number | null;
|
||||
};
|
||||
|
||||
const normalizeNodeScopedWorkloadKeyPart = (value?: string | null): string => (value || '').trim();
|
||||
const normalizeProxmoxNativeKeyPart = (value?: string | null): string => (value || '').trim();
|
||||
|
||||
export const buildCanonicalNodeScopedWorkloadId = ({
|
||||
// Guest metadata and legacy History requests use this native provider key.
|
||||
// Backend metrics targets take precedence. This key is never row identity.
|
||||
export const buildProxmoxGuestNativeId = ({
|
||||
instance,
|
||||
node,
|
||||
vmid,
|
||||
}: NodeScopedWorkloadIdentity): string | null => {
|
||||
const normalizedInstance = normalizeNodeScopedWorkloadKeyPart(instance);
|
||||
const normalizedNode = normalizeNodeScopedWorkloadKeyPart(node);
|
||||
}: ProxmoxGuestNativeIdentity): string | null => {
|
||||
const normalizedInstance = normalizeProxmoxNativeKeyPart(instance);
|
||||
const normalizedNode = normalizeProxmoxNativeKeyPart(node);
|
||||
const normalizedVmid = Number.isFinite(vmid) ? Number(vmid) : 0;
|
||||
if (!normalizedInstance || !normalizedNode || normalizedVmid <= 0) {
|
||||
return null;
|
||||
@@ -274,18 +269,18 @@ export const buildCanonicalNodeScopedWorkloadId = ({
|
||||
return `${normalizedInstance}:${normalizedNode}:${normalizedVmid}`;
|
||||
};
|
||||
|
||||
export const getCanonicalWorkloadIdForResource = (
|
||||
export const getWorkloadMetadataIdForResource = (
|
||||
resource: Pick<Resource, 'id' | 'type' | 'clusterId' | 'proxmox'>,
|
||||
): string => {
|
||||
const workloadType = resolveWorkloadTypeFromString(resource.type);
|
||||
if (workloadType === 'vm' || workloadType === 'system-container') {
|
||||
const canonicalId = buildCanonicalNodeScopedWorkloadId({
|
||||
const metadataId = buildProxmoxGuestNativeId({
|
||||
instance: resource.proxmox?.instance || resource.clusterId,
|
||||
node: resource.proxmox?.node || resource.proxmox?.nodeName,
|
||||
vmid: resource.proxmox?.vmid,
|
||||
});
|
||||
if (canonicalId) {
|
||||
return canonicalId;
|
||||
if (metadataId) {
|
||||
return metadataId;
|
||||
}
|
||||
}
|
||||
return resource.id;
|
||||
|
||||
@@ -370,8 +370,10 @@ func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node stri
|
||||
// Persist while holding the store lock. save() assumes locked access.
|
||||
if err := s.save(); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save guest metadata after migration")
|
||||
s.metadata[legacyID] = legacyMeta
|
||||
delete(s.metadata, guestID)
|
||||
// The admitted guest now uses the new node coordinates. Keep its
|
||||
// operator fields available in memory while atomic persistence
|
||||
// retains the last good disk snapshot. save records the failure;
|
||||
// ordinary identity updates retry through the existing writer.
|
||||
}
|
||||
|
||||
return cloneGuestMetadata(migrated)
|
||||
|
||||
@@ -13,6 +13,57 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGuestMetadataMigrationPersistenceFailureRetainsAcceptedRecord(t *testing.T) {
|
||||
for _, failure := range []string{"write", "rename"} {
|
||||
t.Run(failure, func(t *testing.T) {
|
||||
fs := &guestWriterFaultFS{FileSystem: defaultFileSystem{}}
|
||||
path := t.TempDir()
|
||||
store := NewGuestMetadataStore(path, fs)
|
||||
t.Cleanup(func() { _ = store.Close(time.Second) })
|
||||
oldID, newID := "pve:old-node:100", "pve:new-node:100"
|
||||
operator := &GuestMetadata{
|
||||
CustomURL: "https://guest.example", Tags: []string{"production"},
|
||||
Notes: []string{"operator note"}, LastKnownName: "guest-100", LastKnownType: "qemu",
|
||||
}
|
||||
if err := store.Set(oldID, operator); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writes := fs.writes.Load()
|
||||
fs.failWrite.Store(failure == "write")
|
||||
fs.failRename.Store(failure == "rename")
|
||||
store.GetWithLegacyMigration(newID, "pve", "new-node", 100)
|
||||
migrated := store.Get(newID)
|
||||
if migrated == nil || migrated.ID != newID || migrated.CustomURL != operator.CustomURL ||
|
||||
!reflect.DeepEqual(migrated.Tags, operator.Tags) || !reflect.DeepEqual(migrated.Notes, operator.Notes) ||
|
||||
store.Get(oldID) != nil {
|
||||
t.Fatal("persistence failure discarded the accepted in-memory metadata migration")
|
||||
}
|
||||
if !store.WaitForPendingWrites(time.Second) || fs.writes.Load() != writes+1 {
|
||||
t.Fatal("failed synchronous migration started a retry loop")
|
||||
}
|
||||
lastGood := NewGuestMetadataStore(path, nil)
|
||||
if lastGood.Get(oldID) == nil || lastGood.Get(newID) != nil {
|
||||
t.Fatal("failed migration damaged the last successful persisted snapshot")
|
||||
}
|
||||
_ = lastGood.Close(time.Second)
|
||||
fs.failWrite.Store(false)
|
||||
fs.failRename.Store(false)
|
||||
store.RememberIdentity(newID, operator.LastKnownName, operator.LastKnownType)
|
||||
if err := store.Close(time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded := NewGuestMetadataStore(path, nil)
|
||||
t.Cleanup(func() { _ = reloaded.Close(time.Second) })
|
||||
got := reloaded.Get(newID)
|
||||
if got == nil || got.CustomURL != operator.CustomURL ||
|
||||
!reflect.DeepEqual(got.Tags, operator.Tags) || !reflect.DeepEqual(got.Notes, operator.Notes) ||
|
||||
reloaded.Get(oldID) != nil {
|
||||
t.Fatal("ordinary identity refresh did not persist the retained migration")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func guestWriterTestStore(t *testing.T) (*GuestMetadataStore, *controlledGuestWriterFS) {
|
||||
t.Helper()
|
||||
fs := &controlledGuestWriterFS{FileSystem: defaultFileSystem{}, entered: make(chan struct{}), release: make(chan struct{})}
|
||||
|
||||
@@ -3,6 +3,7 @@ package monitoring
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -15,6 +16,129 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
||||
)
|
||||
|
||||
type guestPublicationMetadataFS struct {
|
||||
blocked atomic.Bool
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (fs *guestPublicationMetadataFS) ReadFile(path string) ([]byte, error) {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
func (fs *guestPublicationMetadataFS) WriteFile(path string, data []byte, mode os.FileMode) error {
|
||||
if fs.blocked.Load() {
|
||||
fs.once.Do(func() { close(fs.entered) })
|
||||
<-fs.release
|
||||
}
|
||||
return os.WriteFile(path, data, mode)
|
||||
}
|
||||
func (fs *guestPublicationMetadataFS) Rename(oldPath, newPath string) error {
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
func (fs *guestPublicationMetadataFS) Remove(path string) error { return os.Remove(path) }
|
||||
func (fs *guestPublicationMetadataFS) Stat(path string) (os.FileInfo, error) {
|
||||
return os.Stat(path)
|
||||
}
|
||||
func (fs *guestPublicationMetadataFS) MkdirAll(path string, mode os.FileMode) error {
|
||||
return os.MkdirAll(path, mode)
|
||||
}
|
||||
|
||||
func TestGuestPollPublicationMigratesMetadataBeforeNewCoordinates(t *testing.T) {
|
||||
client := &guestPollOwnershipClient{}
|
||||
m := guestPollOwnershipMonitor(t, client)
|
||||
fs := &guestPublicationMetadataFS{entered: make(chan struct{}), release: make(chan struct{})}
|
||||
store := config.NewGuestMetadataStore(t.TempDir(), fs)
|
||||
m.guestMetadataStore = store
|
||||
for _, id := range []string{"site-a:old-node:105", "site-a:old-node:106"} {
|
||||
if err := store.Set(id, &config.GuestMetadata{
|
||||
CustomURL: "https://guest.example", Tags: []string{"production"}, Notes: []string{"operator note"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m.state.UpdateGuestsForInstance("site-a",
|
||||
[]models.VM{{ID: "site-a:old-node:105", Instance: "site-a", Node: "old-node", VMID: 105}},
|
||||
[]models.Container{{ID: "site-a:old-node:106", Instance: "site-a", Node: "old-node", VMID: 106}})
|
||||
ctx, owner := m.beginPVEGuestPoll(context.Background(), "site-a", client)
|
||||
if owner == nil {
|
||||
t.Fatal("registered cycle not admitted")
|
||||
}
|
||||
defer m.finishPVEGuestPoll("site-a", owner)
|
||||
// Exercise the former collector effect as well as the publication owner.
|
||||
// Both versions reach the same persistence barrier, so the observation
|
||||
// proves ordering rather than merely requiring a new helper to be called.
|
||||
stagePVEGuestEffect(ctx, func() {
|
||||
store.GetWithLegacyMigration("site-a:new-node:105", "site-a", "new-node", 105)
|
||||
})
|
||||
fs.blocked.Store(true)
|
||||
done := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(fs.release) }) }
|
||||
t.Cleanup(func() {
|
||||
unblock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("publication did not exit")
|
||||
}
|
||||
if err := store.Close(time.Second); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
m.publishPVEGuests(ctx, "site-a",
|
||||
[]models.VM{{ID: "site-a:new-node:105", Instance: "site-a", Node: "new-node", VMID: 105}},
|
||||
[]models.Container{{ID: "site-a:new-node:106", Instance: "site-a", Node: "new-node", VMID: 106}})
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-fs.entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("metadata persistence did not start")
|
||||
}
|
||||
snapshot := m.state.GetSnapshot()
|
||||
if len(snapshot.VMs) != 1 || snapshot.VMs[0].Node != "old-node" ||
|
||||
len(snapshot.Containers) != 1 || snapshot.Containers[0].Node != "old-node" {
|
||||
t.Error("new guest coordinates became visible before metadata migration completed")
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("publication did not complete")
|
||||
}
|
||||
for _, id := range []string{"site-a:new-node:105", "site-a:new-node:106"} {
|
||||
meta := store.Get(id)
|
||||
if meta == nil || meta.CustomURL != "https://guest.example" ||
|
||||
!reflect.DeepEqual(meta.Tags, []string{"production"}) || !reflect.DeepEqual(meta.Notes, []string{"operator note"}) {
|
||||
t.Errorf("published guest lost operator metadata: %s %+v", id, meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestPollRejectedPublicationDoesNotMigrateMetadata(t *testing.T) {
|
||||
client := &guestPollOwnershipClient{}
|
||||
m := guestPollOwnershipMonitor(t, client)
|
||||
store := config.NewGuestMetadataStore(t.TempDir(), nil)
|
||||
m.guestMetadataStore = store
|
||||
if err := store.Set("site-a:old-node:105", &config.GuestMetadata{CustomURL: "https://guest.example"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close(time.Second) })
|
||||
ctx, owner := m.beginPVEGuestPoll(context.Background(), "site-a", client)
|
||||
if owner == nil {
|
||||
t.Fatal("registered cycle not admitted")
|
||||
}
|
||||
defer m.finishPVEGuestPoll("site-a", owner)
|
||||
m.replacePVEClient("site-a", &guestPollOwnershipClient{})
|
||||
m.publishPVEGuests(ctx, "site-a",
|
||||
[]models.VM{{ID: "site-a:new-node:105", Instance: "site-a", Node: "new-node", VMID: 105}}, nil)
|
||||
if store.Get("site-a:old-node:105") == nil || store.Get("site-a:new-node:105") != nil {
|
||||
t.Fatal("replaced poll owner migrated metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore cancellation deliberately: a late inventory reply cannot prove that
|
||||
// its connection/runtime still owns publication. No native guest is involved.
|
||||
type guestPollOwnershipClient struct {
|
||||
|
||||
@@ -80,6 +80,7 @@ func stagePVEGuestEffect(ctx context.Context, effect func()) {
|
||||
func (m *Monitor) publishPVEGuests(ctx context.Context, instance string, vms []models.VM, containers []models.Container) {
|
||||
owner, _ := ctx.Value(pveGuestPollContextKey{}).(*pveGuestPoll)
|
||||
if owner == nil {
|
||||
m.migratePVEGuestMetadata(instance, vms, containers)
|
||||
m.state.UpdateGuestsForInstance(instance, vms, containers)
|
||||
return
|
||||
}
|
||||
@@ -99,6 +100,9 @@ func (m *Monitor) publishPVEGuests(ctx context.Context, instance string, vms []m
|
||||
if !current {
|
||||
return
|
||||
}
|
||||
// Readers can observe the new node as soon as state is published. Move
|
||||
// operator metadata first, under the same accepted connection ownership.
|
||||
m.migratePVEGuestMetadata(instance, vms, containers)
|
||||
m.state.UpdateGuestsForInstance(instance, vms, containers)
|
||||
owner.mu.Lock()
|
||||
effects := owner.effects
|
||||
@@ -109,6 +113,22 @@ func (m *Monitor) publishPVEGuests(ctx context.Context, instance string, vms []m
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) migratePVEGuestMetadata(instance string, vms []models.VM, containers []models.Container) {
|
||||
if m.guestMetadataStore == nil {
|
||||
return
|
||||
}
|
||||
for _, vm := range vms {
|
||||
if vm.ID != "" && vm.Node != "" && vm.VMID > 0 {
|
||||
m.guestMetadataStore.GetWithLegacyMigration(vm.ID, instance, vm.Node, vm.VMID)
|
||||
}
|
||||
}
|
||||
for _, container := range containers {
|
||||
if container.ID != "" && container.Node != "" && container.VMID > 0 {
|
||||
m.guestMetadataStore.GetWithLegacyMigration(container.ID, instance, container.Node, container.VMID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called with m.mu and the publication writer held. Replacing a transport is
|
||||
// not a guest reboot: accepted cache ages and OS-command safety stay intact.
|
||||
func (m *Monitor) cancelPVEGuestPollLocked(instance string) {
|
||||
|
||||
@@ -297,12 +297,7 @@ func (m *Monitor) collectContainersWithNodes(ctx context.Context, instanceName s
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger guest metadata migration if old format exists
|
||||
stagePVEGuestEffect(ctx, func() {
|
||||
if m.guestMetadataStore != nil {
|
||||
m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, n.Node, int(container.VMID))
|
||||
}
|
||||
|
||||
m.recordGuestSnapshot(instanceName, modelContainer.Type, n.Node, int(container.VMID), GuestMemorySnapshot{
|
||||
Name: modelContainer.Name,
|
||||
Status: modelContainer.Status,
|
||||
|
||||
@@ -385,12 +385,7 @@ func (m *Monitor) handleClusterVMResource(
|
||||
return models.VM{}, false
|
||||
}
|
||||
|
||||
// Trigger guest metadata migration if old format exists
|
||||
stagePVEGuestEffect(ctx, func() {
|
||||
if m.guestMetadataStore != nil {
|
||||
m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, res.Node, res.VMID)
|
||||
}
|
||||
|
||||
m.recordGuestSnapshot(instanceName, vm.Type, res.Node, res.VMID, GuestMemorySnapshot{
|
||||
Name: vm.Name,
|
||||
Status: vm.Status,
|
||||
@@ -422,12 +417,7 @@ func (m *Monitor) handleClusterContainerResource(
|
||||
return models.Container{}, false
|
||||
}
|
||||
|
||||
// Trigger guest metadata migration if old format exists
|
||||
stagePVEGuestEffect(ctx, func() {
|
||||
if m.guestMetadataStore != nil {
|
||||
m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, res.Node, res.VMID)
|
||||
}
|
||||
|
||||
m.recordGuestSnapshot(instanceName, container.Type, res.Node, res.VMID, GuestMemorySnapshot{
|
||||
Name: container.Name,
|
||||
Status: container.Status,
|
||||
|
||||
@@ -23,6 +23,7 @@ import sys
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
import format_staged_frontend
|
||||
from repo_file_io import blob_ids, merge_parents, merged_in_paths, run_git
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -40,17 +41,6 @@ FRONTEND_ENTRY_FILES = {"frontend-modern/index.html"}
|
||||
FRONTEND_SOURCE_SUFFIXES = {".css", ".scss", ".ts", ".tsx"}
|
||||
|
||||
|
||||
def run_git(args: Sequence[str], *, repo_root: Path = REPO_ROOT) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def blocked(headline: str, details: Iterable[str] = (), advice: Iterable[str] = ()) -> int:
|
||||
print(f"BLOCKED: {headline}", file=sys.stderr)
|
||||
for detail in details:
|
||||
@@ -321,72 +311,6 @@ def validate_receipt(
|
||||
return errors
|
||||
|
||||
|
||||
def blob_ids(
|
||||
revision: str | None,
|
||||
paths: Sequence[str],
|
||||
*,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
) -> dict[str, str]:
|
||||
"""Object ids under the given paths in a commit, or in the index when revision is None.
|
||||
|
||||
A path that does not exist there is simply absent from the result.
|
||||
"""
|
||||
ids: dict[str, str] = {}
|
||||
for start in range(0, len(paths), 200):
|
||||
chunk = list(paths[start : start + 200])
|
||||
if revision is None:
|
||||
command = ["ls-files", "--stage", "-z", "--", *chunk]
|
||||
else:
|
||||
command = ["ls-tree", "-r", "-z", revision, "--", *chunk]
|
||||
output = run_git(["--literal-pathspecs", *command], repo_root=repo_root)
|
||||
for entry in output.split("\0"):
|
||||
meta, separator, path = entry.partition("\t")
|
||||
if not separator:
|
||||
continue
|
||||
fields = meta.split()
|
||||
ids[path] = fields[1] if revision is None else fields[2]
|
||||
return ids
|
||||
|
||||
|
||||
def merge_parents(*, commit: str | None, repo_root: Path = REPO_ROOT) -> list[str]:
|
||||
"""Parents beyond the first: the merge being concluded, or a merge commit's own."""
|
||||
if commit:
|
||||
return run_git(
|
||||
["rev-list", "--parents", "-n", "1", commit], repo_root=repo_root
|
||||
).split()[2:]
|
||||
merge_head = Path(run_git(["rev-parse", "--git-path", "MERGE_HEAD"], repo_root=repo_root))
|
||||
if not merge_head.is_absolute():
|
||||
merge_head = repo_root / merge_head
|
||||
if not merge_head.exists():
|
||||
return []
|
||||
return merge_head.read_text(encoding="utf-8").split()
|
||||
|
||||
|
||||
def merged_in_paths(
|
||||
paths: Sequence[str],
|
||||
*,
|
||||
commit: str | None,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
) -> set[str]:
|
||||
"""Paths whose new state is exactly what a merged parent already carries.
|
||||
|
||||
Concluding a merge stages everything the other parent brought, so a plain
|
||||
staged-file reading mistakes the other side's frontend work and receipts
|
||||
for this commit's own. Demanding a receipt for them asked the author to
|
||||
vouch for files they never ran, which made a merge unable to repair a
|
||||
branch that fell behind main. Content a parent already holds was verified
|
||||
in that parent's history and is not this commit's change.
|
||||
"""
|
||||
parents = merge_parents(commit=commit, repo_root=repo_root)
|
||||
if not parents or not paths:
|
||||
return set()
|
||||
new = blob_ids(commit, paths, repo_root=repo_root)
|
||||
inherited: set[str] = set()
|
||||
for parent in parents:
|
||||
theirs = blob_ids(parent, paths, repo_root=repo_root)
|
||||
inherited.update(path for path in paths if new.get(path) == theirs.get(path))
|
||||
return inherited
|
||||
|
||||
|
||||
def parse_receipt_changes(name_status: str) -> dict[str, str]:
|
||||
changes: dict[str, str] = {}
|
||||
|
||||
@@ -598,7 +598,7 @@ class IntegrationRangeTest(GuardRepoTestCase):
|
||||
step = workflow.split(" - name: Validate final frontend browser evidence\n", 1)[1]
|
||||
step = step.split("\n - name:", 1)[0]
|
||||
command = textwrap.dedent(step.split(" run: |\n", 1)[1])
|
||||
for name in ("browser_verification_guard.py", "format_staged_frontend.py"):
|
||||
for name in ("browser_verification_guard.py", "format_staged_frontend.py", "repo_file_io.py"):
|
||||
self.write("scripts/release_control/" + name,
|
||||
(REPO_ROOT / "scripts/release_control" / name).read_text())
|
||||
return subprocess.run(
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Dict, List, Sequence, Set
|
||||
|
||||
import browser_verification_guard
|
||||
from control_plane import DEFAULT_CONTROL_PLANE
|
||||
from repo_file_io import load_repo_json
|
||||
from repo_file_io import git_env, load_repo_json, merged_in_paths
|
||||
from subsystem_contracts import load_contract_index, referenced_contracts_for_path
|
||||
|
||||
|
||||
@@ -165,8 +165,11 @@ def git_staged_files() -> List[str]:
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=git_env(REPO_ROOT),
|
||||
)
|
||||
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
paths = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
inherited = merged_in_paths(paths, commit=None, repo_root=REPO_ROOT)
|
||||
return [path for path in paths if path not in inherited]
|
||||
|
||||
|
||||
def stdin_files(stdin: Sequence[str]) -> List[str]:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
import canonical_completion_guard
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -158,6 +159,45 @@ def first_matching_policy_id(rule: dict, rel: str) -> str:
|
||||
|
||||
|
||||
class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
def test_staged_scope_keeps_resolutions_and_modes_but_not_inherited_merge_work(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
env = os.environ.copy()
|
||||
strip_local_git_env(env)
|
||||
env["GIT_CONFIG_GLOBAL"] = os.devnull
|
||||
env["GIT_CONFIG_SYSTEM"] = os.devnull
|
||||
|
||||
def git(*args: str) -> None:
|
||||
subprocess.run(
|
||||
["git", "-c", "user.name=Proof Fixture", "-c", "user.email=proof@example.invalid", *args],
|
||||
cwd=root, env=env, check=True, capture_output=True, text=True,
|
||||
)
|
||||
|
||||
git("init", "-b", "main")
|
||||
for name in ("resolved.go", "mode.go", "removed.go", "old name.go"):
|
||||
(root / name).write_text("base\n", encoding="utf-8")
|
||||
git("add", "resolved.go", "mode.go", "removed.go", "old name.go")
|
||||
git("commit", "-m", "Base fixture")
|
||||
git("checkout", "-b", "reviewed")
|
||||
(root / "resolved.go").write_text("reviewed\n", encoding="utf-8")
|
||||
(root / "inherited.go").write_text("reviewed\n", encoding="utf-8")
|
||||
git("rm", "removed.go")
|
||||
git("mv", "old name.go", "new name.go")
|
||||
git("add", "resolved.go", "inherited.go")
|
||||
git("commit", "-m", "Reviewed fixture")
|
||||
git("checkout", "main")
|
||||
git("merge", "--no-commit", "--no-ff", "reviewed")
|
||||
(root / "resolved.go").write_text("combined resolution\n", encoding="utf-8")
|
||||
git("add", "resolved.go")
|
||||
git("update-index", "--chmod=+x", "mode.go")
|
||||
with patch("canonical_completion_guard.REPO_ROOT", root):
|
||||
self.assertEqual(canonical_completion_guard.git_staged_files(), ["mode.go", "resolved.go"])
|
||||
git("reset", "--hard", "HEAD")
|
||||
(root / "resolved.go").write_text("reviewed\n", encoding="utf-8")
|
||||
git("add", "resolved.go")
|
||||
with patch("canonical_completion_guard.REPO_ROOT", root):
|
||||
self.assertEqual(canonical_completion_guard.git_staged_files(), ["resolved.go"])
|
||||
|
||||
def test_registry_exists_and_contains_required_subsystems(self):
|
||||
self.assertTrue(SUBSYSTEM_REGISTRY.exists())
|
||||
rules = load_subsystem_rules()
|
||||
@@ -249,39 +289,13 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(monitoring["verification"]["exact_files"], [])
|
||||
policy_ids = [policy["id"] for policy in monitoring["verification"]["path_policies"]]
|
||||
self.assertIn("monitoring-runtime", policy_ids)
|
||||
requirements = monitoring["verification_requirements"]
|
||||
self.assertEqual(
|
||||
policy_ids,
|
||||
[
|
||||
"maintenance-verification-runtime",
|
||||
"availability-certificate-runtime",
|
||||
"discovery-provider-runtime",
|
||||
"host-agent-ingest-runtime",
|
||||
"truenas-runtime",
|
||||
"metrics-hot-path",
|
||||
"metrics-history-runtime",
|
||||
"storage-risk-runtime",
|
||||
"memory-source-runtime",
|
||||
"docker-collect-runtime",
|
||||
"docker-swarm-runtime",
|
||||
"kubernetes-native-agent-runtime",
|
||||
"runtime-report-model",
|
||||
"proxmox-guest-counter-runtime",
|
||||
"proxmox-zfs-runtime",
|
||||
"proxmox-cluster-client-runtime",
|
||||
"proxmox-ceph-runtime",
|
||||
"proxmox-backup-identity-monitoring",
|
||||
"container-entrypoint-runtime",
|
||||
"mock-runtime-fixtures",
|
||||
"pbs-client-runtime",
|
||||
"pbs-protection-evidence-runtime",
|
||||
"diskinventory-collection-trust",
|
||||
"agent-fleet-diagnostics-runtime",
|
||||
"monitoring-runtime",
|
||||
"filesystem-observations",
|
||||
{key: value for key, value in requirement.items() if key != "exact_files"}
|
||||
for requirement in requirements
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
monitoring["verification_requirements"],
|
||||
[
|
||||
{
|
||||
"id": "monitoring-runtime",
|
||||
@@ -289,44 +303,21 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
"touched_runtime_files": ["internal/monitoring/monitor.go"],
|
||||
"allow_same_subsystem_tests": False,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/config/docker_metadata_test.go",
|
||||
"internal/config/guest_metadata_test.go",
|
||||
"internal/monitoring/availability_poller_test.go",
|
||||
"internal/monitoring/availability_probe_agent_test.go",
|
||||
"internal/monitoring/availability_udp_test.go",
|
||||
"internal/monitoring/canonical_guardrails_test.go",
|
||||
"internal/monitoring/ceph_test.go",
|
||||
"internal/monitoring/deadman_test.go",
|
||||
"internal/monitoring/issue1485_unraid_lifecycle_test.go",
|
||||
"internal/monitoring/issue1595_collection_trust_test.go",
|
||||
"internal/monitoring/issue1613_contract_test.go",
|
||||
"internal/monitoring/issue1638_dns_cache_test.go",
|
||||
"internal/monitoring/issue1913_host_continuity_test.go",
|
||||
"internal/monitoring/metric_window_provider_test.go",
|
||||
"internal/monitoring/monitor_additional_test.go",
|
||||
"internal/monitoring/monitor_alert_handling_test.go",
|
||||
"internal/monitoring/monitor_alert_intent_test.go",
|
||||
"internal/monitoring/monitor_alert_override_migration_test.go",
|
||||
"internal/monitoring/monitor_backups_readstate_test.go",
|
||||
"internal/monitoring/monitor_docker_test.go",
|
||||
"internal/monitoring/monitor_host_agent_removal_lifecycle_test.go",
|
||||
"internal/monitoring/monitor_host_agents_test.go",
|
||||
"internal/monitoring/monitor_mock_alerts_test.go",
|
||||
"internal/monitoring/monitor_notification_startup_test.go",
|
||||
"internal/monitoring/monitor_pve_cluster_refresh_test.go",
|
||||
"internal/monitoring/monitor_pve_guest_lxc_test.go",
|
||||
"internal/monitoring/physical_disk_roundtrip_test.go",
|
||||
"internal/monitoring/proxmox_large_cluster_poll_budget_test.go",
|
||||
"internal/monitoring/pve_protection_observation_test.go",
|
||||
"internal/monitoring/ratetracker_test.go",
|
||||
"internal/monitoring/reload_test.go",
|
||||
"internal/monitoring/truenas_poller_test.go",
|
||||
"internal/unifiedresources/code_standards_test.go",
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
proofs = [
|
||||
"internal/config/guest_metadata_writer_test.go",
|
||||
"internal/monitoring/guest_poll_ownership_test.go",
|
||||
]
|
||||
self.assertEqual(
|
||||
staged_verification_files_for_requirement(
|
||||
monitoring,
|
||||
requirements[0],
|
||||
proofs + ["internal/api/action_runner_credentials_test.go"],
|
||||
),
|
||||
proofs,
|
||||
)
|
||||
|
||||
def test_docker_collect_runtime_change_requires_monitoring_contract(self):
|
||||
required = infer_impacted_subsystems(["internal/dockeragent/collect.go"])
|
||||
|
||||
@@ -9,7 +9,7 @@ from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -308,3 +308,78 @@ def load_repo_json(
|
||||
return json.loads(
|
||||
read_repo_text(path, staged=staged, strict_staged=strict_staged, revision=revision)
|
||||
)
|
||||
|
||||
|
||||
def run_git(args: Sequence[str], *, repo_root: Path = REPO_ROOT) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=repo_root, check=True, capture_output=True,
|
||||
text=True, env=git_env(repo_root),
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _git_tree_entries(
|
||||
revision: str | None,
|
||||
paths: Sequence[str],
|
||||
*,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
) -> dict[str, tuple[str, str]]:
|
||||
"""Mode and object ID for each committed or indexed path.
|
||||
|
||||
A path that does not exist there is simply absent from the result.
|
||||
"""
|
||||
ids: dict[str, tuple[str, str]] = {}
|
||||
for start in range(0, len(paths), 200):
|
||||
chunk = list(paths[start : start + 200])
|
||||
if revision is None:
|
||||
command = ["ls-files", "--stage", "-z", "--", *chunk]
|
||||
else:
|
||||
command = ["ls-tree", "-r", "-z", revision, "--", *chunk]
|
||||
output = run_git(["--literal-pathspecs", *command], repo_root=repo_root)
|
||||
for entry in output.split("\0"):
|
||||
meta, separator, path = entry.partition("\t")
|
||||
if not separator:
|
||||
continue
|
||||
fields = meta.split()
|
||||
ids[path] = (fields[0], fields[1] if revision is None else fields[2])
|
||||
return ids
|
||||
|
||||
|
||||
def blob_ids(revision: str | None, paths: Sequence[str], *, repo_root: Path = REPO_ROOT) -> dict[str, str]:
|
||||
return {path: entry[1] for path, entry in _git_tree_entries(revision, paths, repo_root=repo_root).items()}
|
||||
|
||||
|
||||
def merge_parents(*, commit: str | None, repo_root: Path = REPO_ROOT) -> list[str]:
|
||||
"""Parents beyond the first: the merge being concluded, or a merge commit's own."""
|
||||
if commit:
|
||||
return run_git(
|
||||
["rev-list", "--parents", "-n", "1", commit], repo_root=repo_root
|
||||
).split()[2:]
|
||||
merge_head = Path(run_git(["rev-parse", "--git-path", "MERGE_HEAD"], repo_root=repo_root))
|
||||
if not merge_head.is_absolute():
|
||||
merge_head = repo_root / merge_head
|
||||
if not merge_head.exists():
|
||||
return []
|
||||
return merge_head.read_text(encoding="utf-8").split()
|
||||
|
||||
|
||||
def merged_in_paths(
|
||||
paths: Sequence[str],
|
||||
*,
|
||||
commit: str | None,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
) -> set[str]:
|
||||
"""Paths with exactly the same indexed or committed state as a merged parent.
|
||||
|
||||
Both object ID and mode must match. New resolutions, edits and mode changes
|
||||
remain changes of the current commit. Inherited deletions also match.
|
||||
"""
|
||||
parents = merge_parents(commit=commit, repo_root=repo_root)
|
||||
if not parents or not paths:
|
||||
return set()
|
||||
new = _git_tree_entries(commit, paths, repo_root=repo_root)
|
||||
inherited: set[str] = set()
|
||||
for parent in parents:
|
||||
theirs = _git_tree_entries(parent, paths, repo_root=repo_root)
|
||||
inherited.update(path for path in paths if new.get(path) == theirs.get(path))
|
||||
return inherited
|
||||
|
||||
@@ -272,7 +272,7 @@ class DevPrepushBrowserTest(DevPrepushFixture):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
for name in ("browser_verification_guard.py", "format_staged_frontend.py"):
|
||||
for name in ("browser_verification_guard.py", "format_staged_frontend.py", "repo_file_io.py"):
|
||||
self.write(
|
||||
"scripts/release_control/" + name,
|
||||
(ROOT / "scripts/release_control" / name).read_text(),
|
||||
|
||||
Reference in New Issue
Block a user