infra: replace fixed ledger explainer with an Add infrastructure flow

The two teaching cards lived permanently above the ledger, so every
repeat visit paid visual cost for content the user only needs when
adding something new. The new shape:

  Ledger      — just the table plus one "Add infrastructure" action.
  Picker      — clicking Add brings up the two cards on their own
                screen ("Add connection" / "Install agent") with full
                teaching context.
  Sub-flow    — the chosen mode runs as before: probe for Platform API,
                install section for the agent. Each sub-flow exposes a
                "Change method" button so the user can step back into
                the picker without leaving add mode.

Legacy ?add=pick deep links still route into the new picker rather than
jumping directly into the probe.
This commit is contained in:
rcourtman
2026-04-20 14:24:58 +01:00
parent f9104115a5
commit a62f185cce
4 changed files with 148 additions and 50 deletions
@@ -5,7 +5,7 @@ import { copyToClipboard } from '@/utils/clipboard';
import { notificationStore } from '@/stores/notifications';
import { AgentProfilesPanel } from './AgentProfilesPanel';
import { ConnectionsExplainer } from './ConnectionsExplainer';
import { ConnectionsTable } from './ConnectionsTable';
import { ConnectionsTable, type ConnectionsTableHeaderAction } from './ConnectionsTable';
import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';
import { NodeCredentialSlot } from './ConnectionEditor/CredentialSlots/NodeCredentialSlot';
import { TrueNASCredentialSlot } from './ConnectionEditor/CredentialSlots/TrueNASCredentialSlot';
@@ -50,6 +50,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const rowActions = useConnectionRowActions({ onMutated: () => ledger.reload() });
const [addMode, setAddMode] = createSignal(false);
const [addPickerMode, setAddPickerMode] = createSignal(false);
const [initialAddType, setInitialAddType] = createSignal<ConnectionType | null>(null);
const [showAgentProfiles, setShowAgentProfiles] = createSignal(false);
const [editingConnection, setEditingConnection] = createSignal<Connection | null>(null);
@@ -97,15 +98,18 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
}
if (!readOnly() && step) {
setAddMode(true);
if (step && step !== 'pick') {
setInitialAddType(ADD_STEP_TO_TYPE[step]);
} else {
if (step === 'pick') {
setAddPickerMode(true);
setInitialAddType(null);
} else {
setAddPickerMode(false);
setInitialAddType(ADD_STEP_TO_TYPE[step]);
}
return;
}
setAddMode(false);
setAddPickerMode(false);
setInitialAddType(null);
setShowAgentProfiles(false);
});
@@ -122,18 +126,41 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const rows = createMemo(() => ledger.rows());
const startAddConnection = () => {
const startAddInfrastructure = () => {
setInitialAddType(null);
setAddPickerMode(true);
setAddMode(true);
setShowAgentProfiles(false);
};
const startInstallAgent = () => {
const pickPlatformApi = () => {
setInitialAddType(null);
setAddPickerMode(false);
};
const pickAgent = () => {
setInitialAddType('agent');
setAddMode(true);
setAddPickerMode(false);
};
const backToPicker = () => {
setInitialAddType(null);
setAddPickerMode(true);
setShowAgentProfiles(false);
};
const headerActions = createMemo<ConnectionsTableHeaderAction[]>(() =>
readOnly()
? []
: [
{
label: 'Add infrastructure',
onSelect: startAddInfrastructure,
tone: 'primary' as const,
},
],
);
const agentUninstallCommands = createMemo(() => ({
linux: operations.getUninstallCommand(),
windows: operations.getWindowsUninstallCommand(),
@@ -150,6 +177,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const exitAddMode = () => {
setAddMode(false);
setAddPickerMode(false);
setInitialAddType(null);
setShowAgentProfiles(false);
};
@@ -194,20 +222,14 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<div class="space-y-8">
<Switch
fallback={
<>
<ConnectionsExplainer
readOnly={readOnly()}
onAddConnection={readOnly() ? undefined : startAddConnection}
onInstallAgent={readOnly() ? undefined : startInstallAgent}
/>
<ConnectionsTable
rows={rows}
actions={readOnly() ? undefined : rowActions}
onEdit={readOnly() ? undefined : handleEditConnection}
agentUninstallCommands={agentUninstallCommands()}
onCopyText={(text) => void handleCopy(text)}
/>
</>
<ConnectionsTable
rows={rows}
headerActions={headerActions()}
actions={readOnly() ? undefined : rowActions}
onEdit={readOnly() ? undefined : handleEditConnection}
agentUninstallCommands={agentUninstallCommands()}
onCopyText={(text) => void handleCopy(text)}
/>
}
>
<Match when={mode() === 'edit' && editingConnection()}>
@@ -311,22 +333,61 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
}}
</Match>
<Match when={mode() === 'add'}>
<Match when={mode() === 'add' && addPickerMode()}>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-base font-semibold text-base-content">Add infrastructure</div>
<div class="mt-0.5 text-xs text-muted">
Pick how Pulse should connect to this system.
</div>
</div>
<button
type="button"
onClick={exitAddMode}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
← Back to systems
</button>
</div>
<ConnectionsExplainer
readOnly={readOnly()}
onAddConnection={readOnly() ? undefined : pickPlatformApi}
onInstallAgent={readOnly() ? undefined : pickAgent}
/>
</div>
</Match>
<Match when={mode() === 'add' && !addPickerMode()}>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-base font-semibold text-base-content">Add connection</div>
<div class="text-base font-semibold text-base-content">
{initialAddType() === 'agent' ? 'Install agent' : 'Add connection'}
</div>
<div class="mt-0.5 text-xs text-muted">
Paste an address. Pulse detects the product, you enter credentials, and save.
{initialAddType() === 'agent'
? 'Install the Pulse Unified Agent on a host for CPU / disk temps, SMART, and power, or on bare-metal Linux / Unraid / FreeBSD.'
: 'Paste an address. Pulse detects the product, you enter credentials, and save.'}
</div>
</div>
<button
type="button"
onClick={exitAddMode}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
← Back to systems
</button>
<div class="flex items-center gap-2">
<button
type="button"
onClick={backToPicker}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
← Change method
</button>
<button
type="button"
onClick={exitAddMode}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Back to systems
</button>
</div>
</div>
<div class="rounded-lg border border-border bg-surface">
@@ -197,7 +197,7 @@ describe('InfrastructureWorkspace', () => {
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'Monitored systems' })).toBeInTheDocument(),
);
expect(screen.getByRole('button', { name: 'Add connection' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add infrastructure' })).toBeInTheDocument();
expect(screen.getByText('zeus')).toBeInTheDocument();
expect(screen.queryByTestId('install-section')).toBeNull();
});
@@ -221,23 +221,37 @@ describe('InfrastructureWorkspace', () => {
expect(screen.getByText('Paused')).toBeInTheDocument();
});
it('opens the inline add flow when Add connection is clicked', () => {
it('opens the picker screen when Add infrastructure is clicked', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add connection/i }));
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Enter credentials manually/i })).toBeInTheDocument();
// The picker screen surfaces both mode choices side-by-side; the probe flow
// has not started yet.
expect(screen.getByRole('button', { name: /^Add connection$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Install agent$/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull();
expect(navigateSpy).not.toHaveBeenCalled();
expect(setSearchParamsSpy).not.toHaveBeenCalled();
});
it('routes to the agent install slot via the Install agent CTA inside the entry card', () => {
it('routes from the picker into the probe flow when Add connection is chosen', () => {
renderWorkspace();
// The agent path is a first-class peer entry inside the unified cards, not
// a subtext offramp hidden inside the Add screen — a user who wants
// CPU/disk temps or is on bare-metal Linux should reach it in one click.
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /^Add connection$/i }));
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Enter credentials manually/i })).toBeInTheDocument();
});
it('routes from the picker into the agent install slot when Install agent is chosen', () => {
renderWorkspace();
// The agent path is a first-class peer entry inside the picker, not a
// subtext offramp hidden elsewhere — a user who wants CPU/disk temps or
// is on bare-metal Linux should reach it in two clicks from the ledger.
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i }));
expect(screen.getByTestId('install-section')).toBeInTheDocument();
@@ -246,7 +260,8 @@ describe('InfrastructureWorkspace', () => {
it('routes to the TrueNAS credential slot when TrueNAS is picked manually', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add connection/i }));
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /^Add connection$/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /TrueNAS SCALE/i }));
@@ -256,7 +271,8 @@ describe('InfrastructureWorkspace', () => {
it('routes to the VMware credential slot when VMware is picked manually', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add connection/i }));
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /^Add connection$/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i }));
@@ -266,7 +282,8 @@ describe('InfrastructureWorkspace', () => {
it('routes to the Proxmox credential slot when Proxmox VE is picked manually', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add connection/i }));
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /^Add connection$/i }));
fireEvent.click(screen.getByRole('button', { name: /Enter credentials manually/i }));
fireEvent.click(screen.getByRole('button', { name: /^Proxmox VE/i }));
@@ -276,6 +293,7 @@ describe('InfrastructureWorkspace', () => {
it('can return to the probe step from a credential slot', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i }));
fireEvent.click(screen.getByRole('button', { name: /Back to probe/i }));
@@ -286,12 +304,29 @@ describe('InfrastructureWorkspace', () => {
it('toggles agent profiles inside the agent install slot', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i }));
fireEvent.click(screen.getByRole('button', { name: 'Manage agent profiles' }));
expect(screen.getByTestId('agent-profiles')).toBeInTheDocument();
});
it('offers Change method from a sub-flow to return to the picker', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i }));
expect(screen.getByTestId('install-section')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Change method/i }));
// Back on the picker screen — both card CTAs are visible again, install
// section is gone.
expect(screen.getByRole('button', { name: /^Add connection$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Install agent$/i })).toBeInTheDocument();
expect(screen.queryByTestId('install-section')).toBeNull();
});
it('exposes Edit / Pause / Remove on each ledger row directly', async () => {
renderWorkspace();
@@ -341,15 +376,17 @@ describe('InfrastructureWorkspace', () => {
await waitFor(() => expect(screen.getByTestId('install-section')).toBeInTheDocument());
});
it('clears the canonical query onboarding route for platform picking without pre-selecting a type', async () => {
it('clears the canonical query onboarding route for platform picking and lands on the picker', async () => {
routeState.search = '?add=pick';
renderWorkspace();
expect(setSearchParamsSpy).toHaveBeenCalledWith({ add: null }, { replace: true });
expect(navigateSpy).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument(),
expect(screen.getByRole('button', { name: /^Add connection$/i })).toBeInTheDocument(),
);
// Picker screen — probe hasn't started yet, install section isn't open.
expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
});
@@ -366,11 +403,12 @@ describe('InfrastructureWorkspace', () => {
expect(screen.queryByTestId('truenas-section')).toBeNull();
});
it('hides Add connection, Install agent, and the add flow in read-only mode', () => {
it('hides Add infrastructure and every add sub-flow in read-only mode', () => {
presentationPolicyIsReadOnlyMock.mockReturnValue(true);
renderWorkspace();
expect(screen.queryByRole('button', { name: /Add connection/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Add infrastructure/i })).toBeNull();
expect(screen.queryByRole('button', { name: /^Add connection$/i })).toBeNull();
expect(screen.queryByRole('button', { name: /^Install agent$/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Probe address/i })).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
@@ -31,7 +31,7 @@ describe('settings architecture guardrails', () => {
it('keeps infrastructure onboarding route-backed under the shared settings shell', () => {
expect(settingsHeaderMetaSource).toContain("'infrastructure-systems': {");
expect(settingsHeaderMetaSource).toContain(
'Review monitored systems and add new connections or agent installs from one place',
'Review monitored systems and add new infrastructure to Pulse',
);
expect(settingsNavigationHookSource).toContain('deriveAddStepFromLegacyPath(path)');
@@ -9,8 +9,7 @@ import {
export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = {
'infrastructure-systems': {
title: 'Infrastructure',
description:
'Review monitored systems and add new connections or agent installs from one place.',
description: 'Review monitored systems and add new infrastructure to Pulse.',
},
'system-general': {
title: 'General',