diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 9476d35d8..483d18512 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 7fc684c5d..3c9e021bc 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index d47a89f68..f6151f779 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 4fa384925..3adb8775c 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 024599337..96ebdad57 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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. diff --git a/frontend-modern/src/components/Workloads/WorkloadsStateCards.tsx b/frontend-modern/src/components/Workloads/WorkloadsStateCards.tsx index 0e2292c76..82420e65a 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsStateCards.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsStateCards.tsx @@ -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) { + 0 + } + > + + + diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsStateCards.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsStateCards.test.tsx index 8cf908f46..41296f6d9 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsStateCards.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsStateCards.test.tsx @@ -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(() => ( + [{ 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'); + }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index 2410b93cd..1f7f6e0df 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -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'); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts new file mode 100644 index 000000000..2546069cc --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.test.ts @@ -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 => + ({ + 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([]); + }); +}); diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index 3b80ca66c..1af5bac2f 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -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( + connectionsResourceKey, + async () => ConnectionsAPI.list(), + { + initialValue: EMPTY_CONNECTIONS_RESPONSE, + }, + ); const dedupeGuests = (guests: WorkloadGuest[]): WorkloadGuest[] => { const seen = new Set(); @@ -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, diff --git a/frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts b/frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts new file mode 100644 index 000000000..2f5a4d67e --- /dev/null +++ b/frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts @@ -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 = new Set([ + 'pve', + 'vmware', + 'docker', + 'kubernetes', +]); + +const WORKLOAD_SURFACE_LABELS: Record = { + 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> = { + docker: 'Docker', + kubernetes: 'Kubernetes', + pve: 'Proxmox VE', + vmware: 'VMware vCenter', +}; + +const STATE_RANK: Record = { + active: 0, + paused: 1, + pending: 2, + stale: 3, + unauthorized: 4, + unreachable: 5, +}; + +const BLOCKING_STATES: ReadonlySet = 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(); + 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); + }); diff --git a/frontend-modern/src/utils/connectionErrorPresentation.ts b/frontend-modern/src/utils/connectionErrorPresentation.ts index a521e4d38..c27bbae7a 100644 --- a/frontend-modern/src/utils/connectionErrorPresentation.ts +++ b/frontend-modern/src/utils/connectionErrorPresentation.ts @@ -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, }, diff --git a/internal/api/connections_grouping.go b/internal/api/connections_grouping.go index 116d3b4e2..9f38d45f1 100644 --- a/internal/api/connections_grouping.go +++ b/internal/api/connections_grouping.go @@ -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 diff --git a/internal/api/connections_grouping_test.go b/internal/api/connections_grouping_test.go index ed4959df2..5ab842bac 100644 --- a/internal/api/connections_grouping_test.go +++ b/internal/api/connections_grouping_test.go @@ -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) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 4db3adebd..b6a863ebc 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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", diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index c5d05433c..93401f703 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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, + } ], )