Add infrastructure setup confidence summary

This commit is contained in:
rcourtman 2026-04-23 22:04:56 +01:00
parent e4b416f0e3
commit d29adbf1ee
5 changed files with 234 additions and 7 deletions

View file

@ -233,7 +233,7 @@ an add-only capacity posture.
Persistence-sensitive NAS targets must keep one canonical continuity model here: installer-owned bootstraps may use flash-backed or immutable-root launch hooks only as thin trampolines, while the durable wrapper, state, and reboot-surviving binary copy stay in the governed persistent state directory that updater continuity also refreshes.
9. Add or change profile management, the extracted agent profiles runtime owner, the infrastructure source-manager landing, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/useConnectionRowActions.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger, source-manager cards, and ConnectionEditor credential slots rather than reintroducing those retired surfaces.
Those lifecycle-owned settings hooks may consume websocket state only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx` or recreate root-shell providers.
Discovery configuration is part of that same lifecycle-owned workspace boundary. `InfrastructureSourceManager.tsx` must open one canonical discovery editor through `InfrastructureDiscoverySettingsDialog.tsx`, `DiscoverySettingsForm.tsx`, and `discoverySettingsModel.ts`, while the System/Network shell stays limited to network-boundary controls instead of reintroducing a second editable discovery surface. That same workspace boundary now owns the add-flow entry split too: the landing guidance exposes `Detect from address`, `Install Pulse Agent`, and `Choose source type` as first-run actions above the source ledger, while header actions stay limited to ongoing discovery controls.
Discovery configuration is part of that same lifecycle-owned workspace boundary. `InfrastructureSourceManager.tsx` must open one canonical discovery editor through `InfrastructureDiscoverySettingsDialog.tsx`, `DiscoverySettingsForm.tsx`, and `discoverySettingsModel.ts`, while the System/Network shell stays limited to network-boundary controls instead of reintroducing a second editable discovery surface. That same workspace boundary now owns the add-flow entry split too: the landing guidance exposes `Detect from address`, `Install Pulse Agent`, and `Choose source type` as first-run actions above the source ledger, while header actions stay limited to ongoing discovery controls. The same landing boundary may surface setup confidence from the unified rows and discovered candidates, including connected-system count, API coverage, agent coverage, discovery review state, and the next setup action, without creating a second inventory model or provider-specific summary fetch.
The same lifecycle-owned workspace boundary now also owns attached-agent composition. When a unified Pulse Agent augments a first-class platform source such as Proxmox VE, the source manager and edit dialog must present one primary platform row with explicit `API` plus `Pulse Agent` composition rather than duplicating that same machine as a second peer row under a generic Pulse Agent platform bucket. Standalone hosts with no owning platform source remain grouped under a standalone-host owner bucket, with `Pulse Agent` shown as the collection method rather than as the pseudo-platform label.
When that primary Proxmox source is cluster-backed, the same workspace boundary must render the row under the canonical cluster moniker carried by the backend grouping contract rather than under one sibling node's hostname. That same backend grouping contract must also carry the explicit member-node list for the cluster so the source manager can render child node composition such as `delly` and `minipc` beneath the owning cluster row with per-node coverage/status. Cluster-member node agents belong as augmentations on that owning Proxmox row and its child nodes, not as separate standalone-host peers.
That same lifecycle-owned workspace boundary also owns agent version

View file

@ -251,8 +251,12 @@ work extends shared components instead of creating new local variants.
and no redundant monitored-systems ledger beneath it. The landing route may
include a compact guidance strip that explains platform APIs and host agents
as Pulse 6 infrastructure sources and exposes `Detect from address`, `Install
Pulse Agent`, and `Choose source type` as first-run actions. Existing sources
stay visible on the page, and add, detect, install, and edit flows open as
Pulse Agent`, and `Choose source type` as first-run actions. It may also show
a compact readiness strip derived from the same unified connection rows and
discovered candidates so operators can confirm connected-system count,
API coverage, agent coverage, discovery review state, and the next setup
action without opening a tour or second ledger. Existing sources stay visible
on the page, and add, detect, install, review, and edit flows open as
secondary interactions from that same destination instead of taking over the
whole page.
Those secondary views must stay under the same single `Infrastructure`
@ -296,10 +300,10 @@ work extends shared components instead of creating new local variants.
discovery status line plus `Run discovery` and `Discovery settings`
actions from the shared landing shell, but it must not start a network scan
just because the page rendered. New-source admission belongs on the table's
per-platform `Add` actions rather than in the discovery strip, and the
direct address-probe utility is picker-owned rather than landing-owned:
`Detect from address` must stay inside the grouped add dialog instead of
competing with discovery actions on the page header.
per-platform `Add` actions or the compact first-run/readiness actions rather
than in the discovery strip, and the direct address-probe utility may appear
as first-run setup guidance while header discovery actions remain dedicated
to saved network scanning.
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.
@ -1576,6 +1580,11 @@ canonical connected-infrastructure projection, fall back only to the compact
dashboard summary that the route already owns, and keep the explicit
Infrastructure handoff above detailed problem, storage, recovery, or trend
rows without restoring platform-special navigation.
That first-viewport copy must distinguish system-level estate health from
resource, alert, storage, or recovery issues that remain elsewhere on the
dashboard, and partial/empty dashboard states must describe synchronization or
infrastructure-source onboarding in operator terms instead of exposing
implementation fallback language.
The recovery feature shell now also depends on the shared
`frontend-modern/src/components/shared/Subtabs.tsx` primitive for its primary
protected-items versus recovery-events workspace switch. The recovery lane may

View file

@ -134,6 +134,32 @@ const memberMethodTitleFor = (
);
};
type SetupConfidenceActionKind = 'add' | 'agent' | 'detect' | 'review' | 'scan';
interface SetupConfidenceAction {
kind: SetupConfidenceActionKind;
label: string;
detail: string;
onClick?: () => void;
disabled?: boolean;
}
const formatCount = (count: number, noun: string): string =>
`${count} ${noun}${count === 1 ? '' : 's'}`;
const rowHasApiCoverage = (row: InfrastructureSystemRow): boolean =>
row.source === 'api' ||
row.source === 'both' ||
row.members.some((member) => member.source === 'api' || member.source === 'both');
const rowHasAgentCoverage = (row: InfrastructureSystemRow): boolean =>
row.source === 'agent' ||
row.source === 'both' ||
row.attachedConnections.some((connection) => connection.type === 'agent') ||
row.members.some(
(member) => member.source === 'agent' || member.source === 'both' || member.agentConnection,
);
export const InfrastructureSourceManager: Component<InfrastructureSourceManagerProps> = (props) => {
let layoutContainerRef: HTMLDivElement | undefined;
const products = createMemo(() => getInfrastructureSourceManagerProducts());
@ -218,6 +244,79 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
const lastDiscoveryResultText = createMemo(() =>
formatRelativeTimestamp(props.discoveryScanStatus().lastResultAt),
);
const connectedSystemCount = createMemo(() => props.rows().length);
const discoveredCandidateCount = createMemo(() => props.discoveredNodes().length);
const apiCoveredSystemCount = createMemo(
() => props.rows().filter((row) => rowHasApiCoverage(row)).length,
);
const agentCoveredSystemCount = createMemo(
() => props.rows().filter((row) => rowHasAgentCoverage(row)).length,
);
const apiOnlySystemCount = createMemo(
() => props.rows().filter((row) => rowHasApiCoverage(row) && !rowHasAgentCoverage(row)).length,
);
const discoveryReadinessLabel = createMemo(() => {
if (props.discoveryScanStatus().scanning) return 'Scanning now';
if (discoveredCandidateCount() > 0) return `${discoveredCandidateCount()} to review`;
const lastResult = lastDiscoveryResultText();
if (lastResult) return `Last scan ${lastResult}`;
return props.discoveryEnabled ? 'Ready to scan' : 'Discovery off';
});
const setupConfidenceAction = createMemo<SetupConfidenceAction>(() => {
const discoveredCandidates = props.discoveredNodes();
if (discoveredCandidates.length > 0 && props.onReviewDiscoveredSource) {
return {
kind: 'review',
label: discoveredCandidates.length === 1 ? 'Review candidate' : 'Review first candidate',
detail: `${formatCount(discoveredCandidates.length, 'candidate')} discovered and waiting to be attached to the infrastructure model.`,
onClick: () => props.onReviewDiscoveredSource?.(discoveredCandidates[0]),
};
}
if (connectedSystemCount() === 0) {
if (props.onDetectFromAddress) {
return {
kind: 'detect',
label: 'Detect first source',
detail: 'Probe an address so Pulse can identify the platform and open the right setup flow.',
onClick: props.onDetectFromAddress,
};
}
return {
kind: 'add',
label: 'Choose source type',
detail: 'Choose a supported platform API or install Pulse Agent to start building the unified infrastructure model.',
onClick: props.onAddInfrastructure,
};
}
if (apiOnlySystemCount() > 0 && props.onAddSource) {
return {
kind: 'agent',
label: 'Install agents',
detail: `Install Pulse Agent on ${formatCount(apiOnlySystemCount(), 'API-backed system')} when you want node-local telemetry such as temperatures, SMART data, and host identity.`,
onClick: () => props.onAddSource?.('agent'),
};
}
if (props.onRunDiscovery && props.discoveryEnabled && !lastDiscoveryResultText()) {
return {
kind: 'scan',
label: props.discoveryScanStatus().scanning ? 'Scanning networks' : 'Scan networks',
detail: 'Run discovery to check whether more platform APIs are waiting on the configured networks.',
onClick: props.onRunDiscovery,
disabled: props.discoveryScanStatus().scanning,
};
}
return {
kind: 'add',
label: 'Add another source',
detail: 'Coverage looks coherent for the connected systems. Add another source when the estate changes.',
onClick: props.onAddInfrastructure,
};
});
const [layoutWidth, setLayoutWidth] = createSignal(
typeof window !== 'undefined' ? window.innerWidth : 1024,
@ -293,6 +392,87 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</Show>
);
const setupConfidenceActionIcon = (kind: SetupConfidenceActionKind) => (
<>
<Show when={kind === 'agent'}>
<Cpu class="h-4 w-4" />
</Show>
<Show when={kind === 'detect' || kind === 'review'}>
<Search class="h-4 w-4" />
</Show>
<Show when={kind === 'scan'}>
<RotateCw
class={`h-4 w-4 ${props.discoveryScanStatus().scanning ? 'animate-spin' : ''}`}
/>
</Show>
<Show when={kind === 'add'}>
<Plus class="h-4 w-4" />
</Show>
</>
);
const setupConfidenceBand = () => (
<section
aria-label="Infrastructure setup confidence"
class="border-b border-border bg-surface-alt/30 px-4 py-4"
>
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="max-w-3xl">
<h3 class="text-sm font-semibold text-base-content">Infrastructure readiness</h3>
<p class="mt-1 text-sm leading-5 text-muted">
Systems are now built from infrastructure sources. Use this summary to confirm Pulse has
API inventory, agent telemetry, and discovery review covered for the estate.
</p>
</div>
<Show when={!props.readOnly && Boolean(setupConfidenceAction().onClick)}>
<button
type="button"
onClick={() => setupConfidenceAction().onClick?.()}
disabled={setupConfidenceAction().disabled}
class={`${onboardingSecondaryButtonClass} self-start`}
>
{setupConfidenceActionIcon(setupConfidenceAction().kind)}
{setupConfidenceAction().label}
</button>
</Show>
</div>
<dl class="mt-4 grid gap-0 border-y border-border-subtle sm:grid-cols-2 xl:grid-cols-4">
<div class="border-b border-border-subtle px-3 py-3 sm:border-r xl:border-b-0">
<dt class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">
Connected systems
</dt>
<dd class="mt-1 text-sm font-semibold text-base-content">
{formatCount(connectedSystemCount(), 'system')}
</dd>
</div>
<div class="border-b border-border-subtle px-3 py-3 xl:border-b-0 xl:border-r">
<dt class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">
API coverage
</dt>
<dd class="mt-1 text-sm font-semibold text-base-content">
{formatCount(apiCoveredSystemCount(), 'system')}
</dd>
</div>
<div class="border-b border-border-subtle px-3 py-3 sm:border-b-0 sm:border-r">
<dt class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">
Agent coverage
</dt>
<dd class="mt-1 text-sm font-semibold text-base-content">
{formatCount(agentCoveredSystemCount(), 'system')}
</dd>
</div>
<div class="px-3 py-3">
<dt class="text-[11px] font-medium uppercase tracking-[0.08em] text-muted">Discovery</dt>
<dd class="mt-1 text-sm font-semibold text-base-content">{discoveryReadinessLabel()}</dd>
</div>
</dl>
<p class="mt-3 text-xs leading-5 text-muted">{setupConfidenceAction().detail}</p>
</section>
);
const onboardingBand = () => (
<div class="border-b border-border bg-surface px-4 py-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
@ -362,6 +542,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
action={headerActions()}
>
{onboardingBand()}
{setupConfidenceBand()}
<Show when={!useCardLayout()}>
<Table class="w-full table-fixed text-sm">

View file

@ -296,6 +296,18 @@ describe('InfrastructureWorkspace', () => {
expect(screen.getByRole('button', { name: /Detect from address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Choose source type/i })).toBeInTheDocument();
const readiness = screen.getByRole('region', {
name: /Infrastructure setup confidence/i,
});
expect(within(readiness).getByText('Infrastructure readiness')).toBeInTheDocument();
expect(within(readiness).getByText('Connected systems')).toBeInTheDocument();
expect(within(readiness).getByText('API coverage')).toBeInTheDocument();
expect(within(readiness).getByText('Agent coverage')).toBeInTheDocument();
expect(within(readiness).getByText('Discovery')).toBeInTheDocument();
expect(within(readiness).getAllByText('1 system')).toHaveLength(2);
expect(within(readiness).getByText('0 systems')).toBeInTheDocument();
expect(within(readiness).getByText('Discovery off')).toBeInTheDocument();
expect(within(readiness).getByRole('button', { name: /Install agents/i })).toBeInTheDocument();
expect(screen.getByText('Proxmox VE')).toBeInTheDocument();
expect(screen.getByText('Proxmox VE').closest('tr')?.className).toContain('bg-base');
expect(screen.queryByText('VMware vCenter')).toBeNull();
@ -324,6 +336,17 @@ describe('InfrastructureWorkspace', () => {
scroll: false,
});
fireEvent.click(
within(
screen.getByRole('region', {
name: /Infrastructure setup confidence/i,
}),
).getByRole('button', { name: /Install agents/i }),
);
expect(navigateSpy).toHaveBeenLastCalledWith('/settings/infrastructure?add=agent', {
scroll: false,
});
fireEvent.click(screen.getByRole('button', { name: /Choose source type/i }));
expect(navigateSpy).toHaveBeenLastCalledWith('/settings/infrastructure?add=pick', {
scroll: false,
@ -388,6 +411,15 @@ describe('InfrastructureWorkspace', () => {
await waitFor(() => expect(screen.getByText('discovered-pve.lab')).toBeInTheDocument());
expect(screen.getByText('Discovered')).toBeInTheDocument();
const readiness = screen.getByRole('region', {
name: /Infrastructure setup confidence/i,
});
expect(within(readiness).getByText('1 to review')).toBeInTheDocument();
expect(within(readiness).getByText(/1 candidate discovered and waiting/i)).toBeInTheDocument();
fireEvent.click(within(readiness).getByRole('button', { name: /Review candidate/i }));
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=pve', {
scroll: false,
});
fireEvent.click(screen.getByRole('button', { name: /Run discovery/i }));
expect(triggerDiscoveryScan).toHaveBeenCalledTimes(1);

View file

@ -178,6 +178,11 @@ describe('settings architecture guardrails', () => {
expect(infrastructureSourceManagerSource).toContain('Install Pulse Agent');
expect(infrastructureSourceManagerSource).toContain('Choose source type');
expect(infrastructureSourceManagerSource).toContain('getInfrastructureEmptyStateSummary');
expect(infrastructureSourceManagerSource).toContain('Infrastructure readiness');
expect(infrastructureSourceManagerSource).toContain('Connected systems');
expect(infrastructureSourceManagerSource).toContain('API coverage');
expect(infrastructureSourceManagerSource).toContain('Agent coverage');
expect(infrastructureSourceManagerSource).toContain('setupConfidenceAction');
expect(infrastructureSourceManagerSource).not.toContain('Connection types');
expect(infrastructureSourcePickerSource).toContain('Detect from address');
expect(infrastructureSourcePickerSource).toContain('getInfrastructureSourcePickerGroups');