mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Render Proxmox cluster members beneath cluster row
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user