396 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|