phase 4: ConnectionEditor shell + probe step

Adds the unified connection flow's frontend skeleton. ConnectionEditor
owns a probe-first UX: paste address → POST /api/connections/probe
fans out fingerprints → detected type dispatches into a credential slot
via renderCredentialSlot. Manual type selection falls through when the
probe returns no match. In edit mode the probe step is skipped and the
slot renders immediately for the caller-supplied type.

- useConnectionEditor: probe state machine (idle/probing/detected/
  no-match/error) + CONNECTION_TYPE_LABELS shared with the ledger.
- AddressProbeStep: address input, probe button, candidate list with
  hints, error/empty states, manual-fallback escape hatch.
- ConnectionEditor: composes probe step with credential-slot dispatch;
  maintains selected type + selected candidate; back-to-probe action.
- ConnectionEditor.test.tsx: probe → dispatch, no-match → manual pick,
  initialType skips probe (edit mode). Three tests passing.

Nothing is wired into InfrastructureWorkspace yet — that happens in the
add-path replacement phase, where the shell also gains scope UI and
per-type credential slots.
This commit is contained in:
rcourtman
2026-04-19 12:58:29 +01:00
parent 6c6221b4af
commit 303988e482
4 changed files with 487 additions and 0 deletions
@@ -0,0 +1,114 @@
import { Component, For, Show } from 'solid-js';
import type { ProbeCandidate } from '@/api/connections';
import { formControl, formField, formHelpText, formLabel } from '@/components/shared/Form';
import type { ConnectionEditorState } from './useConnectionEditor';
import { CONNECTION_TYPE_LABELS } from './useConnectionEditor';
export interface AddressProbeStepProps {
state: ConnectionEditorState;
onSelectCandidate: (candidate: ProbeCandidate) => void;
onChooseManually: () => void;
}
export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
const handleSubmit = (event: SubmitEvent) => {
event.preventDefault();
void props.state.runProbe();
};
return (
<form class="space-y-4" onSubmit={handleSubmit}>
<div class={formField}>
<label class={formLabel} for="connection-address">
Address
</label>
<input
id="connection-address"
type="text"
class={formControl}
placeholder="pve01.lan, 10.0.0.4:8006, https://pbs.lab:8007"
value={props.state.address()}
onInput={(event) => props.state.setAddress(event.currentTarget.value)}
autocomplete="off"
spellcheck={false}
disabled={props.state.phase() === 'probing'}
/>
<p class={formHelpText}>
Paste a hostname, IP, or URL. Pulse detects the product and asks for credentials next.
</p>
</div>
<div class="flex items-center gap-2">
<button
type="submit"
class="inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:opacity-60"
disabled={props.state.phase() === 'probing' || props.state.address().trim().length === 0}
>
{props.state.phase() === 'probing' ? 'Probing…' : 'Probe address'}
</button>
<button
type="button"
class="inline-flex items-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
onClick={props.onChooseManually}
>
Enter credentials manually
</button>
</div>
<Show when={props.state.phase() === 'error' && props.state.errorMessage().length > 0}>
<div class="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200">
{props.state.errorMessage()}
</div>
</Show>
<Show when={props.state.phase() === 'no-match'}>
<div class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
<div class="font-medium">No supported product detected at that address.</div>
<div class="mt-1 text-xs">
Pick a type manually and enter credentials — Pulse will still verify on save.
</div>
</div>
</Show>
<Show when={props.state.phase() === 'detected' && props.state.candidates().length > 0}>
<div class="space-y-2">
<div class="flex items-baseline justify-between">
<div class="text-sm font-semibold text-base-content">Detected</div>
<Show when={props.state.probedMs() > 0}>
<div class="text-xs text-muted">Probed in {props.state.probedMs()} ms</div>
</Show>
</div>
<ul class="divide-y divide-border rounded-md border border-border">
<For each={props.state.candidates()}>
{(candidate) => (
<li>
<button
type="button"
class="flex w-full flex-col items-start gap-1 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover"
onClick={() => props.onSelectCandidate(candidate)}
>
<div class="text-sm font-medium text-base-content">
{CONNECTION_TYPE_LABELS[candidate.type] ?? candidate.type}
</div>
<div class="text-xs text-muted">{candidate.host}</div>
<Show when={candidate.hints && Object.keys(candidate.hints).length > 0}>
<div class="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted">
<For each={Object.entries(candidate.hints ?? {})}>
{([key, value]) => (
<span>
<span class="font-medium">{key}:</span> {value}
</span>
)}
</For>
</div>
</Show>
</button>
</li>
)}
</For>
</ul>
</div>
</Show>
</form>
);
};
@@ -0,0 +1,150 @@
import { Component, type JSX, Show, createMemo, createSignal } from 'solid-js';
import type { ConnectionType, ProbeCandidate } from '@/api/connections';
import { AddressProbeStep } from './AddressProbeStep';
import {
CONNECTION_TYPE_LABELS,
createConnectionEditorState,
type ConnectionEditorState,
} from './useConnectionEditor';
export type ConnectionEditorMode = 'add' | 'edit';
export interface ConnectionEditorSlotContext {
mode: ConnectionEditorMode;
type: ConnectionType;
candidate: ProbeCandidate | null;
onCancel: () => void;
onSaved: () => void;
}
export type CredentialSlotRenderer = (context: ConnectionEditorSlotContext) => JSX.Element;
export interface ConnectionEditorProps {
mode?: ConnectionEditorMode;
initialType?: ConnectionType;
initialAddress?: string;
renderCredentialSlot: CredentialSlotRenderer;
manualTypeOptions?: ConnectionType[];
onClose: () => void;
onSaved?: () => void;
}
const DEFAULT_MANUAL_TYPES: ConnectionType[] = ['pve', 'pbs', 'pmg', 'truenas', 'vmware', 'agent'];
export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const state: ConnectionEditorState = createConnectionEditorState();
if (props.initialAddress) {
state.setAddress(props.initialAddress);
}
const [selectedType, setSelectedType] = createSignal<ConnectionType | null>(
props.initialType ?? null,
);
const [selectedCandidate, setSelectedCandidate] = createSignal<ProbeCandidate | null>(null);
const [manualPickerOpen, setManualPickerOpen] = createSignal(false);
const manualOptions = createMemo(() => props.manualTypeOptions ?? DEFAULT_MANUAL_TYPES);
const activeType = () => selectedType();
const showCredentialSlot = () => activeType() !== null;
const chooseCandidate = (candidate: ProbeCandidate) => {
setSelectedCandidate(candidate);
setSelectedType(candidate.type);
setManualPickerOpen(false);
};
const chooseManualType = (type: ConnectionType) => {
setSelectedCandidate(null);
setSelectedType(type);
setManualPickerOpen(false);
};
const reopenProbe = () => {
setSelectedCandidate(null);
setSelectedType(null);
setManualPickerOpen(false);
};
const handleSaved = () => {
props.onSaved?.();
props.onClose();
};
return (
<div class="flex h-full flex-col">
<Show
when={showCredentialSlot()}
fallback={
<div class="space-y-4 p-4">
<div>
<div class="text-sm font-semibold text-base-content">Add a connection</div>
<div class="mt-0.5 text-xs text-muted">
Paste an address and Pulse detects the product. One flow for every supported
platform.
</div>
</div>
<AddressProbeStep
state={state}
onSelectCandidate={chooseCandidate}
onChooseManually={() => setManualPickerOpen((v) => !v)}
/>
<Show when={manualPickerOpen()}>
<div class="space-y-2 rounded-md border border-border bg-surface p-3">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">
Choose type manually
</div>
<ul class="divide-y divide-border rounded-md border border-border">
{manualOptions().map((type) => (
<li>
<button
type="button"
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-base-content transition-colors hover:bg-surface-hover"
onClick={() => chooseManualType(type)}
>
<span>{CONNECTION_TYPE_LABELS[type] ?? type}</span>
<span class="text-xs text-muted">{type}</span>
</button>
</li>
))}
</ul>
</div>
</Show>
</div>
}
>
<div class="flex items-center justify-between border-b border-border px-4 py-2">
<div class="text-sm">
<span class="font-semibold text-base-content">
{CONNECTION_TYPE_LABELS[activeType()!] ?? activeType()}
</span>
<Show when={selectedCandidate()}>
<span class="ml-2 text-xs text-muted">{selectedCandidate()!.host}</span>
</Show>
</div>
<Show when={(props.mode ?? 'add') === 'add'}>
<button
type="button"
onClick={reopenProbe}
class="inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover"
>
← Back to probe
</button>
</Show>
</div>
<div class="flex-1 overflow-y-auto p-4">
{props.renderCredentialSlot({
mode: props.mode ?? 'add',
type: activeType()!,
candidate: selectedCandidate(),
onCancel: props.onClose,
onSaved: handleSaved,
})}
</div>
</Show>
</div>
);
};
@@ -0,0 +1,115 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, fireEvent, screen, waitFor } from '@solidjs/testing-library';
import { ConnectionEditor } from '../ConnectionEditor';
import { ConnectionsAPI, type ProbeResponse } from '@/api/connections';
vi.mock('@/api/connections', async () => {
const actual = await vi.importActual<typeof import('@/api/connections')>('@/api/connections');
return {
...actual,
ConnectionsAPI: {
list: vi.fn(),
probe: vi.fn(),
},
};
});
const mockedProbe = vi.mocked(ConnectionsAPI.probe);
describe('ConnectionEditor', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('runs a probe and dispatches the detected type into the credential slot', async () => {
const response: ProbeResponse = {
candidates: [
{ type: 'pve', host: 'https://pve.lab:8006', port: 8006, hints: { product: 'Proxmox VE' } },
],
probedMs: 418,
};
mockedProbe.mockResolvedValueOnce(response);
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: 'pve.lab' } });
const probeButton = screen.getByRole('button', { name: /probe address/i });
fireEvent.click(probeButton);
await waitFor(() => expect(mockedProbe).toHaveBeenCalledWith('pve.lab'));
const candidateLabel = await screen.findAllByText('Proxmox VE');
const candidateButton = candidateLabel[0].closest('button');
expect(candidateButton).not.toBeNull();
fireEvent.click(candidateButton!);
await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:pve'));
expect(renderSlot).toHaveBeenCalled();
const lastCall = renderSlot.mock.calls.at(-1)![0];
expect(lastCall.type).toBe('pve');
expect(lastCall.candidate?.host).toBe('https://pve.lab:8006');
expect(lastCall.mode).toBe('add');
});
it('falls back to manual type selection when probe returns no match', 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: /enter credentials manually/i }));
fireEvent.click(screen.getByText('TrueNAS SCALE'));
await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:truenas'));
const lastCall = renderSlot.mock.calls.at(-1)![0];
expect(lastCall.type).toBe('truenas');
expect(lastCall.candidate).toBeNull();
});
it('skips the probe step when an initialType is supplied (edit mode)', () => {
const renderSlot = vi.fn(({ type }) => <div data-testid="slot">slot:{type}</div>);
render(() => (
<ConnectionEditor
mode="edit"
initialType="vmware"
renderCredentialSlot={renderSlot}
onClose={() => {}}
/>
));
expect(screen.getByTestId('slot').textContent).toBe('slot:vmware');
const call = renderSlot.mock.calls.at(0)![0];
expect(call.mode).toBe('edit');
expect(call.type).toBe('vmware');
expect(call.candidate).toBeNull();
});
});
@@ -0,0 +1,108 @@
import { createSignal } from 'solid-js';
import {
ConnectionsAPI,
type ConnectionType,
type ProbeCandidate,
type ProbeResponse,
} from '@/api/connections';
const PROBE_ERROR_FALLBACK = 'Probe failed. Try again or enter credentials manually.';
function describeProbeError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === 'string' && error.trim().length > 0) {
return error.trim();
}
return PROBE_ERROR_FALLBACK;
}
export type ProbePhase = 'idle' | 'probing' | 'detected' | 'no-match' | 'error';
export interface ConnectionEditorState {
address: () => string;
setAddress: (value: string) => void;
phase: () => ProbePhase;
candidates: () => ProbeCandidate[];
probedMs: () => number;
errorMessage: () => string;
reset: () => void;
runProbe: () => Promise<void>;
}
// Validation on the client side is intentionally lenient: the backend is the
// real authority on what constitutes a probeable address. We only reject the
// obviously empty case so the API does not see a payload it will always
// refuse.
function isSubmittableAddress(address: string): boolean {
return address.trim().length > 0;
}
export function createConnectionEditorState(): ConnectionEditorState {
const [address, setAddress] = createSignal('');
const [phase, setPhase] = createSignal<ProbePhase>('idle');
const [candidates, setCandidates] = createSignal<ProbeCandidate[]>([]);
const [probedMs, setProbedMs] = createSignal(0);
const [errorMessage, setErrorMessage] = createSignal('');
const reset = () => {
setAddress('');
setPhase('idle');
setCandidates([]);
setProbedMs(0);
setErrorMessage('');
};
const runProbe = async () => {
const value = address().trim();
if (!isSubmittableAddress(value)) {
setErrorMessage('Enter an address to probe.');
setPhase('error');
return;
}
setPhase('probing');
setErrorMessage('');
setCandidates([]);
setProbedMs(0);
let response: ProbeResponse;
try {
response = await ConnectionsAPI.probe(value);
} catch (error: unknown) {
setErrorMessage(describeProbeError(error));
setPhase('error');
return;
}
setProbedMs(response.probedMs);
setCandidates(response.candidates);
setPhase(response.candidates.length > 0 ? 'detected' : 'no-match');
};
return {
address,
setAddress,
phase,
candidates,
probedMs,
errorMessage,
reset,
runProbe,
};
}
// CONNECTION_TYPE_LABELS drives both the detected-candidate header copy and
// the manual fallback menu. Keeping one table avoids drift between probe
// results and the manual route labels.
export const CONNECTION_TYPE_LABELS: Record<ConnectionType, string> = {
pve: 'Proxmox VE',
pbs: 'Proxmox Backup Server',
pmg: 'Proxmox Mail Gateway',
vmware: 'VMware vCenter / ESXi',
truenas: 'TrueNAS SCALE',
agent: 'Agent (install on host)',
docker: 'Docker',
kubernetes: 'Kubernetes',
};