From 38e7bfd4c74eb15b71f303d1bc2d22197344801c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 10:52:26 +0000 Subject: [PATCH] Auto-detect first host install completion --- .../v6/internal/subsystems/agent-lifecycle.md | 6 +- .../v6/internal/subsystems/api-contracts.md | 7 ++ .../InfrastructureInstallerSection.tsx | 9 +- ...nfrastructureOperationsController.test.tsx | 64 +++++++++++ .../useInfrastructureInstallState.tsx | 106 +++++++++++++++++- 5 files changed, 186 insertions(+), 6 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 435c79cabc..9d8cf25939 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -162,7 +162,11 @@ management, and fleet control surfaces. When the first host reports successfully, the install workflow must treat that as a completion handoff with direct navigation into `/dashboard` and `/settings/infrastructure/operations` instead of leaving operators on a - generic lookup result. + generic lookup result. When the workspace starts from zero active connected + infrastructure and install commands are available, the same lifecycle path + must auto-watch the canonical `/api/state` projection for the first + reporting host rather than requiring a brand-new operator to know and type + a hostname or agent ID just to see the first success handoff. 9. Keep `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx` ordered around the actual first-run operator sequence: credentials that must be saved now should be visible before the operator leaves the screen, and diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a5836dcd43..2e81599203 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -209,6 +209,13 @@ Own canonical runtime payload shapes between backend and frontend. flow must expose direct navigation into `/dashboard` and `/settings/infrastructure/operations` rather than leaving the operator on a transport-only status readout. +19. Keep the shared first-host detection contract explicit on `/api/state` as + used by + `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`: + the canonical `connectedInfrastructure` projection must stay suitable for + detecting the first active reporting system during install so brand-new + operators can receive the first success handoff without typing a hostname + or agent ID. ## Current State diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx index ab9e53cfc2..5e12a84313 100644 --- a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx @@ -520,8 +520,9 @@ export const InfrastructureInstallerSection: Component = () => {

- Enter the hostname (or agent ID) from the machine you just installed. Pulse returns - the latest status instantly. + {state.autoLookupActive() + ? 'Pulse is watching for the first reporting host automatically. Enter a hostname or agent ID only if you want to check a specific machine right now.' + : 'Enter the hostname (or agent ID) from the machine you just installed. Pulse returns the latest status instantly.'}

{ }`} > {isConnected() - ? `${agent().displayName || agent().hostname} is reporting live telemetry to Pulse. Open the dashboard to verify your first overview, or continue in Reporting & control to inspect this host and add more infrastructure.` + ? state.lookupWasAutoDetected() + ? `${agent().displayName || agent().hostname} started reporting and Pulse detected it automatically. Open the dashboard to verify your first overview, or continue in Reporting & control to inspect this host and add more infrastructure.` + : `${agent().displayName || agent().hostname} is reporting live telemetry to Pulse. Open the dashboard to verify your first overview, or continue in Reporting & control to inspect this host and add more infrastructure.` : `${agent().displayName || agent().hostname} has been found, but Pulse is not receiving a live check-in yet. Keep the installer running on that machine and check again.`}

diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx index 6780821edf..265efc8f35 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx @@ -45,6 +45,7 @@ let mockWsStore: { }; const lookupMock = vi.fn(); +const getStateMock = vi.fn(); const createTokenMock = vi.fn(); const deleteAgentMock = vi.fn(); const allowHostAgentReenrollMock = vi.fn(); @@ -98,6 +99,7 @@ describe('InfrastructureOperationsController ownership guardrails', () => { expect(infrastructureOperationsStateSource).not.toContain('renderInventorySection'); expect(infrastructureOperationsStateSource).not.toContain('renderStopMonitoringDialog'); expect(infrastructureInstallStateSource).toContain('export const useInfrastructureInstallState'); + expect(infrastructureInstallStateSource).toContain('MonitoringAPI.getState()'); expect(infrastructureReportingStateSource).toContain( 'export const useInfrastructureReportingState', ); @@ -134,6 +136,7 @@ vi.mock('@/App', () => ({ vi.mock('@/api/monitoring', () => ({ MonitoringAPI: { + getState: (...args: unknown[]) => getStateMock(...args), lookupAgent: (...args: unknown[]) => lookupMock(...args), deleteAgent: (...args: unknown[]) => deleteAgentMock(...args), allowHostAgentReenroll: (...args: unknown[]) => allowHostAgentReenrollMock(...args), @@ -463,6 +466,20 @@ const buildConnectedInfrastructureFromFixtures = ({ return [...items, ...ignoredItems]; }; +const buildMonitoringState = ( + connectedInfrastructure: ConnectedInfrastructureItem[] = [], +): State => ({ + connectedInfrastructure, + metrics: [], + performance: {} as State['performance'], + connectionHealth: {}, + stats: {} as State['stats'], + activeAlerts: [], + recentlyResolved: [], + lastUpdate: Date.now(), + resources: [], +}); + const createIgnoredHostItem = ( overrides?: Partial, ): ConnectedInfrastructureItem => ({ @@ -609,6 +626,7 @@ beforeEach(() => { securityStatusResponse = { requiresAuth: true, apiTokenConfigured: false }; navigateMock.mockReset(); lookupMock.mockReset(); + getStateMock.mockReset(); createTokenMock.mockReset(); deleteAgentMock.mockReset(); allowHostAgentReenrollMock.mockReset(); @@ -627,6 +645,7 @@ beforeEach(() => { trackAgentInstallProfileSelectedMock.mockReset(); refetchResourcesMock.mockReset(); refetchResourcesMock.mockResolvedValue(mockResources()); + getStateMock.mockResolvedValue(buildMonitoringState()); clipboardSpy.mockReset().mockResolvedValue(undefined); fetchMock.mockReset(); fetchMock.mockResolvedValue( @@ -644,6 +663,7 @@ beforeEach(() => { afterEach(() => { cleanup(); + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -724,6 +744,50 @@ describe('InfrastructureOperationsController token generation', () => { }); describe('InfrastructureOperationsController agent lookup', () => { + it('auto-detects the first connected host when install starts from zero active systems', async () => { + vi.useFakeTimers(); + createTokenMock.mockResolvedValue({ + token: 'token-auto', + record: { + id: 'token-record-auto', + name: 'Auto Token', + prefix: 'auto', + suffix: '1234', + createdAt: new Date().toISOString(), + }, + }); + + const host = createAgent({ hostname: 'first-host.local', displayName: 'First Host' }); + getStateMock + .mockResolvedValueOnce(buildMonitoringState()) + .mockResolvedValue(buildMonitoringState(buildConnectedInfrastructureFromFixtures({ hosts: [host] }))); + + setupComponent(); + + fireEvent.click(screen.getByRole('button', { name: /Generate token/i })); + await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); + + await waitFor(() => { + expect( + screen.getByText(/Pulse is watching for the first reporting host automatically\./i), + ).toBeInTheDocument(); + }); + + await vi.advanceTimersByTimeAsync(3000); + + await waitFor(() => expect(screen.getByText('First host connected')).toBeInTheDocument(), { + interval: 0, + }); + expect( + screen.getByText( + 'First Host started reporting and Pulse detected it automatically. Open the dashboard to verify your first overview, or continue in Reporting & control to inspect this host and add more infrastructure.', + ), + ).toBeInTheDocument(); + expect((screen.getByPlaceholderText('Hostname or agent ID') as HTMLInputElement).value).toBe( + 'first-host.local', + ); + }); + it('performs agent lookup and displays results', async () => { createTokenMock.mockResolvedValue({ token: 'token-123', diff --git a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx index 26e343654d..773c5bfd03 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx +++ b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx @@ -1,9 +1,9 @@ -import { createEffect, createMemo, createSignal, onMount } from 'solid-js'; +import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { MonitoringAPI } from '@/api/monitoring'; import { SecurityAPI, type APITokenRecord } from '@/api/security'; import { notificationStore } from '@/stores/notifications'; -import { type AgentLookupResponse } from '@/types/api'; +import type { AgentLookupResponse, ConnectedInfrastructureItem } from '@/types/api'; import type { SecurityStatus } from '@/types/config'; import { AGENT_CONFIG_READ_SCOPE, @@ -43,6 +43,35 @@ export interface InfrastructureInstallStateOptions { embedded?: boolean; } +const FIRST_HOST_AUTODETECT_POLL_MS = 3000; + +const isActiveInfrastructureItem = (item: ConnectedInfrastructureItem) => item.status === 'active'; + +const pickMostRecentInfrastructureItem = ( + items: ConnectedInfrastructureItem[], +): ConnectedInfrastructureItem | null => { + if (items.length === 0) { + return null; + } + + return [...items].sort((left, right) => (right.lastSeen || 0) - (left.lastSeen || 0))[0]; +}; + +const toLookupResponseFromInfrastructureItem = ( + item: ConnectedInfrastructureItem, +): AgentLookupResponse => ({ + success: true, + agent: { + id: item.scopeAgentId?.trim() || item.uninstallAgentId?.trim() || item.id, + hostname: item.hostname?.trim() || item.displayName?.trim() || item.name.trim() || item.id, + displayName: item.displayName?.trim() || item.name.trim() || undefined, + status: item.healthStatus?.trim() || item.status, + connected: true, + lastSeen: item.lastSeen || Date.now(), + agentVersion: item.version?.trim() || undefined, + }, +}); + export const useInfrastructureInstallState = ( options: InfrastructureInstallStateOptions = {}, ) => { @@ -60,6 +89,8 @@ export const useInfrastructureInstallState = ( const [lookupResult, setLookupResult] = createSignal(null); const [lookupError, setLookupError] = createSignal(null); const [lookupLoading, setLookupLoading] = createSignal(false); + const [autoLookupActive, setAutoLookupActive] = createSignal(false); + const [lookupWasAutoDetected, setLookupWasAutoDetected] = createSignal(false); const [insecureMode, setInsecureMode] = createSignal(false); const [enableCommands, setEnableCommands] = createSignal(false); const [installProfile, setInstallProfile] = createSignal('auto'); @@ -236,9 +267,77 @@ Generate a scoped install token below before copying Unified Agent install comma return currentToken(); }; + createEffect(() => { + const shouldAutoDetectFirstHost = + commandsUnlocked() && !lookupValue().trim() && !lookupResult()?.agent?.connected; + + if (!shouldAutoDetectFirstHost) { + setAutoLookupActive(false); + return; + } + + let baselineActiveCount: number | null = null; + let pollInterval: number | undefined; + let cancelled = false; + + const pollForFirstHost = async () => { + try { + const state = await MonitoringAPI.getState(); + if (cancelled) { + return; + } + + const activeItems = (state.connectedInfrastructure || []).filter(isActiveInfrastructureItem); + if (baselineActiveCount === null) { + baselineActiveCount = activeItems.length; + } + + const shouldKeepWatching = baselineActiveCount === 0 && activeItems.length === 0; + setAutoLookupActive(shouldKeepWatching); + + if (baselineActiveCount !== 0 || activeItems.length === 0) { + return; + } + + const detectedItem = pickMostRecentInfrastructureItem(activeItems); + if (!detectedItem) { + return; + } + + const detectedLookup = toLookupResponseFromInfrastructureItem(detectedItem); + setLookupResult(detectedLookup); + setLookupError(null); + setLookupWasAutoDetected(true); + setLookupValue(detectedLookup.agent?.hostname || detectedLookup.agent?.id || ''); + setAutoLookupActive(false); + + if (pollInterval) { + window.clearInterval(pollInterval); + pollInterval = undefined; + } + } catch (err) { + if (!cancelled) { + logger.warn('Failed to auto-detect first connected host', err); + } + } + }; + + void pollForFirstHost(); + pollInterval = window.setInterval(pollForFirstHost, FIRST_HOST_AUTODETECT_POLL_MS); + + onCleanup(() => { + cancelled = true; + setAutoLookupActive(false); + if (pollInterval) { + window.clearInterval(pollInterval); + } + }); + }); + const handleLookup = async () => { const query = lookupValue().trim(); setLookupError(null); + setLookupWasAutoDetected(false); if (!query) { setLookupResult(null); @@ -268,6 +367,7 @@ Generate a scoped install token below before copying Unified Agent install comma const clearLookupState = () => { setLookupError(null); setLookupResult(null); + setLookupWasAutoDetected(false); }; const selectedCustomCaPath = () => customCaPath().trim(); @@ -344,6 +444,7 @@ Generate a scoped install token below before copying Unified Agent install comma return { acknowledgeNoToken, agentUrl, + autoLookupActive, clearLookupState, clearSetupHandoff, commandSections, @@ -370,6 +471,7 @@ Generate a scoped install token below before copying Unified Agent install comma lookupLoading, lookupResult, lookupValue, + lookupWasAutoDetected, openDashboard, openDirectProxmoxSetup, openInfrastructureInventory,