Files
kandev/apps/web/components/settings/ssh-sessions-card.tsx
T
José Almeida 5dc8550962 fix(ssh): drop stale polls + preserve port inheritance + sshd extra-keys
ssh-sessions-card: race-protect polls.
  Polling setInterval + manual refresh shared one state setter, so a
  slow request that resolved after a newer call (or after a parent
  switched executorId) overwrote fresh data with stale rows. Bump a
  monotonic sequence on each refresh and drop the response when the
  sequence has moved on, plus reset on mount and invalidate on
  unmount so an in-flight reply from a teardown can't land. Cubic P2.

ssh-config parseSSHExecutorConfig: don't default ssh_port to 22.
  Reading an executor row that had no ssh_port set rewrote port=22
  on the form, which then round-tripped back to the backend. That
  silently broke ~/.ssh/config Port inheritance for aliases — a save
  could redirect connections to 22 even though the underlying alias
  was supposed to resolve to another port. Leave the field undefined
  so the backend resolver can apply the alias' value. Cubic P2.

ssh-image entrypoint: guard authorized_keys.extra mount.
  When only /authorized_keys.extra (and not /authorized_keys) was
  bind-mounted, the >> redirect ran under set -e against a missing
  destination and aborted container startup. mkdir + touch the
  destination before the append and reapply the ownership/perms.
  CodeRabbit P2.
2026-05-18 00:05:15 +01:00

177 lines
5.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { IconLoader2 } from "@tabler/icons-react";
import { listSSHSessions } from "@/lib/api/domains/ssh-api";
import type { SSHSession } from "@/lib/types/http-ssh";
export interface SSHSessionsCardProps {
executorId: string;
}
const REFRESH_INTERVAL_MS = 90_000;
export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
const [sessions, setSessions] = useState<SSHSession[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Monotonic sequence used to ignore stale poll responses: a slow request
// that resolves after a newer one (or after executorId changes) would
// otherwise overwrite the fresh row set.
const seqRef = useRef(0);
const refresh = useCallback(async () => {
const seq = ++seqRef.current;
setLoading(true);
setError(null);
try {
const rows = await listSSHSessions(executorId);
if (seq !== seqRef.current) return; // a newer call (or executor switch) won the race
setSessions(rows);
} catch (e) {
if (seq !== seqRef.current) return;
setError(e instanceof Error ? e.message : "Failed to load sessions");
} finally {
if (seq === seqRef.current) setLoading(false);
}
}, [executorId]);
useEffect(() => {
// Reset sequence so a previous executor's pending response can't land
// here and pollute the new executor's data.
seqRef.current = 0;
refresh();
const id = setInterval(refresh, REFRESH_INTERVAL_MS);
return () => {
clearInterval(id);
// Bump the sequence to invalidate any in-flight response from the
// unmounted instance.
seqRef.current = -1;
};
}, [refresh]);
return (
<Card data-testid="ssh-sessions-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}
data-testid="ssh-sessions-refresh"
className="cursor-pointer"
>
{loading ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
<SSHSessionsBody loading={loading} error={error} sessions={sessions} />
</CardContent>
</Card>
);
}
function SSHSessionsBody({
loading,
error,
sessions,
}: {
loading: boolean;
error: string | null;
sessions: SSHSession[];
}) {
if (error) {
return (
<p data-testid="ssh-sessions-error" className="text-sm text-red-600">
{error}
</p>
);
}
if (sessions.length === 0 && !loading) {
return (
<p data-testid="ssh-sessions-empty" className="text-sm text-muted-foreground">
No active sessions.
</p>
);
}
if (sessions.length === 0) return null;
return <SSHSessionsTable sessions={sessions} />;
}
function SSHSessionsTable({ sessions }: { sessions: SSHSession[] }) {
return (
<Table data-testid="ssh-sessions-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) => (
<SSHSessionsRow key={s.session_id} session={s} />
))}
</TableBody>
</Table>
);
}
function SSHSessionsRow({ session: s }: { session: SSHSession }) {
return (
<TableRow data-testid={`ssh-session-row-${s.session_id}`}>
<TableCell className="font-mono text-xs" data-testid="ssh-session-task">
{s.task_id.slice(0, 8)}
</TableCell>
<TableCell className="font-mono text-xs" data-testid="ssh-session-id">
{s.session_id.slice(0, 8)}
</TableCell>
<TableCell className="font-mono text-xs" data-testid="ssh-session-host">
{s.user ? `${s.user}@${s.host}` : s.host}
</TableCell>
<TableCell className="font-mono text-xs" data-testid="ssh-session-remote-port">
{s.remote_agentctl_port ?? "—"}
</TableCell>
<TableCell className="font-mono text-xs" data-testid="ssh-session-local-port">
{s.local_forward_port ?? "—"}
</TableCell>
<TableCell className="text-xs" data-testid="ssh-session-uptime">
{formatUptime(s.uptime_seconds)}
</TableCell>
<TableCell>
<Badge
data-testid="ssh-session-status"
data-status={s.status}
variant={s.status === "running" ? "default" : "secondary"}
>
{s.status}
</Badge>
</TableCell>
</TableRow>
);
}
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`;
}