Refine infrastructure source-manager landing

This commit is contained in:
rcourtman 2026-04-22 19:01:46 +01:00
parent c0f48b27ba
commit 4d02f0769f
21 changed files with 1433 additions and 1285 deletions

View file

@ -1,302 +1,287 @@
import { Component, For, Show, type Accessor } from 'solid-js';
import { Archive, Cpu, Database, Mail, Search, Server, ServerCog } from 'lucide-solid';
import { For, Show, createMemo, type Accessor } from 'solid-js';
import { Plus, Search, Server } from 'lucide-solid';
import type { Connection } from '@/api/connections';
import SettingsPanel from '@/components/shared/SettingsPanel';
import type { InfrastructureSystemRow } from './connectionsTableModel';
import type { AgentUninstallCommands } from './ConnectionsTable';
import type { ConnectionRowActions } from './useConnectionRowActions';
import {
getInfrastructureOnboardingProductPresentation,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import type { InfrastructureSystemRow } from './connectionsTableModel';
import {
getInfrastructureSourceManagerProducts,
type InfrastructureOnboardingConnectionType,
} from '@/utils/infrastructureOnboardingPresentation';
interface InfrastructureSourceManagerProps {
rows: Accessor<readonly InfrastructureSystemRow[]>;
readOnly: boolean;
actions?: ConnectionRowActions;
onAddType: (type: InfrastructureOnboardingConnectionType) => void;
onEditConnection?: (connection: Connection) => void;
onAddSource?: (type: InfrastructureOnboardingConnectionType) => void;
onDetectFromAddress?: () => void;
agentUninstallCommands?: AgentUninstallCommands;
onCopyText?: (text: string) => void;
onOpenConnection?: (connection: Connection) => void;
}
const buttonClass =
'inline-flex items-center rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const primaryButtonClass =
'inline-flex items-center rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
const removeButtonClass =
'inline-flex items-center rounded-md border border-rose-300 px-2.5 py-1.5 text-xs font-medium text-rose-700 transition-colors hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-rose-900 dark:text-rose-300 dark:hover:bg-rose-950';
const removeConfirmClass =
'inline-flex items-center rounded-md bg-rose-600 px-2.5 py-1.5 text-xs font-medium text-white transition-colors hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-60';
const inlineButtonClass =
'inline-flex items-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 items-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 childIndentClass = 'pl-4 sm:pl-6';
const CARD_ORDER: InfrastructureOnboardingConnectionType[] = [
'vmware',
'truenas',
'pve',
'pbs',
'pmg',
'agent',
];
const sortRows = (rows: readonly InfrastructureSystemRow[]): InfrastructureSystemRow[] =>
[...rows].sort((left, right) => left.name.localeCompare(right.name));
const CARD_ICON: Record<InfrastructureOnboardingConnectionType, Component<{ class?: string }>> = {
vmware: ServerCog,
truenas: Database,
pve: Server,
pbs: Archive,
pmg: Mail,
agent: Cpu,
};
const EMPTY_STATE_LABEL: Record<InfrastructureOnboardingConnectionType, string> = {
vmware: 'No VMware vCenter connections yet.',
truenas: 'No TrueNAS SCALE connections yet.',
pve: 'No Proxmox VE connections yet.',
pbs: 'No Proxmox Backup Server connections yet.',
pmg: 'No Proxmox Mail Gateway connections yet.',
agent: 'No Pulse Agent hosts connected yet.',
};
const ACTION_LABEL: Record<InfrastructureOnboardingConnectionType, string> = {
vmware: 'Add VMware vCenter',
truenas: 'Add TrueNAS',
pve: 'Add Proxmox VE',
pbs: 'Add Proxmox Backup Server',
pmg: 'Add Proxmox Mail Gateway',
agent: 'Install agent',
};
const pathBadge = (type: InfrastructureOnboardingConnectionType): string =>
type === 'agent' ? 'Agent path' : 'Platform API';
const additionalBadge = (type: InfrastructureOnboardingConnectionType): string | null => {
if (type === 'vmware') return 'Available now';
if (type === 'agent') return 'Docker + Kubernetes';
return null;
};
const additionalNote = (type: InfrastructureOnboardingConnectionType): string | null => {
if (type === 'agent') {
return 'Docker and Kubernetes are discovered from the host after the agent is installed.';
const summarizeCoverage = (labels: readonly string[]): string => {
if (labels.length <= 3) {
return labels.join(', ');
}
return null;
};
const confirmHelpText = (isAgent: boolean): string =>
isAgent
? 'Removing forgets this agent from Pulse. Uninstall it on the host as well if you want to detach it completely.'
: 'Removing forgets this connection from Pulse; credentials on the platform itself are untouched.';
return `${labels.slice(0, 3).join(', ')} +${labels.length - 3} more`;
};
export const InfrastructureSourceManager: Component<InfrastructureSourceManagerProps> = (props) => {
const rowsForType = (type: InfrastructureOnboardingConnectionType) =>
props.rows().filter((row) => row.connection.type === type);
const products = createMemo(() => getInfrastructureSourceManagerProducts());
const productRank = createMemo(() => {
const next = new Map<InfrastructureOnboardingConnectionType, number>();
products().forEach((product, index) => {
next.set(product.type, index);
});
return next;
});
const groupedRows = createMemo(() => {
const next = new Map<InfrastructureOnboardingConnectionType, InfrastructureSystemRow[]>();
for (const product of products()) {
next.set(product.type, []);
}
for (const row of props.rows()) {
const productRows = next.get(row.connection.type as InfrastructureOnboardingConnectionType);
if (!productRows) continue;
productRows.push(row);
}
for (const [type, rows] of next.entries()) {
next.set(type, sortRows(rows));
}
return next;
});
const sortedProducts = createMemo(() =>
[...products()].sort((left, right) => {
const countDifference =
(groupedRows().get(right.type)?.length ?? 0) - (groupedRows().get(left.type)?.length ?? 0);
if (countDifference !== 0) return countDifference;
return (productRank().get(left.type) ?? 0) - (productRank().get(right.type) ?? 0);
}),
);
const rowInteractive = (row: InfrastructureSystemRow): boolean =>
!props.readOnly && Boolean(props.onOpenConnection) && (row.canEdit || row.isAgent);
const actionColumnVisible = () => !props.readOnly;
return (
<SettingsPanel
title="Connection types"
description="Add and manage infrastructure sources by product type. Existing sources stay visible here while add and edit open in-place dialogs."
icon={<Server class="h-5 w-5" strokeWidth={2} />}
title="Infrastructure sources"
description="Grouped by platform."
noPadding
action={
<Show when={!props.readOnly && props.onDetectFromAddress}>
<button type="button" onClick={props.onDetectFromAddress} class={buttonClass}>
<Search class="mr-1.5 h-3.5 w-3.5" />
!props.readOnly && props.onDetectFromAddress ? (
<button
type="button"
onClick={props.onDetectFromAddress}
class="inline-flex w-full items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover sm:w-auto"
>
<Search class="h-4 w-4" />
Detect from address
</button>
</Show>
) : undefined
}
icon={<Server class="h-5 w-5" strokeWidth={2} />}
>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<For each={CARD_ORDER}>
{(type) => {
const presentation = getInfrastructureOnboardingProductPresentation(type);
const Icon = CARD_ICON[type];
const groupRows = () => rowsForType(type);
const secondaryBadge = () => additionalBadge(type);
const note = () => additionalNote(type);
<Table class="w-full table-fixed text-sm">
<TableHeader class="bg-surface-alt/60">
<TableRow>
<TableHead class="w-[24%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[20%]">
Source
</TableHead>
<TableHead class="w-[26%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[24%]">
Endpoint
</TableHead>
<TableHead class="w-[30%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[28%]">
Coverage
</TableHead>
<TableHead class="w-[20%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[16%]">
Status
</TableHead>
<Show when={actionColumnVisible()}>
<TableHead class="w-[16%] px-3 py-1.5 text-right text-[11px] font-medium text-muted whitespace-nowrap xl:w-[12%]">
Actions
</TableHead>
</Show>
</TableRow>
</TableHeader>
return (
<div class="rounded-xl border border-border bg-surface-alt p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 items-start gap-3">
<div class="flex h-10 w-10 flex-none items-center justify-center rounded-md border border-border bg-surface text-base-content">
<Icon class="h-5 w-5" />
</div>
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold text-base-content">{presentation.label}</h3>
<span class="inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
{pathBadge(type)}
</span>
<Show when={secondaryBadge()}>
{(badge) => (
<span class="inline-flex items-center rounded-full border border-blue-200 bg-blue-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200">
{badge()}
</span>
)}
</Show>
</div>
<p class="text-xs text-muted">{presentation.bestFor}</p>
</div>
</div>
<TableBody class="divide-y divide-border-subtle bg-surface">
<For each={sortedProducts()}>
{(product) => {
const rows = () => groupedRows().get(product.type) ?? [];
const groupRowClass = () =>
'bg-surface-alt hover:bg-surface-alt dark:bg-base dark:hover:bg-base';
const groupLabelClass = () => 'text-[15px] font-semibold text-base-content';
<div class="flex items-center gap-2">
<span class="rounded-full border border-border bg-surface px-2 py-0.5 text-[11px] font-medium text-base-content">
{groupRows().length} configured
</span>
<Show when={!props.readOnly}>
<button
type="button"
onClick={() => props.onAddType(type)}
class={primaryButtonClass}
>
{ACTION_LABEL[type]}
</button>
</Show>
</div>
</div>
<p class="mt-3 text-xs text-muted">{presentation.coverage}</p>
<Show when={note()}>
{(value) => <p class="mt-2 text-xs text-muted">{value()}</p>}
</Show>
<div class="mt-4 space-y-2">
return (
<>
<Show
when={groupRows().length > 0}
when={actionColumnVisible()}
fallback={
<div class="rounded-lg border border-dashed border-border bg-surface px-3 py-4 text-sm text-muted">
{EMPTY_STATE_LABEL[type]}
</div>
<TableRow class={groupRowClass()}>
<TableCell colspan={4} class="px-3 py-1.5">
<div class="flex min-w-0 items-center gap-2">
<span class={groupLabelClass()}>{product.label}</span>
</div>
</TableCell>
</TableRow>
}
>
<For each={groupRows()}>
{(row) => {
const pauseBusy = () => props.actions?.pendingAction(row.id) === 'pause';
const removeBusy = () => props.actions?.pendingAction(row.id) === 'remove';
const anyBusy = () => props.actions?.pendingAction(row.id) !== null;
const removeConfirming = () => Boolean(props.actions?.confirmingRemove(row.id));
const rowError = () => props.actions?.actionError(row.id) ?? null;
<TableRow class={groupRowClass()}>
<TableCell colspan={4} class="px-3 py-1.5">
<div class="flex items-center gap-2 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class={groupLabelClass()}>{product.label}</span>
</div>
</div>
</TableCell>
<TableCell class="px-3 py-1.5 text-right">
<Show when={!props.readOnly && props.onAddSource}>
<button
type="button"
onClick={() => props.onAddSource?.(product.type)}
class={`${addSectionButtonClass} whitespace-nowrap`}
aria-label={`Add ${product.label}`}
>
<Plus class="h-3.5 w-3.5" />
Add
</button>
</Show>
</TableCell>
</TableRow>
</Show>
<Show when={rows().length > 0}>
<For each={rows()}>
{(row, index) => {
const isLast = () => index() === rows().length - 1;
return (
<div class="rounded-lg border border-border bg-surface px-3 py-3">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-0 truncate text-sm font-medium text-base-content">
{row.name}
</div>
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${row.statusClassName}`}
>
{row.statusLabel}
<>
<TableRow>
<TableCell class="py-1 pl-3 pr-3 align-top">
<div class={`min-w-0 space-y-0.5 ${childIndentClass}`}>
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
<span aria-hidden="true" class="relative h-5 w-5 flex-none">
<span
class={`absolute left-2 top-0 w-px bg-border ${isLast() ? 'h-2.5' : 'h-5'}`}
/>
<span class="absolute left-2 top-2.5 h-px w-3 bg-border" />
</span>
</div>
<Show when={row.host}>
<div class="break-words text-xs text-muted">{row.host}</div>
</Show>
<Show when={row.subtitle}>
<div class="break-words text-xs text-muted">{row.subtitle}</div>
</Show>
<div class="text-xs text-muted">Last activity: {row.lastActivityText}</div>
<Show when={row.lastErrorMessage}>
<div class="break-words text-xs text-rose-700 dark:text-rose-300">
{row.lastErrorMessage}
</div>
</Show>
</div>
<Show when={!props.readOnly && props.actions}>
<div class="flex flex-wrap items-center gap-1.5">
<Show when={row.canEdit && props.onEditConnection}>
<button
type="button"
disabled={anyBusy()}
onClick={() => props.onEditConnection?.(row.connection)}
class={buttonClass}
<div class="min-w-0">
<div
class="truncate text-[13px] text-base-content/80"
title={row.name}
>
Edit
</button>
</Show>
<Show when={row.canPause}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void props.actions?.togglePause(row.connection)}
class={buttonClass}
>
{pauseBusy()
? 'Working…'
: row.enabled
? 'Pause'
: 'Resume'}
</button>
</Show>
<Show when={row.canRemove}>
<button
type="button"
disabled={anyBusy()}
onClick={() => void props.actions?.requestRemove(row.connection)}
class={removeConfirming() ? removeConfirmClass : removeButtonClass}
>
{removeBusy()
? 'Removing…'
: removeConfirming()
? 'Click again to confirm'
: 'Remove'}
</button>
</Show>
</div>
</Show>
</div>
<Show when={removeConfirming()}>
<div class="mt-3 space-y-2 rounded-md border border-border bg-surface-alt px-3 py-2">
<p class="text-xs text-muted">{confirmHelpText(row.isAgent)}</p>
<Show when={row.isAgent && props.agentUninstallCommands}>
<div class="space-y-2">
<div class="rounded-md border border-border bg-surface px-3 py-2">
<div class="text-[11px] font-medium uppercase tracking-wide text-muted">
Linux / macOS / FreeBSD
</div>
<div class="mt-1 break-all font-mono text-[11px] text-base-content">
{props.agentUninstallCommands!.linux}
</div>
<Show when={props.onCopyText}>
<button
type="button"
class={`${buttonClass} mt-2`}
onClick={() =>
props.onCopyText?.(props.agentUninstallCommands!.linux)
}
>
Copy uninstall command
</button>
</Show>
{row.name}
</div>
</div>
</Show>
</div>
</div>
</Show>
</TableCell>
<Show when={rowError()}>
<div
role="alert"
class="mt-3 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"
<TableCell class="px-3 py-1 align-top">
<Show
when={row.host}
fallback={<span class="text-xs text-muted">-</span>}
>
{rowError()}
<div
class="truncate whitespace-nowrap text-[12px] text-muted"
title={row.host}
>
{row.host}
</div>
</Show>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<Show
when={row.coverageLabels.length > 0}
fallback={<span class="text-xs text-muted">-</span>}
>
<div
class="truncate whitespace-nowrap text-[12px] text-muted"
title={row.coverageLabels.join(', ')}
>
{summarizeCoverage(row.coverageLabels)}
</div>
</Show>
</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 px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${row.statusClassName}`}
>
{row.statusLabel}
</span>
<span class="text-[12px] text-muted/90">{row.lastActivityText}</span>
</div>
</TableCell>
<Show when={actionColumnVisible()}>
<TableCell class="px-3 py-1 align-top text-right">
<Show
when={rowInteractive(row)}
fallback={<span class="text-xs text-muted">Read only</span>}
>
<button
type="button"
onClick={() => props.onOpenConnection?.(row.connection)}
class={inlineButtonClass}
>
Edit
</button>
</Show>
</TableCell>
</Show>
</div>
</TableRow>
<Show when={row.lastErrorMessage}>
<TableRow class="border-b border-border/80">
<TableCell
colspan={actionColumnVisible() ? 5 : 4}
class="bg-surface px-3 pb-1.5 pt-0"
>
<div
role="alert"
class="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>
</TableCell>
</TableRow>
</Show>
</>
);
}}
</For>
</Show>
</div>
</div>
);
}}
</For>
</div>
</>
);
}}
</For>
</TableBody>
</Table>
</SettingsPanel>
);
};