Rework infrastructure setup flows as centered modals

This commit is contained in:
rcourtman 2026-04-18 22:16:13 +01:00
parent 501c61b82f
commit b850e4fb2c
12 changed files with 105 additions and 65 deletions

View file

@ -858,19 +858,20 @@ ledger. Inline detail drawers may surface reporting-item and ignored-item
controls, but installer setup, platform-specific configuration, and profile
management must remain secondary flows rather than being dumped underneath the
default ledger.
Those secondary infrastructure views must open through route-backed drawers
rather than replacing the ledger body or stacking beneath it. When the
Those secondary infrastructure views must open through route-backed modal work
surfaces rather than replacing the ledger body or stacking beneath it. When the
operator opens platform connection management, install tooling, or the legacy
operations workspace route, `InfrastructureWorkspace.tsx` must keep the same
top-level systems ledger anchored in place and layer the secondary surface in a
drawer that can close back to `/settings/infrastructure` without trapping the
user.
centered modal that can close back to `/settings/infrastructure` without
trapping the user or relegating primary setup work to an off-canvas detail
pattern.
That infrastructure surface must now stay single-purpose per route-backed
drawer: the default systems view owns the top-level monitored-system ledger,
the connections drawer owns API-backed platform management, and the install
drawer owns Linux/Windows/macOS/FreeBSD command generation. The shared
modal: the default systems view owns the top-level monitored-system ledger,
the connections modal owns API-backed platform management, and the install
modal owns Linux/Windows/macOS/FreeBSD command generation. The shared
Settings sidebar owns only the top-level `Infrastructure` destination; movement
between those three jobs belongs to explicit drawer actions inside
between those three jobs belongs to explicit ledger actions inside
`InfrastructureWorkspace.tsx`, not extra sidebar entries or body-replacing
workspace subtabs.
That same lifecycle-owned platform-connections workspace must keep API-backed

View file

@ -220,15 +220,17 @@ work extends shared components instead of creating new local variants.
connections must stay in their own management workspace instead of showing up
as peer rows in the default table, while installer tooling, provider setup
workspaces, and profile management remain secondary flows opened by explicit
deep links or drawers instead of being dumped inline underneath the default
deep links or modal work surfaces instead of being dumped inline underneath the default
table.
Those deep-linked secondary views must keep the systems ledger mounted and
open inside route-backed drawers instead of replacing the page body or
open inside route-backed modal surfaces instead of replacing the page body or
stacking another inline workspace underneath it, so the operator always
stays anchored to the same canonical ledger while managing setup flows.
stays anchored to the same canonical ledger while managing setup flows
without relegating primary setup work to off-canvas side drawers.
That same shared shell boundary now owns one canonical infrastructure
destination in the Settings sidebar. `InfrastructureWorkspace.tsx` owns the
one default ledger plus route-backed `Connections` and `Install` drawers
one default ledger plus route-backed `Connections` and `Install` modal work
surfaces
inside that destination, while each secondary flow still stays
single-purpose instead of stacking multiple workspace surfaces at once.
6. Keep Proxmox deep-link route selection on the shared settings-navigation boundary. `frontend-modern/src/components/Settings/settingsNavigationModel.ts` and `frontend-modern/src/components/Settings/useSettingsNavigation.ts` must treat the canonical PBS and PMG Proxmox deep links as agent-selection authority even though those URLs resolve to the shared `infrastructure-operations` tab. Reloading or remounting on a PBS or PMG deep link must not silently fall back to the PVE selector state.

View file

@ -62,6 +62,7 @@ interface AddSystemPickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (choice: AddSystemChoice) => void;
onManageProfiles?: () => void;
}
export const AddSystemPicker: Component<AddSystemPickerProps> = (props) => {
@ -77,10 +78,10 @@ export const AddSystemPicker: Component<AddSystemPickerProps> = (props) => {
<div class="flex items-start justify-between gap-4">
<div>
<h2 id="add-system-picker-title" class="text-lg font-semibold text-base-content">
Add a system
Add infrastructure
</h2>
<p class="mt-1 text-sm text-muted">
Pick what you are connecting. Pulse will route you straight into the right flow.
Choose the system or platform you want Pulse to start monitoring.
</p>
</div>
<button
@ -119,6 +120,22 @@ export const AddSystemPicker: Component<AddSystemPickerProps> = (props) => {
)}
</For>
</ul>
<For each={props.onManageProfiles ? [props.onManageProfiles] : []}>
{(onManageProfiles) => (
<div class="flex items-center justify-between gap-3 rounded-md border border-border bg-surface-alt px-4 py-3">
<div class="text-xs text-muted">
Agent profiles change install defaults for agent-based systems.
</div>
<button
type="button"
onClick={onManageProfiles}
class="inline-flex items-center rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
>
Manage agent profiles
</button>
</div>
)}
</For>
</div>
</Dialog>
);

View file

@ -93,7 +93,9 @@ export const ConnectionsTable: Component<ConnectionsTableProps> = (props) => {
<Show when={row.host}>
<div class="truncate text-xs text-muted">{row.host}</div>
</Show>
<div class="text-xs text-muted">{row.subtitle}</div>
<Show when={row.subtitle}>
<div class="text-xs text-muted">{row.subtitle}</div>
</Show>
</div>
</TableCell>

View file

@ -50,16 +50,6 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
readOnlyWorkspace()
? []
: [
{
label: 'Connections',
onSelect: () => navigate(buildInfrastructureWorkspacePath('platforms'), { scroll: false }),
tone: 'secondary' as const,
},
{
label: 'Agent profiles',
onSelect: () => setProfilesOpen(true),
tone: 'secondary' as const,
},
{
label: 'Add infrastructure',
onSelect: () => setPickerOpen(true),
@ -164,6 +154,10 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
isOpen={pickerOpen()}
onClose={() => setPickerOpen(false)}
onSelect={handleAddSystem}
onManageProfiles={() => {
setPickerOpen(false);
setProfilesOpen(true);
}}
/>
<InfrastructureStopMonitoringDialog />
@ -171,20 +165,45 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<Dialog
isOpen={profilesOpen()}
onClose={() => setProfilesOpen(false)}
layout="drawer-right"
panelClass="max-w-[960px]"
panelClass="h-[calc(100dvh-2rem)] w-full max-w-[1100px]"
ariaLabel="Agent profiles"
>
<div class="h-full overflow-y-auto bg-surface p-4 sm:p-6">
<AgentProfilesPanel />
<div class="flex h-full flex-col bg-surface">
<div class="border-b border-border bg-surface-alt px-4 py-4 sm:px-6">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-lg font-semibold text-base-content">Agent profiles</div>
<div class="text-sm text-muted">
Manage reusable install defaults for agent-based systems.
</div>
</div>
<button
type="button"
onClick={() => setProfilesOpen(false)}
class="rounded-md p-1 hover:bg-surface-hover hover:text-base-content"
aria-label="Close"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 sm:p-6">
<AgentProfilesPanel />
</div>
</div>
</Dialog>
<Dialog
isOpen={!readOnlyWorkspace() && activeView() === 'platforms'}
onClose={closeConnectionsWorkspace}
layout="drawer-right"
panelClass="max-w-[1120px]"
panelClass="h-[calc(100dvh-2rem)] w-full max-w-[1280px]"
ariaLabel="Platform connections"
>
<div class="flex h-full flex-col bg-surface">
@ -225,8 +244,7 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<Dialog
isOpen={!readOnlyWorkspace() && activeView() === 'install'}
onClose={closeInstallWorkspace}
layout="drawer-right"
panelClass="max-w-[1120px]"
panelClass="h-[calc(100dvh-2rem)] w-full max-w-[1280px]"
ariaLabel="Install Pulse agent"
>
<div class="flex h-full flex-col bg-surface">

View file

@ -21,12 +21,12 @@ describe('AddSystemPicker', () => {
expect(screen.getByText(choice.title)).toBeInTheDocument();
}
expect(ADD_SYSTEM_CHOICES.map((c) => c.kind)).toEqual([
'agent',
'pve',
'pbs',
'pmg',
'truenas',
'vmware',
'agent',
]);
});
@ -52,6 +52,21 @@ describe('AddSystemPicker', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it('offers agent profile management as a secondary action when provided', () => {
const onManageProfiles = vi.fn();
render(() => (
<AddSystemPicker
isOpen={true}
onClose={() => {}}
onSelect={() => {}}
onManageProfiles={onManageProfiles}
/>
) as any);
fireEvent.click(screen.getByRole('button', { name: 'Manage agent profiles' }));
expect(onManageProfiles).toHaveBeenCalledTimes(1);
});
it('marks the agent-host choice as an agent install and everything else as an API connection', () => {
const agent = ADD_SYSTEM_CHOICES.find((c) => c.kind === 'agent');
const apis = ADD_SYSTEM_CHOICES.filter((c) => c.kind !== 'agent');

View file

@ -6,7 +6,7 @@ import type { InfrastructureSystemRow } from '../connectionsTableModel';
const row = (overrides: Partial<InfrastructureSystemRow> = {}): InfrastructureSystemRow => ({
id: 'row-1',
name: 'tower',
subtitle: 'Monitored system',
subtitle: undefined,
host: '10.0.0.1',
coverageLabels: ['Host telemetry'],
collectionLabel: 'Agent',
@ -53,7 +53,6 @@ describe('ConnectionsTable', () => {
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('tower')).toBeInTheDocument();
expect(screen.getByText('pbs-docker')).toBeInTheDocument();
expect(screen.getByText('Monitored system')).toBeInTheDocument();
expect(screen.getByText('Ignored by Pulse')).toBeInTheDocument();
expect(screen.getByText('Host telemetry')).toBeInTheDocument();
expect(screen.getByText('API')).toBeInTheDocument();
@ -73,16 +72,10 @@ describe('ConnectionsTable', () => {
onSelect: onAddSystem,
tone: 'primary',
},
{
label: 'Agent profiles',
onSelect: vi.fn(),
tone: 'secondary',
},
]}
/>
) as any);
expect(screen.getByRole('button', { name: 'Agent profiles' })).toBeInTheDocument();
const button = screen.getByRole('button', { name: /Add infrastructure/i });
fireEvent.click(button);
expect(onAddSystem).toHaveBeenCalledTimes(1);

View file

@ -168,8 +168,9 @@ describe('InfrastructureWorkspace', () => {
renderWorkspace();
expect(screen.getByRole('heading', { name: 'Monitored systems' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connections' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Agent profiles' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add infrastructure' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Connections' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Agent profiles' })).toBeNull();
expect(screen.queryByTestId('platform-section')).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
expect(screen.queryByTestId('agent-profiles')).toBeNull();
@ -266,14 +267,13 @@ describe('InfrastructureWorkspace', () => {
expect(screen.queryByTestId('platform-section')).toBeNull();
});
it('opens the platform connections drawer from the header action', () => {
it('opens agent profiles from the add-infrastructure chooser secondary action', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: 'Connections' }));
fireEvent.click(screen.getByRole('button', { name: 'Add infrastructure' }));
fireEvent.click(screen.getByRole('button', { name: 'Manage agent profiles' }));
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms', {
scroll: false,
});
expect(screen.getByTestId('agent-profiles')).toBeInTheDocument();
});
it('closes the platform drawer back to the ledger route', () => {
@ -287,14 +287,6 @@ describe('InfrastructureWorkspace', () => {
});
});
it('opens agent profiles in a dedicated drawer instead of inline', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: 'Agent profiles' }));
expect(screen.getByTestId('agent-profiles')).toBeInTheDocument();
});
it('collapses read-only sessions back to inventory and hides setup sections', () => {
presentationPolicyIsReadOnlyMock.mockReturnValue(true);
mockPathname = '/settings/infrastructure/install';

View file

@ -61,7 +61,7 @@ describe('connectionsTableModel', () => {
manageLabel: 'Review ignored',
});
expect(rows.find((row) => row.name === 'tower')).toMatchObject({
subtitle: 'Monitored system',
subtitle: undefined,
manageLabel: 'View details',
});
});

View file

@ -1433,10 +1433,10 @@ describe('Settings architecture guardrails', () => {
);
expect(SETTINGS_HEADER_META['infrastructure-systems'].title).toBe('Infrastructure');
expect(SETTINGS_HEADER_META['infrastructure-systems'].description).toContain(
'open drawers for platform connections',
'use Add infrastructure',
);
expect(SETTINGS_HEADER_META['infrastructure-systems'].description).toBe(
`Review top-level monitored systems from one ledger, then open drawers for platform connections, install commands, and agent profiles. ${SELF_HOSTED_PRO_BILLING_PRESENTATION.infrastructureRouteReferral}`,
`Review monitored systems in one ledger, then use Add infrastructure when you need platform setup or agent install commands. ${SELF_HOSTED_PRO_BILLING_PRESENTATION.infrastructureRouteReferral}`,
);
expect(SETTINGS_HEADER_META['infrastructure-connections'].title).toBe('Infrastructure');
expect(SETTINGS_HEADER_META['infrastructure-install'].title).toBe('Infrastructure');

View file

@ -12,7 +12,7 @@ export type SystemManageAction =
export interface InfrastructureSystemRow {
id: string;
name: string;
subtitle: string;
subtitle?: string;
host?: string;
coverageLabels: string[];
collectionLabel: string;
@ -65,7 +65,7 @@ const reportingRow = (row: UnifiedAgentRow): InfrastructureSystemRow => {
return {
id: row.rowKey,
name: row.name,
subtitle: row.status === 'removed' ? 'Ignored by Pulse' : 'Monitored system',
subtitle: row.status === 'removed' ? 'Ignored by Pulse' : undefined,
host:
row.hostname && row.hostname !== row.name && row.hostname !== row.displayName
? row.hostname

View file

@ -10,17 +10,17 @@ export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = {
'infrastructure-systems': {
title: 'Infrastructure',
description:
`Review top-level monitored systems from one ledger, then open drawers for platform connections, install commands, and agent profiles. ${SELF_HOSTED_PRO_BILLING_PRESENTATION.infrastructureRouteReferral}`,
`Review monitored systems in one ledger, then use Add infrastructure when you need platform setup or agent install commands. ${SELF_HOSTED_PRO_BILLING_PRESENTATION.infrastructureRouteReferral}`,
},
'infrastructure-connections': {
title: 'Infrastructure',
description:
`Review top-level monitored systems from one ledger, then open drawers for platform connections, install commands, and agent profiles. ${SELF_HOSTED_PRO_BILLING_PRESENTATION.infrastructureRouteReferral}`,
`Review monitored systems in one ledger, then use Add infrastructure when you need platform setup or agent install commands. ${SELF_HOSTED_PRO_BILLING_PRESENTATION.infrastructureRouteReferral}`,
},
'infrastructure-install': {
title: 'Infrastructure',
description:
`Review top-level monitored systems from one ledger, then open drawers for platform connections, install commands, and agent profiles. ${SELF_HOSTED_PRO_BILLING_PRESENTATION.infrastructureRouteReferral}`,
`Review monitored systems in one ledger, then use Add infrastructure when you need platform setup or agent install commands. ${SELF_HOSTED_PRO_BILLING_PRESENTATION.infrastructureRouteReferral}`,
},
'system-general': {
title: 'General',