Auto-detect first host install completion

This commit is contained in:
rcourtman
2026-03-27 10:52:26 +00:00
parent 60fb24210a
commit 38e7bfd4c7
5 changed files with 186 additions and 6 deletions
@@ -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
@@ -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
@@ -520,8 +520,9 @@ export const InfrastructureInstallerSection: Component = () => {
</button>
</div>
<p class="text-xs text-blue-800 dark:text-blue-200">
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.'}
</p>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<input
@@ -572,7 +573,9 @@ export const InfrastructureInstallerSection: Component = () => {
}`}
>
{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.`}
</p>
</div>
@@ -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>,
): 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',
@@ -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<AgentLookupResponse | null>(null);
const [lookupError, setLookupError] = createSignal<string | null>(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<InstallProfile>('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,