From 303988e4827ff970325dac826bea12bb2ff6544b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 19 Apr 2026 12:58:29 +0100 Subject: [PATCH] phase 4: ConnectionEditor shell + probe step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ConnectionEditor/AddressProbeStep.tsx | 114 +++++++++++++ .../ConnectionEditor/ConnectionEditor.tsx | 150 ++++++++++++++++++ .../__tests__/ConnectionEditor.test.tsx | 115 ++++++++++++++ .../ConnectionEditor/useConnectionEditor.ts | 108 +++++++++++++ 4 files changed, 487 insertions(+) create mode 100644 frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx create mode 100644 frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx create mode 100644 frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx create mode 100644 frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx new file mode 100644 index 000000000..cce4143db --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx @@ -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 = (props) => { + const handleSubmit = (event: SubmitEvent) => { + event.preventDefault(); + void props.state.runProbe(); + }; + + return ( +
+
+ + props.state.setAddress(event.currentTarget.value)} + autocomplete="off" + spellcheck={false} + disabled={props.state.phase() === 'probing'} + /> +

+ Paste a hostname, IP, or URL. Pulse detects the product and asks for credentials next. +

+
+ +
+ + +
+ + 0}> +
+ {props.state.errorMessage()} +
+
+ + +
+
No supported product detected at that address.
+
+ Pick a type manually and enter credentials — Pulse will still verify on save. +
+
+
+ + 0}> +
+
+
Detected
+ 0}> +
Probed in {props.state.probedMs()} ms
+
+
+
    + + {(candidate) => ( +
  • + +
  • + )} +
    +
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx new file mode 100644 index 000000000..0b9ad190e --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx @@ -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 = (props) => { + const state: ConnectionEditorState = createConnectionEditorState(); + if (props.initialAddress) { + state.setAddress(props.initialAddress); + } + + const [selectedType, setSelectedType] = createSignal( + props.initialType ?? null, + ); + const [selectedCandidate, setSelectedCandidate] = createSignal(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 ( +
+ +
+
Add a connection
+
+ Paste an address and Pulse detects the product. One flow for every supported + platform. +
+
+ + setManualPickerOpen((v) => !v)} + /> + + +
+
+ Choose type manually +
+
    + {manualOptions().map((type) => ( +
  • + +
  • + ))} +
+
+
+
+ } + > +
+
+ + {CONNECTION_TYPE_LABELS[activeType()!] ?? activeType()} + + + {selectedCandidate()!.host} + +
+ + + +
+ +
+ {props.renderCredentialSlot({ + mode: props.mode ?? 'add', + type: activeType()!, + candidate: selectedCandidate(), + onCancel: props.onClose, + onSaved: handleSaved, + })} +
+ + + ); +}; diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx new file mode 100644 index 000000000..c15b31f86 --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx @@ -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('@/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 }) =>
slot:{type}
); + + render(() => ( + {}} + /> + )); + + 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 }) =>
slot:{type}
); + + render(() => ( + {}} + /> + )); + + 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 }) =>
slot:{type}
); + + render(() => ( + {}} + /> + )); + + 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(); + }); +}); diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts b/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts new file mode 100644 index 000000000..e46ec4230 --- /dev/null +++ b/frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts @@ -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; +} + +// 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('idle'); + const [candidates, setCandidates] = createSignal([]); + 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 = { + 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', +};