mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Auto-detect first host install completion
This commit is contained in:
@@ -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>
|
||||
|
||||
+64
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user