mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Surface explicit discovery in infrastructure manager
This commit is contained in:
@@ -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');
|
||||
|
||||
+162
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user