From 60c1c51eb9b2fdebbf6bb9e0cd3fa4f934082839 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 8 Aug 2026 03:36:55 +0100 Subject: [PATCH] fix(workloads): serve viewer-safe inventory health --- .../v6/internal/subsystems/agent-lifecycle.md | 17 + .../v6/internal/subsystems/api-contracts.md | 35 ++ .../subsystems/performance-and-scalability.md | 19 + .../v6/internal/subsystems/registry.json | 7 + .../internal/subsystems/storage-recovery.md | 16 + frontend-modern/browser-verification.json | 41 +- .../__tests__/runtimeInventorySources.test.ts | 47 ++ .../src/api/runtimeInventorySources.ts | 34 ++ .../components/Workloads/WorkloadsSurface.tsx | 3 - ...loadsSurface.performance.contract.test.tsx | 61 ++- ...adInventorySourceIssues.branchcov2.test.ts | 485 ++---------------- .../workloadInventorySourceIssues.test.ts | 87 +--- .../components/Workloads/useWorkloadsState.ts | 35 +- .../workloadInventorySourceIssues.ts | 94 ++-- internal/api/route_inventory_test.go | 1 + internal/api/router_routes_registration.go | 11 + internal/api/runtime_inventory_sources.go | 166 ++++++ .../api/runtime_inventory_sources_test.go | 262 ++++++++++ 18 files changed, 811 insertions(+), 610 deletions(-) create mode 100644 frontend-modern/src/api/__tests__/runtimeInventorySources.test.ts create mode 100644 frontend-modern/src/api/runtimeInventorySources.ts create mode 100644 internal/api/runtime_inventory_sources.go create mode 100644 internal/api/runtime_inventory_sources_test.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 21593103b..98b385640 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6027,3 +6027,20 @@ state. Monitoring-capable agents may read the same presentation-only defaults as other authenticated clients, but the response contains no connection, credential, or agent identity data. `api-contracts` owns the authoritative payload and authorization proof. + +### Viewer inventory health excludes agent lifecycle identity + +The authenticated `GET /api/runtime/inventory-sources` monitoring projection +derives cached source health through the shared connection aggregator but is not +an agent enumeration, enrollment, update, or command route. Its four-field wire +type carries only source `type`, operator-facing `name`, normalized blocking +`state`, and workload-only `surfaces`. + +It has no stable connection or agent ID, token binding, address, report IP, +hostname identity object, operating-system, kernel or architecture fact, agent +version, update or module state, command-session state, commands-enabled value, +fleet policy, or credential-health object. The handler deliberately skips the +command-session enrichment used by the administrative ledger. Agent reporting, +registration, update, and command authority remain unchanged, and the +fully-populated projection test fails if any of those lifecycle facts cross the +viewer boundary. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4e32f3cd5..d90994a0f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -159,10 +159,12 @@ an enabled external-probe assignment. 64. `internal/api/demo_middleware.go` 65. `frontend-modern/src/stores/aiRuntimeState.ts` 66. `internal/api/connections_types.go` + 66a. `internal/api/runtime_inventory_sources.go` 67. `internal/api/connections_aggregator.go` 68. `internal/api/connections_handlers.go` 69. `internal/api/connections_probe.go` 70. `frontend-modern/src/api/connections.ts` + 70a. `frontend-modern/src/api/runtimeInventorySources.ts` 71. `frontend-modern/src/utils/connectionErrorPresentation.ts` 72. `internal/api/availability_handlers.go` 73. `frontend-modern/src/api/availabilityTargets.ts` @@ -9142,3 +9144,36 @@ route. `TestContract_SecurityStatusPermissionedSettingsCapabilitiesIncludeRouteA pins the extra authorizer predicates, and `TestSettingsCapabilitiesMatchRouteEnforcementWithoutRBAC` proves that a non-admin browser session receives both false capabilities and matching 403s. + +### Workload inventory health has a viewer-safe runtime contract + +`GET /api/runtime/inventory-sources` is the authenticated `monitoring:read` +boundary for Workloads source-health warnings. It is not a privilege-filtered +serialization of the administrative connection ledger. The route wraps the +complete handler in `RequireAuth` plus `monitoring:read`; missing handler wiring +returns `503 inventory_sources_unavailable`, and tokens without the monitoring +scope receive 403. Authenticated viewer and admin browser sessions receive the +same narrow response. `GET /api/connections` remains unchanged behind +`RequireAdmin` plus `settings:read` and continues to be the only connection +configuration and fleet ledger. + +The response envelope is `{"sources": [...]}` and every source has exactly four +fields: operator-facing `type` and `name`, normalized blocking `state`, and a +`surfaces` array containing only effective workload coverage. The backend drops +healthy, disabled, non-workload, and coverage-free connections before the wire; +resolves configured scope over declared surfaces; removes storage and backup +coverage; and normalizes cached invalid or expired credential health to +`state="unauthorized"`. There is no privileged variant. + +The whitelist intentionally has no connection ID, address, host alias, raw +state reason or error, timestamp, source origin, agent identity, version or +module, fleet policy or credential-health object, capability, credential, +configuration, or mutation field. +`TestRuntimeInventorySourceWireShapeIsAnExactWhitelist` fails if the Go wire +type grows, while +`TestRuntimeInventorySourcesOmitAdministrativeAndSensitiveFacts` projects a +fully populated administrative record and proves those values cannot travel. +The route-authorization test proves the exact viewer 200 / ledger 403 pairing, +the scope test proves unauthenticated and wrong-scope refusal, and the frontend +API plus Workloads integration tests prove the surface reads only the runtime +projection. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 214f064e0..8ace7a569 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2307,3 +2307,22 @@ replaces its one `GET /api/system/settings` request with one runs once per authenticated page bootstrap, and adds no poll or serial round trip. Non-admin sessions also stop producing the refused admin request and its fallback path. + +### Workloads polls only the bounded runtime inventory projection + +`useWorkloadsState.ts` retains one non-suspending source-health query on mount, +one background refresh every 15 seconds, and one refresh when the surface +reconnects. That query now uses `RuntimeInventorySourcesAPI.list()` exclusively; +the Workloads owner contains neither `ConnectionsAPI.list()` nor the +`workloads-connections` cache key, so the polling loop cannot regress to the +admin ledger. `WorkloadsSurface.performance.contract.test.tsx` mounts the real +state owner, observes the runtime request, and proves the mocked admin client is +never called; its raw-source ratchet pins the same invariant for every future +interval refetch. + +The server work remains one cached `buildConnections` aggregation with no +probing, persistence, grouped-system construction, or command-session +enrichment. It filters healthy, disabled, non-workload, and coverage-free rows +before serialization, so the response is no larger than the actionable banner +input. Client presentation is a single pass over that bounded list and performs +no per-source fetch or detail lookup. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index fb9b5acb5..17291ede7 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2891,6 +2891,7 @@ "internal/api/issue1640_readiness_transport_test.go", "internal/api/metadata_handlers_test.go", "internal/api/patrol_autopilot_test.go", + "internal/api/runtime_inventory_sources_test.go", "pulse-enterprise:test/extensions_contract_test.go" ] }, @@ -6254,6 +6255,7 @@ "frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts", "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts", "frontend-modern/src/components/Workloads/workloadFilterConfigModel.ts", + "frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts", "frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts", "frontend-modern/src/components/Workloads/WorkloadPanel.tsx", "frontend-modern/src/components/Workloads/workloadRouteModel.ts", @@ -6295,6 +6297,8 @@ "frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts", "frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx", "frontend-modern/src/components/Workloads/__tests__/workloadFilterConfigModel.test.ts", + "frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.branchcov2.test.ts", + "frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadRouteStateModel.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadSelectionModel.test.ts", @@ -6399,6 +6403,7 @@ "frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts", "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts", "frontend-modern/src/components/Workloads/workloadFilterConfigModel.ts", + "frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts", "frontend-modern/src/components/Workloads/workloadMetricHistoryModel.ts", "frontend-modern/src/components/Workloads/WorkloadPanel.tsx", "frontend-modern/src/components/Workloads/workloadRouteModel.ts", @@ -6439,6 +6444,8 @@ "frontend-modern/src/components/Workloads/__tests__/useWorkloadSelectionState.test.ts", "frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx", "frontend-modern/src/components/Workloads/__tests__/workloadFilterConfigModel.test.ts", + "frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.branchcov2.test.ts", + "frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadRouteStateModel.test.ts", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a131fbe14..bd5f5bc9e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -5106,3 +5106,19 @@ presentation-only fields for authenticated clients. It writes no settings, adds no persisted shape, and changes no storage or recovery route, evidence, or freshness semantics. `api-contracts` owns the response and authorization contract. + +### Viewer inventory health is read-only and storage-free + +`GET /api/runtime/inventory-sources` is an additive authenticated +`monitoring:read` route under `internal/api/`. It aggregates already-loaded +configuration and cached poller health without probing, persisting, writing a +cache, or changing backup, restore, snapshot, retention, or recovery state. +Unavailable handler wiring returns 503 rather than an empty healthy-looking +response. + +The projection filters coverage to workload labels (`vms`, containers, pods, +and Kubernetes) before serialization. Storage and backup labels, source +addresses, stable connection IDs, raw error text, credential state objects, and +all administrative configuration remain absent by construction. The +api-contracts contract owns the authoritative four-field payload and +authorization proof. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 3b46c9be1..5341da71f 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,40 +1,41 @@ { "version": 1, - "base_sha": "98ecfb3f10143cdb7b317cb7ab410ae5b2852ab2", - "verified_at": "2026-08-08T00:30:24Z", + "base_sha": "d1f687c0ea8ea6cfd91f6fc942a82f67b25652ab", + "verified_at": "2026-08-08T02:35:34Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/stores/websocket.ts", - "frontend-modern/src/types/api.ts" + "frontend-modern/src/api/runtimeInventorySources.ts", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts" ], "content_sha256": { - "frontend-modern/src/stores/websocket.ts": "2b6d4ae3c797c00e18bd4ec7ad3582c3878bf673345604efd65f8d030fd6de2d", - "frontend-modern/src/types/api.ts": "5ef74848b2fb1eb1a2f3a3e0d5b6d7832247755cd79cba14eb8a33f1facf7e59" + "frontend-modern/src/api/runtimeInventorySources.ts": "39ceabc3047ad6013d6173a6514b39130e99fec52343200ebe84a3c6a929b5b6", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "b389fe9ad4bbe0df8f791eca104159e803373bbbbbb5f3b0bd1eb4c65cc9ecaf", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "3fa8ba82ae7646024b6510fe1eede14157b9bb85332f639002d34a0368277c5a", + "frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts": "5f9bdc807aebbfa4e1c1c33da75bd1ffe834c4cf957a10a0135c20cd8191241d" }, - "routes": [ - "/docker/overview", - "/standalone/machines" - ], + "routes": ["/proxmox/overview"], "viewports": [ { "width": 1280, "height": 800 }, { - "width": 375, - "height": 812 + "width": 390, + "height": 844 } ], "states": [ - "Authenticated Docker overview at 1280x800 on the final combined build, then stopped the backend: the amber reconnect status appeared while the previously received Tower host and pbs container rows remained coherent and readable.", - "At 375x812 the reconnecting Docker view retained its compact responsive tables, mobile navigation, and a document scroll width equal to the 375px viewport.", - "Mobile navigation reached /standalone/machines and returned to /docker/overview with the reconnect status intact; the newly mounted Machines REST projection failed closed while the retained Docker snapshot remained rendered on return.", - "The in-app browser console showed the expected reconnect diagnostics and REST fetch failures caused by deliberately stopping the backend, with no exception or rendering failure attributable to the changed WebSocket store." + "Authenticated Proxmox overview on the final d1f687c0 replay issued GET /api/runtime/inventory-sources and received HTTP 200 application/json, with no /api/connections request in the captured load.", + "A controlled viewer-safe unreachable PVE projection rendered Source unreachable: Primary lab with only the generic recovery message and no raw connection error or administrative detail.", + "The projected source-health state remained readable at 1280x800 and 390x844; the mobile document clientWidth and scrollWidth were both 390 pixels.", + "The local development WebSocket reported its expected origin rejection; the verified runtime inventory HTTP request and rendered source-health state completed independently." ], "interactions": [ - "Built and started the final backend plus frontend source on http://127.0.0.1:5174, signed in with the documented development account, and confirmed a connected Docker host/container snapshot at 1280x800.", - "Stopped the backend, captured and inspected the rendered desktop reconnect state, and confirmed the mounted Docker snapshot remained visible rather than accepting an unbased stream.", - "Resized to 375x812, verified the retained Docker tables and horizontal overflow metrics, then used mobile navigation to open Machines and return to Docker during reconnect.", - "Read the browser console after the interaction matrix and classified the reconnect and failed REST diagnostics against the deliberate backend shutdown." + "Built the production frontend and final Pulse binary from d1f687c0, started the isolated mock runtime, and opened /proxmox/overview in the in-app browser.", + "Captured the final page-load network events and confirmed the Workloads read path used only /api/runtime/inventory-sources rather than the admin-only /api/connections route.", + "Intercepted only the runtime inventory response locally with the exact narrow source projection, applied an unmatched workload search to expose source health, and inspected the rendered desktop and mobile states.", + "Cleared the response interception, restored the viewport, closed all verification tabs, and stopped the isolated frontend and backend processes." ] } diff --git a/frontend-modern/src/api/__tests__/runtimeInventorySources.test.ts b/frontend-modern/src/api/__tests__/runtimeInventorySources.test.ts new file mode 100644 index 000000000..2b814e9c9 --- /dev/null +++ b/frontend-modern/src/api/__tests__/runtimeInventorySources.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { apiFetchJSON } from '@/utils/apiClient'; +import { RuntimeInventorySourcesAPI } from '../runtimeInventorySources'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +const apiFetchJSONMock = vi.mocked(apiFetchJSON); + +describe('RuntimeInventorySourcesAPI', () => { + beforeEach(() => { + apiFetchJSONMock.mockReset(); + }); + + it('reads the monitoring-tier runtime projection', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ + sources: [ + { + type: 'vmware', + name: 'Primary vCenter', + state: 'unreachable', + surfaces: ['vms'], + }, + ], + }); + + await expect(RuntimeInventorySourcesAPI.list()).resolves.toEqual({ + sources: [ + { + type: 'vmware', + name: 'Primary vCenter', + state: 'unreachable', + surfaces: ['vms'], + }, + ], + }); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/runtime/inventory-sources'); + expect(apiFetchJSONMock).not.toHaveBeenCalledWith('/api/connections'); + }); + + it('normalizes a missing source array to an empty list', async () => { + apiFetchJSONMock.mockResolvedValueOnce({}); + + await expect(RuntimeInventorySourcesAPI.list()).resolves.toEqual({ sources: [] }); + }); +}); diff --git a/frontend-modern/src/api/runtimeInventorySources.ts b/frontend-modern/src/api/runtimeInventorySources.ts new file mode 100644 index 000000000..e7d074f9b --- /dev/null +++ b/frontend-modern/src/api/runtimeInventorySources.ts @@ -0,0 +1,34 @@ +import { apiFetchJSON } from '@/utils/apiClient'; + +export type RuntimeInventorySourceType = 'pve' | 'vmware' | 'docker' | 'kubernetes'; + +export type RuntimeInventorySourceState = + 'paused' | 'pending' | 'stale' | 'unauthorized' | 'unreachable'; + +/** + * Complete viewer-safe wire shape returned by GET /api/runtime/inventory-sources. + * This intentionally does not reuse the administrative Connection model. + */ +export interface RuntimeInventorySource { + type: RuntimeInventorySourceType; + name: string; + state: RuntimeInventorySourceState; + surfaces: string[]; +} + +export interface RuntimeInventorySourcesResponse { + sources: RuntimeInventorySource[]; +} + +interface RuntimeInventorySourcesWireResponse { + sources?: RuntimeInventorySource[]; +} + +export class RuntimeInventorySourcesAPI { + private static readonly baseUrl = '/api/runtime/inventory-sources'; + + static async list(): Promise { + const response = await apiFetchJSON(this.baseUrl); + return { sources: Array.isArray(response.sources) ? response.sources : [] }; + } +} diff --git a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx index a9575f874..e5362d5f3 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsSurface.tsx @@ -52,9 +52,6 @@ function WorkloadInventoryIssueList(props: { issues: readonly WorkloadInventoryS {issue.stateLabel}: {issue.name}

{issue.description}

- -

{issue.detail}

-
{issue.coverageLabel} diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index 46eb8b076..54d998f66 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -95,6 +95,9 @@ let setWsConnectedSignal: ((next: boolean) => void) | null = null; const connectionsApiMocks = vi.hoisted(() => ({ list: vi.fn(), })); +const runtimeInventorySourcesApiMocks = vi.hoisted(() => ({ + list: vi.fn(), +})); const pushMockWorkloads = (next: Array>) => { mockWorkloads = next; @@ -190,6 +193,12 @@ vi.mock('@/api/connections', () => ({ }, })); +vi.mock('@/api/runtimeInventorySources', () => ({ + RuntimeInventorySourcesAPI: { + list: runtimeInventorySourcesApiMocks.list, + }, +})); + vi.mock('@/api/guestMetadata', () => ({ GuestMetadataAPI: { getAllMetadata: vi.fn().mockResolvedValue({}) }, })); @@ -502,6 +511,8 @@ describe('Workloads performance contract', () => { navigateSpy.mockReset(); connectionsApiMocks.list.mockReset(); connectionsApiMocks.list.mockResolvedValue({ connections: [], systems: [] }); + runtimeInventorySourcesApiMocks.list.mockReset(); + runtimeInventorySourcesApiMocks.list.mockResolvedValue({ sources: [] }); resetCreateNonSuspendingQueryCacheForTest(); guestRowMountCount = 0; guestRowUnmountCount = 0; @@ -546,10 +557,12 @@ describe('Workloads performance contract', () => { expect(document.body).not.toHaveTextContent('Attempting to reconnect…'); }); - it('keeps workload rows visible while connection inventory refresh is still pending', async () => { + it('keeps workload rows visible while runtime source-health refresh is still pending', async () => { mockLocationSearch = '?type=all'; mockWorkloads = [makeGuest(1, { name: 'refresh-stable-workload' })]; - connectionsApiMocks.list.mockImplementationOnce(() => new Promise(() => undefined)); + runtimeInventorySourcesApiMocks.list.mockImplementationOnce( + () => new Promise(() => undefined), + ); render(() => ); @@ -563,6 +576,27 @@ describe('Workloads performance contract', () => { expect(document.body).not.toHaveTextContent('Loading...'); }); + it('loads viewer-safe source health without requesting the admin connections ledger', async () => { + mockWorkloads = [makeGuest(1, { name: 'viewer-workload' })]; + runtimeInventorySourcesApiMocks.list.mockResolvedValueOnce({ + sources: [ + { + type: 'pve', + name: 'viewer-source', + state: 'stale', + surfaces: ['vms'], + }, + ], + }); + + render(() => ); + + await waitFor(() => expect(runtimeInventorySourcesApiMocks.list).toHaveBeenCalledTimes(1)); + expect(connectionsApiMocks.list).not.toHaveBeenCalled(); + expect(workloadsStateSource).not.toContain('/api/connections'); + expect(workloadsStateSource).not.toContain('ConnectionsAPI.list'); + }); + it('surfaces blocked Proxmox inventory sources when the platform workloads table is empty', () => { render(() => ( @@ -604,7 +638,6 @@ describe('Workloads performance contract', () => { }), workloadInventoryIssues: () => [ { - id: 'pve:delly', name: 'delly', type: 'pve', typeLabel: 'Proxmox VE', @@ -613,11 +646,8 @@ describe('Workloads performance contract', () => { coverageLabel: 'VMs and containers', description: 'Pulse has VMs and containers enabled for delly, but the Proxmox VE API is unreachable.', - detail: - 'Connection blocked. The request was blocked before Pulse could read inventory. Check proxy, firewall, or network policy settings.', }, { - id: 'docker:tower', name: 'Tower', type: 'docker', typeLabel: 'Docker', @@ -646,11 +676,7 @@ describe('Workloads performance contract', () => { 'Pulse has VMs and containers enabled for delly, but the Proxmox VE API is unreachable.', ), ).toBeInTheDocument(); - expect( - screen.getByText( - 'Connection blocked. The request was blocked before Pulse could read inventory. Check proxy, firewall, or network policy settings.', - ), - ).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('request was blocked before Pulse'); expect(screen.getByRole('link', { name: 'Review infrastructure sources' })).toHaveAttribute( 'href', '/settings/infrastructure', @@ -696,7 +722,6 @@ describe('Workloads performance contract', () => { }), workloadInventoryIssues: () => [ { - id: 'pve:delly', name: 'delly', type: 'pve', typeLabel: 'Proxmox VE', @@ -1093,11 +1118,15 @@ describe('Workloads performance contract', () => { expect(workloadsStateSource).toContain('useWorkloadsDerivedState'); expect(workloadsStateSource).toContain('useWorkloadRouteState'); expect(workloadsStateSource).toContain('buildWorkloadInventorySourceIssues'); - expect(workloadsStateSource).toContain('createNonSuspendingQuery = {}): ConnectionFleetGovernance => ({ - enrollmentState: 'configured', - livenessState: 'active', - versionDrift: 'not-applicable', - adapterHealth: 'healthy', - configRollout: 'configured', - credentialStatus: 'verified', - updateStatus: 'not-applicable', - remoteControl: 'not-applicable', +const source = (overrides: Partial = {}): RuntimeInventorySource => ({ + type: 'pve', + name: 'source', + state: 'paused', + surfaces: ['vms'], ...overrides, }); -const connection = (overrides: Partial): Connection => - ({ - id: 'pve:node', - type: 'pve', - name: 'node', - address: 'https://node:8006', - state: 'active', - stateReason: '', - enabled: true, - surfaces: ['vms'], - scope: { vms: true }, - lastSeen: null, - lastError: null, - source: 'agent', - fleet: fleet(), - capabilities: { - supportsPause: true, - supportsScope: true, - supportsTest: true, - }, - ...overrides, - }) as Connection; +describe('workloadInventorySourceIssues branch contract', () => { + it.each([ + ['paused', 'Collection paused', 'collection is paused.'], + ['pending', 'Collection pending', 'collection has not completed yet.'], + ['stale', 'Collection stale', 'the last inventory data is stale.'], + ['unauthorized', 'Credentials invalid', 'API credentials are invalid.'], + ['unreachable', 'Source unreachable', 'API is unreachable.'], + ] as const)('presents %s source state', (state, stateLabel, descriptionFragment) => { + const [issue] = buildWorkloadInventorySourceIssues([source({ state })]); -describe('workloadInventorySourceIssues (branch coverage)', () => { - describe('credentialInvalid', () => { - it('is true when state is unauthorized (first OR arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:unauth', - name: 'unauth', - state: 'unauthorized', - fleet: fleet({ credentialStatus: 'verified' }), - }), - ]); - - expect(issues).toHaveLength(1); - expect(issues[0]?.stateLabel).toBe('Credentials invalid'); - }); - - it('is true when fleet.credentialStatus is invalid (second OR arm, active state)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:badstatus', - name: 'badstatus', - state: 'active', - fleet: fleet({ credentialStatus: 'invalid' }), - }), - ]); - - expect(issues).toHaveLength(1); - expect(issues[0]?.stateLabel).toBe('Credentials invalid'); - }); - - it('is true when fleet.credentialHealth.status is invalid (third OR arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:healthbad', - name: 'healthbad', - state: 'active', - fleet: fleet({ credentialHealth: { status: 'invalid' } }), - }), - ]); - - expect(issues).toHaveLength(1); - expect(issues[0]?.stateLabel).toBe('Credentials invalid'); - }); - - it('is true when fleet.credentialHealth.status is expired (fourth OR arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:expired', - name: 'expired', - state: 'active', - fleet: fleet({ credentialHealth: { status: 'expired' } }), - }), - ]); - - expect(issues).toHaveLength(1); - expect(issues[0]?.stateLabel).toBe('Credentials invalid'); - }); - - it('short-circuits gracefully when fleet is undefined (optional-chain false arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:nofleet', - name: 'nofleet', - state: 'paused', - fleet: undefined as unknown as Connection['fleet'], - }), - ]); - - expect(issues).toHaveLength(1); - expect(issues[0]?.stateLabel).toBe('Collection paused'); - }); + expect(issue?.stateLabel).toBe(stateLabel); + expect(issue?.description).toContain(descriptionFragment); }); - describe('stateLabelFor switch arms (credentialInvalid false)', () => { - it('maps pending to "Collection pending"', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:pend', name: 'pend', state: 'pending' }), - ]); + it.each([ + ['pve', 'Proxmox VE'], + ['vmware', 'VMware vCenter'], + ['docker', 'Docker'], + ['kubernetes', 'Kubernetes'], + ] as const)('uses the canonical %s label', (type, typeLabel) => { + const [issue] = buildWorkloadInventorySourceIssues([source({ type })]); - expect(issues[0]?.stateLabel).toBe('Collection pending'); - }); - - it('maps stale to "Collection stale"', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:stale', name: 'stale', state: 'stale' }), - ]); - - expect(issues[0]?.stateLabel).toBe('Collection stale'); - }); - - it('maps unreachable to "Source unreachable"', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'vmware:down', - type: 'vmware', - name: 'down', - state: 'unreachable', - }), - ]); - - expect(issues[0]?.stateLabel).toBe('Source unreachable'); - }); + expect(issue?.typeLabel).toBe(typeLabel); }); - describe('descriptionFor branches', () => { - it('credentialInvalid arm names the type-label API credentials', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'docker:bad', - type: 'docker', - name: 'dockerhost', - state: 'active', - surfaces: ['containers'], - scope: { containers: true }, - fleet: fleet({ credentialStatus: 'invalid' }), - }), - ]); + it('formats, orders, and deduplicates workload coverage', () => { + const [issue] = buildWorkloadInventorySourceIssues([ + source({ + type: 'kubernetes', + surfaces: ['pods', 'unknown', 'docker', 'containers', 'vms', 'kubernetes'], + }), + ]); - expect(issues[0]?.description).toBe( - 'Pulse has containers enabled for dockerhost, but its Docker API credentials are invalid.', - ); - }); - - it('paused arm says collection is paused', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:paused', name: 'paused', state: 'paused' }), - ]); - - expect(issues[0]?.description).toBe( - 'Pulse has VMs enabled for paused, but collection is paused.', - ); - }); - - it('pending arm says collection has not completed yet', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:pend', name: 'pend', state: 'pending' }), - ]); - - expect(issues[0]?.description).toBe( - 'Pulse has VMs enabled for pend, but collection has not completed yet.', - ); - }); - - it('stale arm says inventory data is stale', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:stale', name: 'stale', state: 'stale' }), - ]); - - expect(issues[0]?.description).toBe( - 'Pulse has VMs enabled for stale, but the last inventory data is stale.', - ); - }); - - it('unreachable arm interpolates the type label before "API is unreachable"', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'vmware:down', - type: 'vmware', - name: 'vc1', - state: 'unreachable', - }), - ]); - - expect(issues[0]?.description).toBe( - 'Pulse has VMs enabled for vc1, but the VMware vCenter API is unreachable.', - ); - }); + expect(issue?.coverageLabel).toBe('VMs, containers, pods, and Kubernetes workloads'); }); - describe('formatCoverage', () => { - it('returns the single label unchanged for one surface (length === 1 arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:one', name: 'one', state: 'paused' }), - ]); + it('uses the one- and two-surface grammar', () => { + const issues = buildWorkloadInventorySourceIssues([ + source({ name: 'one', surfaces: ['vms'] }), + source({ name: 'two', surfaces: ['vms', 'containers'] }), + ]); - expect(issues[0]?.coverageLabel).toBe('VMs'); - }); - - it('joins two labels with "and" (length === 2 arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:two', - name: 'two', - state: 'paused', - surfaces: ['vms', 'containers'], - scope: { vms: true, containers: true }, - }), - ]); - - expect(issues[0]?.coverageLabel).toBe('VMs and containers'); - }); - - it('joins three-plus labels with Oxford comma (length >= 3 arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:three', - type: 'kubernetes', - name: 'three', - state: 'paused', - surfaces: ['vms', 'containers', 'kubernetes'], - scope: { vms: true, containers: true, kubernetes: true }, - }), - ]); - - expect(issues[0]?.coverageLabel).toBe('VMs, containers, and Kubernetes workloads'); - }); + expect(issues.find((issue) => issue.name === 'one')?.coverageLabel).toBe('VMs'); + expect(issues.find((issue) => issue.name === 'two')?.coverageLabel).toBe('VMs and containers'); }); - describe('activeWorkloadSurfaces', () => { - it('keeps only truthy scope entries and drops surfaces with no label', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:scoped', - name: 'scoped', - state: 'paused', - surfaces: ['vms', 'containers', 'storage'], - scope: { vms: true, containers: false, storage: true }, - }), - ]); + it('sorts the most severe state first and names as the tie-breaker', () => { + const issues = buildWorkloadInventorySourceIssues([ + source({ name: 'Zulu', state: 'paused' }), + source({ name: 'Alpha', state: 'unreachable' }), + source({ name: 'Beta', state: 'unreachable' }), + source({ name: 'Middle', state: 'stale' }), + ]); - expect(issues[0]?.coverageLabel).toBe('VMs'); - }); - - it('falls back to connection.surfaces when scope is empty', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:fallback', - name: 'fallback', - state: 'paused', - surfaces: ['vms', 'pods'], - scope: {}, - }), - ]); - - expect(issues[0]?.coverageLabel).toBe('VMs and pods'); - }); - - it('falls back to surfaces when scope is undefined (?? {} arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:noscope', - name: 'noscope', - state: 'paused', - surfaces: ['vms'], - scope: undefined as unknown as Connection['scope'], - }), - ]); - - expect(issues[0]?.coverageLabel).toBe('VMs'); - }); - - it('deduplicates surfaces that map to the same label (seen.has arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'docker:dup', - type: 'docker', - name: 'dup', - state: 'paused', - surfaces: ['containers', 'docker'], - scope: {}, - }), - ]); - - expect(issues[0]?.coverageLabel).toBe('containers'); - }); - - it('sorts an unknown surface via the -1 rank normalization branch', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:unknown', - name: 'unknown', - state: 'paused', - surfaces: ['zzz', 'vms'], - scope: {}, - }), - ]); - - expect(issues[0]?.coverageLabel).toBe('VMs'); - }); + expect(issues.map((issue) => issue.name)).toEqual(['Alpha', 'Beta', 'Middle', 'Zulu']); }); - describe('compactDetail', () => { - it('returns undefined when no error message is available (!formatted arm)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:nomsg', - name: 'nomsg', - state: 'paused', - stateReason: '', - lastError: null, - }), - ]); + it('tolerates a missing surfaces array without creating a generic issue', () => { + const issues = buildWorkloadInventorySourceIssues([source({ surfaces: undefined as never })]); - expect(issues[0]?.detail).toBeUndefined(); - }); - - it('formats a short lastError.message (left ?? operand non-null)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:shorterr', - name: 'shorterr', - state: 'paused', - stateReason: 'should-not-be-used', - lastError: { - at: '2026-07-12T00:00:00Z', - message: 'no such host', - }, - }), - ]); - - expect(issues[0]?.detail).toBe('Host not found. Check the hostname or IP address.'); - }); - - it('falls back to stateReason when lastError is null (right ?? operand)', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:reason', - name: 'reason', - state: 'paused', - stateReason: 'connection refused', - lastError: null, - }), - ]); - - expect(issues[0]?.detail).toBe( - 'Connection refused. The host is reachable but rejected the connection on this port. Check the port is correct and the service is running.', - ); - }); - - it('truncates a formatted message longer than 220 characters (>220 arm)', () => { - const longMessage = 'x'.repeat(300); - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'pve:longerr', - name: 'longerr', - state: 'paused', - lastError: { - at: '2026-07-12T00:00:00Z', - message: longMessage, - }, - }), - ]); - - const detail = issues[0]?.detail; - expect(detail).toBeDefined(); - expect(detail?.length).toBe(220); - expect(detail).toBe(`${'x'.repeat(217)}...`); - expect(detail?.endsWith('...')).toBe(true); - }); - }); - - describe('buildWorkloadInventorySourceIssues pipeline', () => { - it('excludes disabled, non-workload-type, and active-valid connections', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:disabled', name: 'disabled', enabled: false, state: 'unauthorized' }), - connection({ - id: 'pbs:tower', - type: 'pbs', - name: 'tower', - state: 'unreachable', - surfaces: ['backups'], - scope: { backups: true }, - }), - connection({ id: 'pve:healthy', name: 'healthy', state: 'active' }), - connection({ id: 'pve:blocked', name: 'blocked', state: 'stale' }), - ]); - - expect(issues).toHaveLength(1); - expect(issues[0]?.id).toBe('pve:blocked'); - }); - - it('orders by descending STATE_RANK when states differ', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:aaa', name: 'aaa', state: 'paused' }), - connection({ id: 'pve:zzz', name: 'zzz', state: 'unreachable' }), - ]); - - expect(issues.map((issue) => issue.state)).toEqual(['unreachable', 'paused']); - expect(issues.map((issue) => issue.name)).toEqual(['zzz', 'aaa']); - }); - - it('breaks state-rank ties with name localeCompare', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:zeta', name: 'zeta', state: 'paused' }), - connection({ id: 'pve:alpha', name: 'alpha', state: 'paused' }), - ]); - - expect(issues.map((issue) => issue.name)).toEqual(['alpha', 'zeta']); - }); - - it('emits a fully-shaped issue for a kubernetes source', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - id: 'kubernetes:k1', - type: 'kubernetes', - name: 'k1', - state: 'pending', - surfaces: ['kubernetes'], - scope: { kubernetes: true }, - }), - ]); - - expect(issues).toStrictEqual([ - { - id: 'kubernetes:k1', - name: 'k1', - type: 'kubernetes', - typeLabel: 'Kubernetes', - state: 'pending', - stateLabel: 'Collection pending', - coverageLabel: 'Kubernetes workloads', - description: - 'Pulse has Kubernetes workloads enabled for k1, but collection has not completed yet.', - detail: undefined, - }, - ]); - }); + expect(issues).toEqual([]); }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts index 2546069cc..3654566d2 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts @@ -1,85 +1,40 @@ import { describe, expect, it } from 'vitest'; -import type { Connection } from '@/api/connections'; +import type { RuntimeInventorySource } from '@/api/runtimeInventorySources'; import { buildWorkloadInventorySourceIssues } from '../workloadInventorySourceIssues'; -const connection = (overrides: Partial): Connection => - ({ - id: 'pve:delly', - type: 'pve', - name: 'delly', - address: 'https://delly:8006', - state: 'active', - enabled: true, - surfaces: ['vms', 'containers', 'storage', 'backups'], - scope: { vms: true, containers: true, storage: true, backups: true }, - lastSeen: null, - lastError: null, - source: 'agent', - fleet: { - enrollmentState: 'configured', - livenessState: 'active', - versionDrift: 'not-applicable', - adapterHealth: 'healthy', - configRollout: 'configured', - credentialStatus: 'verified', - updateStatus: 'not-applicable', - remoteControl: 'not-applicable', - }, - capabilities: { - supportsPause: true, - supportsScope: true, - supportsTest: true, - }, - ...overrides, - }) as Connection; +const source = (overrides: Partial = {}): RuntimeInventorySource => ({ + type: 'pve', + name: 'delly', + state: 'stale', + surfaces: ['vms', 'containers'], + ...overrides, +}); describe('buildWorkloadInventorySourceIssues', () => { - it('reports enabled workload-capable sources with invalid credentials', () => { - const issues = buildWorkloadInventorySourceIssues([ - connection({ - state: 'unauthorized', - fleet: { - enrollmentState: 'configured', - livenessState: 'unauthorized', - versionDrift: 'not-applicable', - adapterHealth: 'blocked', - configRollout: 'configured', - credentialStatus: 'invalid', - updateStatus: 'not-applicable', - remoteControl: 'not-applicable', - }, - lastError: { - at: '2026-05-13T23:58:54Z', - message: 'Authentication failed - check API token or credentials', - }, - }), - ]); + it('presents the viewer-safe credential issue without diagnostic detail', () => { + const issues = buildWorkloadInventorySourceIssues([source({ state: 'unauthorized' })]); expect(issues).toEqual([ - expect.objectContaining({ - id: 'pve:delly', + { name: 'delly', + type: 'pve', + typeLabel: 'Proxmox VE', + state: 'unauthorized', stateLabel: 'Credentials invalid', coverageLabel: 'VMs and containers', description: 'Pulse has VMs and containers enabled for delly, but its Proxmox VE API credentials are invalid.', - detail: 'Authentication failed. Re-check the API token or username/password.', - }), + }, ]); + expect(issues[0]).not.toHaveProperty('detail'); + expect(issues[0]).not.toHaveProperty('id'); }); - it('ignores active, disabled, and non-workload sources', () => { + it('defensively ignores active, non-workload, and coverage-free rows', () => { const issues = buildWorkloadInventorySourceIssues([ - connection({ id: 'pve:pi', name: 'pi', state: 'active' }), - connection({ id: 'pve:paused', enabled: false, state: 'unauthorized' }), - connection({ - id: 'pbs:tower', - type: 'pbs', - name: 'pbs-docker', - state: 'unreachable', - surfaces: ['backups'], - scope: { backups: true }, - }), + source({ name: 'healthy', state: 'active' as never }), + source({ name: 'unsupported', type: 'pbs' as never }), + source({ name: 'empty', surfaces: [] }), ]); expect(issues).toEqual([]); diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index efda85270..d380c3573 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -1,6 +1,9 @@ import { createEffect, createMemo, onCleanup, type Accessor } from 'solid-js'; import { useNavigate } from '@solidjs/router'; -import { ConnectionsAPI, type ConnectionsListResponse } from '@/api/connections'; +import { + RuntimeInventorySourcesAPI, + type RuntimeInventorySourcesResponse, +} from '@/api/runtimeInventorySources'; import { nodeOverrideIdCandidates } from '@/features/alerts/alertOverridesModel'; import type { VM, Container, Node } from '@/types/api'; import type { Resource } from '@/types/resource'; @@ -51,10 +54,9 @@ import { buildGuestParentNodeMapFromNodes } from './workloadTopology'; const WORKLOADS_INFRASTRUCTURE_SOURCES_QUERY = 'type=agent,docker-host,k8s-cluster,k8s-node,pbs,pmg,storage,physical_disk,ceph'; -const WORKLOADS_CONNECTIONS_POLL_INTERVAL_MS = 15000; -const EMPTY_CONNECTIONS_RESPONSE: ConnectionsListResponse = { - connections: [], - systems: [], +const WORKLOADS_INVENTORY_SOURCES_POLL_INTERVAL_MS = 15000; +const EMPTY_INVENTORY_SOURCES_RESPONSE: RuntimeInventorySourcesResponse = { + sources: [], }; const isProxmoxNodeResource = (resource: Resource): boolean => @@ -146,12 +148,15 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { cacheKey: 'workloads-infrastructure-sources', enabled: workloadsEnabled, }); - const connectionsResourceKey = createMemo(() => (workloadsEnabled() ? 'enabled' : null)); - const connectionsSnapshot = createNonSuspendingQuery({ - source: connectionsResourceKey, - fetcher: () => ConnectionsAPI.list(), - initialValue: EMPTY_CONNECTIONS_RESPONSE, - cacheKey: (key) => `workloads-connections:${key}`, + const inventorySourcesResourceKey = createMemo(() => (workloadsEnabled() ? 'enabled' : null)); + const inventorySourcesSnapshot = createNonSuspendingQuery< + RuntimeInventorySourcesResponse, + string + >({ + source: inventorySourcesResourceKey, + fetcher: () => RuntimeInventorySourcesAPI.list(), + initialValue: EMPTY_INVENTORY_SOURCES_RESPONSE, + cacheKey: (key) => `workloads-inventory-sources:${key}`, }); const dedupeGuests = (guests: WorkloadGuest[]): WorkloadGuest[] => { @@ -341,7 +346,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { getWorkloadsDisconnectedState(reconnecting()), ); const workloadInventoryIssues = createMemo(() => - buildWorkloadInventorySourceIssues(connectionsSnapshot.value().connections ?? []), + buildWorkloadInventorySourceIssues(inventorySourcesSnapshot.value().sources ?? []), ); const workloadMetricHistory = useWorkloadTableMetricHistory({ enabled: () => workloadMetricDisplayMode() === 'sparklines', @@ -372,7 +377,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { const reconnectSurface = () => { if (workloadsEnabled()) { void workloads.refetch(); - void connectionsSnapshot.refetch({ background: true }); + void inventorySourcesSnapshot.refetch({ background: true }); } reconnect(); }; @@ -383,8 +388,8 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { createEffect(() => { if (!workloadsEnabled()) return; const handle = window.setInterval(() => { - void connectionsSnapshot.refetch({ background: true }); - }, WORKLOADS_CONNECTIONS_POLL_INTERVAL_MS); + void inventorySourcesSnapshot.refetch({ background: true }); + }, WORKLOADS_INVENTORY_SOURCES_POLL_INTERVAL_MS); onCleanup(() => window.clearInterval(handle)); }); diff --git a/frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts b/frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts index 3da582600..70972564e 100644 --- a/frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts +++ b/frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts @@ -1,19 +1,20 @@ -import type { Connection, ConnectionState, ConnectionType } from '@/api/connections'; -import { formatConnectionErrorMessage } from '@/utils/connectionErrorPresentation'; +import type { + RuntimeInventorySource, + RuntimeInventorySourceState, + RuntimeInventorySourceType, +} from '@/api/runtimeInventorySources'; export interface WorkloadInventorySourceIssue { - id: string; name: string; - type: ConnectionType; + type: RuntimeInventorySourceType; typeLabel: string; - state: ConnectionState; + state: RuntimeInventorySourceState; stateLabel: string; coverageLabel: string; description: string; - detail?: string; } -const WORKLOAD_CAPABLE_TYPES: ReadonlySet = new Set([ +const WORKLOAD_CAPABLE_TYPES: ReadonlySet = new Set([ 'pve', 'vmware', 'docker', @@ -29,15 +30,14 @@ const WORKLOAD_SURFACE_LABELS: Record = { }; const WORKLOAD_SURFACE_ORDER = ['vms', 'containers', 'docker', 'pods', 'kubernetes']; -const CONNECTION_TYPE_LABELS: Partial> = { +const CONNECTION_TYPE_LABELS: Record = { docker: 'Docker', kubernetes: 'Kubernetes', pve: 'Proxmox VE', vmware: 'VMware vCenter', }; -const STATE_RANK: Record = { - active: 0, +const STATE_RANK: Record = { paused: 1, pending: 2, stale: 3, @@ -45,7 +45,7 @@ const STATE_RANK: Record = { unreachable: 5, }; -const BLOCKING_STATES: ReadonlySet = new Set([ +const BLOCKING_STATES: ReadonlySet = new Set([ 'paused', 'pending', 'stale', @@ -53,16 +53,8 @@ const BLOCKING_STATES: ReadonlySet = new Set([ 'unreachable', ]); -const credentialInvalid = (connection: Connection): boolean => - connection.state === 'unauthorized' || - connection.fleet?.credentialStatus === 'invalid' || - connection.fleet?.credentialHealth?.status === 'invalid' || - connection.fleet?.credentialHealth?.status === 'expired'; - -const activeWorkloadSurfaces = (connection: Connection): string[] => { - const scope = connection.scope ?? {}; - const scoped = Object.keys(scope).filter((surface) => scope[surface]); - const surfaces = scoped.length > 0 ? scoped : (connection.surfaces ?? []); +const activeWorkloadSurfaces = (source: RuntimeInventorySource): string[] => { + const surfaces = source.surfaces ?? []; const seen = new Set(); const labels: string[] = []; const orderedSurfaces = [...surfaces].sort((left, right) => { @@ -89,9 +81,8 @@ const formatCoverage = (labels: readonly string[]): string => { return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`; }; -const stateLabelFor = (connection: Connection): string => { - if (credentialInvalid(connection)) return 'Credentials invalid'; - switch (connection.state) { +const stateLabelFor = (source: RuntimeInventorySource): string => { + switch (source.state) { case 'paused': return 'Collection paused'; case 'pending': @@ -108,56 +99,47 @@ const stateLabelFor = (connection: Connection): string => { }; const descriptionFor = ( - connection: Connection, + source: RuntimeInventorySource, typeLabel: string, coverageLabel: string, ): string => { - if (credentialInvalid(connection)) { - return `Pulse has ${coverageLabel} enabled for ${connection.name}, but its ${typeLabel} API credentials are invalid.`; + if (source.state === 'unauthorized') { + return `Pulse has ${coverageLabel} enabled for ${source.name}, but its ${typeLabel} API credentials are invalid.`; } - switch (connection.state) { + switch (source.state) { case 'paused': - return `Pulse has ${coverageLabel} enabled for ${connection.name}, but collection is paused.`; + return `Pulse has ${coverageLabel} enabled for ${source.name}, but collection is paused.`; case 'pending': - return `Pulse has ${coverageLabel} enabled for ${connection.name}, but collection has not completed yet.`; + return `Pulse has ${coverageLabel} enabled for ${source.name}, but collection has not completed yet.`; case 'stale': - return `Pulse has ${coverageLabel} enabled for ${connection.name}, but the last inventory data is stale.`; + return `Pulse has ${coverageLabel} enabled for ${source.name}, but the last inventory data is stale.`; case 'unreachable': - return `Pulse has ${coverageLabel} enabled for ${connection.name}, but the ${typeLabel} API is unreachable.`; + return `Pulse has ${coverageLabel} enabled for ${source.name}, but the ${typeLabel} API is unreachable.`; default: - return `Pulse has ${coverageLabel} enabled for ${connection.name}, but collection is blocked.`; + return `Pulse has ${coverageLabel} enabled for ${source.name}, but collection is blocked.`; } }; -const compactDetail = (raw?: string | null): string | undefined => { - const formatted = formatConnectionErrorMessage(raw); - if (!formatted) return undefined; - return formatted.length > 220 ? `${formatted.slice(0, 217)}...` : formatted; -}; - -const connectionHasWorkloadCoverage = (connection: Connection): boolean => - WORKLOAD_CAPABLE_TYPES.has(connection.type) && activeWorkloadSurfaces(connection).length > 0; +const sourceHasWorkloadCoverage = (source: RuntimeInventorySource): boolean => + WORKLOAD_CAPABLE_TYPES.has(source.type) && activeWorkloadSurfaces(source).length > 0; export const buildWorkloadInventorySourceIssues = ( - connections: readonly Connection[], + sources: readonly RuntimeInventorySource[], ): WorkloadInventorySourceIssue[] => - connections - .filter((connection) => connection.enabled) - .filter(connectionHasWorkloadCoverage) - .filter((connection) => BLOCKING_STATES.has(connection.state) || credentialInvalid(connection)) - .map((connection) => { - const coverageLabel = formatCoverage(activeWorkloadSurfaces(connection)); - const typeLabel = CONNECTION_TYPE_LABELS[connection.type] ?? connection.type; + sources + .filter(sourceHasWorkloadCoverage) + .filter((source) => BLOCKING_STATES.has(source.state)) + .map((source) => { + const coverageLabel = formatCoverage(activeWorkloadSurfaces(source)); + const typeLabel = CONNECTION_TYPE_LABELS[source.type]; return { - id: connection.id, - name: connection.name, - type: connection.type, + name: source.name, + type: source.type, typeLabel, - state: connection.state, - stateLabel: stateLabelFor(connection), + state: source.state, + stateLabel: stateLabelFor(source), coverageLabel, - description: descriptionFor(connection, typeLabel, coverageLabel), - detail: compactDetail(connection.lastError?.message ?? connection.stateReason), + description: descriptionFor(source, typeLabel, coverageLabel), }; }) .sort((left, right) => { diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index 450c1afb8..cc5f6cbea 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -471,6 +471,7 @@ var allRouteAllowlist = []string{ "/api/config/nodes/test-connection", "/api/config/nodes/", "/api/connections", + "GET /api/runtime/inventory-sources", "/api/connections/probe", "/api/availability-targets", "/api/availability-targets/test", diff --git a/internal/api/router_routes_registration.go b/internal/api/router_routes_registration.go index 99aa479ce..9f9cd1877 100644 --- a/internal/api/router_routes_registration.go +++ b/internal/api/router_routes_registration.go @@ -217,6 +217,17 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) { } RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.connectionsHandlers.HandleList))(w, req) }) + // Workloads needs source health at the monitoring tier, but the connection + // ledger above is intentionally administrative. Authentication and + // monitoring scope wrap the entire handler so unavailable wiring and every + // other response remain behind the same fail-closed boundary. + r.mux.HandleFunc("GET /api/runtime/inventory-sources", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, func(w http.ResponseWriter, req *http.Request) { + if r.connectionsHandlers == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "inventory_sources_unavailable", "Inventory source health unavailable", nil) + return + } + r.connectionsHandlers.HandleRuntimeInventorySources(w, req) + }))) // Connection address probe — stateless type detection before credential entry. r.mux.HandleFunc("/api/connections/probe", func(w http.ResponseWriter, req *http.Request) { diff --git a/internal/api/runtime_inventory_sources.go b/internal/api/runtime_inventory_sources.go new file mode 100644 index 000000000..217b056f2 --- /dev/null +++ b/internal/api/runtime_inventory_sources.go @@ -0,0 +1,166 @@ +package api + +import ( + "net/http" + "sort" + "strings" +) + +var runtimeInventoryWorkloadTypes = map[ConnectionType]struct{}{ + ConnectionTypePVE: {}, + ConnectionTypeVMware: {}, + ConnectionTypeDocker: {}, + ConnectionTypeKubernetes: {}, +} + +var runtimeInventoryWorkloadSurfaces = map[string]struct{}{ + "containers": {}, + "docker": {}, + "kubernetes": {}, + "pods": {}, + "vms": {}, +} + +var runtimeInventoryBlockingStates = map[ConnectionState]struct{}{ + ConnectionStatePaused: {}, + ConnectionStatePending: {}, + ConnectionStateStale: {}, + ConnectionStateUnauthorized: {}, + ConnectionStateUnreachable: {}, +} + +// RuntimeInventorySource is the complete monitoring-tier wire shape for one +// enabled source currently blocking workload inventory. It is deliberately a +// standalone whitelist rather than a serialized or embedded Connection. +// +// Name is the operator-facing source label needed to identify the problem. +// State is normalized to unauthorized when any cached credential signal says +// the credentials are invalid. Surfaces contains only workload coverage labels. +// No source locator, stable connection ID, raw error, timestamp, agent identity, +// fleet policy, capability, credential, or mutation field can cross this type. +type RuntimeInventorySource struct { + Type ConnectionType `json:"type"` + Name string `json:"name"` + State ConnectionState `json:"state"` + Surfaces []string `json:"surfaces"` +} + +type RuntimeInventorySourcesResponse struct { + Sources []RuntimeInventorySource `json:"sources"` +} + +func runtimeInventorySourceCredentialsInvalid(connection Connection) bool { + if connection.State == ConnectionStateUnauthorized { + return true + } + if strings.EqualFold(strings.TrimSpace(connection.Fleet.CredentialStatus), "invalid") { + return true + } + if health := connection.Fleet.CredentialHealth; health != nil { + switch strings.ToLower(strings.TrimSpace(health.Status)) { + case "expired", "invalid": + return true + } + } + return false +} + +// runtimeInventorySourceSurfaces resolves scope-over-declared-surfaces on the +// server, then drops every non-workload label before the projection crosses the +// monitoring boundary. +func runtimeInventorySourceSurfaces(connection Connection) []string { + selected := make([]string, 0, len(connection.Scope)) + for surface, enabled := range connection.Scope { + if enabled { + selected = append(selected, surface) + } + } + if len(selected) == 0 { + selected = append(selected, connection.Surfaces...) + } + + seen := make(map[string]struct{}, len(selected)) + workloadSurfaces := make([]string, 0, len(selected)) + for _, surface := range selected { + surface = strings.ToLower(strings.TrimSpace(surface)) + if _, allowed := runtimeInventoryWorkloadSurfaces[surface]; !allowed { + continue + } + if _, duplicate := seen[surface]; duplicate { + continue + } + seen[surface] = struct{}{} + workloadSurfaces = append(workloadSurfaces, surface) + } + sort.Strings(workloadSurfaces) + return workloadSurfaces +} + +// runtimeInventorySources projects only actionable workload-inventory health +// issues. Healthy, disabled, non-workload, and coverage-free administrative +// records never reach a viewer. +func runtimeInventorySources(connections []Connection) []RuntimeInventorySource { + sources := make([]RuntimeInventorySource, 0, len(connections)) + for _, connection := range connections { + if !connection.Enabled { + continue + } + if _, workloadType := runtimeInventoryWorkloadTypes[connection.Type]; !workloadType { + continue + } + + surfaces := runtimeInventorySourceSurfaces(connection) + if len(surfaces) == 0 { + continue + } + + state := connection.State + if runtimeInventorySourceCredentialsInvalid(connection) { + state = ConnectionStateUnauthorized + } + if _, blocking := runtimeInventoryBlockingStates[state]; !blocking { + continue + } + + sources = append(sources, RuntimeInventorySource{ + Type: connection.Type, + Name: connection.Name, + State: state, + Surfaces: surfaces, + }) + } + + sort.Slice(sources, func(i, j int) bool { + if sources[i].Type != sources[j].Type { + return sources[i].Type < sources[j].Type + } + return strings.ToLower(sources[i].Name) < strings.ToLower(sources[j].Name) + }) + return sources +} + +// HandleRuntimeInventorySources serves cached workload-inventory health only. +// It performs no probing or persistence. Missing handler dependencies fail +// closed instead of returning an empty response that would look healthy. +func (h *ConnectionsHandlers) HandleRuntimeInventorySources(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) + return + } + if h == nil || h.getConfig == nil || h.getPersistence == nil || h.getMonitor == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "inventory_sources_unavailable", "Inventory source health unavailable", nil) + return + } + + ctx := r.Context() + inputs := buildAggregatorInputsWithRuntimeSources( + ctx, + h.getConfig(ctx), + h.getPersistence(ctx), + h.getMonitor(ctx), + h.runtimeSources(ctx, resolveTenantOrgID(r)), + ) + writeJSON(w, http.StatusOK, RuntimeInventorySourcesResponse{ + Sources: runtimeInventorySources(buildConnections(inputs)), + }) +} diff --git a/internal/api/runtime_inventory_sources_test.go b/internal/api/runtime_inventory_sources_test.go new file mode 100644 index 000000000..9b10a05a4 --- /dev/null +++ b/internal/api/runtime_inventory_sources_test.go @@ -0,0 +1,262 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" +) + +func TestRuntimeInventorySourcesProjectsOnlyBlockingWorkloadCoverage(t *testing.T) { + sources := runtimeInventorySources([]Connection{ + { + ID: "pve:blocked", + Type: ConnectionTypePVE, + Name: "Blocked PVE", + State: ConnectionStateStale, + Enabled: true, + Surfaces: []string{"backups", "containers", "storage", "vms"}, + Scope: map[string]bool{"containers": false, "storage": true, "vms": true}, + }, + { + ID: "vmware:healthy", + Type: ConnectionTypeVMware, + Name: "Healthy vCenter", + State: ConnectionStateActive, + Enabled: true, + Surfaces: []string{"vms"}, + }, + { + ID: "docker:disabled", + Type: ConnectionTypeDocker, + Name: "Disabled Docker", + State: ConnectionStateUnreachable, + Enabled: false, + Surfaces: []string{"containers"}, + }, + { + ID: "pbs:blocked", + Type: ConnectionTypePBS, + Name: "Blocked PBS", + State: ConnectionStateUnreachable, + Enabled: true, + Surfaces: []string{"backups"}, + }, + }) + + want := []RuntimeInventorySource{{ + Type: ConnectionTypePVE, + Name: "Blocked PVE", + State: ConnectionStateStale, + Surfaces: []string{"vms"}, + }} + if !reflect.DeepEqual(sources, want) { + t.Fatalf("runtime inventory sources = %#v, want %#v", sources, want) + } +} + +func TestRuntimeInventorySourcesNormalizesCredentialFailureWithoutPublishingFleet(t *testing.T) { + expiredAt := time.Now().UTC().Add(-time.Hour) + sources := runtimeInventorySources([]Connection{ + { + Type: ConnectionTypeVMware, + Name: "vCenter", + State: ConnectionStateActive, + Enabled: true, + Surfaces: []string{"vms", "vms", "storage"}, + Fleet: ConnectionFleetGovernance{ + CredentialHealth: &ConnectionFleetCredentialHealth{ + Status: "expired", + ExpiresAt: &expiredAt, + }, + }, + }, + }) + + if len(sources) != 1 { + t.Fatalf("sources = %d, want 1", len(sources)) + } + if sources[0].State != ConnectionStateUnauthorized { + t.Fatalf("state = %q, want normalized unauthorized", sources[0].State) + } + if !reflect.DeepEqual(sources[0].Surfaces, []string{"vms"}) { + t.Fatalf("surfaces = %#v, want workload-only deduplicated coverage", sources[0].Surfaces) + } +} + +func TestRuntimeInventorySourceWireShapeIsAnExactWhitelist(t *testing.T) { + payload, err := json.Marshal(RuntimeInventorySource{}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var fields map[string]any + if err := json.Unmarshal(payload, &fields); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + want := map[string]struct{}{ + "type": {}, + "name": {}, + "state": {}, + "surfaces": {}, + } + if len(fields) != len(want) { + t.Fatalf("wire fields = %v, want exactly %v", fields, want) + } + for field := range fields { + if _, allowed := want[field]; !allowed { + t.Fatalf("monitoring projection grew unapproved field %q", field) + } + } +} + +func TestRuntimeInventorySourcesOmitAdministrativeAndSensitiveFacts(t *testing.T) { + now := time.Now().UTC() + sources := runtimeInventorySources([]Connection{{ + ID: "vmware:secret-internal-id", + Type: ConnectionTypeVMware, + Name: "Primary vCenter", + Address: "https://vcenter.corp.local:443", + HostAliases: []string{"10.0.1.5", "vcenter-old.corp.local"}, + State: ConnectionStateUnreachable, + StateReason: `Get "https://vcenter.corp.local/sdk": dial tcp 10.0.1.5:443: i/o timeout`, + Enabled: true, + Surfaces: []string{"vms", "storage"}, + LastSeen: &now, + LastError: &ConnectionError{ + At: now, + Message: "credential token secret-value rejected", + }, + AgentIdentity: &ConnectionAgentIdentity{ + Hostname: "collector-01", + ReportIP: "10.0.1.9", + OSName: "Debian", + }, + AgentVersion: "6.2.0", + Fleet: ConnectionFleetGovernance{ + CommandPolicy: &ConnectionFleetCommandPolicy{Reason: "privileged policy detail"}, + }, + }}) + + if len(sources) != 1 { + t.Fatalf("sources = %d, want 1", len(sources)) + } + payload, err := json.Marshal(sources[0]) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, forbidden := range []string{ + "secret-internal-id", + "vcenter.corp.local", + "10.0.1.5", + "secret-value", + "collector-01", + "10.0.1.9", + "Debian", + "6.2.0", + "privileged policy detail", + "storage", + } { + if strings.Contains(string(payload), forbidden) { + t.Fatalf("monitoring projection leaked %q: %s", forbidden, payload) + } + } + if !strings.Contains(string(payload), "Primary vCenter") { + t.Fatalf("projection omitted the operator-facing source label: %s", payload) + } +} + +func TestRuntimeInventorySourcesHandlerFailsClosedWhenUnavailable(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/runtime/inventory-sources", nil) + rec := httptest.NewRecorder() + var handler *ConnectionsHandlers + handler.HandleRuntimeInventorySources(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 (%s)", rec.Code, rec.Body.String()) + } +} + +func TestRuntimeInventorySourcesRouteAuthorizationKeepsAdminLedgerPrivate(t *testing.T) { + cfg := &config.Config{ + DataPath: t.TempDir(), + ProxyAuthSecret: "proxy-secret", + ProxyAuthUserHeader: "X-Proxy-User", + ProxyAuthRoleHeader: "X-Proxy-Roles", + ProxyAuthAdminRole: "admin", + } + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + request := func(path, roles string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret) + req.Header.Set(cfg.ProxyAuthUserHeader, "alice") + req.Header.Set(cfg.ProxyAuthRoleHeader, roles) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + return rec + } + + if rec := request("/api/runtime/inventory-sources", "viewer"); rec.Code != http.StatusOK { + t.Fatalf("viewer runtime projection = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + if rec := request("/api/connections", "viewer"); rec.Code != http.StatusForbidden { + t.Fatalf("viewer admin ledger = %d, want 403 (%s)", rec.Code, rec.Body.String()) + } + if rec := request("/api/runtime/inventory-sources", "viewer|admin"); rec.Code != http.StatusOK { + t.Fatalf("admin runtime projection = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + if rec := request("/api/connections", "viewer|admin"); rec.Code != http.StatusOK { + t.Fatalf("admin ledger = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } +} + +func TestRuntimeInventorySourcesRouteRequiresAuthenticationAndMonitoringScope(t *testing.T) { + monitoringToken := "runtime-inventory-monitoring.12345678" + settingsToken := "runtime-inventory-settings.12345678" + cfg := newTestConfigWithTokens(t, + newTokenRecord(t, monitoringToken, []string{config.ScopeMonitoringRead}, nil), + newTokenRecord(t, settingsToken, []string{config.ScopeSettingsRead}, nil), + ) + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + request := func(rawToken string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/runtime/inventory-sources", nil) + if rawToken != "" { + req.Header.Set("X-API-Token", rawToken) + } + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + return rec + } + + if rec := request(""); rec.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated status = %d, want 401 (%s)", rec.Code, rec.Body.String()) + } + if rec := request(settingsToken); rec.Code != http.StatusForbidden { + t.Fatalf("settings-only token status = %d, want 403 (%s)", rec.Code, rec.Body.String()) + } + if rec := request(monitoringToken); rec.Code != http.StatusOK { + t.Fatalf("monitoring token status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } +} + +func TestRuntimeInventorySourcesHandlerRejectsNonGET(t *testing.T) { + handler := NewConnectionsHandlers( + func(context.Context) *config.Config { return nil }, + func(context.Context) *config.ConfigPersistence { return nil }, + func(context.Context) *monitoring.Monitor { return nil }, + ) + req := httptest.NewRequest(http.MethodPost, "/api/runtime/inventory-sources", nil) + rec := httptest.NewRecorder() + handler.HandleRuntimeInventorySources(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405 (%s)", rec.Code, rec.Body.String()) + } +}