diff --git a/apps/web/components/settings/ssh-settings.tsx b/apps/web/components/settings/ssh-settings.tsx new file mode 100644 index 0000000000..16be163b33 --- /dev/null +++ b/apps/web/components/settings/ssh-settings.tsx @@ -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; + // Called when the user clicks Save after a successful test+trust. The + // returned config carries the freshly pinned fingerprint. + onSave: (config: SSHExecutorConfig) => Promise | 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): 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(() => initialState(props.initial)); + const { form, testing, saving, result, trust, error } = state; + + const update = useCallback( + (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 ( + + +
+
+ + + Connection + + + Run an agent on any Linux box you can reach over SSH. linux/amd64 only in v1. + +
+ +
+
+ + + {c.form.host_fingerprint && } + + {c.error &&

{c.error}

} + {c.result && ( + + )} +
+
+ ); +} + +function SSHConnectionForm({ + form, + onChange, +}: { + form: SSHExecutorConfig; + onChange: (key: K, value: SSHExecutorConfig[K]) => void; +}) { + return ( +
+ + onChange("name", e.target.value)} + placeholder="My VPS" + /> + + + onChange("host_alias", e.target.value)} + placeholder="prod" + /> + + + onChange("host", e.target.value)} + placeholder="dev.example.com" + /> + + + onChange("port", parseInt(e.target.value, 10) || 22)} + placeholder="22" + /> + + + onChange("user", e.target.value)} + placeholder="ubuntu" + /> + + + + + {form.identity_source === "file" && ( + + onChange("identity_file", e.target.value)} + placeholder="~/.ssh/id_ed25519" + /> + + )} + + onChange("proxy_jump", e.target.value)} + placeholder="bastion.example.com" + /> + +
+ ); +} + +function PinnedFingerprintRow({ fingerprint }: { fingerprint: string }) { + return ( +
+ + + Pinned fingerprint: {fingerprint} + +
+ ); +} + +function SSHConnectionActions({ + testing, + saving, + canTest, + canSave, + onTest, + onSave, +}: { + testing: boolean; + saving: boolean; + canTest: boolean; + canSave: boolean; + onTest: () => void; + onSave: () => void; +}) { + return ( +
+ + +
+ ); +} + +function ConnectionBadge({ fingerprint }: { fingerprint?: string }) { + if (!fingerprint) return Unverified; + return ( + + Trusted + + ); +} + +function Field({ + id, + label, + hint, + children, +}: { + id: string; + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +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 ( +
+
+ {result.success ? ( + + ) : ( + + )} + {result.success ? "Connection test passed" : "Connection test failed"} + ({result.total_duration_ms}ms) +
+ {result.steps.map((step: SSHTestStep) => ( + + ))} + {result.error && !result.steps.some((s) => s.error) && ( +

{result.error}

+ )} + {result.success && result.fingerprint && ( +
+
+ Host fingerprint observed: + {result.fingerprint} +
+ {fingerprintChanged && ( +

+ Warning: this fingerprint differs from the one currently pinned ( + {currentlyPinned}). Trusting it overwrites the + pinned key. If you didn’t expect a host re-key, stop here. +

+ )} + +
+ )} +
+ ); +} + +function StepRow({ step }: { step: SSHTestStep }) { + return ( +
+ {step.success ? ( + + ) : ( + + )} +
+
+ {step.name} + ({step.duration_ms}ms) +
+ {step.output && ( +

{step.output}

+ )} + {step.error &&

{step.error}

} +
+
+ ); +} + +export interface SSHSessionsCardProps { + executorId: string; +} + +export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + +
+
+ Active sessions + + Sessions currently running on this SSH host. Refreshes every 90 seconds. + +
+ +
+
+ + {error &&

{error}

} + {!error && sessions.length === 0 && !loading && ( +

No active sessions.

+ )} + {sessions.length > 0 && ( + + + + Task + Session + Host + Remote port + Local fwd + Uptime + Status + + + + {sessions.map((s) => ( + + {s.task_id.slice(0, 8)} + {s.session_id.slice(0, 8)} + + {s.user ? `${s.user}@${s.host}` : s.host} + + + {s.remote_agentctl_port ?? "—"} + + {s.local_forward_port ?? "—"} + {formatUptime(s.uptime_seconds)} + + + {s.status} + + + + ))} + +
+ )} +
+
+ ); +} + +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`; +} diff --git a/apps/web/lib/api/domains/ssh-api.ts b/apps/web/lib/api/domains/ssh-api.ts new file mode 100644 index 0000000000..f3b26b6360 --- /dev/null +++ b/apps/web/lib/api/domains/ssh-api.ts @@ -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 { + return fetchJson("/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 { + return fetchJson( + `/api/v1/ssh/executors/${encodeURIComponent(executorId)}/sessions`, + options, + ); +} diff --git a/apps/web/lib/executor-icons.ts b/apps/web/lib/executor-icons.ts index afd4b9b413..aee2516886 100644 --- a/apps/web/lib/executor-icons.ts +++ b/apps/web/lib/executor-icons.ts @@ -7,6 +7,7 @@ import { IconFolders, IconServer, IconServerOff, + IconTerminal2, } from "@tabler/icons-react"; export const EXECUTOR_ICON_MAP: Record = { @@ -15,6 +16,7 @@ export const EXECUTOR_ICON_MAP: Record = { 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 = { 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", diff --git a/apps/web/lib/types/http-ssh.ts b/apps/web/lib/types/http-ssh.ts new file mode 100644 index 0000000000..309b25cffe --- /dev/null +++ b/apps/web/lib/types/http-ssh.ts @@ -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; +}