diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 55cf1bfcde..e3b8dfa918 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -153,6 +153,13 @@ management, and fleet control surfaces. must default to the install workspace, and the workspace shell must make the first-host sequence explicit before operators drift into reporting and control surfaces. +8. Keep post-install lifecycle completion explicit inside + `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx` + and `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`. + 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. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b8d6b32cd3..a5836dcd43 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -202,6 +202,13 @@ Own canonical runtime payload shapes between backend and frontend. 15. Keep recovery payload platform vocabulary canonical across that same `/api/recovery/*` surface: point payloads must expose `platform`, rollup payloads must expose `platforms`, and any compatibility `provider` / `providers` aliases must remain secondary fallback fields rather than replacing the shared response model 16. Keep recovery linked-resource vocabulary canonical across that same `/api/recovery/*` surface: points and rollups must expose `itemResourceId` as the canonical linked-resource field, accepted legacy `subjectResourceId` aliases must remain compatibility-only input or secondary payload fields, and the shared proof surface must pin that normalization in the same slice as any handler change 17. Keep recovery external item-reference vocabulary canonical across that same `/api/recovery/*` surface: point and rollup payloads must expose `itemRef` as the canonical external item-reference field, accepted legacy `subjectRef` aliases must remain compatibility-only secondary payload fields, and the shared proof surface must pin that normalization in the same slice as any handler change +18. Keep first-host lookup completion explicit on the shared install-state API + boundary: when + `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx` + receives a successful connected-agent lookup result, the canonical install + flow must expose direct navigation into `/dashboard` and + `/settings/infrastructure/operations` rather than leaving the operator on a + transport-only status readout. ## Current State diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx index 9a0aeeea36..2d0e2b77d2 100644 --- a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx @@ -513,11 +513,33 @@ export const InfrastructureInstallerSection: Component = () => { const agent = () => result().agent!; const lookupStatusPresentation = () => getUnifiedAgentLookupStatusPresentation(agent().connected); + const isConnected = () => agent().connected; return ( -
-
-
{agent().displayName || agent().hostname}
+
+
+
+
+ {isConnected() ? 'First host connected' : agent().displayName || agent().hostname} +
+

+ {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.` + : `${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.`} +

+
{
-
- Last seen {formatRelativeTime(agent().lastSeen)} ( - {formatAbsoluteTime(agent().lastSeen)}) +
+
+ Last seen {formatRelativeTime(agent().lastSeen)} ( + {formatAbsoluteTime(agent().lastSeen)}) +
+ +
+ Agent version {agent().agentVersion} +
+
- -
- Agent version {agent().agentVersion} + +
+ +
diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx index f34986bb57..6da1178d00 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsController.test.tsx @@ -25,6 +25,18 @@ import type { State, } from '@/types/api'; +const { navigateMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), +})); + +vi.mock('@solidjs/router', async () => { + const actual = await vi.importActual('@solidjs/router'); + return { + ...actual, + useNavigate: () => navigateMock, + }; +}); + let mockWsStore: { state: Pick; connected: () => boolean; @@ -561,6 +573,7 @@ const getConnectionUrlInput = (): HTMLInputElement => { beforeEach(() => { securityStatusResponse = { requiresAuth: true, apiTokenConfigured: false }; + navigateMock.mockReset(); lookupMock.mockReset(); createTokenMock.mockReset(); deleteAgentMock.mockReset(); @@ -698,6 +711,18 @@ describe('InfrastructureOperationsController agent lookup', () => { await waitFor(() => expect(lookupMock).toHaveBeenCalled(), { interval: 0 }); await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument(), { interval: 0 }); + expect(screen.getByText('First host connected')).toBeInTheDocument(); + expect( + screen.getByText( + 'Host One 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.', + ), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Open dashboard' })); + expect(navigateMock).toHaveBeenCalledWith('/dashboard'); + + fireEvent.click(screen.getByRole('button', { name: 'Open reporting & control' })); + expect(navigateMock).toHaveBeenCalledWith('/settings/infrastructure/operations'); }); it('shows error message when agent is not found', async () => { diff --git a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx index 4c3092bf5a..26e343654d 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx +++ b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx @@ -16,6 +16,7 @@ import { copyToClipboard } from '@/utils/clipboard'; import { getPulseBaseUrl } from '@/utils/url'; import { logger } from '@/utils/logger'; import { STORAGE_KEYS } from '@/utils/localStorage'; +import { buildInfrastructureWorkspacePath } from './infrastructureWorkspaceModel'; import { buildUnixAgentInstallCommand, buildWindowsAgentInstallCommand, @@ -330,6 +331,14 @@ Generate a scoped install token below before copying Unified Agent install comma navigate('/settings/infrastructure/proxmox'); }; + const openDashboard = () => { + navigate('/dashboard'); + }; + + const openInfrastructureInventory = () => { + navigate(buildInfrastructureWorkspacePath('inventory')); + }; + const isEmbedded = () => options.embedded ?? false; return { @@ -361,7 +370,9 @@ Generate a scoped install token below before copying Unified Agent install comma lookupLoading, lookupResult, lookupValue, + openDashboard, openDirectProxmoxSetup, + openInfrastructureInventory, requiresToken, selectedAgentUrl, setCustomAgentUrl,