feat(web): add SSH executor settings UI

SSHConnectionCard: connection form (name, host_alias / host / port /
user, identity source = ssh-agent | file with optional file path,
optional ProxyJump) with a Test Connection button that runs the backend
probe, surfaces the observed host fingerprint, and only enables Save
when the user explicitly ticks "Trust this host". Editing the host /
port / user / identity fields resets both the test result and the trust
checkbox. On save, if the executor already has running sessions, a
confirm modal warns that those keep going on the old host and only new
sessions will use the new config.

SSHSessionsCard: 90s-polled table of active sessions for the executor,
mirroring SpritesInstancesCard / DockerContainersCard. Columns: task,
session, user@host, remote agentctl port, local forward port, uptime,
status badge. Backed by GET /api/v1/ssh/executors/:id/sessions.

Also registers the ssh entry in executor-icons.ts (label "SSH",
IconTerminal2) so it surfaces consistently in the rest of the settings
chrome.
This commit is contained in:
José Almeida
2026-05-17 10:17:01 +01:00
parent 4fded99620
commit ce6d4e6e2a
4 changed files with 659 additions and 0 deletions
@@ -0,0 +1,577 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import {
IconCheck,
IconLoader2,
IconShieldLock,
IconTerminal2,
IconTestPipe,
IconX,
} from "@tabler/icons-react";
import { testSSHConnection, listSSHSessions } from "@/lib/api/domains/ssh-api";
import type {
SSHIdentitySource,
SSHSession,
SSHTestRequest,
SSHTestResult,
SSHTestStep,
} from "@/lib/types/http-ssh";
// SSHExecutorConfig is the shape we persist into executor.Config on save.
// The host_fingerprint is set only after a successful Test Connection has
// completed and the user has ticked "Trust this host".
export interface SSHExecutorConfig {
name: string;
host_alias?: string;
host?: string;
port?: number;
user?: string;
identity_source: SSHIdentitySource;
identity_file?: string;
proxy_jump?: string;
host_fingerprint?: string;
}
export interface SSHConnectionCardProps {
initial?: Partial<SSHExecutorConfig>;
// Called when the user clicks Save after a successful test+trust. The
// returned config carries the freshly pinned fingerprint.
onSave: (config: SSHExecutorConfig) => Promise<void> | void;
// Existing running sessions for this executor. Triggers the "this won't
// affect existing sessions" warning on save.
runningSessionCount?: number;
}
interface SSHConnectionState {
form: SSHExecutorConfig;
testing: boolean;
saving: boolean;
result: SSHTestResult | null;
trust: boolean;
error: string | null;
}
const SSH_FORM_DEFAULTS: SSHExecutorConfig = {
name: "",
host_alias: "",
host: "",
port: 22,
user: "",
identity_source: "agent",
identity_file: "",
proxy_jump: "",
host_fingerprint: undefined,
};
function initialState(initial?: Partial<SSHExecutorConfig>): SSHConnectionState {
return {
form: { ...SSH_FORM_DEFAULTS, ...(initial ?? {}) },
testing: false,
saving: false,
result: null,
trust: false,
error: null,
};
}
function useSSHConnection(props: SSHConnectionCardProps) {
const [state, setState] = useState<SSHConnectionState>(() => initialState(props.initial));
const { form, testing, saving, result, trust, error } = state;
const update = useCallback(
<K extends keyof SSHExecutorConfig>(key: K, value: SSHExecutorConfig[K]) => {
setState((prev) => ({
...prev,
form: { ...prev.form, [key]: value },
result: null,
trust: false,
error: null,
}));
},
[],
);
const setTrust = useCallback((v: boolean) => setState((prev) => ({ ...prev, trust: v })), []);
const canTest = useMemo(() => {
if (testing) return false;
if (form.name.trim() === "") return false;
if ((form.host ?? "").trim() === "" && (form.host_alias ?? "").trim() === "") return false;
return true;
}, [form, testing]);
const handleTest = useCallback(async () => {
setState((prev) => ({ ...prev, testing: true, result: null, error: null }));
try {
const req: SSHTestRequest = {
name: form.name,
host_alias: form.host_alias || undefined,
host: form.host || undefined,
port: form.port || undefined,
user: form.user || undefined,
identity_source: form.identity_source,
identity_file: form.identity_file || undefined,
proxy_jump: form.proxy_jump || undefined,
};
const res = await testSSHConnection(req);
setState((prev) => ({ ...prev, result: res, testing: false }));
} catch (e) {
const msg = e instanceof Error ? e.message : "Failed to reach backend";
setState((prev) => ({ ...prev, error: msg, testing: false }));
}
}, [form]);
const canSave = !!result?.success && !!result.fingerprint && trust && !saving;
const confirmRunningSessions = useCallback(
() =>
props.runningSessionCount
? window.confirm(
`This executor has ${props.runningSessionCount} running session(s). ` +
`They will keep running on the current host. Only new sessions started ` +
`after save will use the updated config. Continue?`,
)
: true,
[props.runningSessionCount],
);
const handleSave = useCallback(async () => {
if (!canSave || !result?.fingerprint) return;
if (!confirmRunningSessions()) return;
setState((prev) => ({ ...prev, saving: true }));
try {
await props.onSave({ ...form, host_fingerprint: result.fingerprint });
} finally {
setState((prev) => ({ ...prev, saving: false }));
}
}, [canSave, confirmRunningSessions, form, props, result]);
return {
form,
testing,
saving,
result,
trust,
error,
canTest,
canSave,
update,
setTrust,
handleTest,
handleSave,
};
}
export function SSHConnectionCard(props: SSHConnectionCardProps) {
const c = useSSHConnection(props);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<IconTerminal2 className="h-5 w-5" />
Connection
</CardTitle>
<CardDescription>
Run an agent on any Linux box you can reach over SSH. linux/amd64 only in v1.
</CardDescription>
</div>
<ConnectionBadge fingerprint={c.form.host_fingerprint} />
</div>
</CardHeader>
<CardContent className="space-y-4">
<SSHConnectionForm form={c.form} onChange={c.update} />
{c.form.host_fingerprint && <PinnedFingerprintRow fingerprint={c.form.host_fingerprint} />}
<SSHConnectionActions
testing={c.testing}
saving={c.saving}
canTest={c.canTest}
canSave={c.canSave}
onTest={c.handleTest}
onSave={c.handleSave}
/>
{c.error && <p className="text-sm text-red-600">{c.error}</p>}
{c.result && (
<TestResultDisplay
result={c.result}
trust={c.trust}
onTrustChange={c.setTrust}
currentlyPinned={c.form.host_fingerprint}
/>
)}
</CardContent>
</Card>
);
}
function SSHConnectionForm({
form,
onChange,
}: {
form: SSHExecutorConfig;
onChange: <K extends keyof SSHExecutorConfig>(key: K, value: SSHExecutorConfig[K]) => void;
}) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field id="ssh-name" label="Name">
<Input
id="ssh-name"
value={form.name}
onChange={(e) => onChange("name", e.target.value)}
placeholder="My VPS"
/>
</Field>
<Field
id="ssh-host-alias"
label="Host alias from ~/.ssh/config (optional)"
hint="If set, inherits HostName / Port / User / IdentityFile / ProxyJump from your config."
>
<Input
id="ssh-host-alias"
value={form.host_alias ?? ""}
onChange={(e) => onChange("host_alias", e.target.value)}
placeholder="prod"
/>
</Field>
<Field id="ssh-host" label="Host">
<Input
id="ssh-host"
value={form.host ?? ""}
onChange={(e) => onChange("host", e.target.value)}
placeholder="dev.example.com"
/>
</Field>
<Field id="ssh-port" label="Port">
<Input
id="ssh-port"
type="number"
value={form.port ?? 22}
onChange={(e) => onChange("port", parseInt(e.target.value, 10) || 22)}
placeholder="22"
/>
</Field>
<Field id="ssh-user" label="User">
<Input
id="ssh-user"
value={form.user ?? ""}
onChange={(e) => onChange("user", e.target.value)}
placeholder="ubuntu"
/>
</Field>
<Field id="ssh-identity-source" label="Identity source">
<Select
value={form.identity_source}
onValueChange={(v) => onChange("identity_source", v as SSHIdentitySource)}
>
<SelectTrigger id="ssh-identity-source">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="agent">ssh-agent (SSH_AUTH_SOCK)</SelectItem>
<SelectItem value="file">Identity file (private key path)</SelectItem>
</SelectContent>
</Select>
</Field>
{form.identity_source === "file" && (
<Field
id="ssh-identity-file"
label="Identity file path"
hint="Passphrase-protected keys must be loaded into ssh-agent first."
>
<Input
id="ssh-identity-file"
value={form.identity_file ?? ""}
onChange={(e) => onChange("identity_file", e.target.value)}
placeholder="~/.ssh/id_ed25519"
/>
</Field>
)}
<Field
id="ssh-proxy-jump"
label="ProxyJump (optional)"
hint="Single bastion hop. Chained jumps unsupported in v1."
>
<Input
id="ssh-proxy-jump"
value={form.proxy_jump ?? ""}
onChange={(e) => onChange("proxy_jump", e.target.value)}
placeholder="bastion.example.com"
/>
</Field>
</div>
);
}
function PinnedFingerprintRow({ fingerprint }: { fingerprint: string }) {
return (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs flex items-center gap-2">
<IconShieldLock className="h-4 w-4 shrink-0" />
<span className="text-muted-foreground">
Pinned fingerprint: <code className="font-mono">{fingerprint}</code>
</span>
</div>
);
}
function SSHConnectionActions({
testing,
saving,
canTest,
canSave,
onTest,
onSave,
}: {
testing: boolean;
saving: boolean;
canTest: boolean;
canSave: boolean;
onTest: () => void;
onSave: () => void;
}) {
return (
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={onTest}
disabled={!canTest}
className="cursor-pointer"
>
{testing ? (
<IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<IconTestPipe className="mr-1.5 h-4 w-4" />
)}
Test connection
</Button>
<Button size="sm" onClick={onSave} disabled={!canSave} className="cursor-pointer">
{saving ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
Save
</Button>
</div>
);
}
function ConnectionBadge({ fingerprint }: { fingerprint?: string }) {
if (!fingerprint) return <Badge variant="secondary">Unverified</Badge>;
return (
<Badge variant="default" className="bg-green-600">
Trusted
</Badge>
);
}
function Field({
id,
label,
hint,
children,
}: {
id: string;
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
);
}
function TestResultDisplay({
result,
trust,
onTrustChange,
currentlyPinned,
}: {
result: SSHTestResult;
trust: boolean;
onTrustChange: (v: boolean) => void;
currentlyPinned?: string;
}) {
const fingerprintChanged =
!!currentlyPinned && !!result.fingerprint && currentlyPinned !== result.fingerprint;
return (
<div className="rounded-md border p-3 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
{result.success ? (
<IconCheck className="h-4 w-4 text-green-600" />
) : (
<IconX className="h-4 w-4 text-red-600" />
)}
{result.success ? "Connection test passed" : "Connection test failed"}
<span className="text-muted-foreground font-normal">({result.total_duration_ms}ms)</span>
</div>
{result.steps.map((step: SSHTestStep) => (
<StepRow key={step.name} step={step} />
))}
{result.error && !result.steps.some((s) => s.error) && (
<p className="text-sm text-red-600">{result.error}</p>
)}
{result.success && result.fingerprint && (
<div className="mt-3 space-y-2">
<div className="text-xs">
<span className="text-muted-foreground">Host fingerprint observed: </span>
<code className="font-mono">{result.fingerprint}</code>
</div>
{fingerprintChanged && (
<p className="text-xs text-amber-600">
Warning: this fingerprint differs from the one currently pinned (
<code className="font-mono">{currentlyPinned}</code>). Trusting it overwrites the
pinned key. If you didn’t expect a host re-key, stop here.
</p>
)}
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={trust}
onChange={(e) => onTrustChange(e.target.checked)}
className="mt-0.5 cursor-pointer"
/>
<span>
<strong>Trust this host.</strong> I’ve verified the fingerprint above matches my
server. Future connections that report a different fingerprint will be refused.
</span>
</label>
</div>
)}
</div>
);
}
function StepRow({ step }: { step: SSHTestStep }) {
return (
<div className="flex items-start gap-2 text-sm pl-2">
{step.success ? (
<IconCheck className="h-3 w-3 text-green-600 shrink-0 mt-1" />
) : (
<IconX className="h-3 w-3 text-red-600 shrink-0 mt-1" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span>{step.name}</span>
<span className="text-muted-foreground text-xs">({step.duration_ms}ms)</span>
</div>
{step.output && (
<p className="text-xs text-muted-foreground truncate font-mono">{step.output}</p>
)}
{step.error && <p className="text-xs text-red-600 truncate">{step.error}</p>}
</div>
</div>
);
}
export interface SSHSessionsCardProps {
executorId: string;
}
export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
const [sessions, setSessions] = useState<SSHSession[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const rows = await listSSHSessions(executorId);
setSessions(rows);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load sessions");
} finally {
setLoading(false);
}
}, [executorId]);
useEffect(() => {
refresh();
const id = setInterval(refresh, 90_000);
return () => clearInterval(id);
}, [refresh]);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Active sessions</CardTitle>
<CardDescription>
Sessions currently running on this SSH host. Refreshes every 90 seconds.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
className="cursor-pointer"
>
{loading ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
{error && <p className="text-sm text-red-600">{error}</p>}
{!error && sessions.length === 0 && !loading && (
<p className="text-sm text-muted-foreground">No active sessions.</p>
)}
{sessions.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Task</TableHead>
<TableHead>Session</TableHead>
<TableHead>Host</TableHead>
<TableHead>Remote port</TableHead>
<TableHead>Local fwd</TableHead>
<TableHead>Uptime</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((s) => (
<TableRow key={s.session_id}>
<TableCell className="font-mono text-xs">{s.task_id.slice(0, 8)}</TableCell>
<TableCell className="font-mono text-xs">{s.session_id.slice(0, 8)}</TableCell>
<TableCell className="font-mono text-xs">
{s.user ? `${s.user}@${s.host}` : s.host}
</TableCell>
<TableCell className="font-mono text-xs">
{s.remote_agentctl_port ?? "—"}
</TableCell>
<TableCell className="font-mono text-xs">{s.local_forward_port ?? "—"}</TableCell>
<TableCell className="text-xs">{formatUptime(s.uptime_seconds)}</TableCell>
<TableCell>
<Badge variant={s.status === "running" ? "default" : "secondary"}>
{s.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}
function formatUptime(s: number): string {
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
+27
View File
@@ -0,0 +1,27 @@
import { fetchJson, type ApiRequestOptions } from "../client";
import type { SSHTestRequest, SSHTestResult, SSHSession } from "@/lib/types/http-ssh";
export async function testSSHConnection(
request: SSHTestRequest,
options?: ApiRequestOptions,
): Promise<SSHTestResult> {
return fetchJson<SSHTestResult>("/api/v1/ssh/test", {
...options,
init: {
method: "POST",
headers: { "Content-Type": "application/json", ...(options?.init?.headers ?? {}) },
body: JSON.stringify(request),
...(options?.init ?? {}),
},
});
}
export async function listSSHSessions(
executorId: string,
options?: ApiRequestOptions,
): Promise<SSHSession[]> {
return fetchJson<SSHSession[]>(
`/api/v1/ssh/executors/${encodeURIComponent(executorId)}/sessions`,
options,
);
}
+9
View File
@@ -7,6 +7,7 @@ import {
IconFolders,
IconServer,
IconServerOff,
IconTerminal2,
} from "@tabler/icons-react";
export const EXECUTOR_ICON_MAP: Record<string, typeof IconFolder> = {
@@ -15,6 +16,7 @@ export const EXECUTOR_ICON_MAP: Record<string, typeof IconFolder> = {
local_docker: IconBox,
remote_docker: IconBox,
sprites: IconCloud,
ssh: IconTerminal2,
};
export function getExecutorIcon(type: string): typeof IconFolder {
@@ -27,6 +29,7 @@ const EXECUTOR_LABEL_MAP: Record<string, string> = {
local_docker: "Local Docker",
remote_docker: "Remote Docker",
sprites: "Sprites.dev",
ssh: "SSH",
};
export function getExecutorLabel(type: string): string {
@@ -55,6 +58,12 @@ export function getExecutorStatusIcon(
testId: "executor-status-cloud-icon",
};
}
if (executorType === "ssh") {
return {
Icon: hasError ? IconServerOff : IconTerminal2,
testId: "executor-status-ssh-icon",
};
}
return {
Icon: hasError ? IconServerOff : IconServer,
testId: "executor-status-server-icon",
+46
View File
@@ -0,0 +1,46 @@
export type SSHIdentitySource = "agent" | "file";
export interface SSHTestRequest {
name: string;
host_alias?: string;
host?: string;
port?: number;
user?: string;
identity_source?: SSHIdentitySource;
identity_file?: string;
proxy_jump?: string;
}
export interface SSHTestStep {
name: string;
duration_ms: number;
success: boolean;
output?: string;
error?: string;
}
export interface SSHTestResult {
success: boolean;
fingerprint?: string;
uname_all?: string;
arch?: string;
git_version?: string;
agentctl_action?: "cached" | "uploaded" | "skipped";
steps: SSHTestStep[];
total_duration_ms: number;
error?: string;
}
export interface SSHSession {
session_id: string;
task_id: string;
task_title?: string;
host: string;
user?: string;
remote_task_dir?: string;
remote_agentctl_port?: number;
local_forward_port?: number;
status: string;
uptime_seconds: number;
created_at: string;
}