From da4933ef31b75a0997663167bdb599fde88e8075 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Apr 2026 19:37:04 +0100 Subject: [PATCH] Surface explicit discovery in infrastructure manager --- .../v6/internal/subsystems/agent-lifecycle.md | 8 + .../v6/internal/subsystems/api-contracts.md | 8 + .../subsystems/frontend-primitives.md | 18 +- .../Settings/InfrastructureSourceManager.tsx | 416 ++++++++++++++---- .../Settings/InfrastructureWorkspace.tsx | 73 ++- .../InfrastructureWorkspace.test.tsx | 39 +- .../__tests__/settingsArchitecture.test.ts | 15 +- ...frastructureDiscoveryRuntimeState.test.tsx | 162 +++++++ .../useInfrastructureDiscoveryRuntimeState.ts | 39 +- .../68-infrastructure-onboarding.spec.ts | 100 +++++ 10 files changed, 784 insertions(+), 94 deletions(-) create mode 100644 frontend-modern/src/components/Settings/__tests__/useInfrastructureDiscoveryRuntimeState.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 8cca93d1e..c607b8bdf 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 8784d3d3b..2d8f00063 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b4266c0d5..643652971 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx index f42c7332b..746de47d2 100644 --- a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx @@ -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; + discoveredNodes: Accessor; + discoveryEnabled: boolean; + discoveryScanStatus: Accessor; 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 = (props) => { const products = createMemo(() => getInfrastructureSourceManagerProducts()); const productRank = createMemo(() => { @@ -50,7 +103,8 @@ export const InfrastructureSourceManager: Component { + + const groupedConfiguredRows = createMemo(() => { const next = new Map(); for (const product of products()) { next.set(product.type, []); @@ -68,11 +122,38 @@ export const InfrastructureSourceManager: Component { + const next = new Map(); + 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 !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 ( - - Detect from address - - ) : undefined - } icon={} > +
+
+
+
+ + {discoveryStatePresentation().badgeLabel} + + Discovery +
+

{discoveryStatePresentation().description}

+ 0}> +

{discoverySummary().join(' · ')}

+
+
+ + +
+ + + + + + + + + + + +
+
+
+
+ @@ -124,12 +302,13 @@ export const InfrastructureSourceManager: Component - + {(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
-
- {product.label} -
+ {product.label}
@@ -170,29 +347,137 @@ export const InfrastructureSourceManager: Component - 0}> - + 0}> + {(row, index) => { - const isLast = () => index() === rows().length - 1; + const isLastConfigured = () => + index() === configuredRows().length - 1 && discoveredRows().length === 0; return ( - <> - + <> + + +
+
+
+
+
+ + + -} + > +
+ {row.host} +
+
+
+ + + 0} + fallback={-} + > +
+ {summarizeCoverage(row.coverageLabels)} +
+
+
+ + +
+ + {row.statusLabel} + + + {row.lastActivityText} + +
+
+ + + + Read only} + > + + + + +
+ + + + + + + + + + ); + }} +
+
+ + 0}> + + {(server, index) => { + const isLastDiscovered = () => index() === discoveredRows().length - 1; + return ( +
@@ -200,78 +485,51 @@ export const InfrastructureSourceManager: Component - -} +
-
- {row.host} -
- + {discoveredServerEndpoint(server)} +
- 0} - fallback={-} +
-
- {summarizeCoverage(row.coverageLabels)} -
- + {discoveredCoverageText(server)} +
- - {row.statusLabel} + + Discovered + + + {lastDiscoveryResultText() ?? 'Waiting for scan'} - {row.lastActivityText}
Read only} > - - - - - - - - - ); }} diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index 211bcfbb3..8f2c20b9d 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -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 = const [showAgentProfiles, setShowAgentProfiles] = createSignal(false); const [editingConnection, setEditingConnection] = createSignal(null); + const [selectedDiscoveredSource, setSelectedDiscoveredSource] = + createSignal(null); const readOnly = createMemo(() => presentationPolicyIsReadOnly()); const routeStep = createMemo(() => { if (readOnly()) return null; @@ -102,6 +106,7 @@ const InfrastructureWorkspaceContent: Component = createSignal( routeStep() ? createOpenedOnboardingTracker() : null, ); + let previousRouteStep: InfrastructurePanelStep | null | undefined = routeStep(); const activeAddType = createMemo(() => { const step = routeStep(); if (!step || step === 'pick' || step === 'detect') return null; @@ -163,14 +168,24 @@ const InfrastructureWorkspaceContent: Component = }; 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 = const handleAddSaved = () => { ledger.reload(); + void props.loadDiscoveredNodes(); closeAddFlow(); }; @@ -215,12 +231,11 @@ const InfrastructureWorkspaceContent: Component = 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 = } }); + 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, ) => { const connectionId = editingRow?.id ?? null; return ( @@ -248,6 +272,7 @@ const InfrastructureWorkspaceContent: Component = 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 = } } + 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; + }; + switch (context.type) { case 'pve': case 'pbs': case 'pmg': - return renderNodeSlot(context.type); + return renderNodeSlot(context.type, undefined, undefined, discoveredPrefill()); case 'truenas': return ( = 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 =
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)} /> @@ -687,7 +746,9 @@ const InfrastructureWorkspaceContent: Component = mode="add" initialType={activeAddType() ?? undefined} showSlotHeader={false} - trackInitialCatalogSelection={activeAddType() !== 'agent'} + trackInitialCatalogSelection={ + activeAddType() !== 'agent' && !selectedDiscoveredSource() + } onboardingMetricsTracker={addFlowTracker()} onClose={closeAddFlow} onSaved={handleAddSaved} diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 9b475c337..d4593a459 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -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(); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index c5c82feee..c9dd8aeea 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -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(''); 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'); diff --git a/frontend-modern/src/components/Settings/__tests__/useInfrastructureDiscoveryRuntimeState.test.tsx b/frontend-modern/src/components/Settings/__tests__/useInfrastructureDiscoveryRuntimeState.test.tsx new file mode 100644 index 000000000..b39b9a63e --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/useInfrastructureDiscoveryRuntimeState.test.tsx @@ -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; + + 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( + undefined, + ); + const [savingDiscoverySettings, setSavingDiscoverySettings] = createSignal(false); + const [envOverrides] = createSignal>({}); + + 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(); + }); +}); diff --git a/frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts b/frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts index 9f9844723..70dd6989d 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts +++ b/frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts @@ -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); diff --git a/tests/integration/tests/68-infrastructure-onboarding.spec.ts b/tests/integration/tests/68-infrastructure-onboarding.spec.ts index 4ffd0c996..70020d33d 100644 --- a/tests/integration/tests/68-infrastructure-onboarding.spec.ts +++ b/tests/integration/tests/68-infrastructure-onboarding.spec.ts @@ -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) => {