Surface explicit discovery in infrastructure manager

This commit is contained in:
rcourtman
2026-04-22 19:37:04 +01:00
parent f6f8a4f6bc
commit da4933ef31
10 changed files with 784 additions and 94 deletions
@@ -336,6 +336,14 @@ an add-only capacity posture.
instance list, the picker dialog owns source-type selection, and
`frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`
owns detect-driven credential handoff plus type-specific form bodies.
That same landing now owns explicit discovery review for API-backed
Proxmox-family systems: `InfrastructureSourceManager.tsx` may surface
discovered VE / PBS / PMG candidates under their matching platform groups,
and `InfrastructureWorkspace.tsx` may route a candidate's `Review` action
into the same typed add dialog with canonical prefills. Lifecycle flows
must not fork a second discovery-specific credential wizard or treat
discovery results as already-enrolled systems before the operator saves
the governed add form.
The editor's probe step calls the aggregator probe endpoint and
dispatches the detected or manually-selected type into a credential
slot; it must not bypass the probe endpoint or fabricate probe
@@ -108,6 +108,14 @@ Own canonical runtime payload shapes between backend and frontend.
22. `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts` shared with `agent-lifecycle`: the direct-node infrastructure settings state hook is both an agent lifecycle control surface and a shared Proxmox node API contract boundary.
23. `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts` shared with `agent-lifecycle`: the infrastructure discovery runtime state hook is both an agent lifecycle control surface and a shared discovery/settings API contract boundary.
That same shared boundary also owns settings-route polling scope for discovery payloads: the `/api/discover` refresh loop and websocket-backed discovery status hydration may run only while the operator is on the infrastructure connections workspace under `/settings/infrastructure/platforms*`, not on the systems ledger or install workspace.
It also owns the explicit manual-scan contract for `/api/discover`: when
the operator runs discovery from the infrastructure manager, the hook must
consume the immediate POST response body as the next source of truth for
discovered candidates and scan errors rather than waiting for a later poll
or websocket update. Cached GET payloads and manual POST payloads must both
normalize their `updated` / `timestamp` values into one millisecond-backed
`lastResultAt` state so discovery review rows do not depend on transport-
specific timestamp shapes.
24. `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx` shared with `agent-lifecycle`: the infrastructure install state hook is both an agent fleet lifecycle control surface and an API token, lookup, and install transport contract boundary.
25. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` shared with `agent-lifecycle`: the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
26. `frontend-modern/src/components/Settings/useNodeModalState.ts` shared with `agent-lifecycle`: the node setup modal state hook is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
@@ -259,6 +259,13 @@ work extends shared components instead of creating new local variants.
source-manager landing inside that destination, while route-backed add flows
and local edit flows stay single-purpose instead of stacking multiple
page-level workspaces at once.
The source-manager landing now also owns the explicit discovery strip for
that destination. `InfrastructureSourceManager.tsx` may expose `Run
discovery`, `Detect from address`, and `Discovery settings` actions from the
shared landing shell, but it must not start a network scan just because the
page rendered. Discovered API-backed candidates stay visible in the same
platform-group table as configured sources, using the existing tree/table
hierarchy instead of spawning a second discovery-only page or card stack.
`InfrastructureWorkspace.tsx` must still open a new connection through
`frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`,
but the editor now serves as governed dialog content under the source
@@ -2209,10 +2216,13 @@ that same shell boundary as
`frontend-modern/src/components/Settings/ProLicensePanel.tsx` and the shared
settings billing presentation owner in
`frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts`;
the `system-billing` navigation label plus header title and description must reuse
`SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle` and
`SELF_HOSTED_PRO_BILLING_PRESENTATION.shellDescription` so the route header and
the billing shell do not narrate the same commercial surface differently.
the `system-billing` navigation label, header title/description, and billing
shell framing must all route through `SELF_HOSTED_PRO_BILLING_PRESENTATION`
instead of drifting independently. The owned split is now explicit: the
navigation label comes from `navLabel`, while the route header and billing
shell reuse `shellTitle` plus `shellDescription`, so the settings IA can stay
plan-owned (`Plans`) while the page itself still names the concrete job
(`Plans & Activation`) without reintroducing local label drift.
That same settings-shell framing boundary also covers adjacent top-level
settings references to the self-hosted commercial surface. When
`InfrastructureWorkspace.tsx` or other settings-shell surfaces point operators
@@ -1,5 +1,5 @@
import { For, Show, createMemo, type Accessor } from 'solid-js';
import { Plus, Search, Server } from 'lucide-solid';
import { For, Show, createMemo, type Accessor, type Component } from 'solid-js';
import { Plus, RotateCw, Search, Server, SlidersHorizontal } from 'lucide-solid';
import type { Connection } from '@/api/connections';
import SettingsPanel from '@/components/shared/SettingsPanel';
import {
@@ -11,28 +11,49 @@ import {
TableRow,
} from '@/components/shared/Table';
import type { InfrastructureSystemRow } from './connectionsTableModel';
import type { DiscoveredServer, DiscoveryScanStatus } from './infrastructureSettingsModel';
import {
getInfrastructureOnboardingProductPresentation,
getInfrastructureSourceManagerProducts,
type InfrastructureOnboardingConnectionType,
} from '@/utils/infrastructureOnboardingPresentation';
interface InfrastructureSourceManagerProps {
rows: Accessor<readonly InfrastructureSystemRow[]>;
discoveredNodes: Accessor<readonly DiscoveredServer[]>;
discoveryEnabled: boolean;
discoveryScanStatus: Accessor<DiscoveryScanStatus>;
readOnly: boolean;
onAddSource?: (type: InfrastructureOnboardingConnectionType) => void;
onRunDiscovery?: () => void;
onDetectFromAddress?: () => void;
onOpenDiscoverySettings?: () => void;
onOpenConnection?: (connection: Connection) => void;
onReviewDiscoveredSource?: (server: DiscoveredServer) => void;
}
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 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 secondaryToolbarButtonClass =
'inline-flex 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 disabled:cursor-not-allowed disabled:opacity-60';
const childIndentClass = 'pl-4 sm:pl-6';
const discoveryRowClass =
'border-b border-border-subtle bg-blue-50/30 hover:bg-blue-50/40 dark:bg-blue-950/10 dark:hover:bg-blue-950/20';
const sortRows = (rows: readonly InfrastructureSystemRow[]): InfrastructureSystemRow[] =>
[...rows].sort((left, right) => left.name.localeCompare(right.name));
const sortDiscoveredNodes = (nodes: readonly DiscoveredServer[]): DiscoveredServer[] =>
[...nodes].sort((left, right) => {
const leftLabel = (left.hostname || left.ip).toLowerCase();
const rightLabel = (right.hostname || right.ip).toLowerCase();
return leftLabel.localeCompare(rightLabel);
});
const summarizeCoverage = (labels: readonly string[]): string => {
if (labels.length <= 3) {
return labels.join(', ');
@@ -41,6 +62,38 @@ const summarizeCoverage = (labels: readonly string[]): string => {
return `${labels.slice(0, 3).join(', ')} +${labels.length - 3} more`;
};
const normalizeDiscoveryTimestamp = (value?: number): number | undefined => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return value < 1_000_000_000_000 ? value * 1000 : value;
};
const formatRelativeTimestamp = (value?: number): string | undefined => {
const timestamp = normalizeDiscoveryTimestamp(value);
if (!timestamp) return undefined;
const diff = Math.max(0, Date.now() - timestamp);
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const days = Math.floor(hr / 24);
return `${days}d ago`;
};
const discoveredServerName = (server: DiscoveredServer): string =>
server.hostname?.trim() || server.ip;
const discoveredServerEndpoint = (server: DiscoveredServer): string =>
`https://${server.hostname?.trim() || server.ip}:${server.port}`;
const discoveredCoverageText = (server: DiscoveredServer): string =>
getInfrastructureOnboardingProductPresentation(server.type).coverage;
export const InfrastructureSourceManager: Component<InfrastructureSourceManagerProps> = (props) => {
const products = createMemo(() => getInfrastructureSourceManagerProducts());
const productRank = createMemo(() => {
@@ -50,7 +103,8 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
});
return next;
});
const groupedRows = createMemo(() => {
const groupedConfiguredRows = createMemo(() => {
const next = new Map<InfrastructureOnboardingConnectionType, InfrastructureSystemRow[]>();
for (const product of products()) {
next.set(product.type, []);
@@ -68,11 +122,38 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
return next;
});
const groupedDiscoveredRows = createMemo(() => {
const next = new Map<InfrastructureOnboardingConnectionType, DiscoveredServer[]>();
for (const product of products()) {
next.set(product.type, []);
}
for (const server of props.discoveredNodes()) {
const productRows = next.get(server.type as InfrastructureOnboardingConnectionType);
if (!productRows) continue;
productRows.push(server);
}
for (const [type, rows] of next.entries()) {
next.set(type, sortDiscoveredNodes(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;
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;
return (productRank().get(left.type) ?? 0) - (productRank().get(right.type) ?? 0);
}),
);
@@ -81,26 +162,123 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
!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 discoveryStatePresentation = createMemo(() => {
if (props.discoveryScanStatus().scanning) {
return {
badgeLabel: 'Scanning',
badgeClass:
'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',
description:
'Scanning the saved network scope for Proxmox VE, Backup Server, and Mail Gateway endpoints.',
};
}
if (props.discoveryEnabled) {
return {
badgeLabel: 'Background on',
badgeClass:
'inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-800 dark:bg-green-950/40 dark:text-green-200',
description:
'Background discovery is on. Run discovery here whenever you want to refresh the current candidates immediately.',
};
}
return {
badgeLabel: 'Manual only',
badgeClass:
'inline-flex items-center rounded-full bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-base-content',
description:
'Background discovery is off. Pulse only scans when you run discovery here or probe a specific address.',
};
});
const discoverySummary = createMemo(() => {
const parts: string[] = [];
if (discoveredCount() > 0) {
parts.push(
`${discoveredCount()} discovered candidate${discoveredCount() === 1 ? '' : 's'} visible`,
);
}
if (lastDiscoveryResultText()) {
parts.push(`Last result ${lastDiscoveryResultText()}`);
}
if (discoveryErrors().length > 0) {
parts.push(
`${discoveryErrors().length} discovery issue${discoveryErrors().length === 1 ? '' : 's'} reported`,
);
}
return parts;
});
return (
<SettingsPanel
title="Infrastructure sources"
description="Grouped by platform."
description="Configured and discovered candidates grouped by platform."
noPadding
action={
!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>
) : undefined
}
icon={<Server class="h-5 w-5" strokeWidth={2} />}
>
<div class="border-b border-border bg-surface-alt/40 px-3 py-3 sm:px-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class={discoveryStatePresentation().badgeClass}>
{discoveryStatePresentation().badgeLabel}
</span>
<span class="text-sm text-base-content">Discovery</span>
</div>
<p class="text-sm text-muted">{discoveryStatePresentation().description}</p>
<Show when={discoverySummary().length > 0}>
<p class="text-xs text-muted">{discoverySummary().join(' · ')}</p>
</Show>
</div>
<Show when={!props.readOnly}>
<div class="flex flex-wrap items-center gap-2 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.onDetectFromAddress}>
<button
type="button"
onClick={props.onDetectFromAddress}
class={secondaryToolbarButtonClass}
>
<Search class="h-4 w-4" />
Detect from address
</button>
</Show>
<Show when={props.onOpenDiscoverySettings}>
<button
type="button"
onClick={props.onOpenDiscoverySettings}
class={secondaryToolbarButtonClass}
>
<SlidersHorizontal class="h-4 w-4" />
Discovery settings
</button>
</Show>
</div>
</Show>
</div>
</div>
<Table class="w-full table-fixed text-sm">
<TableHeader class="bg-surface-alt/60">
<TableRow>
@@ -124,12 +302,13 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle bg-surface">
<TableBody class="bg-surface">
<For each={sortedProducts()}>
{(product) => {
const rows = () => groupedRows().get(product.type) ?? [];
const configuredRows = () => groupedConfiguredRows().get(product.type) ?? [];
const discoveredRows = () => groupedDiscoveredRows().get(product.type) ?? [];
const groupRowClass = () =>
'bg-surface-alt hover:bg-surface-alt dark:bg-base dark:hover:bg-base';
'border-b border-border-subtle bg-surface-alt hover:bg-surface-alt dark:bg-base dark:hover:bg-base';
const groupLabelClass = () => 'text-[15px] font-semibold text-base-content';
return (
@@ -149,9 +328,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<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>
<span class={groupLabelClass()}>{product.label}</span>
</div>
</TableCell>
<TableCell class="px-3 py-1.5 text-right">
@@ -170,29 +347,137 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</TableRow>
</Show>
<Show when={rows().length > 0}>
<For each={rows()}>
<Show when={configuredRows().length > 0}>
<For each={configuredRows()}>
{(row, index) => {
const isLast = () => index() === rows().length - 1;
const isLastConfigured = () =>
index() === configuredRows().length - 1 && discoveredRows().length === 0;
return (
<>
<TableRow>
<>
<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 ${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 ${isLastConfigured() ? 'h-2.5' : 'h-5'}`}
/>
<span class="absolute left-2 top-2.5 h-px w-3 bg-border" />
</span>
<div class="min-w-0">
<div
class="truncate text-[13px] text-base-content/80"
title={row.name}
>
{row.name}
</div>
</div>
</div>
</div>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<Show
when={row.host}
fallback={<span class="text-xs text-muted">-</span>}
>
<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>
</TableRow>
<Show when={row.lastErrorMessage}>
<TableRow class="border-b border-border-subtle">
<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>
<Show when={discoveredRows().length > 0}>
<For each={discoveredRows()}>
{(server, index) => {
const isLastDiscovered = () => index() === discoveredRows().length - 1;
return (
<TableRow class={discoveryRowClass}>
<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'}`}
class={`absolute left-2 top-0 w-px bg-border ${isLastDiscovered() ? 'h-2.5' : 'h-5'}`}
/>
<span class="absolute left-2 top-2.5 h-px w-3 bg-border" />
</span>
<div class="min-w-0">
<div
class="truncate text-[13px] text-base-content/80"
title={row.name}
class="truncate text-[13px] text-base-content/85"
title={`${discoveredServerName(server)}${server.version ? ` · ${server.version}` : ''}`}
>
{row.name}
{discoveredServerName(server)}
</div>
</div>
</div>
@@ -200,78 +485,51 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</TableCell>
<TableCell class="px-3 py-1 align-top">
<Show
when={row.host}
fallback={<span class="text-xs text-muted">-</span>}
<div
class="truncate whitespace-nowrap text-[12px] text-muted"
title={discoveredServerEndpoint(server)}
>
<div
class="truncate whitespace-nowrap text-[12px] text-muted"
title={row.host}
>
{row.host}
</div>
</Show>
{discoveredServerEndpoint(server)}
</div>
</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={discoveredCoverageText(server)}
>
<div
class="truncate whitespace-nowrap text-[12px] text-muted"
title={row.coverageLabels.join(', ')}
>
{summarizeCoverage(row.coverageLabels)}
</div>
</Show>
{discoveredCoverageText(server)}
</div>
</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 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>
<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)}
when={props.onReviewDiscoveredSource}
fallback={<span class="text-xs text-muted">Read only</span>}
>
<button
type="button"
onClick={() => props.onOpenConnection?.(row.connection)}
onClick={() => props.onReviewDiscoveredSource?.(server)}
class={inlineButtonClass}
>
Edit
Review
</button>
</Show>
</TableCell>
</Show>
</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>
@@ -14,7 +14,7 @@ import { VMwareCredentialSlot } from './ConnectionEditor/CredentialSlots/VMwareC
import type { Connection, ConnectionType } from '@/api/connections';
import type { TrueNASConnection } from '@/api/truenas';
import type { VMwareConnection } from '@/api/vmware';
import type { NodeConfigWithStatus } from '@/types/nodes';
import type { NodeConfig, NodeConfigWithStatus } from '@/types/nodes';
import { InfrastructureInstallerSection } from './InfrastructureInstallerSection';
import { InfrastructureSourceManager } from './InfrastructureSourceManager';
import { InfrastructureSourcePicker } from './InfrastructureSourcePicker';
@@ -41,6 +41,8 @@ import {
getInfrastructureOnboardingProductPresentation,
type InfrastructureOnboardingConnectionType,
} from '@/utils/infrastructureOnboardingPresentation';
import { settingsTabPath } from './settingsNavigationModel';
import type { DiscoveredServer } from './infrastructureSettingsModel';
export type InfrastructureWorkspaceProps = InfrastructurePlatformSettingsProps;
@@ -93,6 +95,8 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const [showAgentProfiles, setShowAgentProfiles] = createSignal(false);
const [editingConnection, setEditingConnection] = createSignal<Connection | null>(null);
const [selectedDiscoveredSource, setSelectedDiscoveredSource] =
createSignal<DiscoveredServer | null>(null);
const readOnly = createMemo(() => presentationPolicyIsReadOnly());
const routeStep = createMemo<InfrastructurePanelStep | null>(() => {
if (readOnly()) return null;
@@ -102,6 +106,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
createSignal<InfrastructureOnboardingMetricsTracker | null>(
routeStep() ? createOpenedOnboardingTracker() : null,
);
let previousRouteStep: InfrastructurePanelStep | null | undefined = routeStep();
const activeAddType = createMemo<ConnectionType | null>(() => {
const step = routeStep();
if (!step || step === 'pick' || step === 'detect') return null;
@@ -163,14 +168,24 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
};
const openAddFlow = (step: InfrastructurePanelStep) => {
setSelectedDiscoveredSource(null);
if (!addFlowTracker()) {
setAddFlowTracker(createOpenedOnboardingTracker());
}
navigate(buildInfrastructureOnboardingPath(step), { scroll: false });
};
const reviewDiscoveredSource = (server: DiscoveredServer) => {
if (!addFlowTracker()) {
setAddFlowTracker(createOpenedOnboardingTracker());
}
setSelectedDiscoveredSource(server);
navigate(buildInfrastructureOnboardingPath(server.type), { scroll: false });
};
const closeAddFlow = () => {
resetInlineEditorState();
setSelectedDiscoveredSource(null);
setAddFlowTracker(null);
clearSharedInfrastructureOnboardingMetricsTracker();
navigateToWorkspace(Boolean(routeStep()));
@@ -187,6 +202,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const handleAddSaved = () => {
ledger.reload();
void props.loadDiscoveredNodes();
closeAddFlow();
};
@@ -215,12 +231,11 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const tracker = addFlowTracker();
if (step && !tracker) {
setAddFlowTracker(createOpenedOnboardingTracker());
return;
}
if (!step && tracker) {
} else if (!step && tracker && previousRouteStep) {
setAddFlowTracker(null);
clearSharedInfrastructureOnboardingMetricsTracker();
}
previousRouteStep = step;
});
createEffect(() => {
@@ -237,10 +252,19 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
}
});
createEffect(() => {
const selected = selectedDiscoveredSource();
const activeType = activeAddType();
if (selected && activeType && activeType !== selected.type) {
setSelectedDiscoveredSource(null);
}
});
const renderNodeSlot = (
type: 'pve' | 'pbs' | 'pmg',
editingNode?: NodeConfigWithStatus | null,
editingRow?: Connection | null,
prefillNode?: Partial<NodeConfig>,
) => {
const connectionId = editingRow?.id ?? null;
return (
@@ -248,6 +272,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
nodeType={type}
settings={props}
editingNode={editingNode ?? null}
prefillNode={prefillNode}
onCancel={editingRow ? closeEditFlow : closeAddFlow}
onSaved={editingRow ? handleEditSaved : handleAddSaved}
onToggleEnabled={
@@ -551,11 +576,24 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
}
}
const discoveredPrefill = () => {
const discovered = selectedDiscoveredSource();
if (!discovered || discovered.type !== context.type) {
return undefined;
}
return {
type: discovered.type,
name: discovered.hostname?.trim() || discovered.ip,
host: `https://${discovered.hostname?.trim() || discovered.ip}:${discovered.port}`,
} satisfies Partial<NodeConfig>;
};
switch (context.type) {
case 'pve':
case 'pbs':
case 'pmg':
return renderNodeSlot(context.type);
return renderNodeSlot(context.type, undefined, undefined, discoveredPrefill());
case 'truenas':
return (
<TrueNASCredentialSlot
@@ -598,6 +636,11 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
if (step === 'detect') {
return 'Probe an address and let Pulse open the matching credential flow when it recognizes the platform.';
}
const discovered = selectedDiscoveredSource();
if (discovered && discovered.type === activeAddType()) {
const endpoint = `https://${discovered.hostname?.trim() || discovered.ip}:${discovered.port}`;
return `Pulse discovered ${endpoint}. Review the endpoint, finish the credentials, and then save it as a monitored source.`;
}
const presentation = getInfrastructureOnboardingProductPresentation(
activeAddType() as InfrastructureOnboardingConnectionType,
);
@@ -624,14 +667,30 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<div class="space-y-6">
<InfrastructureSourceManager
rows={rows}
discoveredNodes={props.discoveredNodes}
discoveryEnabled={props.discoveryEnabled()}
discoveryScanStatus={props.discoveryScanStatus}
readOnly={readOnly()}
onAddSource={
readOnly()
? undefined
: (type) => openAddFlow(type === 'agent' ? 'agent' : (type as ManagedAddTypeStep))
}
onRunDiscovery={
readOnly()
? undefined
: () => {
void props.triggerDiscoveryScan();
}
}
onDetectFromAddress={readOnly() ? undefined : () => openAddFlow('detect')}
onOpenDiscoverySettings={
readOnly()
? undefined
: () => navigate(settingsTabPath('system-network'), { scroll: false })
}
onOpenConnection={readOnly() ? undefined : (connection) => setEditingConnection(connection)}
onReviewDiscoveredSource={readOnly() ? undefined : (server) => reviewDiscoveredSource(server)}
/>
<Show when={routeStep() !== null}>
@@ -687,7 +746,9 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
mode="add"
initialType={activeAddType() ?? undefined}
showSlotHeader={false}
trackInitialCatalogSelection={activeAddType() !== 'agent'}
trackInitialCatalogSelection={
activeAddType() !== 'agent' && !selectedDiscoveredSource()
}
onboardingMetricsTracker={addFlowTracker()}
onClose={closeAddFlow}
onSaved={handleAddSaved}
@@ -144,7 +144,7 @@ const baseProps = () =>
initialLoadComplete: () => true,
discoveryEnabled: () => false,
discoveryMode: () => 'auto',
discoveryScanStatus: () => 'idle',
discoveryScanStatus: () => ({ scanning: false }),
discoveredNodes: () => [],
savingDiscoverySettings: () => false,
envOverrides: () => ({}),
@@ -222,6 +222,8 @@ describe('InfrastructureWorkspace', () => {
await waitFor(() =>
expect(screen.getByText('Infrastructure sources')).toBeInTheDocument(),
);
expect(screen.getByRole('button', { name: /Run discovery/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Discovery settings/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Detect from address/i })).toBeInTheDocument();
expect(screen.getByText('VMware vCenter')).toBeInTheDocument();
expect(screen.getByText('TrueNAS SCALE')).toBeInTheDocument();
@@ -231,6 +233,41 @@ describe('InfrastructureWorkspace', () => {
expect(screen.queryByRole('heading', { name: 'Monitored systems' })).not.toBeInTheDocument();
});
it('routes discovery actions from the manager and shows discovered candidates in the matching platform group', async () => {
const triggerDiscoveryScan = vi.fn();
renderWorkspace({
discoveredNodes: () => [
{
ip: '10.0.0.55',
port: 8006,
type: 'pve',
version: '8.2.2',
hostname: 'discovered-pve.lab',
},
],
discoveryScanStatus: () => ({ scanning: false, lastResultAt: Date.now() }),
triggerDiscoveryScan,
});
await waitFor(() =>
expect(screen.getByText('discovered-pve.lab')).toBeInTheDocument(),
);
expect(screen.getByText('Discovered')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Run discovery/i }));
expect(triggerDiscoveryScan).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: /Discovery settings/i }));
expect(navigateSpy).toHaveBeenCalledWith('/settings/system-network', {
scroll: false,
});
fireEvent.click(screen.getByRole('button', { name: /^Review$/i }));
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=pve', {
scroll: false,
});
});
it('opens the detect dialog from the source manager header action', () => {
renderWorkspace();
@@ -106,16 +106,23 @@ describe('settings architecture guardrails', () => {
expect(infrastructureWorkspaceSource).toContain(
": (type) => openAddFlow(type === 'agent' ? 'agent' : (type as ManagedAddTypeStep))",
);
expect(infrastructureWorkspaceSource).toContain('reviewDiscoveredSource');
expect(infrastructureWorkspaceSource).toContain('selectedDiscoveredSource');
expect(infrastructureWorkspaceSource).toContain(
"navigate(settingsTabPath('system-network'), { scroll: false })",
);
expect(infrastructureWorkspaceSource).toContain(
"onDetectFromAddress={readOnly() ? undefined : () => openAddFlow('detect')}",
);
expect(infrastructureWorkspaceSource).toContain('onReviewDiscoveredSource');
expect(infrastructureWorkspaceSource).toContain('void props.loadDiscoveredNodes();');
expect(infrastructureWorkspaceSource).toContain('<InfrastructureSourceManager');
expect(infrastructureWorkspaceSource).toContain('<InfrastructureSourcePicker');
expect(infrastructureWorkspaceSource).not.toContain('<ConnectionsTable rows={rows} />');
expect(infrastructureWorkspaceSource).toContain('flex h-full min-h-0 flex-col');
expect(infrastructureWorkspaceSource).toContain("showSlotHeader={false}");
expect(infrastructureWorkspaceSource).toContain(
"trackInitialCatalogSelection={activeAddType() !== 'agent'}",
"trackInitialCatalogSelection={",
);
expect(infrastructureWorkspaceSource).toContain("onDetectFromAddress={() => openAddFlow('detect')}");
expect(infrastructureWorkspaceSource).toContain("onBackToCatalog={() => openAddFlow('pick')}");
@@ -124,10 +131,16 @@ describe('settings architecture guardrails', () => {
expect(infrastructureWorkspaceSource).not.toContain('InfrastructureOperationsController');
expect(infrastructureWorkspaceSource).not.toContain('PlatformConnectionsWorkspace');
expect(infrastructureSourceManagerSource).toContain('Infrastructure sources');
expect(infrastructureSourceManagerSource).toContain('Run discovery');
expect(infrastructureSourceManagerSource).toContain('Detect from address');
expect(infrastructureSourceManagerSource).toContain('Discovery settings');
expect(infrastructureSourceManagerSource).toContain('Configured and discovered candidates grouped by platform.');
expect(infrastructureSourceManagerSource).toContain('onReviewDiscoveredSource');
expect(infrastructureSourceManagerSource).toContain('Discovered');
expect(infrastructureSourceManagerSource).toContain('getInfrastructureSourceManagerProducts');
expect(infrastructureSourceManagerSource).toContain('TableHeader');
expect(infrastructureSourceManagerSource).toContain('aria-label={`Add ${product.label}`}');
expect(infrastructureSourceManagerSource).toContain('Review');
expect(infrastructureSourceManagerSource).toContain('Edit');
expect(infrastructureSourceManagerSource).not.toContain('Connection types');
expect(infrastructureSourcePickerSource).toContain('Choose a source type');
@@ -0,0 +1,162 @@
import { createRoot, createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useInfrastructureDiscoveryRuntimeState } from '../useInfrastructureDiscoveryRuntimeState';
const apiFetchMock = vi.hoisted(() => vi.fn());
const notificationStoreMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
}));
vi.mock('@/utils/apiClient', () => ({
apiFetch: apiFetchMock,
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: notificationStoreMock,
}));
vi.mock('@/utils/logger', () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('@/api/settings', () => ({
SettingsAPI: {
updateSystemSettings: vi.fn(),
},
}));
const mountHook = () => {
let dispose = () => {};
let state!: ReturnType<typeof useInfrastructureDiscoveryRuntimeState>;
createRoot((d) => {
dispose = d;
const [nodes] = createSignal([] as any[]);
const [discoveryEnabled, setDiscoveryEnabled] = createSignal(false);
const [discoverySubnet] = createSignal('auto');
const [discoveryMode, setDiscoveryMode] = createSignal<'auto' | 'custom'>('auto');
const [discoverySubnetDraft, setDiscoverySubnetDraft] = createSignal('');
const [lastCustomSubnet, setLastCustomSubnet] = createSignal('');
const [_discoverySubnetError, setDiscoverySubnetError] = createSignal<string | undefined>(
undefined,
);
const [savingDiscoverySettings, setSavingDiscoverySettings] = createSignal(false);
const [envOverrides] = createSignal<Record<string, boolean>>({});
state = useInfrastructureDiscoveryRuntimeState({
eventBus: {
on: () => () => {},
},
nodes,
discoveryEnabled,
setDiscoveryEnabled,
discoverySubnet,
discoveryMode,
setDiscoveryMode,
discoverySubnetDraft,
setDiscoverySubnetDraft,
lastCustomSubnet,
setLastCustomSubnet,
setDiscoverySubnetError,
savingDiscoverySettings,
setSavingDiscoverySettings,
envOverrides,
normalizeSubnetList: (value) => value.trim(),
isValidCIDR: () => true,
applySavedDiscoverySubnet: () => {},
});
});
return { dispose, state };
};
describe('useInfrastructureDiscoveryRuntimeState', () => {
afterEach(() => {
apiFetchMock.mockReset();
notificationStoreMock.success.mockReset();
notificationStoreMock.error.mockReset();
notificationStoreMock.info.mockReset();
});
it('applies manual discovery results immediately from the explicit scan response', async () => {
apiFetchMock.mockResolvedValue({
ok: true,
json: async () => ({
servers: [
{
ip: '10.0.0.55',
port: 8006,
type: 'pve',
version: '8.2.2',
hostname: 'discovered-pve.lab',
},
],
errors: [],
timestamp: 1_700_000_000_000,
}),
});
const { dispose, state } = mountHook();
await state.triggerDiscoveryScan();
expect(state.discoveredNodes()).toEqual([
{
ip: '10.0.0.55',
port: 8006,
type: 'pve',
version: '8.2.2',
hostname: 'discovered-pve.lab',
release: undefined,
},
]);
expect(state.discoveryScanStatus().scanning).toBe(false);
expect(state.discoveryScanStatus().lastResultAt).toBe(1_700_000_000_000);
expect(notificationStoreMock.success).toHaveBeenCalledWith('Discovery scan complete', 2000);
dispose();
});
it('normalizes cached discovery updated timestamps into millisecond result times', async () => {
apiFetchMock.mockResolvedValue({
ok: true,
json: async () => ({
servers: [
{
ip: '10.0.0.88',
port: 8007,
type: 'pbs',
version: '3.2.1',
},
],
errors: [],
updated: 1_700_000_010,
}),
});
const { dispose, state } = mountHook();
await state.loadDiscoveredNodes();
expect(state.discoveryScanStatus().lastResultAt).toBe(1_700_000_010_000);
expect(state.discoveredNodes()).toEqual([
{
ip: '10.0.0.88',
port: 8007,
type: 'pbs',
version: '3.2.1',
hostname: undefined,
release: undefined,
},
]);
dispose();
});
});
@@ -82,6 +82,13 @@ export const useInfrastructureDiscoveryRuntimeState = ({
scanning: false,
});
const normalizeDiscoveryTimestamp = (value: unknown): number | undefined => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return value < 1_000_000_000_000 ? value * 1000 : value;
};
const updateDiscoveredNodesFromServers = (
servers: RawDiscoveredServer[] | undefined | null,
options: { merge?: boolean } = {},
@@ -183,11 +190,13 @@ export const useInfrastructureDiscoveryRuntimeState = ({
}
const data = await response.json();
const responseTimestamp = normalizeDiscoveryTimestamp(data?.timestamp ?? data?.updated);
if (Array.isArray(data.servers)) {
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[]);
setDiscoveryScanStatus((previous) => ({
...previous,
lastResultAt: typeof data.timestamp === 'number' ? data.timestamp : Date.now(),
scanning: false,
lastResultAt: responseTimestamp ?? Date.now(),
errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined,
}));
return;
@@ -196,7 +205,8 @@ export const useInfrastructureDiscoveryRuntimeState = ({
updateDiscoveredNodesFromServers([]);
setDiscoveryScanStatus((previous) => ({
...previous,
lastResultAt: typeof data?.timestamp === 'number' ? data.timestamp : previous.lastResultAt,
scanning: false,
lastResultAt: responseTimestamp ?? previous.lastResultAt,
errors: Array.isArray(data?.errors) && data.errors.length > 0 ? data.errors : undefined,
}));
} catch (error) {
@@ -230,8 +240,31 @@ export const useInfrastructureDiscoveryRuntimeState = ({
throw new Error(message || 'Discovery request failed');
}
const data = await response.json().catch(() => null);
const responseTimestamp = normalizeDiscoveryTimestamp(
data && typeof data === 'object'
? (data as { timestamp?: unknown; updated?: unknown }).timestamp ??
(data as { timestamp?: unknown; updated?: unknown }).updated
: undefined,
);
if (data && typeof data === 'object' && Array.isArray((data as { servers?: unknown }).servers)) {
updateDiscoveredNodesFromServers((data as { servers: RawDiscoveredServer[] }).servers);
}
setDiscoveryScanStatus((previous) => ({
...previous,
scanning: false,
lastResultAt: responseTimestamp ?? Date.now(),
errors:
data &&
typeof data === 'object' &&
Array.isArray((data as { errors?: unknown }).errors) &&
(data as { errors: unknown[] }).errors.length > 0
? ((data as { errors: string[] }).errors ?? undefined)
: undefined,
}));
if (!quiet) {
notificationStore.info('Discovery scan started', 2000);
notificationStore.success('Discovery scan complete', 2000);
}
} catch (error) {
logger.error('Failed to start discovery scan', error);
@@ -182,6 +182,8 @@ test.describe('Infrastructure onboarding', () => {
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await expect(page.getByText('Infrastructure sources', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /Run discovery/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Discovery settings/i })).toBeVisible();
await expect(page.getByText('VMware vCenter', { exact: true })).toBeVisible();
await expect(page.getByText('TrueNAS SCALE', { exact: true })).toBeVisible();
await expect(page.getByText('Proxmox VE', { exact: true })).toBeVisible();
@@ -241,6 +243,104 @@ test.describe('Infrastructure onboarding', () => {
.toBe(1);
});
test('desktop explicit discovery scan surfaces a candidate row and opens a prefilled review dialog', async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only discovery-candidate review coverage',
);
const metricEvents = await recordUpgradeMetricEvents(page);
await prepareOnboardingPage(page);
await page.route('**/api/discover', async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname !== '/api/discover') {
await route.continue();
return;
}
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
servers: [],
errors: [],
cached: true,
updated: 0,
age: 0,
}),
});
return;
}
if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
servers: [
{
ip: '10.0.0.55',
port: 8006,
type: 'pve',
version: '8.2.2',
hostname: 'discovered-pve.lab',
},
],
errors: [],
cached: false,
scanning: false,
timestamp: 1_700_000_000_000,
}),
});
return;
}
await route.continue();
});
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await page.getByRole('button', { name: /Run discovery/i }).click();
await expect(page.getByText('discovered-pve.lab', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /^Review$/i })).toBeVisible();
await page.getByRole('button', { name: /^Review$/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=pve$/, { timeout: 15_000 });
await expect(page.getByRole('dialog')).toBeVisible();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Add Proxmox VE', exact: true }),
).toBeVisible();
await expect(
page.getByPlaceholder('https://proxmox.example.com:8006'),
).toHaveValue('https://discovered-pve.lab:8006');
await expect
.poll(() => countUpgradeEvents(metricEvents, 'infrastructure_onboarding_opened'))
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_path_selected', 'api'),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_credentials_opened', 'pve'),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_catalog_selected', 'pve'),
)
.toBe(0);
});
test('desktop detect utility records no-match agent fallback from the landing header action', async ({
page,
}, testInfo) => {