Reframe infrastructure systems taxonomy

This commit is contained in:
rcourtman 2026-04-23 08:04:19 +01:00
parent 86aabe6103
commit 897e5dff92
9 changed files with 411 additions and 187 deletions

View file

@ -227,7 +227,7 @@ an add-only capacity posture.
9. Add or change profile management, the extracted agent profiles runtime owner, the infrastructure source-manager landing, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/useConnectionRowActions.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger, source-manager cards, and ConnectionEditor credential slots rather than reintroducing those retired surfaces.
Those lifecycle-owned settings hooks may consume websocket state only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx` or recreate root-shell providers.
Discovery configuration is part of that same lifecycle-owned workspace boundary. `InfrastructureSourceManager.tsx` must open one canonical discovery editor through `InfrastructureDiscoverySettingsDialog.tsx`, `DiscoverySettingsForm.tsx`, and `discoverySettingsModel.ts`, while the System/Network shell stays limited to network-boundary controls instead of reintroducing a second editable discovery surface. That same workspace boundary now owns the add-flow entry split too: the landing strip opens the grouped `Add infrastructure` picker, while `Detect from address` remains a picker-owned utility route instead of a competing top-level header action.
The same lifecycle-owned workspace boundary now also owns attached-agent composition. When a unified Pulse Agent augments a first-class platform source such as Proxmox VE, the source manager and edit dialog must present one primary platform row with explicit `API` plus `Pulse Agent` composition rather than duplicating that same machine as a second peer row under the generic Pulse Agent group. Standalone hosts with no owning platform source remain in the Pulse Agent group.
The same lifecycle-owned workspace boundary now also owns attached-agent composition. When a unified Pulse Agent augments a first-class platform source such as Proxmox VE, the source manager and edit dialog must present one primary platform row with explicit `API` plus `Pulse Agent` composition rather than duplicating that same machine as a second peer row under a generic Pulse Agent platform bucket. Standalone hosts with no owning platform source remain grouped under a standalone-host owner bucket, with `Pulse Agent` shown as the collection method rather than as the pseudo-platform label.
That same lifecycle-owned workspace boundary also owns agent version
posture on infrastructure settings. The landing table stays compact and
should only raise row-level attention when an attached or standalone agent

View file

@ -259,6 +259,12 @@ work extends shared components instead of creating new local variants.
composition on the owning row, with explicit badges and edit-dialog detail,
instead of duplicating the same machine across multiple peer groups and
forcing operators to infer the relationship visually.
That same shared shell boundary owns the landing taxonomy too: the primary
grouping labels in the infrastructure manager must describe real
platform/system owners, not collection methods. Agent-only machines belong
in a standalone-host bucket, while `Pulse Agent` remains a badge, install
path, and detail-surface method label rather than a peer top-level
pseudo-platform beside Proxmox, VMware, and TrueNAS.
That same shared shell boundary also owns compact version visibility for
agent-backed rows. The infrastructure source table must not grow a dedicated
always-on version column for Pulse Agent; exact version text belongs in the

View file

@ -239,8 +239,8 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
return (
<SettingsPanel
title="Infrastructure sources"
description="Configured and discovered candidates grouped by platform."
title="Infrastructure systems"
description="Configured systems and discovered candidates grouped by platform or host type."
noPadding
icon={<Server class="h-5 w-5" strokeWidth={2} />}
>
@ -307,7 +307,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<TableHeader class="bg-surface-alt/60">
<TableRow>
<TableHead class="w-[24%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[20%]">
Source
System
</TableHead>
<TableHead class="w-[26%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[24%]">
Endpoint
@ -361,10 +361,10 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
type="button"
onClick={() => props.onAddSource?.(product.type)}
class={`${addSectionButtonClass} whitespace-nowrap`}
aria-label={`Add ${product.label}`}
aria-label={product.actionLabel}
>
<Plus class="h-3.5 w-3.5" />
Add
{product.type === 'agent' ? 'Add host' : 'Add'}
</button>
</Show>
</TableCell>

View file

@ -66,7 +66,7 @@ describe('ConnectionsTable', () => {
expect(screen.getByText('Start monitoring infrastructure')).toBeInTheDocument();
expect(
screen.getByText(/Source types available: VMware vCenter, TrueNAS SCALE/i),
screen.getByText(/Available system types: VMware vCenter, TrueNAS SCALE/i),
).toBeInTheDocument();
expect(screen.queryByRole('table')).toBeNull();
});

View file

@ -240,7 +240,7 @@ describe('InfrastructureWorkspace', () => {
it('renders the instance-first source manager as the only landing surface', async () => {
renderWorkspace();
await waitFor(() => expect(screen.getByText('Infrastructure sources')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Infrastructure systems')).toBeInTheDocument());
expect(screen.getByRole('button', { name: /Run discovery/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Add infrastructure/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Discovery settings/i })).toBeInTheDocument();
@ -248,6 +248,7 @@ describe('InfrastructureWorkspace', () => {
expect(screen.getByText('VMware vCenter')).toBeInTheDocument();
expect(screen.getByText('TrueNAS SCALE')).toBeInTheDocument();
expect(screen.getByText('Proxmox VE')).toBeInTheDocument();
expect(screen.getByText('Standalone hosts')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Add Proxmox VE/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Edit/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Monitored systems' })).not.toBeInTheDocument();
@ -476,7 +477,7 @@ describe('InfrastructureWorkspace', () => {
expect(screen.queryByRole('button', { name: /^Edit$/i })).toBeNull();
expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
expect(screen.getByText('Infrastructure sources')).toBeInTheDocument();
expect(screen.getByText('Infrastructure systems')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Monitored systems' })).not.toBeInTheDocument();
});
});

View file

@ -96,7 +96,9 @@ describe('settings architecture guardrails', () => {
"shellDescription:\n 'Review your current self-hosted plan, activation status, and unlocked capabilities.'",
);
expect(selfHostedBillingPresentationSource).toContain("planSectionTitle: 'Current plan'");
expect(selfHostedBillingPresentationSource).toContain("recoverySectionTitle: 'Activation & Recovery'");
expect(selfHostedBillingPresentationSource).toContain(
"recoverySectionTitle: 'Activation & Recovery'",
);
expect(settingsNavCatalogSource).toContain(
'label: SELF_HOSTED_PRO_BILLING_PRESENTATION.navLabel',
@ -134,7 +136,7 @@ describe('settings architecture guardrails', () => {
"import { InfrastructureDiscoverySettingsDialog } from './InfrastructureDiscoverySettingsDialog';",
);
expect(infrastructureWorkspaceSource).toContain(
"const [showDiscoverySettings, setShowDiscoverySettings] = createSignal(false);",
'const [showDiscoverySettings, setShowDiscoverySettings] = createSignal(false);',
);
expect(infrastructureWorkspaceSource).toContain('<InfrastructureDiscoverySettingsDialog');
expect(infrastructureWorkspaceSource).toContain(
@ -146,26 +148,28 @@ describe('settings architecture guardrails', () => {
expect(infrastructureWorkspaceSource).toContain('<InfrastructureSourcePicker');
expect(infrastructureWorkspaceSource).not.toContain('<ConnectionsTable rows={rows} />');
expect(infrastructureWorkspaceSource).toContain('flex h-full min-h-0 flex-col');
expect(infrastructureWorkspaceSource).toContain("showSlotHeader={false}");
expect(infrastructureWorkspaceSource).toContain('showSlotHeader={false}');
expect(infrastructureWorkspaceSource).toContain('trackInitialCatalogSelection={');
expect(infrastructureWorkspaceSource).toContain(
"trackInitialCatalogSelection={",
"onDetectFromAddress={() => openAddFlow('detect')}",
);
expect(infrastructureWorkspaceSource).toContain("onDetectFromAddress={() => openAddFlow('detect')}");
expect(infrastructureWorkspaceSource).toContain("onBackToCatalog={() => openAddFlow('pick')}");
expect(infrastructureWorkspaceSource).toContain('recordCatalogSelection(type);');
expect(infrastructureWorkspaceSource).toContain('renderAgentConnectionDetails');
expect(infrastructureWorkspaceSource).not.toContain('InfrastructureOperationsController');
expect(infrastructureWorkspaceSource).not.toContain('PlatformConnectionsWorkspace');
expect(infrastructureSourceManagerSource).toContain('Infrastructure sources');
expect(infrastructureSourceManagerSource).toContain('Infrastructure systems');
expect(infrastructureSourceManagerSource).toContain('Run discovery');
expect(infrastructureSourceManagerSource).toContain('Add infrastructure');
expect(infrastructureSourceManagerSource).toContain('Discovery settings');
expect(infrastructureSourceManagerSource).toContain('Configured and discovered candidates grouped by platform.');
expect(infrastructureSourceManagerSource).toContain(
'Configured systems and discovered candidates grouped by platform or host type.',
);
expect(infrastructureSourceManagerSource).toContain('onReviewDiscoveredSource');
expect(infrastructureSourceManagerSource).toContain('Discovered');
expect(infrastructureSourceManagerSource).toContain('getInfrastructureSourceManagerProducts');
expect(infrastructureSourceManagerSource).toContain('TableHeader');
expect(infrastructureSourceManagerSource).toContain('aria-label={`Add ${product.label}`}');
expect(infrastructureSourceManagerSource).toContain('aria-label={product.actionLabel}');
expect(infrastructureSourceManagerSource).toContain('Review');
expect(infrastructureSourceManagerSource).toContain('Edit');
expect(infrastructureSourceManagerSource).not.toContain('Detect from address');
@ -183,7 +187,9 @@ describe('settings architecture guardrails', () => {
expect(connectionEditorSource).toContain(
"import { AddressProbeStep } from './AddressProbeStep';",
);
expect(connectionEditorSource).toContain("from '@/utils/infrastructureOnboardingPresentation';");
expect(connectionEditorSource).toContain(
"from '@/utils/infrastructureOnboardingPresentation';",
);
expect(connectionsTableSource).toContain(
"from '@/utils/infrastructureOnboardingPresentation';",
);
@ -214,7 +220,9 @@ describe('settings architecture guardrails', () => {
expect(connectionEditorStateSource).not.toContain('buildConnectionEditorCatalogEntries');
expect(connectionEditorStateSource).not.toContain('getSourcePlatformFamily');
expect(infrastructureOnboardingPresentationSource).toContain('getSourcePlatformManifestEntry');
expect(infrastructureOnboardingPresentationSource).toContain('getInfrastructureSourcePickerGroups');
expect(infrastructureOnboardingPresentationSource).toContain(
'getInfrastructureSourcePickerGroups',
);
expect(infrastructureOnboardingPresentationSource).toContain(
'getInfrastructureSupportSummaryBadges',
);

View file

@ -5,6 +5,7 @@ import {
getInfrastructureEmptyStateDetail,
getInfrastructureEmptyStateSummary,
getInfrastructureOnboardingProductPresentation,
getInfrastructureSourceManagerProducts,
getInfrastructureSourcePickerGroups,
getInfrastructureSupportSummaryBadges,
} from '@/utils/infrastructureOnboardingPresentation';
@ -28,6 +29,39 @@ describe('infrastructureOnboardingPresentation', () => {
});
it('derives picker groups, auto-detect copy, and landing summaries from the shared helper', () => {
expect(getInfrastructureSourceManagerProducts()).toEqual([
expect.objectContaining({
type: 'vmware',
label: 'VMware vCenter',
actionLabel: 'Add VMware vCenter',
}),
expect.objectContaining({
type: 'truenas',
label: 'TrueNAS SCALE',
actionLabel: 'Add TrueNAS SCALE',
}),
expect.objectContaining({
type: 'pve',
label: 'Proxmox VE',
actionLabel: 'Add Proxmox VE',
}),
expect.objectContaining({
type: 'pbs',
label: 'Proxmox Backup Server',
actionLabel: 'Add Proxmox Backup Server',
}),
expect.objectContaining({
type: 'pmg',
label: 'Proxmox Mail Gateway',
actionLabel: 'Add Proxmox Mail Gateway',
}),
expect.objectContaining({
type: 'agent',
label: 'Standalone hosts',
actionLabel: 'Add host',
}),
]);
expect(getInfrastructureSourcePickerGroups()).toEqual([
{
id: 'virtualization',
@ -88,10 +122,10 @@ describe('infrastructureOnboardingPresentation', () => {
});
expect(getInfrastructureEmptyStateSummary()).toBe(
'Add infrastructure sources to start monitoring your environment.',
'Add infrastructure systems to start monitoring your environment.',
);
expect(getInfrastructureEmptyStateDetail()).toContain('Source types available: VMware vCenter');
expect(getInfrastructureEmptyStateDetail()).toContain('Pulse Agent');
expect(getInfrastructureEmptyStateDetail()).toContain('Available system types: VMware vCenter');
expect(getInfrastructureEmptyStateDetail()).toContain('standalone hosts through Pulse Agent');
expect(getInfrastructureEmptyStateDetail()).toContain('Docker and Kubernetes are discovered');
expect(getInfrastructureEmptyStateDetail()).toContain('VMware vCenter is also available now.');
});

View file

@ -19,6 +19,10 @@ export interface InfrastructureOnboardingProductPresentation {
governanceState: PlatformGovernanceState;
}
export interface InfrastructureSourceManagerProductPresentation extends InfrastructureOnboardingProductPresentation {
actionLabel: string;
}
export interface InfrastructureSourcePickerGroupPresentation {
id: 'virtualization' | 'storage' | 'backup-mail' | 'host-monitoring';
label: string;
@ -116,6 +120,21 @@ const SOURCE_MANAGER_PRODUCT_ORDER: InfrastructureOnboardingConnectionType[] = [
'agent',
];
const SOURCE_MANAGER_LABEL_OVERRIDES: Partial<
Record<
InfrastructureOnboardingConnectionType,
{
label: string;
actionLabel: string;
}
>
> = {
agent: {
label: 'Standalone hosts',
actionLabel: 'Add host',
},
};
export const INFRASTRUCTURE_ONBOARDING_PATHS: Record<
'api' | 'agent',
InfrastructureOnboardingPathPresentation
@ -192,8 +211,16 @@ export const getInfrastructureApiProductPresentations =
API_PRODUCT_ORDER.map((type) => getInfrastructureOnboardingProductPresentation(type));
export const getInfrastructureSourceManagerProducts =
(): InfrastructureOnboardingProductPresentation[] =>
SOURCE_MANAGER_PRODUCT_ORDER.map((type) => getInfrastructureOnboardingProductPresentation(type));
(): InfrastructureSourceManagerProductPresentation[] =>
SOURCE_MANAGER_PRODUCT_ORDER.map((type) => {
const product = getInfrastructureOnboardingProductPresentation(type);
const override = SOURCE_MANAGER_LABEL_OVERRIDES[type];
return {
...product,
label: override?.label ?? product.label,
actionLabel: override?.actionLabel ?? `Add ${product.label}`,
};
});
export const getInfrastructureApiProductsByGovernanceState = (
governanceState: PlatformGovernanceState,
@ -202,16 +229,15 @@ export const getInfrastructureApiProductsByGovernanceState = (
(product) => product.governanceState === governanceState,
);
export const getInfrastructureSourcePickerGroups =
(): Array<
InfrastructureSourcePickerGroupPresentation & {
products: InfrastructureOnboardingProductPresentation[];
}
> =>
SOURCE_PICKER_GROUPS.map((group) => ({
...group,
products: group.types.map((type) => getInfrastructureOnboardingProductPresentation(type)),
}));
export const getInfrastructureSourcePickerGroups = (): Array<
InfrastructureSourcePickerGroupPresentation & {
products: InfrastructureOnboardingProductPresentation[];
}
> =>
SOURCE_PICKER_GROUPS.map((group) => ({
...group,
products: group.types.map((type) => getInfrastructureOnboardingProductPresentation(type)),
}));
export const getInfrastructureAutoDetectLabels = (): string[] =>
getInfrastructureApiProductPresentations()
@ -234,7 +260,7 @@ export const getInfrastructureSupportSummaryBadges = (): {
});
export const getInfrastructureEmptyStateSummary = (): string =>
'Add infrastructure sources to start monitoring your environment.';
'Add infrastructure systems to start monitoring your environment.';
export const getInfrastructureEmptyStateDetail = (): string =>
'Source types available: VMware vCenter, TrueNAS SCALE, Proxmox VE, Proxmox Backup Server, Proxmox Mail Gateway, and Pulse Agent. Docker and Kubernetes are discovered from supported agent hosts. VMware vCenter is also available now.';
'Available system types: VMware vCenter, TrueNAS SCALE, Proxmox VE, Proxmox Backup Server, Proxmox Mail Gateway, and standalone hosts through Pulse Agent. Docker and Kubernetes are discovered from supported agent hosts. VMware vCenter is also available now.';

View file

@ -1,8 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test as base, type Page } from '@playwright/test';
import { createAuthenticatedStorageState } from './helpers';
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test as base, type Page } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -28,51 +28,59 @@ const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
'..',
'..',
'tmp',
'playwright-auth',
`infrastructure-onboarding-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
}, { scope: 'worker' }],
authStorageStatePath: [
async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
"..",
"..",
"tmp",
"playwright-auth",
`infrastructure-onboarding-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
},
{ scope: "worker" },
],
});
async function stubConnectionsList(page: Page): Promise<void> {
await page.route('**/api/connections', async (route) => {
await page.route("**/api/connections", async (route) => {
const requestUrl = new URL(route.request().url());
if (route.request().method() !== 'GET' || requestUrl.pathname !== '/api/connections') {
if (
route.request().method() !== "GET" ||
requestUrl.pathname !== "/api/connections"
) {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
});
}
async function recordUpgradeMetricEvents(page: Page): Promise<UpgradeMetricEventPayload[]> {
async function recordUpgradeMetricEvents(
page: Page,
): Promise<UpgradeMetricEventPayload[]> {
const events: UpgradeMetricEventPayload[] = [];
await page.route('**/api/upgrade-metrics/events', async (route) => {
await page.route("**/api/upgrade-metrics/events", async (route) => {
const payload = route.request().postDataJSON() as UpgradeMetricEventPayload;
events.push(payload);
await route.fulfill({
status: 202,
contentType: 'application/json',
body: '{}',
contentType: "application/json",
body: "{}",
});
});
@ -81,7 +89,7 @@ async function recordUpgradeMetricEvents(page: Page): Promise<UpgradeMetricEvent
async function prepareOnboardingPage(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
localStorage.setItem("pulse_whats_new_v2_shown", "true");
});
await stubConnectionsList(page);
@ -95,13 +103,13 @@ function countUpgradeEvents(
const matchingKeys = new Set<string>();
for (const event of events) {
if (event.surface !== 'settings_infrastructure_add') continue;
if (event.surface !== "settings_infrastructure_add") continue;
if (event.type !== type) continue;
if (capability !== undefined && event.capability !== capability) continue;
matchingKeys.add(
event.idempotency_key ??
`${event.type}:${event.surface}:${event.capability ?? ''}:${matchingKeys.size}`,
`${event.type}:${event.surface}:${event.capability ?? ""}:${matchingKeys.size}`,
);
}
@ -130,7 +138,10 @@ async function scrollToBottom(page: Page): Promise<void> {
async function auditHorizontalOverflow(page: Page): Promise<OverflowAudit> {
return page.evaluate(() => {
const viewportWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
const viewportWidth = Math.max(
document.documentElement.clientWidth,
window.innerWidth || 0,
);
const pageWidth = Math.max(
document.body.scrollWidth,
document.documentElement.scrollWidth,
@ -138,21 +149,27 @@ async function auditHorizontalOverflow(page: Page): Promise<OverflowAudit> {
document.documentElement.offsetWidth,
);
const offenders = Array.from(document.querySelectorAll('body *'))
const offenders = Array.from(document.querySelectorAll("body *"))
.map((el) => {
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
const style = window.getComputedStyle(el);
if (style.position === 'fixed' || style.position === 'absolute') return null;
if (style.position === "fixed" || style.position === "absolute")
return null;
const overflow = rect.right - viewportWidth;
if (overflow <= 1) return null;
return {
tag: el.tagName.toLowerCase(),
className: (el.getAttribute('class') || '').trim().slice(0, 120),
className: (el.getAttribute("class") || "").trim().slice(0, 120),
overflow: Number(overflow.toFixed(1)),
};
})
.filter((entry): entry is { tag: string; className: string; overflow: number } => Boolean(entry))
.filter(
(
entry,
): entry is { tag: string; className: string; overflow: number } =>
Boolean(entry),
)
.slice(0, 8);
return {
@ -164,135 +181,197 @@ async function auditHorizontalOverflow(page: Page): Promise<OverflowAudit> {
});
}
test.describe('Infrastructure onboarding', () => {
test.describe("Infrastructure onboarding", () => {
test.setTimeout(180_000);
test('desktop landing shows platform sections before any onboarding flow opens', async ({
test("desktop landing shows platform sections before any onboarding flow opens", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only infrastructure manager coverage',
testInfo.project.name.startsWith("mobile-"),
"Desktop-only infrastructure manager coverage",
);
const metricEvents = await recordUpgradeMetricEvents(page);
await prepareOnboardingPage(page);
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, {
timeout: 15_000,
});
await expect(page.getByText('Infrastructure sources', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /Run discovery/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Add infrastructure/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Discovery settings/i })).toBeVisible();
await expect(page.getByText('VMware vCenter', { exact: true })).toBeVisible();
await expect(page.getByText('TrueNAS SCALE', { exact: true })).toBeVisible();
await expect(page.getByText('Proxmox VE', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /Add TrueNAS SCALE/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Detect from address/i })).toHaveCount(0);
await expect(page.getByText('Monitored systems', { exact: true })).toHaveCount(0);
await expect(page.getByText('Connection types', { exact: true })).toHaveCount(0);
await expect(
page.getByText("Infrastructure systems", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Run discovery/i }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Add infrastructure/i }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Discovery settings/i }),
).toBeVisible();
await expect(
page.getByText("VMware vCenter", { exact: true }),
).toBeVisible();
await expect(
page.getByText("TrueNAS SCALE", { exact: true }),
).toBeVisible();
await expect(page.getByText("Proxmox VE", { exact: true })).toBeVisible();
await expect(
page.getByText("Standalone hosts", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Add TrueNAS SCALE/i }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Detect from address/i }),
).toHaveCount(0);
await expect(
page.getByText("Monitored systems", { exact: true }),
).toHaveCount(0);
await expect(
page.getByText("Connection types", { exact: true }),
).toHaveCount(0);
await expect
.poll(() => countUpgradeEvents(metricEvents, 'infrastructure_onboarding_opened'))
.poll(() =>
countUpgradeEvents(metricEvents, "infrastructure_onboarding_opened"),
)
.toBe(0);
});
test('desktop discovery settings open as an infrastructure-owned dialog', async ({
test("desktop discovery settings open as an infrastructure-owned dialog", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only infrastructure manager coverage',
testInfo.project.name.startsWith("mobile-"),
"Desktop-only infrastructure manager coverage",
);
await prepareOnboardingPage(page);
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, {
timeout: 15_000,
});
await page.getByRole('button', { name: /Discovery settings/i }).click();
await page.getByRole("button", { name: /Discovery settings/i }).click();
const dialog = page.getByRole('dialog', { name: 'Discovery settings' });
const dialog = page.getByRole("dialog", { name: "Discovery settings" });
await expect(dialog).toBeVisible();
await expect(
dialog.getByText(
'Configure the saved network scope and background scan behavior for infrastructure source discovery.',
"Configure the saved network scope and background scan behavior for infrastructure source discovery.",
),
).toBeVisible();
await expect(dialog.getByText('Automatic scanning')).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Close discovery settings dialog' })).toBeVisible();
await expect(dialog.getByText("Automatic scanning")).toBeVisible();
await expect(
dialog.getByRole("button", { name: "Close discovery settings dialog" }),
).toBeVisible();
await expect(page).toHaveURL(/\/settings\/infrastructure(?:\?.*)?$/);
});
test('desktop per-platform add opens the matching modal and records direct type onboarding', async ({
test("desktop per-platform add opens the matching modal and records direct type onboarding", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only manager add-path instrumentation coverage',
testInfo.project.name.startsWith("mobile-"),
"Desktop-only manager add-path instrumentation coverage",
);
const metricEvents = await recordUpgradeMetricEvents(page);
await prepareOnboardingPage(page);
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, {
timeout: 15_000,
});
await page.getByRole('button', { name: /Add TrueNAS SCALE/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=truenas$/, { timeout: 15_000 });
await page.getByRole("button", { name: /Add TrueNAS SCALE/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=truenas$/, {
timeout: 15_000,
});
await expect(page.getByText('Infrastructure sources', { exact: true })).toBeVisible();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Add TrueNAS SCALE', exact: true }),
page.getByText("Infrastructure systems", { exact: true }),
).toBeVisible();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(
page
.getByRole("dialog")
.getByRole("heading", { name: "Add TrueNAS SCALE", exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Close add infrastructure dialog', exact: true }),
page.getByRole("button", {
name: "Close add infrastructure dialog",
exact: true,
}),
).toBeVisible();
await expect
.poll(() => countUpgradeEvents(metricEvents, 'infrastructure_onboarding_opened'))
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_path_selected', 'api'),
countUpgradeEvents(metricEvents, "infrastructure_onboarding_opened"),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_catalog_selected', 'truenas'),
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_path_selected",
"api",
),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_credentials_opened', 'truenas'),
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_catalog_selected",
"truenas",
),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_credentials_opened",
"truenas",
),
)
.toBe(1);
});
test('desktop explicit discovery scan surfaces a candidate row and opens a prefilled review dialog', async ({
test("desktop explicit discovery scan surfaces a candidate row and opens a prefilled review dialog", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only discovery-candidate review coverage',
testInfo.project.name.startsWith("mobile-"),
"Desktop-only discovery-candidate review coverage",
);
const metricEvents = await recordUpgradeMetricEvents(page);
await prepareOnboardingPage(page);
await page.route('**/api/discover', async (route) => {
await page.route("**/api/discover", async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname !== '/api/discover') {
if (requestUrl.pathname !== "/api/discover") {
await route.continue();
return;
}
if (route.request().method() === 'GET') {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
servers: [],
errors: [],
@ -304,18 +383,18 @@ test.describe('Infrastructure onboarding', () => {
return;
}
if (route.request().method() === 'POST') {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
servers: [
{
ip: '10.0.0.55',
ip: "10.0.0.55",
port: 8006,
type: 'pve',
version: '8.2.2',
hostname: 'discovered-pve.lab',
type: "pve",
version: "8.2.2",
hostname: "discovered-pve.lab",
},
],
errors: [],
@ -330,66 +409,93 @@ test.describe('Infrastructure onboarding', () => {
await route.continue();
});
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, {
timeout: 15_000,
});
await page.getByRole('button', { name: /Run discovery/i }).click();
await page.getByRole("button", { name: /Run discovery/i }).click();
await expect(page.getByText('discovered-pve.lab', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /^Review$/i })).toBeVisible();
await page.getByRole('button', { name: /^Review$/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=pve$/, { timeout: 15_000 });
await expect(page.getByRole('dialog')).toBeVisible();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Add Proxmox VE', exact: true }),
page.getByText("discovered-pve.lab", { exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: /^Review$/i })).toBeVisible();
await page.getByRole("button", { name: /^Review$/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=pve$/, {
timeout: 15_000,
});
await expect(page.getByRole("dialog")).toBeVisible();
await expect(
page
.getByRole("dialog")
.getByRole("heading", { name: "Add Proxmox VE", exact: true }),
).toBeVisible();
await expect(
page.getByPlaceholder('https://proxmox.example.com:8006'),
).toHaveValue('https://discovered-pve.lab:8006');
page.getByPlaceholder("https://proxmox.example.com:8006"),
).toHaveValue("https://discovered-pve.lab:8006");
await expect
.poll(() => countUpgradeEvents(metricEvents, 'infrastructure_onboarding_opened'))
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_path_selected', 'api'),
countUpgradeEvents(metricEvents, "infrastructure_onboarding_opened"),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_credentials_opened', 'pve'),
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_path_selected",
"api",
),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_catalog_selected', 'pve'),
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_credentials_opened",
"pve",
),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_catalog_selected",
"pve",
),
)
.toBe(0);
});
test('desktop detect utility records no-match agent fallback from the add-infrastructure picker', async ({
test("desktop detect utility records no-match agent fallback from the add-infrastructure picker", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only onboarding probe instrumentation coverage',
testInfo.project.name.startsWith("mobile-"),
"Desktop-only onboarding probe instrumentation coverage",
);
const metricEvents = await recordUpgradeMetricEvents(page);
await prepareOnboardingPage(page);
await page.route('**/api/connections/probe', async (route) => {
await page.route("**/api/connections/probe", async (route) => {
const requestUrl = new URL(route.request().url());
if (route.request().method() !== 'POST' || requestUrl.pathname !== '/api/connections/probe') {
if (
route.request().method() !== "POST" ||
requestUrl.pathname !== "/api/connections/probe"
) {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
candidates: [],
probedMs: 184,
@ -397,59 +503,102 @@ test.describe('Infrastructure onboarding', () => {
});
});
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await page.getByRole('button', { name: /Add infrastructure/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=pick$/, { timeout: 15_000 });
await page.getByRole('button', { name: /Detect from address/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=detect$/, { timeout: 15_000 });
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, {
timeout: 15_000,
});
await page.getByRole("button", { name: /Add infrastructure/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=pick$/, {
timeout: 15_000,
});
await page.getByRole("button", { name: /Detect from address/i }).click();
await page.waitForURL(/\/settings\/infrastructure\?add=detect$/, {
timeout: 15_000,
});
await page.getByLabel('Address').fill('baremetal.lab');
await page.getByRole('button', { name: 'Probe address', exact: true }).click();
await page.getByLabel("Address").fill("baremetal.lab");
await page
.getByRole("button", { name: "Probe address", exact: true })
.click();
await expect(
page.getByText('No supported API-backed platform detected at that address.', { exact: true }),
page.getByText(
"No supported API-backed platform detected at that address.",
{ exact: true },
),
).toBeVisible();
await expect
.poll(() => countUpgradeEvents(metricEvents, 'infrastructure_onboarding_path_selected', 'api'))
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_probe_result', 'no-match'),
)
.toBe(1);
await page.getByRole('button', { name: 'install Pulse Agent instead', exact: true }).click();
await expect(page.getByRole('button', { name: /Back to detect/i })).toBeVisible();
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_path_selected', 'agent'),
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_path_selected",
"api",
),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(metricEvents, 'infrastructure_onboarding_credentials_opened', 'agent'),
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_probe_result",
"no-match",
),
)
.toBe(1);
await page
.getByRole("button", { name: "install Pulse Agent instead", exact: true })
.click();
await expect(
page.getByRole("button", { name: /Back to detect/i }),
).toBeVisible();
await expect
.poll(() =>
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_path_selected",
"agent",
),
)
.toBe(1);
await expect
.poll(() =>
countUpgradeEvents(
metricEvents,
"infrastructure_onboarding_credentials_opened",
"agent",
),
)
.toBe(1);
});
test('mobile landing and grouped platform tables stay inside the viewport', async ({
test("mobile landing and grouped platform tables stay inside the viewport", async ({
page,
}, testInfo) => {
test.skip(
!testInfo.project.name.startsWith('mobile-'),
'Mobile-only infrastructure manager overflow coverage',
!testInfo.project.name.startsWith("mobile-"),
"Mobile-only infrastructure manager overflow coverage",
);
await prepareOnboardingPage(page);
await page.goto('/settings/infrastructure', { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, { timeout: 15_000 });
await expect(page.getByText('Infrastructure sources', { exact: true })).toBeVisible();
await expect(page.getByText('Pulse Agent', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /Add Pulse Agent/i })).toBeVisible();
await page.goto("/settings/infrastructure", {
waitUntil: "domcontentloaded",
});
await page.waitForURL(/\/settings\/infrastructure(?:\?.*)?$/, {
timeout: 15_000,
});
await expect(
page.getByText("Infrastructure systems", { exact: true }),
).toBeVisible();
await expect(page.getByText("Pulse Agent", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: /Add Pulse Agent/i }),
).toBeVisible();
await scrollToBottom(page);
const audit = await auditHorizontalOverflow(page);