infra: align agent-led add landing with Proxmox auto-detect

This commit is contained in:
rcourtman 2026-04-20 22:48:34 +01:00
parent d8d94c48e1
commit 4bd023fe1c
11 changed files with 169 additions and 117 deletions

View file

@ -1006,6 +1006,17 @@ presenting `Platform connections` as the explicit API-backed alternative
instead of leaving first-session install guidance implicit in generic
settings-shell prose or retreating to one provider's name as the primary
alternative.
That same first-run infrastructure handoff now also owns the inline
add-infrastructure landing in `ConnectionEditor.tsx`: when the target can run
the Unified Agent, the landing must lead with the recommended host-install
card above the platform-API probe and picker instead of teaching probe-first
as the default story, and returning `Back to catalog` must reset probe input
and result state rather than reopening the landing with stale no-match or
detected-product state already rendered.
Render-order proof for that landing belongs to DOM-backed settings tests, not
raw source-string position checks, so lifecycle ownership continues to guard
the operator-visible order after reasonable component extraction or copy
refactors.
When that infrastructure workspace needs to redirect operators to the Pulse Pro
surface for billing, monitored-system limits, or license status, it must
consume the settings-owned referral copy from

View file

@ -1420,6 +1420,14 @@ API-backed lifecycle state. The retired
`InfrastructureOperationsController.tsx` shell and
`useInfrastructureReportingState.tsx` reporting path must not be reintroduced
as parallel transport owners.
That same shared infrastructure-settings boundary also owns install-profile
semantics surfaced by
`frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`:
the recommended auto profile may describe Proxmox auto-detect only as the
canonical unpinned runtime mode that lets the agent register every detected
local PVE or PBS service. Frontend copy on that shared model must not imply a
hidden single-type selection or invent a profile flag that the installer and
auto-register contract do not actually persist.
That shared `frontend-modern/src/api/agentProfiles.ts` boundary must also stay
under explicit proof routing on both sides instead of remaining a generic
frontend-client match on the API-contract side: assignment, delete, unassign,

View file

@ -759,6 +759,12 @@ and it must fail closed unless the response comes back on the canonical
installer response handling must also use the returned canonical
`nodeId`/`nodeName` identity instead of continuing to report the caller's local
hostname after Pulse stores a disambiguated node record.
When Proxmox is only auto-detected rather than explicitly profile-pinned, that
same installer-owned boundary must enable Proxmox without persisting a forced
`--proxmox-type` service argument. Auto mode must stay unpinned so the runtime
can detect and register every supported local Proxmox service it finds; only
an operator-selected install profile may lock the persisted runtime to one
specific `pve` or `pbs` type.
That same installer-owned bootstrap step against `/api/setup-script-url` must
also validate the returned canonical `type`, normalized `host`, and live
`expires` metadata before using the one-time setup token, so install-time

View file

@ -78,6 +78,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
};
const reopenProbe = () => {
state.reset();
setSelectedCandidate(null);
setSelectedType(null);
};
@ -116,15 +117,18 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
</div>
</div>
<div class="text-xs text-muted">
<span class="font-medium text-base-content">On a Proxmox host, this is the
fastest path.</span> The agent creates the PVE / PBS API token itself and
auto-registers the node — no address or credentials to paste.
<span class="font-medium text-base-content">
On a Proxmox host, this is the fastest path.
</span>{' '}
The installer auto-detects Proxmox on the machine, creates the needed API
token(s), and auto-registers any detected PVE / PBS services — no address or
credentials to paste.
</div>
<div class="text-xs text-muted">
It also reports CPU temperature, disk SMART, systemd services, and network
metrics from the host, and auto-detects Docker and Kubernetes on that
machine. Required for bare-metal Linux / Unraid / FreeBSD targets that have
no platform API to connect.
metrics from the host, and auto-detects Docker and Kubernetes on that machine.
Required for bare-metal Linux / Unraid / FreeBSD targets that have no platform
API to connect.
</div>
</div>
<div
@ -143,8 +147,8 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
<span class="h-px flex-1 bg-border" aria-hidden="true" />
</div>
<p class="text-xs text-muted">
For VMware, TrueNAS, PMG, or a remote Proxmox you can't install the agent on.
Paste the address and Pulse will auto-detect the product, or pick it below.
For VMware, TrueNAS, PMG, or a remote Proxmox you can't install the agent on. Paste
the address and Pulse will auto-detect the product, or pick it below.
</p>
<AddressProbeStep
@ -191,7 +195,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
<span class="font-semibold text-base-content">
{activeType() === 'agent'
? 'Install Pulse Agent'
: CONNECTION_TYPE_LABELS[activeType()!] ?? activeType()}
: (CONNECTION_TYPE_LABELS[activeType()!] ?? activeType())}
</span>
<Show when={selectedCandidate()}>
<span class="ml-2 text-xs text-muted">{selectedCandidate()!.host}</span>

View file

@ -16,6 +16,10 @@ vi.mock('@/api/connections', async () => {
const mockedProbe = vi.mocked(ConnectionsAPI.probe);
function expectNodeBefore(a: Node, b: Node) {
expect(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
describe('ConnectionEditor', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -32,16 +36,9 @@ describe('ConnectionEditor', () => {
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
render(() => (
<ConnectionEditor
renderCredentialSlot={renderSlot}
onClose={() => {}}
/>
));
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
const input = screen.getByPlaceholderText(
/pve01\.lan/,
) as HTMLInputElement;
const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
fireEvent.input(input, { target: { value: 'pve.lab' } });
const probeButton = screen.getByRole('button', { name: /probe address/i });
@ -67,16 +64,9 @@ describe('ConnectionEditor', () => {
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
render(() => (
<ConnectionEditor
renderCredentialSlot={renderSlot}
onClose={() => {}}
/>
));
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
const input = screen.getByPlaceholderText(
/pve01\.lan/,
) as HTMLInputElement;
const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
fireEvent.input(input, { target: { value: '192.168.1.50' } });
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
@ -94,23 +84,23 @@ describe('ConnectionEditor', () => {
expect(lastCall.candidate).toBeNull();
});
it('surfaces the probe input and the full product catalog as peers', () => {
render(() => (
<ConnectionEditor
renderCredentialSlot={() => <div />}
onClose={() => {}}
/>
));
it('renders an agent-led catalog with the API fallback beneath it', () => {
render(() => <ConnectionEditor renderCredentialSlot={() => <div />} onClose={() => {}} />);
// Catalog landing — the probe input sits above a tile grid that includes
// every supported product as a peer, Install Pulse Agent included. There
// is no intermediate mode picker or "Platform API" framing.
expect(screen.getByRole('button', { name: /probe address/i })).toBeInTheDocument();
const agentButton = screen.getByRole('button', { name: /Install Pulse Agent/i });
const apiHeading = screen.getByText('Or connect a platform API directly');
const probeButton = screen.getByRole('button', { name: /probe address/i });
// Catalog landing — lead with the agent card, then collapse the probe and
// direct platform options into one API fallback section below it.
expect(agentButton).toBeInTheDocument();
expect(apiHeading).toBeInTheDocument();
expect(probeButton).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Proxmox VE/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /TrueNAS SCALE/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.queryByText('Platform API')).toBeNull();
expectNodeBefore(agentButton, apiHeading);
expectNodeBefore(apiHeading, probeButton);
expect(
screen.queryByRole('button', { name: /install the unified agent on a host/i }),
).toBeNull();
@ -121,12 +111,7 @@ describe('ConnectionEditor', () => {
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
render(() => (
<ConnectionEditor
renderCredentialSlot={renderSlot}
onClose={() => {}}
/>
));
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
fireEvent.input(input, { target: { value: 'baremetal.lan' } });
@ -165,4 +150,29 @@ describe('ConnectionEditor', () => {
expect(call.type).toBe('vmware');
expect(call.candidate).toBeNull();
});
it('resets probe state when returning to the catalog from a credential slot', async () => {
mockedProbe.mockResolvedValueOnce({ candidates: [], probedMs: 203 });
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
const input = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
fireEvent.input(input, { target: { value: '192.168.1.50' } });
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
await waitFor(() => expect(mockedProbe).toHaveBeenCalled());
await screen.findByText(/no supported product detected/i);
fireEvent.click(screen.getByRole('button', { name: /TrueNAS SCALE/i }));
await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:truenas'));
fireEvent.click(screen.getByRole('button', { name: /back to catalog/i }));
const resetInput = screen.getByPlaceholderText(/pve01\.lan/) as HTMLInputElement;
expect(resetInput.value).toBe('');
expect(screen.queryByText(/no supported product detected/i)).toBeNull();
expect(screen.queryByTestId('slot')).toBeNull();
});
});

View file

@ -154,6 +154,15 @@ describe('infrastructure operations model', () => {
expect(INSTALL_PROFILE_OPTIONS.map((option) => option.value)).not.toContain('truenas');
});
it('keeps the recommended auto profile aligned with unpinned Proxmox detection', () => {
const autoProfile = INSTALL_PROFILE_OPTIONS.find((option) => option.value === 'auto');
expect(autoProfile).toBeDefined();
expect(autoProfile?.flags).toEqual([]);
expect(autoProfile?.description).toContain('leaves the type unpinned');
expect(autoProfile?.description).toContain('every detected PVE / PBS service');
});
it('keeps the embedded installer section on the canonical host-install framing', () => {
expect(infrastructureInstallerSectionSource).toContain(
"title={state.isEmbedded() ? 'Install on a host' : 'Infrastructure'}",

View file

@ -59,7 +59,8 @@ vi.mock('../useConnectionsLedger', () => ({
isAgent: connection.type === 'agent',
connection,
})),
findById: (id: string) => connectionState.connections.find((connection) => connection.id === id),
findById: (id: string) =>
connectionState.connections.find((connection) => connection.id === id),
reload: vi.fn(),
loading: () => false,
error: () => null,
@ -226,11 +227,11 @@ describe('InfrastructureWorkspace', () => {
fireEvent.click(screen.getByRole('button', { name: /Add infrastructure/i }));
// The catalog landing surfaces the probe input and a tile grid of every
// supported product — including Install Pulse Agent as a peer tile — with
// no intermediate picker screen.
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
// The catalog landing leads with the agent path and keeps the direct API
// probe/catalog visible below it in the same shared editor.
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.getByText('Or connect a platform API directly')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Proxmox VE/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /TrueNAS SCALE/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /VMware vCenter \/ ESXi/i })).toBeInTheDocument();
@ -354,8 +355,8 @@ describe('InfrastructureWorkspace', () => {
await waitFor(() =>
expect(screen.getByRole('button', { name: /Probe address/i })).toBeInTheDocument(),
);
// Catalog landing — probe input + tile grid are visible, no credential
// slot has been entered yet.
// Catalog landing — the shared agent-led entry is visible again, and no
// credential slot has been entered yet.
expect(screen.getByRole('button', { name: /Install Pulse Agent/i })).toBeInTheDocument();
expect(screen.queryByTestId('install-section')).toBeNull();
});

View file

@ -40,7 +40,9 @@ describe('settings architecture guardrails', () => {
expect(settingsNavigationHookSource).toContain(
'navigate(buildInfrastructureOnboardingPath(infrastructureOnboardingStep), {',
);
expect(settingsNavigationHookSource).toContain('navigate(buildInfrastructureWorkspacePath(), {');
expect(settingsNavigationHookSource).toContain(
'navigate(buildInfrastructureWorkspacePath(), {',
);
expect(settingsNavigationHookSource).toContain('resolveCanonicalSettingsPath(path)');
expect(infrastructureWorkspaceModelSource).toContain(
@ -64,8 +66,12 @@ describe('settings architecture guardrails', () => {
expect(infrastructureWorkspaceSource).toContain('NodeCredentialSlot');
expect(infrastructureWorkspaceSource).toContain('TrueNASCredentialSlot');
expect(infrastructureWorkspaceSource).toContain('VMwareCredentialSlot');
expect(infrastructureWorkspaceSource).toContain('navigate(buildInfrastructureWorkspacePath(), { replace: true });');
expect(infrastructureWorkspaceSource).toContain('const [, setSearchParams] = useSearchParams();');
expect(infrastructureWorkspaceSource).toContain(
'navigate(buildInfrastructureWorkspacePath(), { replace: true });',
);
expect(infrastructureWorkspaceSource).toContain(
'const [, setSearchParams] = useSearchParams();',
);
expect(infrastructureWorkspaceSource).toContain(
'setSearchParams({ [INFRASTRUCTURE_ADD_QUERY_PARAM]: null }, { replace: true });',
);
@ -75,8 +81,10 @@ describe('settings architecture guardrails', () => {
expect(infrastructureWorkspaceSource).not.toContain('layout="drawer-right"');
});
it('keeps probe-first connection setup and inline node credentials on the shared editor model', () => {
expect(connectionEditorSource).toContain("import { AddressProbeStep } from './AddressProbeStep';");
it('keeps the agent-led add landing and inline node credentials on the shared editor model', () => {
expect(connectionEditorSource).toContain(
"import { AddressProbeStep } from './AddressProbeStep';",
);
// Platform integrations render as peer tiles; the agent lives in its own
// section below so it can explain what host-level telemetry adds instead
// of being mistaken for one more peer of Proxmox / VMware / TrueNAS.
@ -84,21 +92,13 @@ describe('settings architecture guardrails', () => {
expect(connectionEditorSource).not.toContain("'agent'] =");
expect(connectionEditorSource).toContain('<AddressProbeStep');
expect(connectionEditorSource).toContain('Or connect a platform API directly');
// The agent card is the *primary* path on the Add landing — it sits above
// the probe input AND the platform grid so a user doesn't read the probe
// as the lead action. The probe + platforms collapse into one "connect
// via API instead" fallback block below the agent. Guarding ordering
// here prevents regression to "agent as an alternative to the probe."
const agentPrimaryIdx = connectionEditorSource.indexOf('On a Proxmox host, this is the');
const apiSectionIdx = connectionEditorSource.indexOf('Or connect a platform API directly');
const probeIdx = connectionEditorSource.indexOf('<AddressProbeStep');
expect(agentPrimaryIdx).toBeGreaterThan(-1);
expect(apiSectionIdx).toBeGreaterThan(-1);
expect(probeIdx).toBeGreaterThan(-1);
expect(agentPrimaryIdx).toBeLessThan(apiSectionIdx);
expect(apiSectionIdx).toBeLessThan(probeIdx);
// Keep the landing source aligned with the canonical agent-first story, but
// leave actual DOM ordering to the render test rather than raw source
// string positions.
expect(connectionEditorSource).not.toContain('Or install the agent on the host');
expect(connectionEditorSource).toContain('auto-registers the node');
expect(connectionEditorSource).toContain('On a Proxmox host, this is the');
expect(connectionEditorSource).toContain('auto-registers any detected PVE / PBS services');
expect(connectionEditorSource).not.toContain('auto-registers the node');
expect(connectionEditorSource).toContain('Recommended');
// The agent install path is a first-class ledger-header action, not a
// subtext offramp inside the editor — make sure it doesn't drift back.

View file

@ -1,15 +1,7 @@
import type { JSX } from 'solid-js';
import type {
ConnectedInfrastructureItem,
ConnectedInfrastructureSurface,
} from '@/types/api';
import {
getAgentCapabilityLabel,
type AgentCapability,
} from '@/utils/agentCapabilityPresentation';
import {
type PlatformConnectionsView,
} from './platformConnectionsModel';
import type { ConnectedInfrastructureItem, ConnectedInfrastructureSurface } from '@/types/api';
import { getAgentCapabilityLabel, type AgentCapability } from '@/utils/agentCapabilityPresentation';
import { type PlatformConnectionsView } from './platformConnectionsModel';
import { buildInfrastructureWorkspacePath } from './infrastructureWorkspaceModel';
export const TOKEN_PLACEHOLDER = '<api-token>';
@ -18,12 +10,7 @@ export const UNIFIED_AGENT_TELEMETRY_SURFACE = 'settings_unified_agents';
export type AgentPlatform = 'linux' | 'macos' | 'freebsd' | 'windows';
export type UnifiedAgentStatus = 'active' | 'removed';
export type ScopeCategory = 'default' | 'profile' | 'ai-managed' | 'na';
export type InstallProfile =
| 'auto'
| 'docker'
| 'kubernetes'
| 'proxmox-pve'
| 'proxmox-pbs';
export type InstallProfile = 'auto' | 'docker' | 'kubernetes' | 'proxmox-pve' | 'proxmox-pbs';
export type SetupHandoffState = {
username: string;
@ -309,7 +296,7 @@ export const INSTALL_PROFILE_OPTIONS: InstallProfileOption[] = [
value: 'auto',
label: 'Auto-detect (recommended)',
description:
'Let the installer detect Docker, Kubernetes, Proxmox, and agent capabilities automatically.',
'Let the installer detect Docker, Kubernetes, and Proxmox automatically. On Proxmox hosts, auto mode leaves the type unpinned so the agent can register every detected PVE / PBS service.',
flags: [],
},
{
@ -426,7 +413,9 @@ export const buildCommandsByPlatform = (
},
});
const agentCapabilityFromSurfaceKind = (kind: ConnectedInfrastructureSurface['kind']): AgentCapability => {
const agentCapabilityFromSurfaceKind = (
kind: ConnectedInfrastructureSurface['kind'],
): AgentCapability => {
switch (kind) {
case 'agent':
case 'docker':

View file

@ -26,9 +26,10 @@
#
# Auto-Detection:
# The installer automatically detects Docker, Kubernetes, and Proxmox on the
# target machine and enables monitoring for detected platforms. Use --disable-*
# flags to skip specific platforms, or --enable-* to force enable even if not
# detected.
# target machine and enables monitoring for detected platforms. Proxmox auto
# mode keeps the runtime unpinned so the agent can register every detected
# PVE / PBS service on that host. Use --disable-* flags to skip specific
# platforms, or --enable-* to force enable even if not detected.
set -euo pipefail
@ -774,19 +775,6 @@ detect_proxmox() {
return 1
}
detect_proxmox_type() {
if [[ -d "/etc/proxmox-backup" ]] || command -v proxmox-backup-manager &>/dev/null; then
echo "pbs"
return 0
fi
if [[ -d "/etc/pve" ]] || command -v pveversion &>/dev/null; then
echo "pve"
return 0
fi
echo ""
return 1
}
pulse_url_uses_plain_http() {
local url_lower
url_lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
@ -1106,22 +1094,18 @@ if [[ "$PROXMOX_EXPLICIT" != "true" ]]; then
fi
fi
if [[ "$ENABLE_PROXMOX" == "true" && -z "$PROXMOX_TYPE" ]]; then
auto_type="$(detect_proxmox_type || true)"
if [[ -n "$auto_type" ]]; then
PROXMOX_TYPE="$auto_type"
log_info "Proxmox mode detected: ${PROXMOX_TYPE}"
fi
fi
# Summary of what will be monitored
log_info "Monitoring configuration:"
log_info " Agent metrics: $ENABLE_HOST"
log_info " Docker/Podman: $ENABLE_DOCKER"
log_info " Kubernetes: $ENABLE_KUBERNETES"
log_info " Proxmox: $ENABLE_PROXMOX"
if [[ "$ENABLE_PROXMOX" == "true" && -n "$PROXMOX_TYPE" ]]; then
log_info " Proxmox type: $PROXMOX_TYPE"
if [[ "$ENABLE_PROXMOX" == "true" ]]; then
if [[ -n "$PROXMOX_TYPE" ]]; then
log_info " Proxmox type: $PROXMOX_TYPE"
else
log_info " Proxmox type: auto-detect all installed services"
fi
fi
# --- Uninstall Logic ---

View file

@ -38,6 +38,36 @@ func TestInstallSHAllowsMissingTokenForOptionalAuth(t *testing.T) {
}
}
func TestInstallSHAutoDetectProxmoxKeepsRuntimeTypeUnpinned(t *testing.T) {
content, err := os.ReadFile(repoFile("scripts", "install.sh"))
if err != nil {
t.Fatalf("read install.sh: %v", err)
}
script := string(content)
required := []string{
`if detect_proxmox; then`,
`log_info "Proxmox detected - enabling Proxmox integration"`,
`log_info " Proxmox type: auto-detect all installed services"`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
t.Fatalf("install.sh missing unpinned Proxmox auto-detect contract: %s", needle)
}
}
forbidden := []string{
`detect_proxmox_type() {`,
`auto_type="$(detect_proxmox_type || true)"`,
`PROXMOX_TYPE="$auto_type"`,
}
for _, needle := range forbidden {
if strings.Contains(script, needle) {
t.Fatalf("install.sh preserved stale single-type Proxmox auto-detect contract: %s", needle)
}
}
}
// TestConnectionEnvRecovery verifies the canonical helper logic that parses
// connection.env without using shell source (to prevent injection).
func TestConnectionEnvRecovery(t *testing.T) {