fix(workloads): serve viewer-safe inventory health

This commit is contained in:
rcourtman 2026-08-08 03:36:55 +01:00
parent d1f687c0ea
commit 60c1c51eb9
18 changed files with 811 additions and 610 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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",

View file

@ -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.

View file

@ -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."
]
}

View file

@ -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: [] });
});
});

View file

@ -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<RuntimeInventorySourcesResponse> {
const response = await apiFetchJSON<RuntimeInventorySourcesWireResponse>(this.baseUrl);
return { sources: Array.isArray(response.sources) ? response.sources : [] };
}
}

View file

@ -52,9 +52,6 @@ function WorkloadInventoryIssueList(props: { issues: readonly WorkloadInventoryS
{issue.stateLabel}: {issue.name}
</p>
<p class="text-sm leading-6 text-muted">{issue.description}</p>
<Show when={issue.detail}>
<p class="text-xs leading-5 text-muted">{issue.detail}</p>
</Show>
</div>
<span class="shrink-0 rounded bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-950 dark:text-amber-200">
{issue.coverageLabel}

View file

@ -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<Record<string, unknown>>) => {
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(() => <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
@ -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(() => <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />);
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(() => (
<Router>
@ -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<ConnectionsListResponse');
expect(workloadsStateSource).toContain('connectionsSnapshot.refetch({ background: true })');
expect(workloadsStateSource).not.toContain('createResource<ConnectionsListResponse');
expect(workloadsStateSource).toContain('RuntimeInventorySourcesAPI.list()');
expect(workloadsStateSource).toContain(
'inventorySourcesSnapshot.refetch({ background: true })',
);
expect(workloadsStateSource).not.toContain('ConnectionsAPI');
expect(workloadsStateSource).not.toContain('ConnectionsListResponse');
expect(workloadsStateSource).not.toContain('workloads-connections:');
expect(workloadInventorySourceIssuesSource).toContain('WORKLOAD_CAPABLE_TYPES');
expect(workloadInventorySourceIssuesSource).toContain('formatConnectionErrorMessage');
expect(workloadInventorySourceIssuesSource).not.toContain('formatConnectionErrorMessage');
expect(workloadsStateSource).toContain('createWorkloadSortComparator');
expect(workloadsStateSource).toContain('filterWorkloads(params)');
expect(workloadsStateSource).not.toContain('useBreakpoint');

View file

@ -1,458 +1,75 @@
import { describe, expect, it } from 'vitest';
import type { Connection, ConnectionFleetGovernance } from '@/api/connections';
import type { RuntimeInventorySource } from '@/api/runtimeInventorySources';
import { buildWorkloadInventorySourceIssues } from '../workloadInventorySourceIssues';
const fleet = (overrides: Partial<ConnectionFleetGovernance> = {}): 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> = {}): RuntimeInventorySource => ({
type: 'pve',
name: 'source',
state: 'paused',
surfaces: ['vms'],
...overrides,
});
const connection = (overrides: Partial<Connection>): 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([]);
});
});

View file

@ -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>): 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> = {}): 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([]);

View file

@ -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<ConnectionsListResponse, string>({
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));
});

View file

@ -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<ConnectionType> = new Set([
const WORKLOAD_CAPABLE_TYPES: ReadonlySet<RuntimeInventorySourceType> = new Set([
'pve',
'vmware',
'docker',
@ -29,15 +30,14 @@ const WORKLOAD_SURFACE_LABELS: Record<string, string> = {
};
const WORKLOAD_SURFACE_ORDER = ['vms', 'containers', 'docker', 'pods', 'kubernetes'];
const CONNECTION_TYPE_LABELS: Partial<Record<ConnectionType, string>> = {
const CONNECTION_TYPE_LABELS: Record<RuntimeInventorySourceType, string> = {
docker: 'Docker',
kubernetes: 'Kubernetes',
pve: 'Proxmox VE',
vmware: 'VMware vCenter',
};
const STATE_RANK: Record<ConnectionState, number> = {
active: 0,
const STATE_RANK: Record<RuntimeInventorySourceState, number> = {
paused: 1,
pending: 2,
stale: 3,
@ -45,7 +45,7 @@ const STATE_RANK: Record<ConnectionState, number> = {
unreachable: 5,
};
const BLOCKING_STATES: ReadonlySet<ConnectionState> = new Set([
const BLOCKING_STATES: ReadonlySet<RuntimeInventorySourceState> = new Set([
'paused',
'pending',
'stale',
@ -53,16 +53,8 @@ const BLOCKING_STATES: ReadonlySet<ConnectionState> = 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<string>();
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) => {

View file

@ -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",

View file

@ -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) {

View file

@ -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)),
})
}

View file

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