diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e3297de07..6f9f87376 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -186,7 +186,11 @@ Unraid, TrueNAS, Proxmox, Docker, Kubernetes, or standalone hosts, and the helper maps those choices into API-backed, agent-backed, or availability-probe routes only after selection. The first picker surface must support aliases for common operator terms and must not make API/agent/probe taxonomy the primary -choice model. +choice model. Agent-backed typed routes must keep that context in the +installer: Unraid, Docker, Kubernetes, and generic host choices may share the +same unified agent installer, but their visible title, recommendation copy, +preferred install profile, and first command section must match the selected +system instead of falling back to an all-platform generic agent screen. Agent runtime normalization must use that same governed host-profile manifest: profile identity tokens and runtime platform fallback values such as `unraid` -> `linux` are generated into the runtime resolver instead of being diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 1a31cca36..cefa04625 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -548,7 +548,10 @@ prompt explain the same operator-facing priority. The picker must keep that first choice in recognizable system/service vocabulary, while the typed add dialog may use the shared source-strategy vocabulary after selection to explain API inventory, Agent telemetry, or - API + Agent coverage. + API + Agent coverage. Agent-backed typed add routes keep the same governed + dialog shell, but their embedded installer surface should stay focused on + the selected system so the first visible command path does not re-expand + into irrelevant platform choices. Credential slots are dispatched by the detected or manually-selected type and must still reach the canonical form body rather than diverging into a revived provider-specific workspace. diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx index efa6a6a04..a12cdd98b 100644 --- a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx @@ -1,5 +1,5 @@ import type { Component } from 'solid-js'; -import { For, Show, createSignal } from 'solid-js'; +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'; import { FormSelect } from '@/components/shared/FormSelect'; import SettingsPanel from '@/components/shared/SettingsPanel'; import { copyToClipboard } from '@/utils/clipboard'; @@ -12,20 +12,145 @@ import { } from '@/utils/unifiedAgentInventoryPresentation'; import { INSTALL_PROFILE_OPTIONS, + type AgentPlatform, + type InfrastructureCommandSection, type InstallProfile, } from './infrastructureOperationsModel'; import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState'; -export const InfrastructureInstallerSection: Component = () => { +export type InfrastructureInstallerFocus = + | 'agent' + | 'linux-host' + | 'unraid' + | 'docker' + | 'kubernetes'; + +interface InfrastructureInstallerSectionProps { + focus?: InfrastructureInstallerFocus; +} + +type InfrastructureCommandSectionWithPlatform = InfrastructureCommandSection & { + platform: AgentPlatform; +}; + +type InfrastructureInstallerFocusPresentation = { + title: string; + description: string; + recommendationTitle: string; + recommendationDetail: string; + preferredProfile: InstallProfile; + platforms: readonly AgentPlatform[]; +}; + +const ALL_AGENT_PLATFORMS: readonly AgentPlatform[] = ['linux', 'macos', 'freebsd', 'windows']; + +const INSTALLER_FOCUS_PRESENTATION: Record< + InfrastructureInstallerFocus, + InfrastructureInstallerFocusPresentation +> = { + agent: { + title: 'Install on a host', + description: + 'Start here to add the first system you want Pulse to monitor, then expand into Docker, Kubernetes, Proxmox, and related infrastructure.', + recommendationTitle: 'Recommended install model', + recommendationDetail: + '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.', + preferredProfile: 'auto', + platforms: ALL_AGENT_PLATFORMS, + }, + 'linux-host': { + title: 'Install on a host', + description: + 'Choose the command for the operating system on the machine you want Pulse to monitor.', + recommendationTitle: 'Host install path', + recommendationDetail: + 'Install Pulse Agent on the machine itself. Pulse will collect host telemetry, services, SMART disk health, sensors, and network metrics from that host after it checks in.', + preferredProfile: 'auto', + platforms: ALL_AGENT_PLATFORMS, + }, + unraid: { + title: 'Install on Unraid', + description: + 'Run the Linux installer from the Unraid terminal or SSH session on the server you want Pulse to monitor.', + recommendationTitle: 'Unraid install path', + recommendationDetail: + 'Install Pulse Agent directly on the Unraid server. Pulse will classify the reporting host as Unraid from the agent host profile and collect array health, disks, SMART, services, Docker containers, and host telemetry.', + preferredProfile: 'auto', + platforms: ['linux'], + }, + docker: { + title: 'Install on the Docker host', + description: + 'Run the installer on the machine that runs Docker or Podman so Pulse can discover containers from that host.', + recommendationTitle: 'Docker install path', + recommendationDetail: + 'Install Pulse Agent on the Docker or Podman host. The Docker profile is selected for this flow so copied commands force container monitoring when automatic detection is restricted.', + preferredProfile: 'docker', + platforms: ['linux'], + }, + kubernetes: { + title: 'Install on a Kubernetes node', + description: + 'Run the installer on a cluster node that should report Kubernetes workload context and host telemetry.', + recommendationTitle: 'Kubernetes install path', + recommendationDetail: + 'Install Pulse Agent on a Kubernetes node. The Kubernetes profile is selected for this flow so copied commands enable workload context while preserving node-local telemetry.', + preferredProfile: 'kubernetes', + platforms: ['linux'], + }, +}; + +const getCommandSectionTitle = ( + section: InfrastructureCommandSectionWithPlatform, + focus: InfrastructureInstallerFocus, +): string => { + if (section.platform !== 'linux') return section.title; + if (focus === 'unraid') return 'Run on Unraid'; + if (focus === 'docker') return 'Run on the Docker host'; + if (focus === 'kubernetes') return 'Run on a Kubernetes node'; + return section.title; +}; + +const getCommandSectionDescription = ( + section: InfrastructureCommandSectionWithPlatform, + focus: InfrastructureInstallerFocus, +): string => { + if (section.platform !== 'linux') return section.description; + if (focus === 'unraid') { + return 'Use the Linux installer from Unraid terminal or SSH. The agent stores its local uninstall helper under the Unraid plugin path.'; + } + if (focus === 'docker') { + return 'Use the Linux installer on the Docker or Podman host. Commands in this flow include the Docker profile.'; + } + if (focus === 'kubernetes') { + return 'Use the Linux installer on a Kubernetes node. Commands in this flow include the Kubernetes profile.'; + } + return section.description; +}; + +export const InfrastructureInstallerSection: Component = ( + props, +) => { const state = useInfrastructureOperationsContext(); const [showAdvancedOptions, setShowAdvancedOptions] = createSignal(false); + const focus = createMemo(() => props.focus ?? 'agent'); + const presentation = createMemo(() => INSTALLER_FOCUS_PRESENTATION[focus()]); + const commandSections = createMemo(() => + state + .commandSections() + .filter((section) => presentation().platforms.includes(section.platform)), + ); + + createEffect(() => { + state.handleInstallProfileChange(presentation().preferredProfile); + }); return ( {
-

Recommended install model

+

{presentation().recommendationTitle}

- 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. + {presentation().recommendationDetail}

@@ -260,12 +382,16 @@ export const InfrastructureInstallerSection: Component = () => {

- + {(section) => (
-
{section.title}
-

{section.description}

+
+ {getCommandSectionTitle(section, focus())} +
+

+ {getCommandSectionDescription(section, focus())} +

@@ -467,12 +593,16 @@ export const InfrastructureInstallerSection: Component = () => {
- + {(section) => (
-
{section.title}
-

{section.description}

+
+ {getCommandSectionTitle(section, focus())} +
+

+ {getCommandSectionDescription(section, focus())} +

diff --git a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx index 602837bfc..f5167de63 100644 --- a/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx @@ -18,7 +18,10 @@ import type { TrueNASConnection } from '@/api/truenas'; import type { VMwareConnection } from '@/api/vmware'; import type { NodeConfig, NodeConfigWithStatus } from '@/types/nodes'; import { InfrastructureDiscoverySettingsDialog } from './InfrastructureDiscoverySettingsDialog'; -import { InfrastructureInstallerSection } from './InfrastructureInstallerSection'; +import { + InfrastructureInstallerSection, + type InfrastructureInstallerFocus, +} from './InfrastructureInstallerSection'; import { InfrastructureSourceManager } from './InfrastructureSourceManager'; import { InfrastructureSourcePicker } from './InfrastructureSourcePicker'; import { @@ -71,6 +74,16 @@ const ADD_STEP_TO_TYPE: Record = { vmware: 'vmware', }; +const ADD_STEP_TO_INSTALLER_FOCUS: Partial< + Record +> = { + agent: 'agent', + 'linux-host': 'linux-host', + unraid: 'unraid', + docker: 'docker', + kubernetes: 'kubernetes', +}; + const closeButtonClass = 'inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover'; const buttonClass = @@ -126,6 +139,11 @@ const InfrastructureWorkspaceContent: Component = const activeCatalogItem = createMemo(() => getInfrastructureSourcePickerItemForRouteStep(routeStep()), ); + const activeInstallerFocus = createMemo(() => { + const step = routeStep(); + if (!step || step === 'pick' || step === 'detect') return 'agent'; + return ADD_STEP_TO_INSTALLER_FOCUS[step as ManagedAddTypeStep] ?? 'agent'; + }); const editingConnection = createMemo(() => editingRow()?.connection ?? null); const attachedAgentConnections = createMemo(() => (editingRow()?.attachedConnections ?? []).filter((connection) => connection.type === 'agent'), @@ -337,7 +355,7 @@ const InfrastructureWorkspaceContent: Component =
- +
); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index e5e9803bb..464d3aebd 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -222,14 +222,23 @@ describe('infrastructure operations model', () => { it('keeps the embedded installer section on the canonical host-install framing', () => { expect(infrastructureInstallerSectionSource).toContain( - "title={state.isEmbedded() ? 'Install on a host' : 'Infrastructure'}", + "title={state.isEmbedded() ? presentation().title : 'Infrastructure'}", + ); + expect(infrastructureInstallerSectionSource).toContain('Install on Unraid'); + expect(infrastructureInstallerSectionSource).toContain('Run on Unraid'); + expect(infrastructureInstallerSectionSource).toContain('Install on the Docker host'); + expect(infrastructureInstallerSectionSource).toContain('Install on a Kubernetes node'); + expect(infrastructureInstallerSectionSource).toContain( + 'state.handleInstallProfileChange(presentation().preferredProfile)', ); expect(infrastructureInstallerSectionSource).toContain('Generate install token'); expect(infrastructureInstallerSectionSource).toContain('Generate token'); 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( + 'Pulse Agent is a low-overhead background service.', + ); expect(infrastructureInstallerSectionSource).toContain( 'For Proxmox clusters, keep the cluster API', ); @@ -285,16 +294,16 @@ describe('infrastructure operations model', () => { }); it('does not reintroduce the retired reporting state hook on the operations state', async () => { - const operationsStateSource = await import( - '../useInfrastructureOperationsState?raw' - ).then((mod) => (mod as { default: string }).default); + const operationsStateSource = await import('../useInfrastructureOperationsState?raw').then( + (mod) => (mod as { default: string }).default, + ); expect(operationsStateSource).not.toContain('useInfrastructureReportingState'); }); it('keeps discovered-node filtering anchored to canonical represented-host dedupe', async () => { - const discoveryStateSource = await import( - '../useInfrastructureDiscoveryRuntimeState?raw' - ).then((mod) => (mod as { default: string }).default); + const discoveryStateSource = await import('../useInfrastructureDiscoveryRuntimeState?raw').then( + (mod) => (mod as { default: string }).default, + ); expect(discoveryStateSource).toContain('filterRepresentedDiscoveredServers'); expect(discoveryStateSource).toContain('nodes()'); }); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 30b21423c..4613ef9c2 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -133,7 +133,11 @@ vi.mock('../useConnectionsLedger', () => ({ })); vi.mock('../InfrastructureInstallerSection', () => ({ - InfrastructureInstallerSection: () =>
install
, + InfrastructureInstallerSection: (props: { focus?: string }) => ( +
+ install +
+ ), })); vi.mock('../ConnectionEditor/CredentialSlots/NodeCredentialSlot', () => ({ @@ -617,9 +621,7 @@ describe('InfrastructureWorkspace', () => { const dialog = screen.getByRole('dialog'); expect(screen.getAllByText('Add infrastructure').length).toBeGreaterThan(0); expect( - screen.getByText( - 'Choose the system, platform, host, or service you want Pulse to monitor.', - ), + screen.getByText('Choose the system, platform, host, or service you want Pulse to monitor.'), ).toBeInTheDocument(); expect( within(dialog).getByPlaceholderText('Search Unraid, TrueNAS, Proxmox, Docker...'), @@ -686,6 +688,16 @@ describe('InfrastructureWorkspace', () => { await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); expect(screen.getAllByText('Add Pulse Agent').length).toBeGreaterThan(0); expect(screen.getByTestId('install-section')).toBeInTheDocument(); + expect(screen.getByTestId('install-section')).toHaveAttribute('data-focus', 'agent'); + }); + + it('tailors agent-backed catalog routes to the selected system', async () => { + routeState.search = '?add=unraid'; + renderWorkspace(); + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + expect(screen.getAllByText('Add Unraid').length).toBeGreaterThan(0); + expect(screen.getByTestId('install-section')).toHaveAttribute('data-focus', 'unraid'); }); it('renders a direct type route before the add dialog mounts', async () => {