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())
+ }
+}