Render Proxmox cluster members beneath cluster row

This commit is contained in:
rcourtman
2026-04-23 13:28:31 +01:00
parent 0e08caee77
commit 2ae16e885b
16 changed files with 1161 additions and 125 deletions
@@ -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
@@ -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
@@ -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`,
@@ -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
@@ -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 });
+11
View File
@@ -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 {
@@ -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<DiscoveryScanStatus>;
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<InfrastructureSourceManagerProps> = (props) => {
const products = createMemo(() => getInfrastructureSourceManagerProducts());
const productRank = createMemo(() => {
@@ -150,47 +172,82 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
});
const sortedProducts = createMemo(() =>
[...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 = () => (
<Show when={!props.readOnly}>
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
<Show when={props.onRunDiscovery}>
<button
type="button"
onClick={props.onRunDiscovery}
disabled={props.discoveryScanStatus().scanning}
class={utilityToolbarButtonClass}
aria-label="Run discovery"
title="Run discovery"
>
<RotateCw
class={`h-4 w-4 ${props.discoveryScanStatus().scanning ? 'animate-spin' : ''}`}
/>
{props.discoveryScanStatus().scanning ? 'Scanning…' : 'Run discovery'}
</button>
</Show>
<Show when={props.onOpenDiscoverySettings}>
<button
type="button"
onClick={props.onOpenDiscoverySettings}
class={utilityToolbarButtonClass}
aria-label="Discovery settings"
title="Discovery settings"
>
<SlidersHorizontal class="h-4 w-4" />
Settings
</button>
</Show>
</div>
</Show>
);
return (
<SettingsPanel
@@ -198,66 +255,29 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
description="Configured systems and discovered candidates grouped by platform or host type. Install Pulse Agent on each machine where you want full node-local telemetry."
noPadding
icon={<Server class="h-5 w-5" strokeWidth={2} />}
action={headerActions()}
>
<div class="border-b border-border bg-surface-alt/40 px-3 py-2.5 sm:px-4">
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span class="font-medium text-base-content">Discovery</span>
<span class="text-muted">{discoverySummary().join(' · ')}</span>
</div>
</div>
<Show when={!props.readOnly}>
<div class="flex flex-wrap items-center gap-3 lg:justify-end">
<Show when={props.onRunDiscovery}>
<button
type="button"
onClick={props.onRunDiscovery}
disabled={props.discoveryScanStatus().scanning}
class={primaryToolbarButtonClass}
>
<RotateCw
class={`h-4 w-4 ${props.discoveryScanStatus().scanning ? 'animate-spin' : ''}`}
/>
{props.discoveryScanStatus().scanning ? 'Scanning…' : 'Run discovery'}
</button>
</Show>
<Show when={props.onOpenDiscoverySettings}>
<button
type="button"
onClick={props.onOpenDiscoverySettings}
class={utilityToolbarButtonClass}
aria-label="Discovery settings"
title="Discovery settings"
>
<SlidersHorizontal class="h-4 w-4" />
Settings
</button>
</Show>
</div>
</Show>
</div>
</div>
<Show when={!useCardLayout()}>
<Table class="w-full table-fixed text-sm">
<TableHeader class="bg-surface-alt/60">
<TableRow>
<TableHead class="w-[30%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[26%]">
<TableHead class="w-[22%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[20%]">
System
</TableHead>
<TableHead class="w-[22%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[22%]">
<TableHead class="w-[7.5rem] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Source
</TableHead>
<TableHead class="w-[21%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[20%]">
Endpoint
</TableHead>
<TableHead class="w-[22%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[24%]">
<TableHead class="w-[18%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[22%]">
Coverage
</TableHead>
<TableHead class="w-[16%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[16%]">
<TableHead class="w-[16%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[15%]">
Status
</TableHead>
<Show when={actionColumnVisible()}>
<TableHead class="w-[12%] px-3 py-1.5 text-right text-[11px] font-medium text-muted whitespace-nowrap xl:w-[12%]">
<TableHead class="w-[7rem] px-3 py-1.5 text-right text-[11px] font-medium text-muted whitespace-nowrap">
Actions
</TableHead>
</Show>
@@ -279,7 +299,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
when={actionColumnVisible()}
fallback={
<TableRow class={groupRowClass()}>
<TableCell colspan={4} class="px-3 py-1.5">
<TableCell colspan={5} class="px-3 py-1.5">
<div class="flex min-w-0 items-center gap-2">
<span class={groupLabelClass()}>{product.label}</span>
</div>
@@ -288,7 +308,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
}
>
<TableRow class={groupRowClass()}>
<TableCell colspan={4} class="px-3 py-1.5">
<TableCell colspan={5} class="px-3 py-1.5">
<div class="flex items-center gap-2 whitespace-nowrap">
<span class={groupLabelClass()}>{product.label}</span>
</div>
@@ -317,26 +337,29 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<>
<TableRow class="border-b border-border-subtle">
<TableCell class="py-1 pl-3 pr-3 align-top">
<div class="min-w-0 space-y-0.5">
<div class="min-w-0 flex-1">
<div
class={`text-[13px] text-base-content/80 ${wrappedFieldClass}`}
title={row.name}
>
{row.name}
</div>
</div>
<Show when={row.subtitle}>
<div
class="text-[11px] leading-4 text-muted"
title={agentMethodTitleFor(row) ?? row.subtitle}
>
{row.subtitle}
</div>
</Show>
<div
class={`text-[13px] text-base-content/80 ${wrappedFieldClass}`}
title={row.name}
>
{row.name}
</div>
</TableCell>
<TableCell class="px-3 py-1 align-top">
{(() => {
const presentation = infrastructureSourcePresentation(row.source);
const title = agentMethodTitleFor(row) ?? presentation.title;
return (
<span
class={`${presentation.badgeClassName} whitespace-nowrap`}
title={title}
>
{presentation.label}
</span>
);
})()}
</TableCell>
<TableCell class="px-3 py-1 align-top">
<Show
when={row.host}
@@ -366,20 +389,20 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</TableCell>
<TableCell class="px-3 py-1 align-top">
<div class="flex items-center gap-1.5 whitespace-nowrap">
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${row.statusClassName}`}
>
{row.statusLabel}
</span>
<Show when={row.agentUpdateCount > 0}>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200">
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium whitespace-nowrap text-amber-800 dark:bg-amber-900 dark:text-amber-200">
{row.agentUpdateCount === 1
? 'Agent update'
: `${row.agentUpdateCount} agent updates`}
</span>
</Show>
<span class="text-[12px] text-muted/90">
<span class="whitespace-nowrap text-[12px] text-muted/90">
{row.lastActivityText}
</span>
</div>
@@ -406,7 +429,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<Show when={row.lastErrorMessage}>
<TableRow class="border-b border-border-subtle">
<TableCell
colspan={actionColumnVisible() ? 5 : 4}
colspan={actionColumnVisible() ? 6 : 5}
class="bg-surface px-3 pb-1.5 pt-0"
>
<div
@@ -418,6 +441,95 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</TableCell>
</TableRow>
</Show>
<Show when={row.members.length > 0}>
<For each={row.members}>
{(member, memberIndex) => {
const memberPresentation = infrastructureSourcePresentation(
member.source,
);
const memberSourceTitle =
memberMethodTitleFor(row, memberIndex()) ??
memberPresentation.title;
return (
<TableRow class="border-b border-border-subtle bg-surface-alt/30">
<TableCell class="py-1 pl-3 pr-3 align-top">
<div class="flex min-w-0 items-start gap-2 pl-4">
<span class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-border" />
<div class="min-w-0">
<div
class={`text-[13px] text-base-content/85 ${wrappedFieldClass}`}
title={member.name}
>
{member.name}
</div>
<div class="mt-0.5 text-[11px] text-muted">
{member.subtitle}
</div>
</div>
</div>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<span
class={`${memberPresentation.badgeClassName} whitespace-nowrap`}
title={memberSourceTitle}
>
{memberPresentation.label}
</span>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<Show
when={member.host}
fallback={<span class="text-xs text-muted">-</span>}
>
<div
class="truncate whitespace-nowrap text-[12px] text-muted"
title={member.host}
>
{member.host}
</div>
</Show>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<Show
when={member.coverageLabels.length > 0}
fallback={<span class="text-xs text-muted">-</span>}
>
<div
class="whitespace-normal break-words text-[12px] leading-4 text-muted"
title={member.coverageLabels.join(', ')}
>
{member.coverageLabels.join(', ')}
</div>
</Show>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${member.statusClassName}`}
>
{member.statusLabel}
</span>
<span class="whitespace-nowrap text-[12px] text-muted/90">
{member.lastActivityText}
</span>
</div>
</TableCell>
<Show when={actionColumnVisible()}>
<TableCell class="px-3 py-1 align-top text-right">
<span class="text-xs text-muted">Managed by cluster</span>
</TableCell>
</Show>
</TableRow>
);
}}
</For>
</Show>
</>
);
}}
@@ -430,16 +542,23 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
return (
<TableRow class={discoveryRowClass}>
<TableCell class="py-1 pl-3 pr-3 align-top">
<div class="min-w-0 flex-1">
<div
class={`text-[13px] text-base-content/85 ${wrappedFieldClass}`}
title={`${discoveredServerName(server)}${server.version ? ` · ${server.version}` : ''}`}
>
{discoveredServerName(server)}
</div>
<div
class={`text-[13px] text-base-content/85 ${wrappedFieldClass}`}
title={`${discoveredServerName(server)}${server.version ? ` · ${server.version}` : ''}`}
>
{discoveredServerName(server)}
</div>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<span
class="inline-flex items-center rounded-full border border-dashed border-border bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-muted whitespace-nowrap"
title="Discovery candidate — review to attach a source"
>
Candidate
</span>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<div
class="truncate whitespace-nowrap text-[12px] text-muted"
@@ -459,11 +578,11 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</TableCell>
<TableCell class="px-3 py-1 align-top">
<div class="flex items-center gap-1.5 whitespace-nowrap">
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-800 dark:bg-blue-950/40 dark:text-blue-200">
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium whitespace-nowrap text-blue-800 dark:bg-blue-950/40 dark:text-blue-200">
Discovered
</span>
<span class="text-[12px] text-muted/90">
<span class="whitespace-nowrap text-[12px] text-muted/90">
{lastDiscoveryResultText() ?? 'Waiting for scan'}
</span>
</div>
@@ -494,8 +613,284 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
);
}}
</For>
<Show when={!hasAnyConfigured() && !hasAnyDiscovered()}>
<TableRow>
<TableCell
colspan={actionColumnVisible() ? 6 : 5}
class="px-3 py-6 text-center text-sm text-muted"
>
No infrastructure configured yet.
</TableCell>
</TableRow>
</Show>
<Show when={!props.readOnly && props.onAddInfrastructure}>
<TableRow class="border-t border-border-subtle bg-surface-alt/40">
<TableCell
colspan={actionColumnVisible() ? 6 : 5}
class="px-3 py-2 text-center"
>
<button
type="button"
onClick={props.onAddInfrastructure}
class={`${addSectionButtonClass} whitespace-nowrap`}
>
<Plus class="h-3.5 w-3.5" />
Add infrastructure
</button>
</TableCell>
</TableRow>
</Show>
</TableBody>
</Table>
</Show>
<Show when={useCardLayout()}>
<div class="space-y-3 p-3">
<For each={sortedProducts()}>
{(product) => {
const configuredRows = () => groupedConfiguredRows().get(product.type) ?? [];
const discoveredRows = () => groupedDiscoveredRows().get(product.type) ?? [];
return (
<section class="space-y-2">
<header class="flex items-center justify-between gap-2">
<h3 class="text-[14px] font-semibold text-base-content">{product.label}</h3>
<Show when={!props.readOnly && props.onAddSource}>
<button
type="button"
onClick={() => props.onAddSource?.(product.type)}
class={`${addSectionButtonClass} whitespace-nowrap`}
aria-label={product.actionLabel}
title={product.actionLabel}
>
<Plus class="h-3.5 w-3.5" />
Add
</button>
</Show>
</header>
<For each={configuredRows()}>
{(row) => {
const presentation = infrastructureSourcePresentation(row.source);
const sourceTitle = agentMethodTitleFor(row) ?? presentation.title;
return (
<article class="rounded-md border border-border-subtle bg-surface p-3 shadow-sm">
<header class="flex items-start justify-between gap-2">
<div
class="min-w-0 flex-1 break-words text-[13px] font-medium text-base-content"
title={row.name}
>
{row.name}
</div>
<span
class={`${presentation.badgeClassName} flex-shrink-0`}
title={sourceTitle}
>
{presentation.label}
</span>
</header>
<Show when={row.host}>
<div
class="mt-1 truncate text-[12px] text-muted"
title={row.host}
>
{row.host}
</div>
</Show>
<Show when={row.coverageLabels.length > 0}>
<div class="mt-1 text-[12px] leading-4 text-muted">
{row.coverageLabels.join(', ')}
</div>
</Show>
<Show when={row.members.length > 0}>
<div class="mt-3 border-t border-border-subtle pt-2">
<div class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">
Cluster nodes
</div>
<div class="mt-2 space-y-2">
<For each={row.members}>
{(member, memberIndex) => {
const memberPresentation = infrastructureSourcePresentation(
member.source,
);
const memberSourceTitle =
memberMethodTitleFor(row, memberIndex()) ??
memberPresentation.title;
return (
<div class="rounded-md border border-border-subtle bg-surface-alt/30 px-2.5 py-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div
class="break-words text-[13px] font-medium text-base-content"
title={member.name}
>
{member.name}
</div>
<div class="mt-0.5 text-[11px] text-muted">
{member.subtitle}
</div>
</div>
<span
class={`${memberPresentation.badgeClassName} flex-shrink-0`}
title={memberSourceTitle}
>
{memberPresentation.label}
</span>
</div>
<Show when={member.host}>
<div class="mt-1 truncate text-[12px] text-muted" title={member.host}>
{member.host}
</div>
</Show>
<Show when={member.coverageLabels.length > 0}>
<div class="mt-1 text-[12px] leading-4 text-muted">
{member.coverageLabels.join(', ')}
</div>
</Show>
<div class="mt-2 flex flex-wrap items-center gap-1.5">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${member.statusClassName}`}
>
{member.statusLabel}
</span>
<span class="text-[12px] text-muted/90">
{member.lastActivityText}
</span>
</div>
</div>
);
}}
</For>
</div>
</div>
</Show>
<footer class="mt-2 flex items-center justify-between gap-2 border-t border-border-subtle pt-2">
<div class="flex flex-wrap items-center gap-1.5">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${row.statusClassName}`}
>
{row.statusLabel}
</span>
<Show when={row.agentUpdateCount > 0}>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-900 dark:text-amber-200">
{row.agentUpdateCount === 1
? 'Agent update'
: `${row.agentUpdateCount} agent updates`}
</span>
</Show>
<span class="text-[12px] text-muted/90">
{row.lastActivityText}
</span>
</div>
<Show when={!props.readOnly && rowInteractive(row)}>
<button
type="button"
onClick={() => props.onOpenConnection?.(row)}
class={`${inlineButtonClass} flex-shrink-0`}
>
Edit
</button>
</Show>
</footer>
<Show when={row.lastErrorMessage}>
<div
role="alert"
class="mt-2 rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-xs text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
{row.lastErrorMessage}
</div>
</Show>
</article>
);
}}
</For>
<For each={discoveredRows()}>
{(server) => (
<article class="rounded-md border border-blue-200 bg-blue-50/50 p-3 shadow-sm dark:border-blue-900 dark:bg-blue-950/20">
<header class="flex items-start justify-between gap-2">
<div
class="min-w-0 flex-1 break-words text-[13px] font-medium text-base-content"
title={`${discoveredServerName(server)}${server.version ? ` · ${server.version}` : ''}`}
>
{discoveredServerName(server)}
</div>
<span
class="inline-flex flex-shrink-0 items-center rounded-full border border-dashed border-border bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-muted whitespace-nowrap"
title="Discovery candidate — review to attach a source"
>
Candidate
</span>
</header>
<div
class="mt-1 truncate text-[12px] text-muted"
title={discoveredServerEndpoint(server)}
>
{discoveredServerEndpoint(server)}
</div>
<div class="mt-1 text-[12px] leading-4 text-muted">
{discoveredCoverageText(server)}
</div>
<footer class="mt-2 flex items-center justify-between gap-2 border-t border-border-subtle pt-2">
<div class="flex flex-wrap items-center gap-1.5">
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium text-blue-800 dark:bg-blue-950/40 dark:text-blue-200">
Discovered
</span>
<span class="text-[12px] text-muted/90">
{lastDiscoveryResultText() ?? 'Waiting for scan'}
</span>
</div>
<Show when={!props.readOnly && props.onReviewDiscoveredSource}>
<button
type="button"
onClick={() => props.onReviewDiscoveredSource?.(server)}
class={`${inlineButtonClass} flex-shrink-0`}
>
Review
</button>
</Show>
</footer>
</article>
)}
</For>
</section>
);
}}
</For>
<Show when={!hasAnyConfigured() && !hasAnyDiscovered()}>
<div class="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted">
No infrastructure configured yet.
</div>
</Show>
<Show when={!props.readOnly && props.onAddInfrastructure}>
<div class="border-t border-border-subtle pt-3 text-center">
<button
type="button"
onClick={props.onAddInfrastructure}
class={`${addSectionButtonClass} whitespace-nowrap`}
>
<Plus class="h-3.5 w-3.5" />
Add infrastructure
</button>
</div>
</Show>
</div>
</Show>
</SettingsPanel>
);
};
@@ -29,6 +29,7 @@ const row = (overrides: Partial<InfrastructureSystemRow> = {}): 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<InfrastructureSystemRow> = {}): InfrastructureSy
canRemove: connection.type !== 'docker' && connection.type !== 'kubernetes',
isAgent: connection.type === 'agent',
attachedConnections: [],
members: [],
connection,
...overrides,
};
@@ -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', () => {
@@ -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,
},
]);
});
});
@@ -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<string, string> = {
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<InfrastructureSourceKind, InfrastructureSourcePresentation> = {
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;
}
@@ -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<ConnectionState, number> = {
active: 0,
paused: 1,
pending: 2,
stale: 3,
unauthorized: 4,
unreachable: 5,
};
const coverageLabelsFor = (connections: readonly Connection[]): string[] => {
const seen = new Set<string>();
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<string, Connection>,
): 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 {
+277
View File
@@ -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:
+44
View File
@@ -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) {
+15
View File
@@ -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.
+46
View File
@@ -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",