diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index ee3b4ea7c..8e539282b 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -228,7 +228,7 @@ an add-only capacity posture. Those lifecycle-owned settings hooks may consume websocket state only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx` or recreate root-shell providers. Discovery configuration is part of that same lifecycle-owned workspace boundary. `InfrastructureSourceManager.tsx` must open one canonical discovery editor through `InfrastructureDiscoverySettingsDialog.tsx`, `DiscoverySettingsForm.tsx`, and `discoverySettingsModel.ts`, while the System/Network shell stays limited to network-boundary controls instead of reintroducing a second editable discovery surface. That same workspace boundary now owns the add-flow entry split too: the landing strip opens the grouped `Add infrastructure` picker, while `Detect from address` remains a picker-owned utility route instead of a competing top-level header action. The same lifecycle-owned workspace boundary now also owns attached-agent composition. When a unified Pulse Agent augments a first-class platform source such as Proxmox VE, the source manager and edit dialog must present one primary platform row with explicit `API` plus `Pulse Agent` composition rather than duplicating that same machine as a second peer row under a generic Pulse Agent platform bucket. Standalone hosts with no owning platform source remain grouped under a standalone-host owner bucket, with `Pulse Agent` shown as the collection method rather than as the pseudo-platform label. - When that primary Proxmox source is cluster-backed, the same workspace boundary must render the row under the canonical cluster moniker carried by the backend grouping contract rather than under one sibling node's hostname. Cluster-member node agents belong as augmentations on that owning Proxmox row, not as separate standalone-host peers. + When that primary Proxmox source is cluster-backed, the same workspace boundary must render the row under the canonical cluster moniker carried by the backend grouping contract rather than under one sibling node's hostname. That same backend grouping contract must also carry the explicit member-node list for the cluster so the source manager can render child node composition such as `delly` and `minipc` beneath the owning cluster row with per-node coverage/status. Cluster-member node agents belong as augmentations on that owning Proxmox row and its child nodes, not as separate standalone-host peers. That same lifecycle-owned workspace boundary also owns agent version posture on infrastructure settings. The landing table stays compact and should only raise row-level attention when an attached or standalone agent diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index feb3046d5..667a80b3f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -423,7 +423,11 @@ the canonical monitored-system blocked payload. can prove they augment the same source. When the owning source is a Proxmox cluster, that same backend-authored system payload must also carry the canonical cluster identity so the frontend can label the row by - cluster moniker instead of by one endpoint node's hostname. Agent-backed + cluster moniker instead of by one endpoint node's hostname. That grouped + payload must also carry the backend-authored cluster member collection + with node identity, endpoint, node-local status, and any linked agent + connection id so the frontend can render child node composition without + reverse-engineering it from standalone agent rows. 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/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4499c5cd1..f04790c7a 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -327,7 +327,10 @@ work extends shared components instead of creating new local variants. backend marks a grouped Proxmox row with canonical cluster identity, the table primitive must render that cluster moniker as the row title instead of falling back to one sibling node hostname or reopening a standalone-host - presentation for cluster-member agents. + presentation for cluster-member agents. When that grouped row also carries + backend-authored cluster members, the table primitive must render those + nodes as child composition beneath the cluster row rather than flattening + them back into peer top-level systems or hiding them entirely. Phase 9 retired the parallel reporting/inventory surface entirely: `useInfrastructureReportingState`, `InfrastructureOperationsController`, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 2ff07508b..31d05005b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -242,7 +242,11 @@ querying, and the operator-facing storage health presentation layer. taxonomy. When that grouped platform row is a Proxmox cluster, storage and recovery must also treat the backend-authored cluster moniker as the canonical row identity instead of re-expanding cluster-member agents into - sibling host rows or per-node storage owners. The same shared `/api/connections` payload also owns any + sibling host rows or per-node storage owners. If the grouped row carries + backend-authored cluster member nodes, adjacent storage/recovery surfaces + may use that composition for explanatory UI only; they must not promote + those child nodes into a second top-level grouped-system taxonomy or infer + per-node storage ownership from the settings payload. The same shared `/api/connections` payload also owns any agent-version/update facts carried alongside those grouped rows; adjacent storage or recovery surfaces may reuse that signal for operator context, but must not fork their own version-comparison semantics or another agent diff --git a/frontend-modern/src/api/__tests__/connections.test.ts b/frontend-modern/src/api/__tests__/connections.test.ts index 76a38d0b9..7ae8e210c 100644 --- a/frontend-modern/src/api/__tests__/connections.test.ts +++ b/frontend-modern/src/api/__tests__/connections.test.ts @@ -42,6 +42,16 @@ describe('ConnectionsAPI', () => { type: 'pve', clusterName: 'homelab', components: [{ connectionId: 'pve-lab', type: 'pve', role: 'primary' }], + members: [ + { + id: 'node-lab', + name: 'lab', + endpoint: 'https://lab:8006', + state: 'active', + lastSeen: '2026-04-23T12:00:00Z', + primary: true, + }, + ], }, ]; mockedApiFetchJSON.mockResolvedValueOnce({ connections, systems }); diff --git a/frontend-modern/src/api/connections.ts b/frontend-modern/src/api/connections.ts index f743f0fe9..3c0ea4636 100644 --- a/frontend-modern/src/api/connections.ts +++ b/frontend-modern/src/api/connections.ts @@ -59,11 +59,22 @@ export interface ConnectionSystemComponent { role: ConnectionSystemComponentRole; } +export interface ConnectionSystemMember { + id: string; + name: string; + endpoint?: string; + state: ConnectionState; + lastSeen?: string | null; + primary?: boolean; + agentConnectionId?: string; +} + export interface ConnectionSystem { id: string; type: ConnectionType; clusterName?: string; components: ConnectionSystemComponent[]; + members?: ConnectionSystemMember[]; } export interface ConnectionsListResponse { diff --git a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx index 742c224fa..00f19ddc9 100644 --- a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx @@ -1,4 +1,13 @@ -import { For, Show, createMemo, type Accessor, type Component } from 'solid-js'; +import { + For, + Show, + createMemo, + createSignal, + onCleanup, + onMount, + type Accessor, + type Component, +} from 'solid-js'; import { Plus, RotateCw, Server, SlidersHorizontal } from 'lucide-solid'; import SettingsPanel from '@/components/shared/SettingsPanel'; import { @@ -11,6 +20,8 @@ import { } from '@/components/shared/Table'; import { connectionAgentVersionPresentation, + infrastructureSourcePresentation, + surfaceLabel, type InfrastructureSystemRow, } from './connectionsTableModel'; import type { DiscoveredServer, DiscoveryScanStatus } from './infrastructureSettingsModel'; @@ -27,6 +38,7 @@ interface InfrastructureSourceManagerProps { discoveryScanStatus: Accessor; readOnly: boolean; onAddSource?: (type: InfrastructureOnboardingConnectionType) => void; + onAddInfrastructure?: () => void; onRunDiscovery?: () => void; onOpenDiscoverySettings?: () => void; onOpenConnection?: (row: InfrastructureSystemRow) => void; @@ -37,8 +49,6 @@ const inlineButtonClass = 'inline-flex min-w-[4.5rem] items-center justify-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60'; const addSectionButtonClass = 'inline-flex min-w-[4.5rem] items-center justify-center gap-1.5 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-200 dark:hover:bg-blue-900/40'; -const primaryToolbarButtonClass = - 'inline-flex items-center justify-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-200 dark:hover:bg-blue-900/40'; const utilityToolbarButtonClass = 'inline-flex items-center gap-1.5 rounded-md px-1 py-1 text-sm font-medium text-muted transition-colors hover:text-base-content disabled:cursor-not-allowed disabled:opacity-60'; const discoveryRowClass = @@ -84,8 +94,11 @@ const discoveredServerName = (server: DiscoveredServer): string => const discoveredServerEndpoint = (server: DiscoveredServer): string => `https://${server.hostname?.trim() || server.ip}:${server.port}`; -const discoveredCoverageText = (server: DiscoveredServer): string => - getInfrastructureOnboardingProductPresentation(server.type).coverage; +const discoveredCoverageText = (server: DiscoveredServer): string => { + const keys = getInfrastructureOnboardingProductPresentation(server.type).defaultSurfaceKeys; + if (keys.length === 0) return ''; + return keys.map(surfaceLabel).join(', '); +}; const agentMethodTitleFor = (row: InfrastructureSystemRow): string | undefined => { const agentConnections = row.attachedConnections.filter( @@ -101,6 +114,15 @@ const agentMethodTitleFor = (row: InfrastructureSystemRow): string | undefined = return `${agentConnections.length} Pulse Agent attachments`; }; +const memberMethodTitleFor = (row: InfrastructureSystemRow, memberIndex: number): string | undefined => { + const member = row.members[memberIndex]; + if (!member?.agentConnection) return undefined; + return ( + connectionAgentVersionPresentation(member.agentConnection)?.title ?? + 'Pulse Agent attached to cluster member' + ); +}; + export const InfrastructureSourceManager: Component = (props) => { const products = createMemo(() => getInfrastructureSourceManagerProducts()); const productRank = createMemo(() => { @@ -150,47 +172,82 @@ export const InfrastructureSourceManager: Component - [...products()].sort((left, right) => { - const configuredDifference = - (groupedConfiguredRows().get(right.type)?.length ?? 0) - - (groupedConfiguredRows().get(left.type)?.length ?? 0); - if (configuredDifference !== 0) return configuredDifference; + [...products()] + .filter((product) => { + const configuredCount = groupedConfiguredRows().get(product.type)?.length ?? 0; + const discoveredCount = groupedDiscoveredRows().get(product.type)?.length ?? 0; + return configuredCount + discoveredCount > 0; + }) + .sort((left, right) => { + const configuredDifference = + (groupedConfiguredRows().get(right.type)?.length ?? 0) - + (groupedConfiguredRows().get(left.type)?.length ?? 0); + if (configuredDifference !== 0) return configuredDifference; - const discoveredDifference = - (groupedDiscoveredRows().get(right.type)?.length ?? 0) - - (groupedDiscoveredRows().get(left.type)?.length ?? 0); - if (discoveredDifference !== 0) return discoveredDifference; + const discoveredDifference = + (groupedDiscoveredRows().get(right.type)?.length ?? 0) - + (groupedDiscoveredRows().get(left.type)?.length ?? 0); + if (discoveredDifference !== 0) return discoveredDifference; - return (productRank().get(left.type) ?? 0) - (productRank().get(right.type) ?? 0); - }), + return (productRank().get(left.type) ?? 0) - (productRank().get(right.type) ?? 0); + }), ); + const hasAnyConfigured = createMemo(() => props.rows().length > 0); + const hasAnyDiscovered = createMemo(() => props.discoveredNodes().length > 0); + const rowInteractive = (row: InfrastructureSystemRow): boolean => !props.readOnly && Boolean(props.onOpenConnection) && (row.canEdit || row.isAgent); const actionColumnVisible = () => !props.readOnly; - const discoveredCount = createMemo(() => props.discoveredNodes().length); - const discoveryErrors = createMemo(() => props.discoveryScanStatus().errors ?? []); const lastDiscoveryResultText = createMemo(() => formatRelativeTimestamp(props.discoveryScanStatus().lastResultAt), ); - const discoverySummary = createMemo(() => { - const parts: string[] = []; - parts.push(props.discoveryEnabled ? 'Automatic discovery on' : 'Automatic discovery off'); - if (props.discoveryScanStatus().scanning) { - parts.push('Scanning now'); - } - if (discoveredCount() > 0) { - parts.push(`${discoveredCount()} candidate${discoveredCount() === 1 ? '' : 's'}`); - } - if (lastDiscoveryResultText()) { - parts.push(`Updated ${lastDiscoveryResultText()}`); - } - if (discoveryErrors().length > 0) { - parts.push(`${discoveryErrors().length} issue${discoveryErrors().length === 1 ? '' : 's'}`); - } - return parts; + + const [viewportWidth, setViewportWidth] = createSignal( + typeof window !== 'undefined' ? window.innerWidth : 1024, + ); + onMount(() => { + const handler = () => setViewportWidth(window.innerWidth); + window.addEventListener('resize', handler); + onCleanup(() => window.removeEventListener('resize', handler)); }); + const useCardLayout = createMemo(() => viewportWidth() < 768); + + const headerActions = () => ( + +
+ + + + + + + +
+
+ ); return ( } + action={headerActions()} > -
-
-
-
- Discovery - {discoverySummary().join(' · ')} -
-
- - -
- - - - - - - -
-
-
-
- + - + System - + + Source + + Endpoint - + Coverage - + Status - + Actions @@ -279,7 +299,7 @@ export const InfrastructureSourceManager: Component - +
{product.label}
@@ -288,7 +308,7 @@ export const InfrastructureSourceManager: Component - +
{product.label}
@@ -317,26 +337,29 @@ export const InfrastructureSourceManager: Component -
-
-
- {row.name} -
-
- -
- {row.subtitle} -
-
+
+ {row.name}
+ + {(() => { + const presentation = infrastructureSourcePresentation(row.source); + const title = agentMethodTitleFor(row) ?? presentation.title; + return ( + + {presentation.label} + + ); + })()} + + -
+
{row.statusLabel} 0}> - + {row.agentUpdateCount === 1 ? 'Agent update' : `${row.agentUpdateCount} agent updates`} - + {row.lastActivityText}
@@ -406,7 +429,7 @@ export const InfrastructureSourceManager: Component
+ + 0}> + + {(member, memberIndex) => { + const memberPresentation = infrastructureSourcePresentation( + member.source, + ); + const memberSourceTitle = + memberMethodTitleFor(row, memberIndex()) ?? + memberPresentation.title; + return ( + + +
+ +
+
+ {member.name} +
+
+ {member.subtitle} +
+
+
+
+ + + + {memberPresentation.label} + + + + + -} + > +
+ {member.host} +
+
+
+ + + 0} + fallback={-} + > +
+ {member.coverageLabels.join(', ')} +
+
+
+ + +
+ + {member.statusLabel} + + + {member.lastActivityText} + +
+
+ + + + Managed by cluster + + +
+ ); + }} +
+
); }} @@ -430,16 +542,23 @@ export const InfrastructureSourceManager: Component -
-
- {discoveredServerName(server)} -
+
+ {discoveredServerName(server)}
+ + + Candidate + + +
-
- +
+ Discovered - + {lastDiscoveryResultText() ?? 'Waiting for scan'}
@@ -494,8 +613,284 @@ export const InfrastructureSourceManager: Component + + + + + No infrastructure configured yet. + + + + + + + + + + +
+
+ + +
+ + {(product) => { + const configuredRows = () => groupedConfiguredRows().get(product.type) ?? []; + const discoveredRows = () => groupedDiscoveredRows().get(product.type) ?? []; + + return ( +
+
+

{product.label}

+ + + +
+ + + {(row) => { + const presentation = infrastructureSourcePresentation(row.source); + const sourceTitle = agentMethodTitleFor(row) ?? presentation.title; + return ( +
+
+
+ {row.name} +
+ + {presentation.label} + +
+ + +
+ {row.host} +
+
+ + 0}> +
+ {row.coverageLabels.join(', ')} +
+
+ + 0}> +
+
+ Cluster nodes +
+
+ + {(member, memberIndex) => { + const memberPresentation = infrastructureSourcePresentation( + member.source, + ); + const memberSourceTitle = + memberMethodTitleFor(row, memberIndex()) ?? + memberPresentation.title; + return ( +
+
+
+
+ {member.name} +
+
+ {member.subtitle} +
+
+ + {memberPresentation.label} + +
+ + +
+ {member.host} +
+
+ + 0}> +
+ {member.coverageLabels.join(', ')} +
+
+ +
+ + {member.statusLabel} + + + {member.lastActivityText} + +
+
+ ); + }} +
+
+
+
+ +
+
+ + {row.statusLabel} + + 0}> + + {row.agentUpdateCount === 1 + ? 'Agent update' + : `${row.agentUpdateCount} agent updates`} + + + + {row.lastActivityText} + +
+ + + +
+ + + + +
+ ); + }} +
+ + + {(server) => ( +
+
+
+ {discoveredServerName(server)} +
+ + Candidate + +
+ +
+ {discoveredServerEndpoint(server)} +
+ +
+ {discoveredCoverageText(server)} +
+ +
+
+ + Discovered + + + {lastDiscoveryResultText() ?? 'Waiting for scan'} + +
+ + + +
+
+ )} +
+
+ ); + }} +
+ + +
+ No infrastructure configured yet. +
+
+ + +
+ +
+
+
+
); }; diff --git a/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx b/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx index fb2f0fa4b..0ccf21554 100644 --- a/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ConnectionsTable.test.tsx @@ -29,6 +29,7 @@ const row = (overrides: Partial = {}): InfrastructureSy ownerType: overrides.ownerType ?? connection.type, name: overrides.name ?? 'tower', subtitle: undefined, + source: 'api', host: '10.0.0.1', coverageLabels: ['Host telemetry'], statusLabel: 'online', @@ -42,6 +43,7 @@ const row = (overrides: Partial = {}): InfrastructureSy canRemove: connection.type !== 'docker' && connection.type !== 'kubernetes', isAgent: connection.type === 'agent', attachedConnections: [], + members: [], connection, ...overrides, }; diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 710842eea..7916d4b7c 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -84,6 +84,7 @@ vi.mock('../useConnectionsLedger', () => ({ canRemove: connection.type !== 'docker' && connection.type !== 'kubernetes', isAgent: connection.type === 'agent', attachedConnections: [], + members: [], connection, })), findById: (id: string) => @@ -437,6 +438,7 @@ describe('InfrastructureWorkspace', () => { canRemove: true, isAgent: false, attachedConnections: [attachedAgent], + members: [], connection: primaryConnection, }, ]; @@ -502,6 +504,34 @@ describe('InfrastructureWorkspace', () => { canRemove: true, isAgent: false, attachedConnections: [dellyAgent, minipcAgent], + members: [ + { + id: 'node-delly', + name: 'delly', + subtitle: 'Primary cluster node', + source: 'both', + host: 'https://delly:8006', + coverageLabels: ['Host telemetry'], + statusLabel: 'Active', + statusClassName: 'bg-green-100 text-green-800', + lastActivityText: '1m ago', + primary: true, + agentConnection: dellyAgent, + }, + { + id: 'node-minipc', + name: 'minipc', + subtitle: 'Cluster member', + source: 'both', + host: 'https://minipc:8006', + coverageLabels: ['Host telemetry'], + statusLabel: 'Active', + statusClassName: 'bg-green-100 text-green-800', + lastActivityText: '1m ago', + primary: false, + agentConnection: minipcAgent, + }, + ], connection: primaryConnection, }, ]; @@ -509,9 +539,11 @@ describe('InfrastructureWorkspace', () => { renderWorkspace(); await waitFor(() => expect(screen.getByText('homelab')).toBeInTheDocument()); - expect(screen.getByText('API + Agent')).toBeInTheDocument(); + expect(screen.getAllByText('API + Agent').length).toBeGreaterThan(0); + expect(screen.getByText('delly')).toBeInTheDocument(); + expect(screen.getByText('minipc')).toBeInTheDocument(); + expect(screen.getAllByText('Host telemetry').length).toBeGreaterThan(0); expect(screen.queryByText('Standalone hosts')).toBeNull(); - expect(screen.queryByText(/^minipc$/)).toBeNull(); }); it('hides the add flow and source-manager actions in read-only mode', () => { diff --git a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts index 4d2ae7f6d..43b5d6e87 100644 --- a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts @@ -66,6 +66,25 @@ describe('useConnectionsLedger', () => { { connectionId: 'agent:agent-delly', type: 'agent', role: 'attachment' }, { connectionId: 'agent:agent-minipc', type: 'agent', role: 'attachment' }, ], + members: [ + { + id: 'node-delly', + name: 'delly', + endpoint: 'https://delly:8006', + state: 'active', + lastSeen: '2026-04-23T12:00:00Z', + primary: true, + agentConnectionId: 'agent:agent-delly', + }, + { + id: 'node-minipc', + name: 'minipc', + endpoint: 'https://minipc:8006', + state: 'active', + lastSeen: '2026-04-23T12:00:00Z', + agentConnectionId: 'agent:agent-minipc', + }, + ], }, ]; vi.spyOn(ConnectionsAPI, 'list').mockResolvedValue({ connections, systems }); @@ -84,5 +103,27 @@ describe('useConnectionsLedger', () => { 'agent:agent-delly', 'agent:agent-minipc', ]); + expect(result.rows()[0].members).toMatchObject([ + { + id: 'node-delly', + name: 'delly', + subtitle: 'Primary cluster node', + source: 'both', + host: 'https://delly:8006', + coverageLabels: ['Host telemetry'], + statusLabel: 'Active', + primary: true, + }, + { + id: 'node-minipc', + name: 'minipc', + subtitle: 'Cluster member', + source: 'both', + host: 'https://minipc:8006', + coverageLabels: ['Host telemetry'], + statusLabel: 'Active', + primary: false, + }, + ]); }); }); diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index 8b0e28145..6d0875d77 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -1,9 +1,9 @@ import type { Connection } from '@/api/connections'; import type { ConnectionType } from '@/api/connections'; -export const connectionLastActivityText = (connection: Connection): string => { - if (!connection.lastSeen) return 'No activity yet'; - const ts = Date.parse(connection.lastSeen); +export const lastActivityTextFromLastSeen = (lastSeen?: string | null): string => { + if (!lastSeen) return 'No activity yet'; + const ts = Date.parse(lastSeen); if (Number.isNaN(ts)) return 'Unknown'; const diff = Math.max(0, Date.now() - ts); const sec = Math.floor(diff / 1000); @@ -16,6 +16,9 @@ export const connectionLastActivityText = (connection: Connection): string => { return `${days}d ago`; }; +export const connectionLastActivityText = (connection: Connection): string => + lastActivityTextFromLastSeen(connection.lastSeen); + export interface ConnectionAgentVersionPresentation { badgeClassName: string; badgeLabel: string; @@ -62,11 +65,87 @@ export const connectionAgentVersionPresentation = ( return null; }; +const SURFACE_LABELS: Record = { + vms: 'VMs', + containers: 'Containers', + storage: 'Storage', + backups: 'Backups', + datastores: 'Datastores', + syncJobs: 'Sync jobs', + verifyJobs: 'Verify jobs', + pruneJobs: 'Prune jobs', + garbageJobs: 'GC jobs', + mailStats: 'Mail stats', + queues: 'Queues', + quarantine: 'Quarantine', + domainStats: 'Domain stats', + host: 'Host telemetry', + hosts: 'Hosts', + datasets: 'Datasets', + pools: 'Pools', + replication: 'Replication', +}; + +export const surfaceLabel = (key: string): string => SURFACE_LABELS[key] ?? key; + +export type InfrastructureSourceKind = 'api' | 'agent' | 'both' | 'unknown'; + +export interface InfrastructureSourcePresentation { + label: string; + badgeClassName: string; + title: string; +} + +const SOURCE_PRESENTATION: Record = { + api: { + label: 'API', + badgeClassName: + 'inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-slate-800/60 dark:text-slate-200', + title: 'Data collected via the platform API', + }, + agent: { + label: 'Agent', + badgeClassName: + 'inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-200', + title: 'Data collected via Pulse Agent', + }, + both: { + label: 'API + Agent', + badgeClassName: + 'inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-medium text-indigo-800 dark:bg-indigo-950/40 dark:text-indigo-200', + title: 'Data collected via the platform API and Pulse Agent', + }, + unknown: { + label: '—', + badgeClassName: 'text-[12px] text-muted', + title: 'No source attached', + }, +}; + +export const infrastructureSourcePresentation = ( + source: InfrastructureSourceKind, +): InfrastructureSourcePresentation => SOURCE_PRESENTATION[source]; + +export interface InfrastructureSystemMemberRow { + id: string; + name: string; + subtitle: string; + source: InfrastructureSourceKind; + host?: string; + coverageLabels: string[]; + statusLabel: string; + statusClassName: string; + lastActivityText: string; + primary: boolean; + agentConnection?: Connection; +} + export interface InfrastructureSystemRow { id: string; ownerType: ConnectionType; name: string; subtitle?: string; + source: InfrastructureSourceKind; host?: string; coverageLabels: string[]; statusLabel: string; @@ -80,5 +159,6 @@ export interface InfrastructureSystemRow { canRemove: boolean; isAgent: boolean; attachedConnections: Connection[]; + members: InfrastructureSystemMemberRow[]; connection: Connection; } diff --git a/frontend-modern/src/components/Settings/useConnectionsLedger.ts b/frontend-modern/src/components/Settings/useConnectionsLedger.ts index 5ff165237..b7c94b13e 100644 --- a/frontend-modern/src/components/Settings/useConnectionsLedger.ts +++ b/frontend-modern/src/components/Settings/useConnectionsLedger.ts @@ -3,13 +3,16 @@ import { ConnectionsAPI, type Connection, type ConnectionState, + type ConnectionSystemMember, type ConnectionSystem, type ConnectionType, } from '@/api/connections'; import { connectionLastActivityText, + lastActivityTextFromLastSeen, surfaceLabel, type InfrastructureSourceKind, + type InfrastructureSystemMemberRow, type InfrastructureSystemRow, } from './connectionsTableModel'; @@ -71,6 +74,15 @@ const EDITABLE_CONNECTION_TYPES: readonly ConnectionType[] = [ 'truenas', ]; +const CONNECTION_STATE_SEVERITY: Record = { + active: 0, + paused: 1, + pending: 2, + stale: 3, + unauthorized: 4, + unreachable: 5, +}; + const coverageLabelsFor = (connections: readonly Connection[]): string[] => { const seen = new Set(); for (const connection of connections) { @@ -122,11 +134,66 @@ const agentUpdateCountFor = (connections: readonly Connection[]): number => (connection) => connection.type === 'agent' && Boolean(connection.agentUpdateAvailable), ).length; +const moreSevereState = ( + left: ConnectionState, + right?: ConnectionState | null, +): ConnectionState => { + if (!right) return left; + return CONNECTION_STATE_SEVERITY[right] > CONNECTION_STATE_SEVERITY[left] ? right : left; +}; + +const memberSubtitleFor = (member: ConnectionSystemMember): string => + member.primary ? 'Primary cluster node' : 'Cluster member'; + +const buildMemberRow = ( + ownerType: ConnectionType, + primaryConnection: Connection, + member: ConnectionSystemMember, + connectionsByID: Map, +): InfrastructureSystemMemberRow | null => { + const name = member.name?.trim(); + if (!name) return null; + + const agentConnection = member.agentConnectionId + ? connectionsByID.get(member.agentConnectionId) + : undefined; + const memberConnections = PLATFORM_API_TYPES.has(ownerType) + ? [primaryConnection, ...(agentConnection ? [agentConnection] : [])] + : agentConnection + ? [agentConnection] + : []; + const source = + memberConnections.length > 0 + ? sourceFor(memberConnections) + : ('unknown' as InfrastructureSourceKind); + const state = moreSevereState(member.state, agentConnection?.state); + const presentation = STATE_PRESENTATION[state] ?? STATE_PRESENTATION.pending; + const lastSeen = + agentConnection && state === agentConnection.state + ? agentConnection.lastSeen + : (member.lastSeen ?? agentConnection?.lastSeen); + + return { + id: member.id, + name, + subtitle: memberSubtitleFor(member), + source, + host: member.endpoint?.trim() || undefined, + coverageLabels: agentConnection ? coverageLabelsFor([agentConnection]) : [], + statusLabel: presentation.label, + statusClassName: presentation.badgeClass, + lastActivityText: lastActivityTextFromLastSeen(lastSeen), + primary: Boolean(member.primary), + agentConnection, + }; +}; + const buildRow = ( ownerType: ConnectionType, primaryConnection: Connection, componentConnections: readonly Connection[], system?: ConnectionSystem | null, + members: InfrastructureSystemMemberRow[] = [], ): InfrastructureSystemRow => { const presentation = STATE_PRESENTATION[primaryConnection.state] ?? STATE_PRESENTATION.pending; const clusterName = system?.clusterName?.trim(); @@ -164,12 +231,13 @@ const buildRow = ( canRemove: primaryConnection.type !== 'docker' && primaryConnection.type !== 'kubernetes', isAgent: primaryConnection.type === 'agent', attachedConnections, + members, connection: primaryConnection, }; }; export const connectionToRow = (connection: Connection): InfrastructureSystemRow => - buildRow(connection.type, connection, [connection], null); + buildRow(connection.type, connection, [connection], null, []); const systemToRow = ( system: ConnectionSystem, @@ -186,7 +254,11 @@ const systemToRow = ( componentConnections.push(primaryConnection); } - return buildRow(system.type, primaryConnection, componentConnections, system); + const members = (system.members ?? []) + .map((member) => buildMemberRow(system.type, primaryConnection, member, connectionsByID)) + .filter((member): member is InfrastructureSystemMemberRow => Boolean(member)); + + return buildRow(system.type, primaryConnection, componentConnections, system, members); }; export interface ConnectionsLedger { diff --git a/internal/api/connections_grouping.go b/internal/api/connections_grouping.go index 245aac1b6..3133dba9e 100644 --- a/internal/api/connections_grouping.go +++ b/internal/api/connections_grouping.go @@ -5,7 +5,9 @@ import ( "net/url" "sort" "strings" + "time" + "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -34,6 +36,7 @@ func buildConnectionSystems( clusterNames := buildProxmoxClusterNames(connectionByID, monitor) hostIndex := buildConnectionHostIndex(connections) agentAttachments := resolveAgentAttachments(connectionByID, hostIndex, monitor) + systemMembers := buildConnectionSystemMembers(connectionByID, agentAttachments, monitor) groups := make(map[string]*connectionSystemGroup, len(connections)) ensureGroup := func(primary Connection) *connectionSystemGroup { @@ -103,6 +106,7 @@ func buildConnectionSystems( Type: group.typ, ClusterName: group.clusterName, Components: components, + Members: systemMembers[group.id], }) } @@ -121,6 +125,279 @@ func buildConnectionSystems( return out } +func buildConnectionSystemMembers( + connectionByID map[string]Connection, + agentAttachments map[string]string, + monitor *monitoring.Monitor, +) map[string][]ConnectionSystemMember { + systemMembers := make(map[string][]ConnectionSystemMember) + if monitor == nil { + return systemMembers + } + + bySystemKey := make(map[string]map[string]ConnectionSystemMember) + register := func(primaryID string, member ConnectionSystemMember) { + primaryID = strings.TrimSpace(primaryID) + member.Name = strings.TrimSpace(member.Name) + if primaryID == "" || member.Name == "" { + return + } + if _, ok := connectionByID[primaryID]; !ok { + return + } + + memberKey := connectionSystemMemberKey(member) + if memberKey == "" { + return + } + member.ID = strings.TrimSpace(member.ID) + if member.ID == "" { + member.ID = memberKey + } + + bucket := bySystemKey[primaryID] + if bucket == nil { + bucket = make(map[string]ConnectionSystemMember) + bySystemKey[primaryID] = bucket + } + + existing, exists := bucket[memberKey] + if !exists { + bucket[memberKey] = member + return + } + + bucket[memberKey] = mergeConnectionSystemMembers(existing, member) + } + + for _, node := range monitor.NodesSnapshot() { + member, primaryID, ok := connectionSystemMemberFromNode(node, connectionByID, agentAttachments) + if !ok { + continue + } + register(primaryID, member) + } + + resources, _ := monitor.UnifiedResourceSnapshot() + for _, resource := range resources { + member, primaryID, ok := connectionSystemMemberFromResource(resource, connectionByID, agentAttachments) + if !ok { + continue + } + register(primaryID, member) + } + + for primaryID, membersByKey := range bySystemKey { + members := make([]ConnectionSystemMember, 0, len(membersByKey)) + for _, member := range membersByKey { + members = append(members, member) + } + sort.Slice(members, func(i, j int) bool { + if members[i].Primary != members[j].Primary { + return members[i].Primary + } + leftName := strings.ToLower(strings.TrimSpace(members[i].Name)) + rightName := strings.ToLower(strings.TrimSpace(members[j].Name)) + if leftName == rightName { + return members[i].ID < members[j].ID + } + return leftName < rightName + }) + systemMembers[primaryID] = members + } + + return systemMembers +} + +func connectionSystemMemberFromNode( + node models.Node, + connectionByID map[string]Connection, + agentAttachments map[string]string, +) (ConnectionSystemMember, string, bool) { + if !node.IsClusterMember { + return ConnectionSystemMember{}, "", false + } + primaryID := "pve:" + strings.TrimSpace(node.Instance) + primary, ok := connectionByID[primaryID] + if !ok || primary.Type != ConnectionTypePVE { + return ConnectionSystemMember{}, "", false + } + + var lastSeen *time.Time + if !node.LastSeen.IsZero() { + t := node.LastSeen + lastSeen = &t + } + + return ConnectionSystemMember{ + ID: strings.TrimSpace(node.ID), + Name: firstNonEmptyTrimmed(node.DisplayName, node.Name), + Endpoint: strings.TrimSpace(node.Host), + State: connectionSystemMemberStateFromNode(node), + LastSeen: lastSeen, + Primary: isPrimaryProxmoxSystemMember(primary, node.Name, node.Host), + AgentConnectionID: attachedAgentConnectionID(node.LinkedAgentID, primaryID, connectionByID, agentAttachments), + }, primaryID, true +} + +func connectionSystemMemberFromResource( + resource unified.Resource, + connectionByID map[string]Connection, + agentAttachments map[string]string, +) (ConnectionSystemMember, string, bool) { + if resource.Proxmox == nil || !resource.Proxmox.IsClusterMember { + return ConnectionSystemMember{}, "", false + } + nodeName := strings.TrimSpace(resource.Proxmox.NodeName) + if nodeName == "" { + return ConnectionSystemMember{}, "", false + } + primaryID := "pve:" + strings.TrimSpace(resource.Proxmox.Instance) + primary, ok := connectionByID[primaryID] + if !ok || primary.Type != ConnectionTypePVE { + return ConnectionSystemMember{}, "", false + } + + linkedAgentID := resource.Proxmox.LinkedAgentID + if linkedAgentID == "" && resource.Agent != nil { + linkedAgentID = resource.Agent.AgentID + } + + var lastSeen *time.Time + if !resource.LastSeen.IsZero() { + t := resource.LastSeen + lastSeen = &t + } + + return ConnectionSystemMember{ + ID: strings.TrimSpace(resource.ID), + Name: firstNonEmptyTrimmed(resource.Name, nodeName), + Endpoint: strings.TrimSpace(resource.Proxmox.HostURL), + State: connectionSystemMemberStateFromResource(resource), + LastSeen: lastSeen, + Primary: isPrimaryProxmoxSystemMember(primary, nodeName, resource.Proxmox.HostURL), + AgentConnectionID: attachedAgentConnectionID(linkedAgentID, primaryID, connectionByID, agentAttachments), + }, primaryID, true +} + +func mergeConnectionSystemMembers( + existing ConnectionSystemMember, + candidate ConnectionSystemMember, +) ConnectionSystemMember { + if strings.TrimSpace(existing.ID) == "" { + existing.ID = candidate.ID + } + if strings.TrimSpace(existing.Name) == "" { + existing.Name = candidate.Name + } + if strings.TrimSpace(existing.Endpoint) == "" { + existing.Endpoint = candidate.Endpoint + } + if existing.State == ConnectionStatePending && candidate.State != ConnectionStatePending { + existing.State = candidate.State + } + if existing.LastSeen == nil || + (candidate.LastSeen != nil && candidate.LastSeen.After(*existing.LastSeen)) { + existing.LastSeen = candidate.LastSeen + } + if !existing.Primary && candidate.Primary { + existing.Primary = true + } + if strings.TrimSpace(existing.AgentConnectionID) == "" { + existing.AgentConnectionID = candidate.AgentConnectionID + } + return existing +} + +func connectionSystemMemberKey(member ConnectionSystemMember) string { + for _, candidate := range []string{ + strings.TrimSpace(member.Name), + strings.TrimSpace(member.Endpoint), + strings.TrimSpace(member.ID), + } { + if normalized := normalizeHost(candidate); normalized != "" { + return normalized + } + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if candidate != "" { + return candidate + } + } + return "" +} + +func connectionSystemMemberStateFromNode(node models.Node) ConnectionState { + status := strings.ToLower(strings.TrimSpace(node.Status)) + health := strings.ToLower(strings.TrimSpace(node.ConnectionHealth)) + switch { + case status == "offline" || health == "error" || health == "offline" || health == "unhealthy": + return ConnectionStateUnreachable + case health == "degraded" || health == "unknown": + return ConnectionStateStale + case node.LastSeen.IsZero(): + return ConnectionStatePending + default: + return ConnectionStateActive + } +} + +func connectionSystemMemberStateFromResource(resource unified.Resource) ConnectionState { + switch resource.Status { + case unified.StatusOffline: + return ConnectionStateUnreachable + case unified.StatusWarning: + return ConnectionStateStale + case unified.StatusUnknown: + return ConnectionStatePending + default: + if resource.LastSeen.IsZero() { + return ConnectionStatePending + } + return ConnectionStateActive + } +} + +func attachedAgentConnectionID( + rawAgentID string, + primaryID string, + connectionByID map[string]Connection, + agentAttachments map[string]string, +) string { + agentID := strings.TrimSpace(rawAgentID) + if agentID == "" { + return "" + } + connectionID := "agent:" + agentID + connection, ok := connectionByID[connectionID] + if !ok || connection.Type != ConnectionTypeAgent { + return "" + } + if attachedPrimary := strings.TrimSpace(agentAttachments[connectionID]); attachedPrimary != "" && + attachedPrimary != primaryID { + return "" + } + return connectionID +} + +func isPrimaryProxmoxSystemMember(primary Connection, nodeName, endpoint string) bool { + nodeName = strings.TrimSpace(nodeName) + if nodeName != "" { + for _, candidate := range []string{ + primary.Name, + strings.TrimPrefix(primary.ID, "pve:"), + } { + if strings.EqualFold(nodeName, strings.TrimSpace(candidate)) { + return true + } + } + } + + return connectionsShareHost( + Connection{Name: nodeName, Address: strings.TrimSpace(endpoint)}, + primary, + ) +} + func connectionComponentRolePriority(role ConnectionSystemComponentRole) int { switch role { case ConnectionSystemComponentRolePrimary: diff --git a/internal/api/connections_grouping_test.go b/internal/api/connections_grouping_test.go index 0a794a8e1..9ae48b907 100644 --- a/internal/api/connections_grouping_test.go +++ b/internal/api/connections_grouping_test.go @@ -129,6 +129,7 @@ func TestBuildConnectionSystems_ClusterMemberAgentsAttachToOwningProxmoxSystem(t NodeName: "delly", ClusterName: "homelab", IsClusterMember: true, + HostURL: "https://delly:8006", LinkedAgentID: "agent-delly", }, Agent: &unified.AgentData{ @@ -157,6 +158,7 @@ func TestBuildConnectionSystems_ClusterMemberAgentsAttachToOwningProxmoxSystem(t NodeName: "minipc", ClusterName: "homelab", IsClusterMember: true, + HostURL: "https://minipc:8006", LinkedAgentID: "agent-minipc", }, Agent: &unified.AgentData{ @@ -229,6 +231,9 @@ func TestBuildConnectionSystems_ClusterMemberAgentsAttachToOwningProxmoxSystem(t if len(system.Components) != 3 { t.Fatalf("expected 3 system components, got %+v", system.Components) } + if len(system.Members) != 2 { + t.Fatalf("expected 2 system members, got %+v", system.Members) + } componentRoles := make(map[string]ConnectionSystemComponentRole, len(system.Components)) for _, component := range system.Components { @@ -243,6 +248,45 @@ func TestBuildConnectionSystems_ClusterMemberAgentsAttachToOwningProxmoxSystem(t if componentRoles["agent:agent-minipc"] != ConnectionSystemComponentRoleAttachment { t.Fatalf("agent:agent-minipc role = %q, want %q", componentRoles["agent:agent-minipc"], ConnectionSystemComponentRoleAttachment) } + + membersByName := make(map[string]ConnectionSystemMember, len(system.Members)) + for _, member := range system.Members { + membersByName[member.Name] = member + } + + dellyMember, ok := membersByName["delly"] + if !ok { + t.Fatalf("expected delly member, got %+v", system.Members) + } + if !dellyMember.Primary { + t.Fatalf("expected delly to be the primary cluster member, got %+v", dellyMember) + } + if dellyMember.Endpoint != "https://delly:8006" { + t.Fatalf("delly endpoint = %q, want %q", dellyMember.Endpoint, "https://delly:8006") + } + if dellyMember.AgentConnectionID != "agent:agent-delly" { + t.Fatalf("delly agent connection = %q, want %q", dellyMember.AgentConnectionID, "agent:agent-delly") + } + if dellyMember.State != ConnectionStateActive { + t.Fatalf("delly state = %q, want %q", dellyMember.State, ConnectionStateActive) + } + + minipcMember, ok := membersByName["minipc"] + if !ok { + t.Fatalf("expected minipc member, got %+v", system.Members) + } + if minipcMember.Primary { + t.Fatalf("minipc should not be marked primary: %+v", minipcMember) + } + if minipcMember.Endpoint != "https://minipc:8006" { + t.Fatalf("minipc endpoint = %q, want %q", minipcMember.Endpoint, "https://minipc:8006") + } + if minipcMember.AgentConnectionID != "agent:agent-minipc" { + t.Fatalf("minipc agent connection = %q, want %q", minipcMember.AgentConnectionID, "agent:agent-minipc") + } + if minipcMember.State != ConnectionStateActive { + t.Fatalf("minipc state = %q, want %q", minipcMember.State, ConnectionStateActive) + } } func TestBuildConnectionSystems_GuestAgentStaysStandaloneWhenOnlyClusterInstanceMatches(t *testing.T) { diff --git a/internal/api/connections_types.go b/internal/api/connections_types.go index 1038b943f..88a76a582 100644 --- a/internal/api/connections_types.go +++ b/internal/api/connections_types.go @@ -96,6 +96,20 @@ type ConnectionSystemComponent struct { Role ConnectionSystemComponentRole `json:"role"` } +// ConnectionSystemMember identifies one runtime member that composes a grouped +// infrastructure source row. For Proxmox clusters this carries the canonical +// cluster node list so the frontend can render node composition beneath the +// owning cluster source instead of reopening standalone host rows. +type ConnectionSystemMember struct { + ID string `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint,omitempty"` + State ConnectionState `json:"state"` + LastSeen *time.Time `json:"lastSeen,omitempty"` + Primary bool `json:"primary,omitempty"` + AgentConnectionID string `json:"agentConnectionId,omitempty"` +} + // ConnectionSystem is the source-oriented grouping contract for the settings // infrastructure manager. One row owns a primary connection and can carry // attached collection methods such as a unified agent augmenting a Proxmox @@ -105,6 +119,7 @@ type ConnectionSystem struct { Type ConnectionType `json:"type"` ClusterName string `json:"clusterName,omitempty"` Components []ConnectionSystemComponent `json:"components"` + Members []ConnectionSystemMember `json:"members,omitempty"` } // ConnectionsListResponse is the envelope for GET /api/connections. diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 17a6fc9f8..9dd19aa5d 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -11500,6 +11500,52 @@ func TestContract_ConnectionPayloadShapeStaysCanonical(t *testing.T) { assertJSONSnapshot(t, body, want) } +func TestContract_ConnectionSystemMembersPayloadShapeStaysCanonical(t *testing.T) { + system := ConnectionSystem{ + ID: "pve-lab", + Type: ConnectionTypePVE, + ClusterName: "homelab", + Components: []ConnectionSystemComponent{ + { + ConnectionID: "pve-lab", + Type: ConnectionTypePVE, + Role: ConnectionSystemComponentRolePrimary, + }, + { + ConnectionID: "agent:agent-lab", + Type: ConnectionTypeAgent, + Role: ConnectionSystemComponentRoleAttachment, + }, + }, + Members: []ConnectionSystemMember{ + { + ID: "node-lab", + Name: "lab", + Endpoint: "https://lab:8006", + State: ConnectionStateActive, + LastSeen: timePtr(time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)), + Primary: true, + AgentConnectionID: "agent:agent-lab", + }, + { + ID: "node-minipc", + Name: "minipc", + Endpoint: "https://minipc:8006", + State: ConnectionStateStale, + LastSeen: timePtr(time.Date(2026, 4, 23, 11, 55, 0, 0, time.UTC)), + }, + }, + } + + body, err := json.Marshal(system) + if err != nil { + t.Fatalf("marshal ConnectionSystem with members: %v", err) + } + + want := `{"id":"pve-lab","type":"pve","clusterName":"homelab","components":[{"connectionId":"pve-lab","type":"pve","role":"primary"},{"connectionId":"agent:agent-lab","type":"agent","role":"attachment"}],"members":[{"id":"node-lab","name":"lab","endpoint":"https://lab:8006","state":"active","lastSeen":"2026-04-23T12:00:00Z","primary":true,"agentConnectionId":"agent:agent-lab"},{"id":"node-minipc","name":"minipc","endpoint":"https://minipc:8006","state":"stale","lastSeen":"2026-04-23T11:55:00Z"}]}` + assertJSONSnapshot(t, body, want) +} + func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) { conn := Connection{ ID: "agent:host-1",