Improve infrastructure settings workspace

This commit is contained in:
rcourtman
2026-04-24 12:44:55 +01:00
parent 73c79afb23
commit 56bc13cb52
16 changed files with 871 additions and 660 deletions
+80 -1
View File
@@ -2398,6 +2398,71 @@
"kind": "file"
}
]
},
{
"id": "RA26",
"summary": "Infrastructure Settings is a release-critical configuration surface: it must remain a first-class source-management workspace whose first viewport makes adding, discovering, installing agents, and verifying API plus Pulse Agent coverage clear, with stable source grouping and browser-verified layout before release-ready claims.",
"kind": "journey",
"blocking_level": "release-ready",
"proof_type": "hybrid",
"lane_ids": [
"L8",
"L16"
],
"subsystem_ids": [
"agent-lifecycle",
"frontend-primitives"
],
"release_gate_ids": [
"settings-surface-layout-consistency"
],
"proof_commands": [
{
"id": "infrastructure-settings-workspace-tests",
"run": [
"npm",
"--prefix",
"frontend-modern",
"test",
"--",
"src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx",
"src/components/Settings/__tests__/settingsArchitecture.test.ts",
"src/utils/__tests__/discoveryPresentation.test.ts"
]
}
],
"evidence": [
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/status.json",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Settings/DiscoverySettingsForm.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Settings/SettingsPageShell.tsx",
"kind": "file"
}
]
}
],
"evidence_reference_policy": {
@@ -4416,7 +4481,21 @@
]
}
],
"work_claims": [],
"work_claims": [
{
"id": "codex-lane-followup-mobile-post-rc-hardening",
"agent_id": "codex",
"summary": "Harden Pulse Mobile GA proof tooling and run fresh iPad approval proof",
"target_id": "v6-product-lane-expansion",
"claimed_at": "2026-04-24T11:40:01Z",
"heartbeat_at": "2026-04-24T11:40:01Z",
"expires_at": "2026-04-24T13:40:01Z",
"work_item": {
"kind": "lane-followup",
"id": "mobile-post-rc-hardening"
}
}
],
"open_decisions": [],
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
"resolved_decisions": [
@@ -248,7 +248,7 @@ an add-only capacity posture.
mismatched, and signature-invalid grants through agent metrics.
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 guidance exposes `Detect from address`, `Install Pulse Agent`, and `Choose source type` as first-run actions above the source ledger, while header actions stay limited to ongoing discovery controls. The same landing boundary may surface setup confidence from the unified rows and discovered candidates, including connected-system count, API coverage, agent coverage, discovery review state, and the next setup action, without creating a second inventory model or provider-specific summary fetch.
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 infrastructure source-management toolbar too: the landing page exposes `Add infrastructure`, `Detect address`, `Install agent`, `Run discovery`, and `Discovery settings` as first-viewport actions inside the source manager rather than splitting first-run guidance from ongoing controls. The same landing boundary may surface setup confidence from the unified rows and discovered candidates, including connected-system count, API coverage, agent coverage, sources that still need an agent, and discovery review state, without creating a second inventory model or provider-specific summary fetch. Source groups must stay in the governed source catalog order instead of re-sorting by current row count, row-level lifecycle entry points must use `Manage` language, and locked agent-install states must show a compact command inventory without raw token placeholders or disabled copy commands until a token exists. Network discovery settings remain safety-critical: automatic scanning must surface the shared-network/subnet warning before operators save scan mode changes.
Setup-completion handoff belongs to that same single add-flow boundary. The first-run completion screen must keep credentials as the first surfaced object, then present one compact next-step surface that sends operators to Add infrastructure or directly to the Agent handoff; source-choice explanation may live inside that surface, but lifecycle work must not reintroduce a separate setup-wizard tour, duplicate CTA section, or inline install-command owner before the canonical infrastructure workspace.
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.
When that primary Proxmox source is cluster-backed, the same workspace boundary must render the row under the canonical cluster moniker carried by the backend grouping contract rather than under one sibling node's hostname. That same backend grouping contract must also carry the explicit member-node list for the cluster so the source manager can render child node composition such as `delly` and `minipc` beneath the owning cluster row with per-node coverage/status. Cluster-member node agents belong as augmentations on that owning Proxmox row and its child nodes, not as separate standalone-host peers.
@@ -409,7 +409,10 @@ an add-only capacity posture.
`NodeModalAuthenticationSection`, `NodeModalMonitoringSection`, and
`NodeModalStatusFooter` primitives inline under the editor — dropping
the Dialog wrapper and the surrounding discovery/configured-nodes
workspace. For TrueNAS and VMware, the credential slots are
workspace. The inline credential slot must keep the visible setup sequence
as `Endpoint`, `Authentication`, and `Coverage` before the PVE/PBS/PMG
setup forms so lifecycle actions keep a stable operator model inside the
unified editor. For TrueNAS and VMware, the credential slots are
`frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`
and
`frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`;
@@ -131,6 +131,11 @@ Own canonical runtime payload shapes between backend and frontend.
That same client contract must expose the setup strategy before a token
path is chosen: Agent Install is API + Agent, Direct Connection is API
inventory, and Manual Token Setup is a manual API-token escape hatch.
The inline node credential slot must keep the visible submit sequence as
`Endpoint`, `Authentication`, and `Coverage` before the API-backed setup
controls. That sequence is presentation guidance for the existing setup
payload phases; it does not create a second node setup API model or allow
page-local payload ownership.
32. `internal/api/agent_install_command_shared.go` shared with `agent-lifecycle`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary.
33. `internal/api/ai_handler.go` shared with `ai-runtime`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
34. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
@@ -256,14 +256,16 @@ work extends shared components instead of creating new local variants.
`/settings/infrastructure`: the landing route should read as one
source-manager workspace with configured infrastructure instances first
and no redundant monitored-systems ledger beneath it. The landing route may
include a compact guidance strip that explains platform APIs and host agents
as Pulse 6 infrastructure sources and exposes `Detect from address`, `Install
Pulse Agent`, and `Choose source type` as first-run actions. It may also show
a compact readiness strip derived from the same unified connection rows and
discovered candidates so operators can confirm connected-system count,
API coverage, agent coverage, discovery review state, and the next setup
action without opening a tour or second ledger. Existing sources stay visible
on the page, and add, detect, install, review, and edit flows open as
include a dedicated first-viewport toolbar that explains platform APIs and
Pulse Agent telemetry as infrastructure sources and exposes `Add
infrastructure`, `Detect address`, `Install agent`, `Run discovery`, and
`Discovery settings` inside the source manager. It may also show a compact
coverage strip derived from the same unified connection rows and discovered
candidates so operators can confirm connected-system count, API coverage,
agent coverage, sources that still need an agent, and discovery review state
without opening a tour or second ledger. Existing sources stay visible in
stable source-catalog order, and add, detect, install, review, and manage
flows open as
secondary interactions from that same destination instead of taking over the
whole page.
Those secondary views must stay under the same single `Infrastructure`
@@ -28,9 +28,8 @@ export interface NodeCredentialSlotProps {
}
export const NodeCredentialSlot: Component<NodeCredentialSlotProps> = (props) => {
const prefill: Partial<NodeConfig> | undefined = props.prefillNode ?? (
props.initialAddress ? { host: props.initialAddress } : undefined
);
const prefill: Partial<NodeConfig> | undefined =
props.prefillNode ?? (props.initialAddress ? { host: props.initialAddress } : undefined);
const handleSave = async (nodeData: Partial<NodeConfig>) => {
await props.settings.saveNode(nodeData);
@@ -64,6 +63,40 @@ export const NodeCredentialSlot: Component<NodeCredentialSlotProps> = (props) =>
return (
<form onSubmit={state.handleSubmit} class="space-y-6">
<section
aria-label="Infrastructure connection sequence"
class="rounded-md border border-border bg-surface-alt px-4 py-3"
>
<div class="grid gap-3 text-sm sm:grid-cols-3">
<div class="flex items-start gap-2">
<span class="mt-0.5 inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-semibold text-white">
1
</span>
<div>
<div class="font-medium text-base-content">Endpoint</div>
<div class="text-xs text-muted">Name and API address</div>
</div>
</div>
<div class="flex items-start gap-2">
<span class="mt-0.5 inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-semibold text-white">
2
</span>
<div>
<div class="font-medium text-base-content">Authentication</div>
<div class="text-xs text-muted">Assisted setup or manual token</div>
</div>
</div>
<div class="flex items-start gap-2">
<span class="mt-0.5 inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-semibold text-white">
3
</span>
<div>
<div class="font-medium text-base-content">Coverage</div>
<div class="text-xs text-muted">Monitoring scope and save</div>
</div>
</div>
</div>
</section>
<NodeModalBasicInfoSection modalProps={modalProps} state={state} />
<NodeModalAuthenticationSection modalProps={modalProps} state={state} />
<NodeModalMonitoringSection modalProps={modalProps} state={state} />
@@ -25,10 +25,10 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
return (
<div class="space-y-5">
<div class="rounded-md border border-blue-200 bg-blue-50/70 p-4 dark:border-blue-800 dark:bg-blue-950/40">
<div class="rounded-md border border-amber-200 bg-amber-50/80 p-4 dark:border-amber-800 dark:bg-amber-950/40">
<div class="flex items-start gap-3">
<svg
class="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
class="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -40,7 +40,7 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="text-sm text-blue-800 dark:text-blue-200">
<div class="text-sm text-amber-900 dark:text-amber-100">
<p class="mb-1 font-medium">{priorityNotice.title}</p>
<ul class="space-y-1">
<For each={priorityNotice.items}>{(item) => <li> {item}</li>}</For>
@@ -108,11 +108,15 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
await props.handleDiscoveryModeChange('auto');
}
}}
disabled={props.envOverrides().discoverySubnet || props.savingDiscoverySettings()}
disabled={
props.envOverrides().discoverySubnet || props.savingDiscoverySettings()
}
class="mt-1 h-5 w-5 border-slate-300 text-blue-600 focus:ring-blue-500 sm:h-4 sm:w-4"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-base-content">{autoModePresentation.label}</p>
<p class="text-sm font-medium text-base-content">
{autoModePresentation.label}
</p>
<p class="text-xs text-muted">{autoModePresentation.description}</p>
</div>
</label>
@@ -134,7 +138,9 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
void props.handleDiscoveryModeChange('custom');
}
}}
disabled={props.envOverrides().discoverySubnet || props.savingDiscoverySettings()}
disabled={
props.envOverrides().discoverySubnet || props.savingDiscoverySettings()
}
class="mt-1 h-5 w-5 border-slate-300 text-blue-600 focus:ring-blue-500 sm:h-4 sm:w-4"
/>
<div class="space-y-1">
@@ -181,9 +187,7 @@ export const DiscoverySettingsForm: Component<DiscoverySettingsFormProps> = (pro
return;
}
const updatedValue = props.normalizeSubnetList(
selections.join(', '),
);
const updatedValue = props.normalizeSubnetList(selections.join(', '));
props.setDiscoveryMode('custom');
props.setDiscoverySubnetDraft(updatedValue);
props.setLastCustomSubnet(updatedValue);
@@ -37,7 +37,9 @@ export const InfrastructureInstallerSection: Component = () => {
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-950 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-50">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-2">
<p class="font-semibold">Security configured. Save these first-run credentials now.</p>
<p class="font-semibold">
Security configured. Save these first-run credentials now.
</p>
<p class="text-xs text-emerald-800 dark:text-emerald-200">
This is the Pulse Agent handoff from first-run setup inside Add infrastructure.
<Show
@@ -96,7 +98,10 @@ export const InfrastructureInstallerSection: Component = () => {
<button
type="button"
onClick={() =>
void state.copySetupHandoffField(handoff().password, 'Copied first-run password.')
void state.copySetupHandoffField(
handoff().password,
'Copied first-run password.',
)
}
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100 dark:hover:bg-emerald-800"
>
@@ -138,11 +143,10 @@ export const InfrastructureInstallerSection: Component = () => {
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-950 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-50">
<p class="font-semibold">Recommended install model</p>
<p class="mt-1 text-xs text-emerald-800 dark:text-emerald-200">
Pulse Agent is a low-overhead background service. Install it on each machine where
you want full node-local telemetry such as temperatures, SMART disk health, services,
Docker, or Kubernetes coverage. For Proxmox clusters, keep the cluster API
connection for platform inventory and add the agent to each node for host-level
augmentation.
Pulse Agent is a low-overhead background service. Install it on each machine where you
want full node-local telemetry such as temperatures, SMART disk health, services,
Docker, or Kubernetes coverage. For Proxmox clusters, keep the cluster API connection
for platform inventory and add the agent to each node for host-level augmentation.
</p>
</div>
@@ -196,7 +200,13 @@ export const InfrastructureInstallerSection: Component = () => {
<Show when={state.latestRecord()}>
<div class="flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-200">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>
@@ -238,63 +248,33 @@ export const InfrastructureInstallerSection: Component = () => {
</Show>
<Show when={state.requiresToken() && !state.commandsUnlocked()}>
<div class="space-y-3">
<div>
<div class="space-y-3 rounded-md border border-border bg-surface-alt px-4 py-4">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
<span class="mr-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-slate-400 text-xs font-bold text-white">
2
</span>
Installation commands
</h4>
<p class="ml-6 mt-0.5 text-xs text-muted">
Preview generate a token above to enable copying. The{' '}
<code class="rounded bg-surface-hover px-1">{'<api-token>'}</code> placeholder
below will be replaced automatically once a token exists.
<p class="ml-6 text-xs text-muted">
Generate an install token first. Pulse will then build copy-ready commands with the
credential inserted for the target host.
</p>
</div>
<div class="space-y-4">
<div class="grid gap-3 lg:grid-cols-2">
<For each={state.commandSections()}>
{(section) => (
<div class="space-y-3 rounded-md border border-border p-4 opacity-70">
<div class="rounded-md border border-border bg-surface px-3 py-3">
<div class="space-y-1">
<h5 class="text-sm font-semibold text-base-content">{section.title}</h5>
<p class="text-xs text-muted">{section.description}</p>
</div>
<div class="space-y-3">
<div class="mt-3 flex flex-wrap gap-2">
<For each={section.snippets}>
{(snippet) => (
<div class="space-y-2">
<h6 class="text-xs font-semibold uppercase tracking-wide text-muted">
{snippet.label}
</h6>
<div class="relative">
<button
type="button"
disabled
class="absolute right-2 top-2 inline-flex min-h-10 min-w-10 cursor-not-allowed items-center justify-center rounded-md bg-surface-hover p-2 opacity-50 sm:min-h-9 sm:min-w-9"
title="Generate a token above to enable copying"
aria-label="Copy disabled until an install token is generated"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
</button>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>{snippet.command}</code>
</pre>
</div>
<Show when={snippet.note}>
<p class="text-xs text-muted">{snippet.note}</p>
</Show>
</div>
<span class="inline-flex items-center rounded-full border border-border bg-surface-alt px-2.5 py-1 text-[11px] font-medium text-base-content">
{snippet.label}
</span>
)}
</For>
</div>
@@ -366,8 +346,8 @@ export const InfrastructureInstallerSection: Component = () => {
/>
</div>
<p class="mt-1.5 text-xs text-muted">
Override the address agents use to connect to this server (e.g., use IP address{' '}
<code>http://192.0.2.50:7655</code> if DNS fails).
Override the address agents use to connect to this server (e.g., use IP
address <code>http://192.0.2.50:7655</code> if DNS fails).
<Show when={!state.customAgentUrl()}>
<span class="ml-1 opacity-75">
Currently using auto-detected: {state.agentUrl()}
@@ -399,16 +379,16 @@ export const InfrastructureInstallerSection: Component = () => {
</div>
<p class="mt-1.5 text-xs text-muted">
Preserves custom trust for copied install, upgrade, and uninstall commands.
Shell commands pass <code>--cacert</code> to both the download and the installer.
Windows commands set <code>PULSE_CACERT</code> and use a transport-aware
PowerShell bootstrap for the initial script fetch.
Shell commands pass <code>--cacert</code> to both the download and the
installer. Windows commands set <code>PULSE_CACERT</code> and use a
transport-aware PowerShell bootstrap for the initial script fetch.
</p>
</div>
<Show when={state.insecureMode()}>
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200">
<span class="font-medium">TLS verification disabled</span> skip cert checks for
self-signed setups. Not recommended for production.
<span class="font-medium">TLS verification disabled</span> skip cert checks
for self-signed setups. Not recommended for production.
</div>
</Show>
@@ -440,15 +420,16 @@ export const InfrastructureInstallerSection: Component = () => {
<Show when={state.enableCommands()}>
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-2 text-sm text-blue-800 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200">
<span class="font-medium">Pulse commands enabled</span> The agent will accept
diagnostic and fix commands from Pulse Patrol features.
<span class="font-medium">Pulse commands enabled</span> The agent will
accept diagnostic and fix commands from Pulse Patrol features.
</div>
</Show>
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm text-emerald-900 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-100">
<span class="font-medium">Config signing (optional)</span> Require signed remote
config payloads with <code>PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED=true</code>.
Provide keys via <code>PULSE_AGENT_CONFIG_SIGNING_KEY</code> (Pulse) and{' '}
<span class="font-medium">Config signing (optional)</span> Require signed
remote config payloads with{' '}
<code>PULSE_AGENT_CONFIG_SIGNATURE_REQUIRED=true</code>. Provide keys via{' '}
<code>PULSE_AGENT_CONFIG_SIGNING_KEY</code> (Pulse) and{' '}
<code>PULSE_AGENT_CONFIG_PUBLIC_KEYS</code> (agents).
</div>
@@ -463,7 +444,9 @@ export const InfrastructureInstallerSection: Component = () => {
id="install-profile-select"
value={state.installProfile()}
onChange={(event) =>
state.handleInstallProfileChange(event.currentTarget.value as InstallProfile)
state.handleInstallProfileChange(
event.currentTarget.value as InstallProfile,
)
}
class="w-full rounded-md border bg-surface px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:border-blue-400 dark:focus:ring-blue-800"
>
@@ -598,7 +581,9 @@ export const InfrastructureInstallerSection: Component = () => {
/>
</div>
<Show when={state.lookupError()}>
<p class="text-xs font-medium text-red-600 dark:text-red-300">{state.lookupError()}</p>
<p class="text-xs font-medium text-red-600 dark:text-red-300">
{state.lookupError()}
</p>
</Show>
<Show when={state.lookupResult()}>
{(result) => {
@@ -618,7 +603,9 @@ export const InfrastructureInstallerSection: Component = () => {
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<div class="text-sm font-semibold">
{isConnected() ? 'First host connected' : agent().displayName || agent().hostname}
{isConnected()
? 'First host connected'
: agent().displayName || agent().hostname}
</div>
<p
class={`text-xs ${
@@ -692,7 +679,9 @@ export const InfrastructureInstallerSection: Component = () => {
</summary>
<div class="mt-3 space-y-4">
<div>
<p class="text-xs uppercase tracking-wide text-muted">Auto-detection not working?</p>
<p class="text-xs uppercase tracking-wide text-muted">
Auto-detection not working?
</p>
<p class="mt-1 text-xs text-muted">
If Docker, Kubernetes, or Proxmox isn't detected automatically, add these flags
to the install command:
@@ -749,7 +738,14 @@ export const InfrastructureInstallerSection: Component = () => {
class="absolute right-2 top-2 inline-flex min-h-10 min-w-10 items-center justify-center rounded-md bg-surface-hover p-2 text-slate-400 transition-colors hover:bg-slate-700 hover:text-slate-200 sm:min-h-9 sm:min-w-9"
title="Copy command"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
@@ -775,7 +771,9 @@ export const InfrastructureInstallerSection: Component = () => {
)
</p>
<div class="space-y-1">
<span class="text-xs font-medium text-muted">Windows (PowerShell as Administrator)</span>
<span class="text-xs font-medium text-muted">
Windows (PowerShell as Administrator)
</span>
<div class="relative">
<button
type="button"
@@ -790,7 +788,14 @@ export const InfrastructureInstallerSection: Component = () => {
class="absolute right-2 top-2 inline-flex min-h-10 min-w-10 items-center justify-center rounded-md bg-surface-hover p-2 text-slate-400 transition-colors hover:bg-slate-700 hover:text-slate-200 sm:min-h-9 sm:min-w-9"
title="Copy command"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
File diff suppressed because it is too large Load Diff
@@ -849,13 +849,13 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const editDialogTitle = createMemo(() => {
const row = editingRow();
if (!row) return 'Edit source';
return `Edit ${row.name}`;
if (!row) return 'Manage source';
return `Manage ${row.name}`;
});
const editDialogDescription = createMemo(() => {
const connection = editingConnection();
if (!connection) return 'Update this source without leaving the infrastructure manager.';
if (!connection) return 'Update source state, credentials, and lifecycle actions here.';
const label = describeManagedSourceType(connection.type);
const methods = attachedAgentConnections().length > 0 ? ' · API + Pulse Agent' : '';
return `${label}${connection.address ? ` · ${connection.address}` : ''}${methods}`;
@@ -1,4 +1,4 @@
import { Accessor, Component, For, JSX, Setter, Show } from 'solid-js';
import { Accessor, Component, For, JSX, Setter, Show, createSignal } from 'solid-js';
import ChevronRight from 'lucide-solid/icons/chevron-right';
import { Card } from '@/components/shared/Card';
import { PageHeader } from '@/components/shared/PageHeader';
@@ -8,11 +8,7 @@ import {
getSettingsUnsavedChangesBanner,
SETTINGS_SHELL_COPY,
} from '@/utils/settingsShellPresentation';
import type {
SettingsHeaderMeta,
SettingsNavGroup,
SettingsTab,
} from './settingsNavigationModel';
import type { SettingsHeaderMeta, SettingsNavGroup, SettingsTab } from './settingsNavigationModel';
import { isInfrastructureSettingsTab } from './settingsNavigationModel';
interface SettingsPageShellProps {
@@ -36,7 +32,29 @@ interface SettingsPageShellProps {
}
export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
const [focusedNavigationExpanded, setFocusedNavigationExpanded] = createSignal(false);
const unsavedChangesBanner = () => getSettingsUnsavedChangesBanner();
const infrastructureWorkspaceActive = () => isInfrastructureSettingsTab(props.activeTab());
const effectiveSidebarCollapsed = () =>
infrastructureWorkspaceActive() && !props.isMobileMenuOpen()
? !focusedNavigationExpanded()
: props.sidebarCollapsed();
const collapseSidebar = () => {
if (infrastructureWorkspaceActive() && !props.isMobileMenuOpen()) {
setFocusedNavigationExpanded(false);
return;
}
props.setSidebarCollapsed(true);
};
const expandSidebar = () => {
if (infrastructureWorkspaceActive() && !props.isMobileMenuOpen()) {
setFocusedNavigationExpanded(true);
return;
}
props.setSidebarCollapsed(false);
};
const isSidebarItemActive = (itemId: SettingsTab) =>
itemId === 'infrastructure-systems'
? isInfrastructureSettingsTab(props.activeTab())
@@ -96,21 +114,21 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
<Card padding="none" class="relative flex lg:flex-row overflow-hidden min-h-[600px]">
<div
class={`${props.isMobileMenuOpen() ? 'flex flex-col w-full' : 'hidden lg:flex lg:flex-col'} ${props.sidebarCollapsed() ? 'lg:w-16 lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:w-72 lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-border lg:border-b-0 lg:border-r lg:align-top flex-shrink-0 transition-all duration-200 bg-surface lg:bg-transparent z-10`}
class={`${props.isMobileMenuOpen() ? 'flex flex-col w-full' : 'hidden lg:flex lg:flex-col'} ${effectiveSidebarCollapsed() ? 'lg:w-16 lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:w-72 lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-border lg:border-b-0 lg:border-r lg:align-top flex-shrink-0 transition-all duration-200 bg-surface lg:bg-transparent z-10`}
aria-label={SETTINGS_SHELL_COPY.navigationAriaLabel}
aria-expanded={!props.sidebarCollapsed()}
aria-expanded={!effectiveSidebarCollapsed()}
>
<div
class={`sticky top-0 ${props.sidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
class={`sticky top-0 ${effectiveSidebarCollapsed() ? 'px-2' : 'px-4'} py-5 space-y-5 transition-all duration-200`}
>
<Show when={!props.sidebarCollapsed()}>
<Show when={!effectiveSidebarCollapsed()}>
<div class="flex items-center justify-between pb-2 border-b border-border">
<h2 class="text-sm font-semibold text-base-content">
{SETTINGS_SHELL_COPY.navigationTitle}
</h2>
<button
type="button"
onClick={() => props.setSidebarCollapsed(true)}
onClick={collapseSidebar}
class="p-1 rounded-md hover:bg-surface-hover transition-colors"
aria-label={SETTINGS_SHELL_COPY.collapseSidebarLabel}
>
@@ -125,10 +143,10 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
</button>
</div>
</Show>
<Show when={props.sidebarCollapsed()}>
<Show when={effectiveSidebarCollapsed()}>
<button
type="button"
onClick={() => props.setSidebarCollapsed(false)}
onClick={expandSidebar}
class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors"
aria-label={SETTINGS_SHELL_COPY.expandSidebarLabel}
>
@@ -143,7 +161,7 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
</button>
</Show>
<div id="settings-sidebar-menu" class="space-y-4">
<Show when={!props.sidebarCollapsed()}>
<Show when={!effectiveSidebarCollapsed()}>
<div class="px-2 pb-2">
<SearchInput
value={props.searchQuery}
@@ -172,7 +190,7 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
<div class="mb-6 lg:mb-2 lg:space-y-2">
<Show
when={
!props.sidebarCollapsed() &&
!effectiveSidebarCollapsed() &&
!(group.items.length === 1 && group.items[0]?.label === group.label)
}
>
@@ -189,13 +207,13 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
type="button"
aria-current={isActive() ? 'page' : undefined}
disabled={item.disabled}
class={`group flex w-full items-center ${props.sidebarCollapsed() ? 'justify-center' : 'justify-between'} lg:rounded-md ${props.sidebarCollapsed() ? 'px-2 py-2.5' : 'px-4 py-3.5 lg:px-3 lg:py-2'} text-[15px] lg:text-sm font-medium transition-colors ${item.disabled ? 'opacity-60 cursor-not-allowed text-muted' : isActive() ? 'lg:bg-blue-50 text-blue-600 dark:lg:bg-blue-900 dark:text-blue-300 lg:dark:text-blue-200 bg-surface' : ' lg:hover:bg-surface-hover hover:text-base-content active:bg-surface-hover lg:active:bg-transparent'}`}
class={`group flex w-full items-center ${effectiveSidebarCollapsed() ? 'justify-center' : 'justify-between'} lg:rounded-md ${effectiveSidebarCollapsed() ? 'px-2 py-2.5' : 'px-4 py-3.5 lg:px-3 lg:py-2'} text-[15px] lg:text-sm font-medium transition-colors ${item.disabled ? 'opacity-60 cursor-not-allowed text-muted' : isActive() ? 'lg:bg-blue-50 text-blue-600 dark:lg:bg-blue-900 dark:text-blue-300 lg:dark:text-blue-200 bg-surface' : ' lg:hover:bg-surface-hover hover:text-base-content active:bg-surface-hover lg:active:bg-transparent'}`}
onClick={() => {
if (item.disabled) return;
props.setActiveTab(item.id);
props.setIsMobileMenuOpen(false);
}}
title={props.sidebarCollapsed() ? item.label : undefined}
title={effectiveSidebarCollapsed() ? item.label : undefined}
>
<div class="flex items-center gap-3.5 lg:gap-2.5 w-full">
<div
@@ -206,7 +224,7 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
{...(item.iconProps || {})}
/>
</div>
<Show when={!props.sidebarCollapsed()}>
<Show when={!effectiveSidebarCollapsed()}>
<span
class={`truncate flex-1 text-left ${isActive() ? 'font-semibold lg:font-medium' : ''}`}
>
@@ -261,7 +279,9 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
</div>
</Show>
<div class="p-4 sm:p-6 lg:p-8">{props.children}</div>
<div class={infrastructureWorkspaceActive() ? 'p-3 sm:p-4 lg:p-5' : 'p-4 sm:p-6 lg:p-8'}>
{props.children}
</div>
</div>
</Card>
</div>
@@ -179,13 +179,23 @@ describe('infrastructure operations model', () => {
);
expect(infrastructureInstallerSectionSource).toContain('Generate install token');
expect(infrastructureInstallerSectionSource).toContain('Generate token');
expect(infrastructureInstallerSectionSource).not.toContain('Add infrastructure');
expect(infrastructureInstallerSectionSource).toContain(
'This is the Pulse Agent handoff from first-run setup inside Add infrastructure.',
);
expect(infrastructureInstallerSectionSource).toContain('Pulse Agent is a low-overhead background service.');
expect(infrastructureInstallerSectionSource).toContain(
'For Proxmox clusters, keep the cluster API',
);
expect(infrastructureInstallerSectionSource).toContain('host-level');
expect(infrastructureInstallerSectionSource).toContain('augmentation.');
expect(infrastructureInstallerSectionSource).toContain('Installation commands');
expect(infrastructureInstallerSectionSource).toContain(
'Generate an install token first. Pulse will then build copy-ready commands',
);
expect(infrastructureInstallerSectionSource).not.toContain('<api-token>');
expect(infrastructureInstallerSectionSource).not.toContain(
'Copy disabled until an install token is generated',
);
});
it('does not reintroduce the retired reporting state hook on the operations state', async () => {
@@ -287,24 +287,24 @@ describe('InfrastructureWorkspace', () => {
renderWorkspace();
await waitFor(() => expect(screen.getByText('Infrastructure systems')).toBeInTheDocument());
expect(screen.getByText('Start by connecting what Pulse should monitor')).toBeInTheDocument();
expect(
screen.getByText(/Pulse 6 treats platform APIs and host agents as infrastructure sources/i),
screen.getByText(/Add, discover, and verify the platform APIs plus Pulse Agent telemetry/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Run discovery/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Discovery settings/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Detect from address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Choose source type/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Add infrastructure$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Detect address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Install agent$/i })).toBeInTheDocument();
const readiness = screen.getByRole('region', {
name: /Infrastructure setup confidence/i,
});
expect(within(readiness).getByText('Infrastructure readiness')).toBeInTheDocument();
expect(within(readiness).getByText('Infrastructure coverage')).toBeInTheDocument();
expect(within(readiness).getByText('Connected systems')).toBeInTheDocument();
expect(within(readiness).getByText('API coverage')).toBeInTheDocument();
expect(within(readiness).getByText('Agent coverage')).toBeInTheDocument();
expect(within(readiness).getByText('Needs agent')).toBeInTheDocument();
expect(within(readiness).getByText('Discovery')).toBeInTheDocument();
expect(within(readiness).getAllByText('1 system')).toHaveLength(2);
expect(within(readiness).getAllByText('1 system')).toHaveLength(3);
expect(within(readiness).getByText('0 systems')).toBeInTheDocument();
expect(within(readiness).getByText('Discovery off')).toBeInTheDocument();
expect(within(readiness).getByRole('button', { name: /Install agents/i })).toBeInTheDocument();
@@ -316,8 +316,7 @@ describe('InfrastructureWorkspace', () => {
expect(screen.getByRole('button', { name: /Add Proxmox VE/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Add TrueNAS SCALE/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Add host/i })).toBeNull();
expect(screen.getByRole('button', { name: /^Add infrastructure$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Edit/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Manage/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Monitored systems' })).not.toBeInTheDocument();
});
@@ -326,12 +325,12 @@ describe('InfrastructureWorkspace', () => {
await waitFor(() => expect(screen.getByText('Infrastructure systems')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /Detect from address/i }));
fireEvent.click(screen.getByRole('button', { name: /Detect address/i }));
expect(navigateSpy).toHaveBeenLastCalledWith('/settings/infrastructure?add=detect', {
scroll: false,
});
fireEvent.click(screen.getByRole('button', { name: /Install Pulse Agent/i }));
fireEvent.click(screen.getByRole('button', { name: /^Install agent$/i }));
expect(navigateSpy).toHaveBeenLastCalledWith('/settings/infrastructure?add=agent', {
scroll: false,
});
@@ -347,12 +346,116 @@ describe('InfrastructureWorkspace', () => {
scroll: false,
});
fireEvent.click(screen.getByRole('button', { name: /Choose source type/i }));
fireEvent.click(screen.getByRole('button', { name: /^Add infrastructure$/i }));
expect(navigateSpy).toHaveBeenLastCalledWith('/settings/infrastructure?add=pick', {
scroll: false,
});
});
it('keeps source groups in the catalog order instead of count order', async () => {
const pveConnection = connectionFixture({
id: 'pve:zeus',
type: 'pve',
name: 'zeus',
address: 'https://10.0.0.1:8006',
});
const towerAgent = connectionFixture({
id: 'agent:tower',
type: 'agent',
name: 'Tower',
address: 'Tower',
source: 'agent',
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
});
const miniAgent = connectionFixture({
id: 'agent:mini',
type: 'agent',
name: 'Mini',
address: 'Mini',
source: 'agent',
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
});
connectionState.connections = [pveConnection, towerAgent, miniAgent];
connectionState.rows = [
{
id: pveConnection.id,
ownerType: 'pve',
name: 'zeus',
subtitle: 'via platform API',
source: 'api',
host: pveConnection.address,
coverageLabels: ['VMs'],
statusLabel: 'Active',
statusClassName: 'bg-green-100 text-green-800',
agentUpdateCount: 0,
lastActivityText: '1m ago',
enabled: true,
canEdit: true,
canPause: true,
canRemove: true,
isAgent: false,
isCluster: false,
attachedConnections: [],
members: [],
connection: pveConnection,
},
{
id: towerAgent.id,
ownerType: 'agent',
name: 'Tower',
subtitle: 'via Pulse Agent',
source: 'agent',
host: 'Tower',
coverageLabels: ['Host telemetry'],
statusLabel: 'Active',
statusClassName: 'bg-green-100 text-green-800',
agentUpdateCount: 0,
lastActivityText: '1m ago',
enabled: true,
canEdit: false,
canPause: false,
canRemove: true,
isAgent: true,
isCluster: false,
attachedConnections: [],
members: [],
connection: towerAgent,
},
{
id: miniAgent.id,
ownerType: 'agent',
name: 'Mini',
subtitle: 'via Pulse Agent',
source: 'agent',
host: 'Mini',
coverageLabels: ['Host telemetry'],
statusLabel: 'Active',
statusClassName: 'bg-green-100 text-green-800',
agentUpdateCount: 0,
lastActivityText: '1m ago',
enabled: true,
canEdit: false,
canPause: false,
canRemove: true,
isAgent: true,
isCluster: false,
attachedConnections: [],
members: [],
connection: miniAgent,
},
];
renderWorkspace();
await waitFor(() => expect(screen.getByText('Proxmox VE')).toBeInTheDocument());
const pveGroup = screen.getByText('Proxmox VE');
const hostGroup = screen.getByText('Standalone hosts');
expect(pveGroup.compareDocumentPosition(hostGroup) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it('switches the source manager layout from measured container width during live resize', async () => {
installResizeObserverMock();
Object.defineProperty(window, 'innerWidth', {
@@ -510,7 +613,7 @@ describe('InfrastructureWorkspace', () => {
expect(onboardingMetricsTrackers[0]?.recordOpened).toHaveBeenCalledTimes(1);
});
it('opens the platform picker from the Add infrastructure footer button', () => {
it('opens the platform picker from the Add infrastructure action', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('button', { name: /^Add infrastructure$/i }));
@@ -607,18 +710,18 @@ describe('InfrastructureWorkspace', () => {
expect(onboardingMetricsTrackers[0]?.recordOpened).toHaveBeenCalledTimes(1);
});
it('opens the edit dialog directly from an existing source card', async () => {
it('opens the manage dialog directly from an existing source card', async () => {
renderWorkspace({
pveNodes: () => [{ name: 'zeus', host: 'https://10.0.0.1:8006' } as any],
});
await waitFor(() =>
expect(screen.getByRole('button', { name: /^Edit$/i })).toBeInTheDocument(),
expect(screen.getByRole('button', { name: /^Manage$/i })).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole('button', { name: /^Edit$/i }));
fireEvent.click(screen.getByRole('button', { name: /^Manage$/i }));
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(screen.getByText('Edit zeus')).toBeInTheDocument();
expect(screen.getByText('Manage zeus')).toBeInTheDocument();
expect(screen.getByTestId('proxmox-section')).toBeInTheDocument();
});
@@ -679,7 +782,7 @@ describe('InfrastructureWorkspace', () => {
expect(screen.getByText('Unraid 7.1.0')).toBeInTheDocument();
expect(screen.getByText('192.168.0.10')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^Edit$/i }));
fireEvent.click(screen.getByRole('button', { name: /^Manage$/i }));
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(screen.getByText('Pulse Agent version')).toBeInTheDocument();
@@ -745,9 +848,9 @@ describe('InfrastructureWorkspace', () => {
});
await waitFor(() =>
expect(screen.getByRole('button', { name: /^Edit$/i })).toBeInTheDocument(),
expect(screen.getByRole('button', { name: /^Manage$/i })).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole('button', { name: /^Edit$/i }));
fireEvent.click(screen.getByRole('button', { name: /^Manage$/i }));
expect(screen.getByText('Agent update')).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
@@ -853,9 +956,9 @@ describe('InfrastructureWorkspace', () => {
routeState.search = '?add=agent';
renderWorkspace();
expect(screen.queryByRole('button', { name: /Detect from address/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Detect address/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Add Proxmox VE/i })).toBeNull();
expect(screen.queryByRole('button', { name: /^Edit$/i })).toBeNull();
expect(screen.queryByRole('button', { name: /^Manage$/i })).toBeNull();
expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.queryByTestId('install-section')).toBeNull();
expect(screen.getByText('Infrastructure systems')).toBeInTheDocument();
@@ -45,7 +45,9 @@ describe('settings architecture guardrails', () => {
it('keeps infrastructure onboarding route-backed under the shared settings shell', () => {
expect(settingsHeaderMetaSource).toContain("'infrastructure-systems': {");
expect(settingsHeaderMetaSource).toContain('Manage infrastructure sources.');
expect(settingsHeaderMetaSource).toContain(
'Add, discover, and verify the infrastructure Pulse monitors.',
);
expect(settingsHeaderMetaSource).toContain("'organization-access': {");
expect(settingsHeaderMetaSource).toContain(
'Manage organization invitations, member roles, and ownership transfers.',
@@ -163,7 +165,7 @@ describe('settings architecture guardrails', () => {
expect(infrastructureSourceManagerSource).toContain('Run discovery');
expect(infrastructureSourceManagerSource).toContain('Discovery settings');
expect(infrastructureSourceManagerSource).toContain(
'Configured systems and discovered candidates grouped by platform or host type. Install Pulse Agent on each machine where you want full node-local telemetry.',
"Add, discover, and verify the platform APIs plus Pulse Agent telemetry that make up Pulse's infrastructure model.",
);
expect(infrastructureSourceManagerSource).toContain('onReviewDiscoveredSource');
expect(infrastructureSourceManagerSource).toContain('Discovered');
@@ -171,18 +173,16 @@ describe('settings architecture guardrails', () => {
expect(infrastructureSourceManagerSource).toContain('TableHeader');
expect(infrastructureSourceManagerSource).toContain('aria-label={product.actionLabel}');
expect(infrastructureSourceManagerSource).toContain('Review');
expect(infrastructureSourceManagerSource).toContain('Edit');
expect(infrastructureSourceManagerSource).toContain(
'Start by connecting what Pulse should monitor',
);
expect(infrastructureSourceManagerSource).toContain('Detect from address');
expect(infrastructureSourceManagerSource).toContain('Install Pulse Agent');
expect(infrastructureSourceManagerSource).toContain('Manage');
expect(infrastructureSourceManagerSource).toContain('Detect address');
expect(infrastructureSourceManagerSource).toContain('Install agent');
expect(infrastructureSourceManagerSource).toContain('Choose source type');
expect(infrastructureSourceManagerSource).toContain('getInfrastructureEmptyStateSummary');
expect(infrastructureSourceManagerSource).toContain('Infrastructure readiness');
expect(infrastructureSourceManagerSource).toContain('Infrastructure coverage');
expect(infrastructureSourceManagerSource).toContain('Connected systems');
expect(infrastructureSourceManagerSource).toContain('API coverage');
expect(infrastructureSourceManagerSource).toContain('Agent coverage');
expect(infrastructureSourceManagerSource).toContain('Needs agent');
expect(infrastructureSourceManagerSource).toContain('setupConfidenceAction');
expect(infrastructureSourceManagerSource).not.toContain('Connection types');
expect(infrastructureSourcePickerSource).toContain('Detect from address');
@@ -244,7 +244,7 @@ describe('settings architecture guardrails', () => {
'getInfrastructureSupportSummaryBadges',
);
expect(infrastructureOnboardingPresentationSource).toContain(
'VMware vCenter is also available now.',
'Supported source types include VMware vCenter',
);
expect(nodeCredentialSlotSource).toContain('useNodeModalState(modalProps)');
@@ -9,7 +9,7 @@ import {
export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = {
'infrastructure-systems': {
title: 'Infrastructure',
description: 'Manage infrastructure sources.',
description: 'Add, discover, and verify the infrastructure Pulse monitors.',
},
'system-general': {
title: 'General',
@@ -45,11 +45,13 @@ export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = {
},
'support-reporting': {
title: 'Data & Reports',
description: 'Export inventory data and generate performance reports from the canonical settings shell.',
description:
'Export inventory data and generate performance reports from the canonical settings shell.',
},
'support-logs': {
title: 'System Logs',
description: 'Inspect the live Pulse log stream and download the captured buffer for support work.',
description:
'Inspect the live Pulse log stream and download the captured buffer for support work.',
},
'organization-overview': {
title: 'Organization Overview',
@@ -64,11 +64,11 @@ describe('discoveryPresentation', () => {
it('returns canonical network discovery settings copy', () => {
expect(getNetworkDiscoveryPriorityNotice()).toEqual({
title: 'Configuration precedence',
title: 'Network scan safety',
items: [
'Environment variables still override these settings.',
'Changes made here are saved to system.json immediately.',
'These settings remain in effect until an environment override replaces them.',
'Automatic mode can scan every detected interface, including bridge or shared networks; use custom subnets when scope matters.',
],
});
@@ -64,11 +64,11 @@ export function getDiscoveryNotesEmptyState() {
export function getNetworkDiscoveryPriorityNotice() {
return {
title: 'Configuration precedence',
title: 'Network scan safety',
items: [
'Environment variables still override these settings.',
'Changes made here are saved to system.json immediately.',
'These settings remain in effect until an environment override replaces them.',
'Automatic mode can scan every detected interface, including bridge or shared networks; use custom subnets when scope matters.',
],
} as const;
}
@@ -110,9 +110,7 @@ export function getNetworkDiscoverySubnetPresentation(mode: 'auto' | 'custom') {
helpTooltip:
'Use CIDR notation, for example 192.168.1.0/24 or 10.0.0.0/24. Smaller ranges finish more quickly.',
placeholder:
mode === 'auto'
? 'automatic (scan every detected network)'
: '192.168.1.0/24, 10.0.0.0/24',
mode === 'auto' ? 'automatic (scan every detected network)' : '192.168.1.0/24, 10.0.0.0/24',
guidance:
mode === 'auto'
? 'Automatic mode scans all host network interfaces, which can include shared or corporate networks. Switch to custom subnets for a faster, more targeted scan.'