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', +};