mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-22 07:23:27 +00:00
fix(workloads): serve viewer-safe inventory health
This commit is contained in:
parent
d1f687c0ea
commit
60c1c51eb9
18 changed files with 811 additions and 610 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
});
|
||||
34
frontend-modern/src/api/runtimeInventorySources.ts
Normal file
34
frontend-modern/src/api/runtimeInventorySources.ts
Normal 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 : [] };
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
166
internal/api/runtime_inventory_sources.go
Normal file
166
internal/api/runtime_inventory_sources.go
Normal 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)),
|
||||
})
|
||||
}
|
||||
262
internal/api/runtime_inventory_sources_test.go
Normal file
262
internal/api/runtime_inventory_sources_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue