Surface blocked workload inventory sources

Show workload-capable source failures on Workloads and keep matching Proxmox host agents attached to their API source when inventory collection is blocked.
This commit is contained in:
rcourtman 2026-05-14 00:22:18 +01:00
parent 649a601dca
commit 321f563a52
17 changed files with 715 additions and 12 deletions

View file

@ -2959,6 +2959,26 @@
"path": "frontend-modern/src/utils/alertOverviewPresentation.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/connectionErrorPresentation.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/connections_grouping.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/connections_grouping_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/contract_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/resources.go",
@ -3159,16 +3179,36 @@
"path": "frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Workloads/workloadSelectors.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Workloads/WorkloadsStateCards.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",

View file

@ -556,6 +556,11 @@ profile and assignment columns, but embedded table framing must route through
columns: the landing table reuses the `System` and `Endpoint` cells for a
short host fact such as OS/platform plus a reported endpoint, while the
richer agent identity facts stay in the edit/detail surface.
Exact Proxmox host-agent attachments remain backend-authored through
`/api/connections` even when Proxmox workload inventory is blocked by
credentials or reachability; lifecycle surfaces must render that attached
agent as part of the API source row and must not re-offer host install or
standalone lifecycle actions for the same physical host.
Public demo and other read-only settings posture must stay reporting-first
on that same lifecycle-owned workspace boundary: infrastructure workspace
routes may retain install and platform setup surfaces for manageable

View file

@ -93,6 +93,7 @@ product API routes free of maintainer commercial analytics.
59. `internal/api/connections_handlers.go`
60. `internal/api/connections_probe.go`
61. `frontend-modern/src/api/connections.ts`
62. `frontend-modern/src/utils/connectionErrorPresentation.ts`
62. `frontend-modern/src/api/hostedSignup.ts`
63. `internal/api/availability_handlers.go`
64. `frontend-modern/src/api/availabilityTargets.ts`
@ -856,7 +857,12 @@ the canonical monitored-system blocked payload.
discovery and settings surfaces can reconcile hostname-only and IP-only
views of the same enrolled machine instead of showing a second
"discovered" candidate row for an already represented source member or
API-plus-agent source row. Agent-backed
API-plus-agent source row. Exact host-agent matches for a configured
Proxmox primary source must also attach through the backend systems
payload when workload node inventory is absent because credentials or
reachability are blocked, so the infrastructure ledger does not split the
same physical host into an API row plus a standalone host-agent row.
Agent-backed
connections also own canonical version/update facts on that same payload:
when a source or attachment is backed by Pulse Agent, `/api/connections`
carries the

View file

@ -28,7 +28,9 @@ regression protection.
5. `internal/api/slo_bench_test.go`
6. `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`
7. `frontend-modern/src/components/Workloads/WorkloadsStateCards.tsx`
8. `frontend-modern/src/components/Workloads/WorkloadsStatsStrip.tsx`
8. `frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts`
9. `frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts`
10. `frontend-modern/src/components/Workloads/WorkloadsStatsStrip.tsx`
9. `frontend-modern/src/components/Workloads/WorkloadsTable.tsx`
10. `frontend-modern/src/components/Workloads/WorkloadPanel.tsx`
11. `frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx`
@ -209,6 +211,14 @@ regression protection.
`platformData.sources` hints, and must not reconstruct platform identity by
scanning sibling resources or making row-time API calls.
5. Extend workload hot-path filter, sort, grouping, and stats math through `frontend-modern/src/components/Workloads/workloadSelectors.ts`, and extend workload identity, discovery routing, and node-topology helpers through `frontend-modern/src/components/Workloads/workloadTopology.ts`, rather than duplicating selector or topology logic in `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`
Workloads source-health messaging must derive from the canonical
`/api/connections` ledger through
`frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts`.
The Workloads page may show a bounded partial-inventory banner when a
configured workload-capable source is unauthorized, unreachable, stale,
pending, or paused, but it must not fabricate VM/container rows from host
telemetry or hide source blockers just because another source still has
workload rows.
The retired dashboard overview route must not return as a hot-path
orientation shortcut. First-viewport system count, health, source coverage,
and freshness now belong to Infrastructure and Add infrastructure, derived

View file

@ -590,6 +590,10 @@ bypass the API fail-closed execution gate.
unknown because no trustworthy applied fingerprint exists, storage and
recovery must preserve that uncertainty instead of translating it into a
storage-local current/drifted verdict.
When `/api/connections` attaches an exact-match host agent to a blocked
Proxmox API source without fresh node inventory, storage and recovery must
treat that as one represented source with host telemetry, not as a second
protected system or a storage-local duplicate host.
22. Keep backend-native platform actions on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app control for Assistant, storage and recovery may consume the refreshed recovery points afterward, but they must not grow a parallel recovery-local action transport or action-specific payload shape.
23. Keep backend-native platform diagnostics on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app log reads for Assistant, storage and recovery may use those diagnostics during investigation, but they must not grow a parallel recovery-local log transport or diagnostic payload shape.
24. Keep backend-native platform configuration reads on the adjacent AI/runtime and platform contracts. When `internal/api/` wires native TrueNAS app config for Assistant, storage and recovery may use that runtime shape during investigation, but they must not grow a parallel recovery-local config transport or provider-shaped configuration payload.

View file

@ -1,4 +1,4 @@
import { Show } from 'solid-js';
import { For, Show } from 'solid-js';
import {
buildInfrastructureOnboardingPath,
@ -25,6 +25,7 @@ type WorkloadsStateCardsProps = Pick<
| 'kioskMode'
| 'navigate'
| 'reconnect'
| 'workloadInventoryIssues'
| 'workloads'
>;
@ -61,6 +62,66 @@ export function WorkloadsStateCards(props: WorkloadsStateCardsProps) {
</Card>
</Show>
<Show
when={
props.connected() &&
props.initialDataReceived() &&
!props.workloads.loading() &&
props.workloadInventoryIssues().length > 0
}
>
<div
role="alert"
class="mb-3 rounded-md border border-amber-400/40 bg-amber-50 px-4 py-3 text-sm text-amber-950 shadow-sm dark:border-amber-500/30 dark:bg-amber-950/30 dark:text-amber-100"
>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0 space-y-2">
<div>
<p class="text-sm font-semibold">Workload inventory is incomplete</p>
<p class="mt-1 text-xs text-amber-900/80 dark:text-amber-100/75">
One or more configured workload sources cannot currently report inventory.
</p>
</div>
<ul class="space-y-1.5">
<For each={props.workloadInventoryIssues().slice(0, 3)}>
{(issue) => (
<li class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-amber-950 dark:text-amber-50">
{issue.name}
</span>
<span class="rounded border border-amber-400/40 px-1.5 py-0.5 text-[11px] font-medium uppercase text-amber-900 dark:border-amber-400/30 dark:text-amber-100">
{issue.stateLabel}
</span>
</div>
<p class="mt-0.5 text-xs text-amber-900/85 dark:text-amber-100/80">
{issue.description}
</p>
<Show when={issue.detail}>
{(detail) => (
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/65">
{detail()}
</p>
)}
</Show>
</li>
)}
</For>
</ul>
</div>
<Show when={!props.kioskMode()}>
<button
type="button"
onClick={() => props.navigate(buildInfrastructureWorkspacePath())}
class="inline-flex shrink-0 items-center justify-center rounded-md border border-amber-500/40 bg-amber-100 px-3 py-1.5 text-xs font-medium text-amber-950 transition-colors hover:bg-amber-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:border-amber-400/30 dark:bg-amber-900/50 dark:text-amber-50 dark:hover:bg-amber-900"
>
Review sources
</button>
</Show>
</div>
</div>
</Show>
<Show
when={
props.connected() &&

View file

@ -65,6 +65,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceProps) {
kioskMode={state.kioskMode}
navigate={state.navigate}
reconnect={state.reconnectSurface}
workloadInventoryIssues={state.workloadInventoryIssues}
workloads={state.workloads}
/>

View file

@ -42,6 +42,7 @@ describe('WorkloadsStateCards', () => {
kioskMode={() => false}
navigate={navigate}
reconnect={() => undefined}
workloadInventoryIssues={() => []}
workloads={{
workloads: [] as any,
loading: () => false,
@ -96,6 +97,7 @@ describe('WorkloadsStateCards', () => {
kioskMode={() => false}
navigate={navigate}
reconnect={() => undefined}
workloadInventoryIssues={() => []}
workloads={{
workloads: [] as any,
loading: () => false,
@ -112,4 +114,76 @@ describe('WorkloadsStateCards', () => {
expect(navigate).toHaveBeenCalledWith('/settings/infrastructure');
});
it('surfaces partial workload inventory blockers even when other guests exist', () => {
const navigate = vi.fn();
render(() => (
<WorkloadsStateCards
allGuests={() => [{ id: 'guest-1' } as any]}
connected={() => true}
workloadsDisconnectedState={() => ({
title: 'Connection lost',
description: 'Unable to connect to the backend server',
actionLabel: 'Reconnect now',
})}
workloadsGuestsEmptyState={() => ({
title: 'No guests found',
description: 'No guests match your current filters',
})}
workloadsInfrastructureEmptyState={() => ({
title: 'No infrastructure sources connected',
description:
'Start in Settings → Infrastructure by choosing a source strategy. Connect a platform API for inventory and health, install Pulse Agent for host telemetry, or use both when you want full coverage.',
actionLabel: 'Add infrastructure source',
})}
workloadsLoadingState={() => ({
title: 'Loading workloads...',
description: 'Connecting to monitoring service',
})}
workloadsNoInventoryState={() => ({
title: 'No workload inventory available',
description:
'Pulse has infrastructure sources, but no VM, container, or pod inventory is available right now. Review source credentials, permissions, and collection status in Settings → Infrastructure.',
actionLabel: 'Review infrastructure sources',
})}
filteredGuests={() => [{ id: 'guest-1' } as any]}
hasInfrastructureSources={() => true}
infrastructureSourceStateReady={() => true}
initialDataReceived={() => true}
kioskMode={() => false}
navigate={navigate}
reconnect={() => undefined}
workloadInventoryIssues={() => [
{
id: 'pve:delly',
name: 'delly',
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.',
},
]}
workloads={{
workloads: [{ id: 'guest-1' }] as any,
loading: () => false,
refetch: async () => [],
mutate: (value) => (Array.isArray(value) ? value : value([])),
error: () => null,
}}
/>
));
expect(screen.getByText('Workload inventory is incomplete')).toBeInTheDocument();
expect(screen.getByText('delly')).toBeInTheDocument();
expect(screen.getByText('Credentials invalid')).toBeInTheDocument();
expect(screen.queryByText('No workload inventory available')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Review sources' }));
expect(navigate).toHaveBeenCalledWith('/settings/infrastructure');
});
});

View file

@ -25,6 +25,7 @@ import workloadsWorkloadViewportSyncSource from '../useWorkloadViewportSync.ts?r
import workloadsWorkloadRouteStateSource from '../useWorkloadRouteState.ts?raw';
import workloadsWorkloadUrlSyncSource from '../useWorkloadUrlSync.ts?raw';
import workloadsStateSource from '../useWorkloadsState.ts?raw';
import workloadInventorySourceIssuesSource from '../workloadInventorySourceIssues.ts?raw';
import groupedTableWindowingSource from '../useGroupedTableWindowing.ts?raw';
import thresholdSliderSource from '../ThresholdSlider.tsx?raw';
import thresholdSliderModelSource from '../thresholdSliderModel.ts?raw';
@ -173,6 +174,12 @@ vi.mock('@/hooks/useUnifiedResources', () => ({
},
}));
vi.mock('@/api/connections', () => ({
ConnectionsAPI: {
list: vi.fn().mockResolvedValue({ connections: [], systems: [] }),
},
}));
vi.mock('@/api/guestMetadata', () => ({
GuestMetadataAPI: { getAllMetadata: vi.fn().mockResolvedValue({}) },
}));
@ -656,6 +663,10 @@ describe('Workloads performance contract', () => {
expect(workloadsStateSource).toContain('useWorkloadSelectionState');
expect(workloadsStateSource).toContain('useWorkloadsDerivedState');
expect(workloadsStateSource).toContain('useWorkloadRouteState');
expect(workloadsStateSource).toContain('buildWorkloadInventorySourceIssues');
expect(workloadsStateCardsSource).toContain('workloadInventoryIssues');
expect(workloadInventorySourceIssuesSource).toContain('WORKLOAD_CAPABLE_TYPES');
expect(workloadInventorySourceIssuesSource).toContain('formatConnectionErrorMessage');
expect(workloadsStateSource).toContain('createWorkloadSortComparator');
expect(workloadsStateSource).toContain('filterWorkloads(params)');
expect(workloadsStateSource).not.toContain('useBreakpoint');

View file

@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import type { Connection } from '@/api/connections';
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;
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',
},
}),
]);
expect(issues).toEqual([
expect.objectContaining({
id: 'pve:delly',
name: 'delly',
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.',
}),
]);
});
it('ignores active, disabled, and non-workload sources', () => {
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 },
}),
]);
expect(issues).toEqual([]);
});
});

View file

@ -1,5 +1,6 @@
import { createEffect, createMemo } from 'solid-js';
import { createEffect, createMemo, createResource, onCleanup } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { ConnectionsAPI, type ConnectionsListResponse } from '@/api/connections';
import type { VM, Container, Node } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
import { useWebSocket } from '@/contexts/appRuntime';
@ -28,9 +29,15 @@ import { useWorkloadGuestMetadataState } from './useWorkloadGuestMetadataState';
import { useWorkloadSelectionState } from './useWorkloadSelectionState';
import { useWorkloadsDerivedState } from './useWorkloadsDerivedState';
import { useWorkloadRouteState } from './useWorkloadRouteState';
import { buildWorkloadInventorySourceIssues } from './workloadInventorySourceIssues';
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: [],
};
export interface WorkloadsSurfaceProps {
vms: VM[];
@ -59,6 +66,15 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
cacheKey: 'workloads-infrastructure-sources',
enabled: workloadsEnabled,
});
const connectionsResourceKey = createMemo(() => (workloadsEnabled() ? 'enabled' : null));
const [connectionsSnapshot, { refetch: refetchConnectionsSnapshot }] =
createResource<ConnectionsListResponse, string | null>(
connectionsResourceKey,
async () => ConnectionsAPI.list(),
{
initialValue: EMPTY_CONNECTIONS_RESPONSE,
},
);
const dedupeGuests = (guests: WorkloadGuest[]): WorkloadGuest[] => {
const seen = new Set<string>();
@ -159,6 +175,9 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
const workloadsDisconnectedState = createMemo(() =>
getWorkloadsDisconnectedState(reconnecting()),
);
const workloadInventoryIssues = createMemo(() =>
buildWorkloadInventorySourceIssues(connectionsSnapshot()?.connections ?? []),
);
const hasWorkloadsData = createMemo(() => allGuests().length > 0);
const hasInfrastructureSources = createMemo(() =>
workloadsEnabled()
@ -183,10 +202,19 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
const reconnectSurface = () => {
if (workloadsEnabled()) {
void workloads.refetch();
void refetchConnectionsSnapshot();
}
reconnect();
};
createEffect(() => {
if (!workloadsEnabled()) return;
const handle = window.setInterval(() => {
void refetchConnectionsSnapshot();
}, WORKLOADS_CONNECTIONS_POLL_INTERVAL_MS);
onCleanup(() => window.clearInterval(handle));
});
let lastConnected = connected();
let hasSeenConnectedState = connected();
createEffect(() => {
@ -392,6 +420,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
workloadsSummaryFallbackSnapshots,
workloadsSummaryRange,
workloadsSummaryVisibleIds,
workloadInventoryIssues,
workloadsNoInventoryState,
ws,
groupingMode,

View file

@ -0,0 +1,165 @@
import type { Connection, ConnectionState, ConnectionType } from '@/api/connections';
import { formatConnectionErrorMessage } from '@/utils/connectionErrorPresentation';
export interface WorkloadInventorySourceIssue {
id: string;
name: string;
typeLabel: string;
state: ConnectionState;
stateLabel: string;
coverageLabel: string;
description: string;
detail?: string;
}
const WORKLOAD_CAPABLE_TYPES: ReadonlySet<ConnectionType> = new Set([
'pve',
'vmware',
'docker',
'kubernetes',
]);
const WORKLOAD_SURFACE_LABELS: Record<string, string> = {
containers: 'containers',
docker: 'containers',
kubernetes: 'Kubernetes workloads',
pods: 'pods',
vms: 'VMs',
};
const WORKLOAD_SURFACE_ORDER = ['vms', 'containers', 'docker', 'pods', 'kubernetes'];
const CONNECTION_TYPE_LABELS: Partial<Record<ConnectionType, string>> = {
docker: 'Docker',
kubernetes: 'Kubernetes',
pve: 'Proxmox VE',
vmware: 'VMware vCenter',
};
const STATE_RANK: Record<ConnectionState, number> = {
active: 0,
paused: 1,
pending: 2,
stale: 3,
unauthorized: 4,
unreachable: 5,
};
const BLOCKING_STATES: ReadonlySet<ConnectionState> = new Set([
'paused',
'pending',
'stale',
'unauthorized',
'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 seen = new Set<string>();
const labels: string[] = [];
const orderedSurfaces = [...surfaces].sort((left, right) => {
const leftRank = WORKLOAD_SURFACE_ORDER.indexOf(left);
const rightRank = WORKLOAD_SURFACE_ORDER.indexOf(right);
const normalizedLeftRank = leftRank === -1 ? WORKLOAD_SURFACE_ORDER.length : leftRank;
const normalizedRightRank = rightRank === -1 ? WORKLOAD_SURFACE_ORDER.length : rightRank;
if (normalizedLeftRank !== normalizedRightRank) return normalizedLeftRank - normalizedRightRank;
return left.localeCompare(right);
});
for (const surface of orderedSurfaces) {
const label = WORKLOAD_SURFACE_LABELS[surface];
if (!label || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels;
};
const formatCoverage = (labels: readonly string[]): string => {
if (labels.length === 0) return 'workload inventory';
if (labels.length === 1) return labels[0] ?? 'workload inventory';
if (labels.length === 2) return `${labels[0]} and ${labels[1]}`;
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) {
case 'paused':
return 'Collection paused';
case 'pending':
return 'Collection pending';
case 'stale':
return 'Collection stale';
case 'unreachable':
return 'Source unreachable';
case 'unauthorized':
return 'Credentials invalid';
default:
return 'Collection blocked';
}
};
const descriptionFor = (
connection: Connection,
typeLabel: string,
coverageLabel: string,
): string => {
if (credentialInvalid(connection)) {
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but its ${typeLabel} API credentials are invalid.`;
}
switch (connection.state) {
case 'paused':
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but collection is paused.`;
case 'pending':
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but collection has not completed yet.`;
case 'stale':
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but the last inventory data is stale.`;
case 'unreachable':
return `Pulse has ${coverageLabel} enabled for ${connection.name}, but the ${typeLabel} API is unreachable.`;
default:
return `Pulse has ${coverageLabel} enabled for ${connection.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;
export const buildWorkloadInventorySourceIssues = (
connections: readonly Connection[],
): 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;
return {
id: connection.id,
name: connection.name,
typeLabel,
state: connection.state,
stateLabel: stateLabelFor(connection),
coverageLabel,
description: descriptionFor(connection, typeLabel, coverageLabel),
detail: compactDetail(connection.lastError?.message ?? connection.stateReason),
};
})
.sort((left, right) => {
const stateDelta = STATE_RANK[right.state] - STATE_RANK[left.state];
if (stateDelta !== 0) return stateDelta;
return left.name.localeCompare(right.name);
});

View file

@ -68,7 +68,7 @@ const HUMANIZED_PATTERNS: { match: RegExp; headline: string; hint: string | null
hint: TLS_HINT,
},
{
match: /\b401\b|unauthorized|invalid (?:credentials|api token)/i,
match: /\b401\b|unauthorized|authentication failed|invalid (?:credentials|api token)/i,
headline: 'Authentication failed',
hint: AUTH_HINT,
},

View file

@ -538,6 +538,18 @@ func resolveAgentAttachments(
}
}
for _, agent := range sortedConnectionsByID(connectionByID) {
if agent.Type != ConnectionTypeAgent {
continue
}
if _, exists := attachments[agent.ID]; exists {
continue
}
if primaryID := directProxmoxHostAttachment(agent, connectionByID); primaryID != "" {
register(agent.ID, primaryID, false, false)
}
}
for agentID, primaryID := range attachments {
if primaryID == "" {
delete(attachments, agentID)
@ -547,6 +559,41 @@ func resolveAgentAttachments(
return attachments
}
func sortedConnectionsByID(connectionByID map[string]Connection) []Connection {
connections := make([]Connection, 0, len(connectionByID))
for _, connection := range connectionByID {
connections = append(connections, connection)
}
sort.Slice(connections, func(i, j int) bool {
return connections[i].ID < connections[j].ID
})
return connections
}
func directProxmoxHostAttachment(
agent Connection,
connectionByID map[string]Connection,
) string {
if agent.Type != ConnectionTypeAgent {
return ""
}
var matchedID string
for _, primary := range sortedConnectionsByID(connectionByID) {
if primary.Type != ConnectionTypePVE || !primary.Enabled {
continue
}
if !connectionsShareHost(agent, primary) {
continue
}
if matchedID != "" {
return ""
}
matchedID = primary.ID
}
return matchedID
}
func buildProxmoxClusterNames(
connectionByID map[string]Connection,
monitor *monitoring.Monitor,
@ -679,7 +726,9 @@ func normalizedConnectionHost(connection Connection) string {
func connectionHostCandidates(connection Connection) []string {
seen := make(map[string]struct{}, 2)
out := make([]string, 0, 2)
for _, candidate := range []string{connection.Address, connection.Name} {
candidates := []string{connection.Address, connection.Name}
candidates = append(candidates, connection.HostAliases...)
for _, candidate := range candidates {
normalized := normalizeHost(candidate)
if normalized == "" {
continue

View file

@ -426,3 +426,84 @@ func TestBuildConnectionSystems_GuestAgentStaysStandaloneWhenOnlyClusterInstance
t.Fatalf("guest agent should remain standalone, got %+v", guestSystem.Components)
}
}
func TestBuildConnectionSystems_AttachesHostAgentToMatchingProxmoxSourceWithoutNodeInventory(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
monitor, err := monitoring.New(cfg)
if err != nil {
t.Fatalf("monitoring.New: %v", err)
}
t.Cleanup(func() { monitor.Stop() })
now := time.Date(2026, 5, 13, 23, 45, 0, 0, time.UTC)
connections := []Connection{
{
ID: "pve:delly",
Type: ConnectionTypePVE,
Name: "delly",
Address: "https://delly:8006",
HostAliases: []string{"delly"},
State: ConnectionStateUnauthorized,
Enabled: true,
Surfaces: []string{"vms", "containers", "storage", "backups"},
Scope: map[string]bool{"vms": true, "containers": true, "storage": true, "backups": true},
Source: ConnectionSourceAgent,
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
},
{
ID: "agent:agent-delly",
Type: ConnectionTypeAgent,
Name: "delly",
Address: "delly",
HostAliases: []string{"delly", "192.168.0.5"},
State: ConnectionStateActive,
Enabled: true,
Surfaces: []string{"host"},
Scope: map[string]bool{"host": true},
LastSeen: &now,
Source: ConnectionSourceAgent,
Capabilities: ConnectionCapabilities{SupportsPause: false, SupportsScope: false, SupportsTest: false},
},
{
ID: "agent:agent-minipc",
Type: ConnectionTypeAgent,
Name: "minipc",
Address: "minipc",
HostAliases: []string{"minipc", "192.168.0.134"},
State: ConnectionStateActive,
Enabled: true,
Surfaces: []string{"host"},
Scope: map[string]bool{"host": true},
LastSeen: &now,
Source: ConnectionSourceAgent,
Capabilities: ConnectionCapabilities{SupportsPause: false, SupportsScope: false, SupportsTest: false},
},
}
systems := buildConnectionSystems(connections, monitor)
systemsByID := make(map[string]ConnectionSystem, len(systems))
for _, system := range systems {
systemsByID[system.ID] = system
}
dellySystem := systemsByID["pve:delly"]
if len(dellySystem.Components) != 2 {
t.Fatalf("expected delly API and agent components, got %+v", dellySystem.Components)
}
componentRoles := make(map[string]ConnectionSystemComponentRole, len(dellySystem.Components))
for _, component := range dellySystem.Components {
componentRoles[component.ConnectionID] = component.Role
}
if componentRoles["pve:delly"] != ConnectionSystemComponentRolePrimary {
t.Fatalf("pve:delly role = %q, want %q", componentRoles["pve:delly"], ConnectionSystemComponentRolePrimary)
}
if componentRoles["agent:agent-delly"] != ConnectionSystemComponentRoleAttachment {
t.Fatalf("agent:agent-delly role = %q, want %q", componentRoles["agent:agent-delly"], ConnectionSystemComponentRoleAttachment)
}
minipcSystem := systemsByID["agent:agent-minipc"]
if len(minipcSystem.Components) != 1 || minipcSystem.Components[0].Role != ConnectionSystemComponentRolePrimary {
t.Fatalf("minipc should remain standalone without a direct source host match, got %+v", minipcSystem.Components)
}
}

View file

@ -13045,6 +13045,86 @@ func TestContract_ConnectionSystemMembersPayloadShapeStaysCanonical(t *testing.T
assertJSONSnapshot(t, body, want)
}
func TestContract_ConnectionSystemHostAgentAttachmentPayloadShapeStaysCanonical(t *testing.T) {
response := ConnectionsListResponse{
Connections: []Connection{
{
ID: "pve:delly",
Type: ConnectionTypePVE,
Name: "delly",
Address: "https://delly:8006",
HostAliases: []string{"delly"},
State: ConnectionStateUnauthorized,
Enabled: true,
Surfaces: []string{"vms", "containers", "storage", "backups"},
Scope: map[string]bool{"vms": true, "containers": true, "storage": true, "backups": true},
Source: ConnectionSourceAgent,
Fleet: ConnectionFleetGovernance{
EnrollmentState: fleetStateConfigured,
LivenessState: string(ConnectionStateUnauthorized),
VersionDrift: fleetStateNotApplicable,
AdapterHealth: fleetStateBlocked,
ConfigRollout: fleetStateConfigured,
CredentialStatus: fleetStateInvalid,
UpdateStatus: fleetStateNotApplicable,
RemoteControl: fleetStateNotApplicable,
},
Capabilities: ConnectionCapabilities{SupportsPause: true, SupportsScope: true, SupportsTest: true},
},
{
ID: "agent:agent-delly",
Type: ConnectionTypeAgent,
Name: "delly",
Address: "delly",
HostAliases: []string{"delly", "192.168.0.5"},
State: ConnectionStateActive,
Enabled: true,
Surfaces: []string{"host"},
Scope: map[string]bool{"host": true},
LastSeen: timePtr(time.Date(2026, 5, 13, 23, 45, 0, 0, time.UTC)),
Source: ConnectionSourceAgent,
Fleet: ConnectionFleetGovernance{
EnrollmentState: fleetStateEnrolled,
LivenessState: fleetStateActive,
VersionDrift: fleetStateUnknown,
AdapterHealth: fleetStateHealthy,
ConfigRollout: fleetStateReported,
CredentialStatus: fleetStateVerified,
UpdateStatus: fleetStateUnknown,
RemoteControl: fleetStateDisabled,
},
Capabilities: ConnectionCapabilities{SupportsPause: false, SupportsScope: false, SupportsTest: false},
},
},
Systems: []ConnectionSystem{
{
ID: "pve:delly",
Type: ConnectionTypePVE,
Components: []ConnectionSystemComponent{
{
ConnectionID: "pve:delly",
Type: ConnectionTypePVE,
Role: ConnectionSystemComponentRolePrimary,
},
{
ConnectionID: "agent:agent-delly",
Type: ConnectionTypeAgent,
Role: ConnectionSystemComponentRoleAttachment,
},
},
},
},
}
body, err := json.Marshal(response)
if err != nil {
t.Fatalf("marshal ConnectionsListResponse with direct host agent attachment: %v", err)
}
want := `{"connections":[{"id":"pve:delly","type":"pve","name":"delly","address":"https://delly:8006","hostAliases":["delly"],"state":"unauthorized","enabled":true,"surfaces":["vms","containers","storage","backups"],"scope":{"backups":true,"containers":true,"storage":true,"vms":true},"source":"agent","fleet":{"enrollmentState":"configured","livenessState":"unauthorized","versionDrift":"not-applicable","adapterHealth":"blocked","configRollout":"configured","credentialStatus":"invalid","updateStatus":"not-applicable","remoteControl":"not-applicable"},"capabilities":{"supportsPause":true,"supportsScope":true,"supportsTest":true}},{"id":"agent:agent-delly","type":"agent","name":"delly","address":"delly","hostAliases":["delly","192.168.0.5"],"state":"active","enabled":true,"surfaces":["host"],"scope":{"host":true},"lastSeen":"2026-05-13T23:45:00Z","source":"agent","fleet":{"enrollmentState":"enrolled","livenessState":"active","versionDrift":"unknown","adapterHealth":"healthy","configRollout":"reported","credentialStatus":"verified","updateStatus":"unknown","remoteControl":"disabled"},"capabilities":{"supportsPause":false,"supportsScope":false,"supportsTest":false}}],"systems":[{"id":"pve:delly","type":"pve","components":[{"connectionId":"pve:delly","type":"pve","role":"primary"},{"connectionId":"agent:agent-delly","type":"agent","role":"attachment"}]}]}`
assertJSONSnapshot(t, body, want)
}
func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) {
conn := Connection{
ID: "agent:host-1",

View file

@ -3625,12 +3625,12 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
api_match["matched_contract_references"],
[
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 203,
"heading_line": 106,
}
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 204,
"heading_line": 107,
}
],
)