Files
kandev/apps/web/components/settings/ssh-agent-readiness-card.tsx
T
2026-08-16 19:18:21 +00:00

396 lines
13 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
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, IconCopy, IconLoader2, IconX } from "@tabler/icons-react";
import { toast } from "@/lib/toast/sonner";
import { probeSSHAgents, probeSSHShells } from "@/lib/api/domains/ssh-api";
import type { SSHAgentReadinessRow } from "@/lib/types/http-ssh";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
import { settingsActionClassName } from "@/components/settings/settings-control";
export interface SSHAgentReadinessCardProps {
executorId: string;
/** The shell currently saved on the profile. Drives initial selection + the
* shell sent to the probe endpoint. */
shell?: string;
baselineShell?: string;
/** Persist a shell change up to the profile. Called when the user picks a
* different option in the dropdown so the choice survives the page reload
* and flows into the next agent launch as ssh_shell metadata. */
onShellChange?: (shell: string) => void | Promise<void>;
}
const DEFAULT_SHELL = "bash";
function normalizeShell(shell: string): string {
return shell.trim();
}
export function readinessProbeBody(shell: string): { shell?: string } {
const trimmed = normalizeShell(shell);
return trimmed ? { shell: trimmed } : {};
}
export function readinessDisplayShell(shell: string, defaultShell: string): string {
return normalizeShell(shell) || defaultShell || DEFAULT_SHELL;
}
// useReadinessState owns the card's fetch + selection state so the component
// renders a thin view layer. Pulled out to keep the component body under the
// max-lines-per-function lint budget.
function useReadinessState({
executorId,
shellProp,
onShellChange,
}: {
executorId: string;
shellProp?: string;
onShellChange?: (s: string) => void | Promise<void>;
}) {
const { t } = useTranslation();
const [rows, setRows] = useState<SSHAgentReadinessRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasProbed, setHasProbed] = useState(false);
const [shells, setShells] = useState<string[] | null>(null);
const [shellsLoading, setShellsLoading] = useState(false);
const [shell, setShell] = useState(shellProp ?? "");
const [defaultShell, setDefaultShell] = useState(DEFAULT_SHELL);
// Drop stale responses if the user clicks Refresh again before the
// previous request lands — same pattern as ssh-sessions-card.
const seqRef = useRef(0);
const refresh = useCallback(async () => {
const seq = ++seqRef.current;
setLoading(true);
setError(null);
try {
const probeBody = readinessProbeBody(shell);
const resp = await probeSSHAgents(executorId, probeBody);
if (seq !== seqRef.current) return;
if (!probeBody.shell && resp.shell) setDefaultShell(resp.shell);
setRows(resp.rows);
setHasProbed(true);
} catch (e) {
if (seq !== seqRef.current) return;
setError(e instanceof Error ? e.message : t("executors:sshFailedToProbeAgents"));
} finally {
if (seq === seqRef.current) setLoading(false);
}
}, [executorId, shell, t]);
// Auto-probe the shells once on mount so the dropdown has real options.
// Cheap (one SSH dial, ~1s) and fully out-of-band of agent probing.
const probeShells = useCallback(async () => {
setShellsLoading(true);
try {
const resp = await probeSSHShells(executorId);
setDefaultShell(resp.default_shell || DEFAULT_SHELL);
setShells(resp.available);
} catch {
// On failure the dropdown stays empty; user can type / save the shell
// via the profile form. We don't toast — a transient probe error
// shouldn't nag.
} finally {
setShellsLoading(false);
}
}, [executorId]);
useEffect(() => {
// Switching to a different executor should reset the displayed
// probe state — otherwise the previous executor's rows / error /
// hasProbed sit in the UI until the next manual refresh, which
// reads like "still loading" while actually showing stale data.
seqRef.current = 0;
setRows([]);
setError(null);
setHasProbed(false);
setLoading(false);
setDefaultShell(DEFAULT_SHELL);
void probeShells();
return () => {
seqRef.current = -1;
};
}, [executorId, probeShells]);
const handleShellChange = useCallback(
async (next: string) => {
setShell(next);
// Stale rows = stale answer (PATH depends on shell init). Clear the
// table so the user re-probes explicitly with the new shell.
setRows([]);
setHasProbed(false);
if (onShellChange) {
try {
await onShellChange(next);
} catch (e) {
toast.error(e instanceof Error ? e.message : t("executors:sshFailedToSaveShell"));
}
}
},
[onShellChange, t],
);
return {
rows,
loading,
error,
hasProbed,
shells,
shellsLoading,
shell,
defaultShell,
refresh,
handleShellChange,
};
}
/**
* Probes the remote host for each kandev-enabled agent's required binary
* (the first token of the agent's BuildCommand) under the user-chosen login
* shell. Surfaces availability + a copy-button for the agent's install
* command — installs themselves stay manual so the user keeps full control
* over what runs on their machine. Manual refresh only — a real SSH dial
* happens per probe so we don't poll on a timer.
*/
export function SSHAgentReadinessCard({
executorId,
shell: shellProp,
baselineShell,
onShellChange,
}: SSHAgentReadinessCardProps) {
const { t } = useTranslation();
const state = useReadinessState({ executorId, shellProp, onShellChange });
const {
rows,
loading,
error,
hasProbed,
shells,
shellsLoading,
shell,
defaultShell,
refresh,
handleShellChange,
} = state;
const displayShell = readinessDisplayShell(shell, defaultShell);
const shellDirty = baselineShell !== undefined && shell !== baselineShell;
return (
<SettingsCard isDirty={shellDirty} data-testid="ssh-agent-readiness-card">
<SettingsCardHeader
title={t("executors:sshAvailableAgents")}
description={t("executors:sshAvailableAgentsDescription", { pathVar: "$PATH" })}
actions={
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={loading}
data-testid="ssh-agent-readiness-probe"
className={settingsActionClassName("cursor-pointer")}
>
{loading ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
{hasProbed ? t("executors:sshReprobe") : t("executors:sshProbeAgents")}
</Button>
}
/>
<div className="px-4">
<ShellSelector
shell={displayShell}
shells={shells}
loading={shellsLoading}
defaultShell={defaultShell}
onChange={handleShellChange}
isDirty={shellDirty}
/>
</div>
<CardContent>
<ReadinessContent error={error} hasProbed={hasProbed} rows={rows} />
</CardContent>
</SettingsCard>
);
}
function ShellSelector({
shell,
shells,
loading,
defaultShell,
onChange,
isDirty,
}: {
shell: string;
shells: string[] | null;
loading: boolean;
defaultShell: string;
onChange: (s: string) => void | Promise<void>;
isDirty: boolean;
}) {
const { t } = useTranslation();
// While probing, show a placeholder option so the dropdown can't surface
// a stale "bash" selection that we haven't confirmed exists on the host.
// After probing, render whatever the host has, plus the current value so
// a saved shell that isn't actually installed is still selectable + loud.
const options = uniqueShellOptions(shell, shells);
return (
<div className="flex items-center gap-2 pt-3">
<Label htmlFor="ssh-readiness-shell" className="text-xs text-muted-foreground">
{t("executors:sshLoginShell")}
</Label>
<Select value={shell} onValueChange={(v) => void onChange(v)} disabled={loading}>
<SelectTrigger
id="ssh-readiness-shell"
data-testid="ssh-readiness-shell"
className="h-7 w-32 text-xs"
data-settings-dirty={isDirty}
>
<SelectValue placeholder={defaultShell || DEFAULT_SHELL} />
</SelectTrigger>
<SelectContent>
{options.map((s) => (
<SelectItem key={s} value={s} className="text-xs">
{s}
{shells && !shells.includes(s) ? (
<span className="text-amber-600 ml-1">{t("executors:sshShellNotDetected")}</span>
) : null}
</SelectItem>
))}
</SelectContent>
</Select>
{loading ? <IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" /> : null}
</div>
);
}
function uniqueShellOptions(currentShell: string, detected: string[] | null): string[] {
const set = new Set<string>();
if (detected) detected.forEach((s) => set.add(s));
set.add(currentShell || DEFAULT_SHELL);
if (set.size === 0) set.add(DEFAULT_SHELL);
return Array.from(set);
}
function ReadinessContent({
error,
hasProbed,
rows,
}: {
error: string | null;
hasProbed: boolean;
rows: SSHAgentReadinessRow[];
}) {
const { t } = useTranslation();
if (error) {
return (
<p data-testid="ssh-agent-readiness-error" className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
);
}
if (!hasProbed) {
return (
<p className="text-sm text-muted-foreground">
{t("executors:sshProbeHint", { button: t("executors:sshProbeAgents") })}
</p>
);
}
if (rows.length === 0) {
return <p className="text-sm text-muted-foreground">{t("executors:sshNoEnabledAgents")}</p>;
}
return <ReadinessTable rows={rows} />;
}
function ReadinessTable({ rows }: { rows: SSHAgentReadinessRow[] }) {
const { t } = useTranslation();
return (
<Table data-testid="ssh-agent-readiness-table">
<TableHeader>
<TableRow>
<TableHead>{t("executors:agent")}</TableHead>
<TableHead>{t("executors:sshBinary")}</TableHead>
<TableHead>{t("executors:status")}</TableHead>
<TableHead>{t("executors:sshInstallHint")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<ReadinessRow key={row.agent_id} row={row} />
))}
</TableBody>
</Table>
);
}
function ReadinessRow({ row }: { row: SSHAgentReadinessRow }) {
const slug = row.agent_id.replace(/[^a-z0-9]+/gi, "-");
return (
<TableRow data-testid={`ssh-readiness-row-${slug}`} data-available={row.available}>
<TableCell className="font-medium">{row.agent_name || row.agent_id}</TableCell>
<TableCell className="font-mono text-xs">{row.binary}</TableCell>
<TableCell>
<StatusBadge row={row} />
</TableCell>
<TableCell className="text-xs">
<InstallHint hint={row.install_hint} available={row.available} />
</TableCell>
</TableRow>
);
}
function StatusBadge({ row }: { row: SSHAgentReadinessRow }) {
const { t } = useTranslation();
if (row.error) {
return (
<Badge variant="outline" className="border-amber-500/30 bg-amber-500/10 text-amber-700">
<IconX className="mr-1 h-3 w-3" /> {t("executors:sshProbeError")}
</Badge>
);
}
if (row.available) {
return (
<Badge variant="outline" className="border-green-500/30 bg-green-500/10 text-green-700">
<IconCheck className="mr-1 h-3 w-3" /> {t("executors:sshInstalled")}
</Badge>
);
}
return (
<Badge variant="outline" className="border-red-500/30 bg-red-500/10 text-red-700">
<IconX className="mr-1 h-3 w-3" /> {t("executors:sshMissing")}
</Badge>
);
}
function InstallHint({ hint, available }: { hint?: string; available: boolean }) {
const { t } = useTranslation();
if (available) return <span className="text-muted-foreground">-</span>;
if (!hint)
return <span className="text-muted-foreground">{t("executors:sshNoHintAvailable")}</span>;
return (
<div className="flex items-center gap-1">
<code className="truncate">{hint}</code>
<button
type="button"
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label={t("executors:sshCopyInstallHint")}
onClick={() => {
void copyToClipboard(hint).then((success) => {
if (success) toast.success(t("executors:sshInstallHintCopied"));
});
}}
>
<IconCopy className="h-3 w-3" />
</button>
</div>
);
}