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

View file

@ -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>
);
};