Files
kandev/apps/web/components/task/executor-environment-info.tsx
T
2026-08-02 20:14:53 +01:00

278 lines
8.5 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { IconCopy, IconLoader } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { toast } from "@/lib/toast/sonner";
import {
type ContainerLiveStatus,
type SSHLiveStatus,
type TaskEnvironment,
} from "@/lib/api/domains/task-environment-api";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { resolveExecutorEnvironmentStatus, type StatusTone } from "./executor-environment-status";
const TONE_CLASSES: Record<StatusTone, string> = {
running: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300",
stopped: "border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
warn: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
error: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
neutral: "border-muted text-muted-foreground",
};
export function EnvironmentInfo({
env,
container,
ssh,
loading,
}: {
env: TaskEnvironment | null;
container: ContainerLiveStatus | null;
ssh?: SSHLiveStatus | null;
loading: boolean;
}) {
if (loading && !env) {
return (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<IconLoader className="h-4 w-4 animate-spin" />
</div>
);
}
if (!env) {
return (
<div className="px-3 py-4 text-muted-foreground">
<p className="font-medium text-foreground">No environment yet</p>
<p className="text-xs mt-1">
An environment is created when you start a session on this task.
</p>
</div>
);
}
return (
<div className="px-3 pt-2.5 pb-1.5 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground">{formatExecutorType(env.executor_type)}</span>
<StatusBadge env={env} container={container} />
</div>
<EnvironmentFields env={env} container={container} ssh={ssh ?? null} />
</div>
);
}
function StatusBadge({
env,
container,
}: {
env: TaskEnvironment;
container: ContainerLiveStatus | null;
}) {
// For container-backed envs the live state is the source of truth; for the
// others fall back to the recorded TaskEnvironment.status.
const { label, tone } = resolveExecutorEnvironmentStatus(env, container);
const className = TONE_CLASSES[tone];
return (
<Badge variant="outline" className={`text-[10px] uppercase ${className}`}>
{label}
</Badge>
);
}
function EnvironmentFields({
env,
container,
ssh,
}: {
env: TaskEnvironment;
container: ContainerLiveStatus | null;
ssh: SSHLiveStatus | null;
}) {
const fields = useMemo(() => buildFields(env, container, ssh), [env, container, ssh]);
if (fields.length === 0) {
return <p className="text-xs text-muted-foreground">No resource details available.</p>;
}
return (
<dl className="space-y-1 text-xs">
{fields.map((f) => (
<Field
key={f.label}
label={f.label}
value={f.value}
copy={f.copy}
copyValue={f.copyValue}
/>
))}
</dl>
);
}
function Field({
label,
value,
copy,
copyValue,
}: {
label: string;
value: string;
copy?: boolean;
copyValue?: string;
}) {
return (
<div className="flex items-start gap-2">
<dt className="text-muted-foreground min-w-[80px]">{label}</dt>
<dd className="flex-1 flex items-center gap-1 break-all font-mono">
<span className="flex-1">{value}</span>
{copy && (
<button
type="button"
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label={`Copy ${label}`}
onClick={() => {
void copyToClipboard(copyValue ?? value).then((success) => {
if (success) toast.success(`${label} copied`);
});
}}
>
<IconCopy className="h-3 w-3" />
</button>
)}
</dd>
</div>
);
}
// copyValue overrides the clipboard payload when the displayed value is a
// truncated representation (fingerprint hash suffix, …) — copying the
// truncation is useless. Omit to copy `value` verbatim.
type FieldRow = { label: string; value: string; copy?: boolean; copyValue?: string };
function buildFields(
env: TaskEnvironment,
container: ContainerLiveStatus | null,
ssh: SSHLiveStatus | null,
): FieldRow[] {
const rows: FieldRow[] = [];
if (env.worktree_path) {
rows.push({ label: "Worktree", value: env.worktree_path, copy: true });
}
if (env.worktree_branch) {
rows.push({ label: "Branch", value: env.worktree_branch, copy: true });
}
if (env.container_id) {
const short = env.container_id.slice(0, 12);
rows.push({ label: "Container", value: short, copy: true });
// Use `sh` rather than `bash` — user-built images may only ship
// /bin/sh (busybox/alpine/etc.), and the bootstrap entrypoint already
// assumes sh-only.
rows.push({
label: "Shell",
value: `docker exec -it ${short} sh`,
copy: true,
});
if (container?.started_at && container.state === "running") {
rows.push({ label: "Uptime", value: formatUptime(container.started_at) });
}
}
if (env.sandbox_id) {
rows.push({ label: "Sprite", value: env.sandbox_id, copy: true });
}
if (env.executor_type === "ssh" && ssh) {
addSshRows(rows, ssh);
}
return rows;
}
function addSshRows(rows: FieldRow[], ssh: SSHLiveStatus) {
// user@host[:port] — matches what a user would paste into an SSH client.
// Suppress :22 since the canonical port reads as noise.
if (ssh.host) {
rows.push({ label: "Host", value: formatHostTarget(ssh), copy: true });
}
if (ssh.remote_task_dir) {
rows.push({ label: "Workdir", value: ssh.remote_task_dir, copy: true });
}
const agentctl = formatAgentctlSummary(ssh);
if (agentctl) {
rows.push({ label: "Agentctl", value: agentctl });
}
if (ssh.fingerprint) {
rows.push({
label: "Fingerprint",
value: formatFingerprint(ssh.fingerprint),
copy: true,
copyValue: ssh.fingerprint,
});
}
// Shell affordance: paste-ready ssh command that mirrors how kandev
// connects. Helpful when the user wants to inspect the remote dir by hand.
if (ssh.host) {
rows.push({ label: "Shell", value: formatShellCommand(ssh), copy: true });
}
}
function formatHostTarget(ssh: SSHLiveStatus): string {
const userPart = ssh.user ? `${ssh.user}@` : "";
const portPart = ssh.port && ssh.port !== 22 ? `:${ssh.port}` : "";
return `${userPart}${ssh.host ?? ""}${portPart}`;
}
function formatShellCommand(ssh: SSHLiveStatus): string {
const userPart = ssh.user ? `${ssh.user}@` : "";
const portPart = ssh.port && ssh.port !== 22 ? ` -p ${ssh.port}` : "";
return `ssh${portPart} ${userPart}${ssh.host ?? ""}`;
}
// Compact one-liner for the agentctl process: pid → remote:port → local:port.
// Each is optional (e.g. a recovered session may have lost the local
// forward port mid-restore), so the helper omits gracefully. Returns ""
// when nothing is set so callers can skip the row entirely.
function formatAgentctlSummary(ssh: SSHLiveStatus): string {
const parts: string[] = [];
if (ssh.remote_agentctl_pid) parts.push(`pid ${ssh.remote_agentctl_pid}`);
if (ssh.remote_agentctl_port) parts.push(`remote :${ssh.remote_agentctl_port}`);
if (ssh.local_forward_port) parts.push(`local :${ssh.local_forward_port}`);
return parts.join(" → ");
}
// Show the suffix only — full fingerprint is verbose, and the trust gate
// already pinned it on save. The copy button hands back the full string.
function formatFingerprint(fingerprint: string): string {
return fingerprint.startsWith("SHA256:") ? `…${fingerprint.slice(-12)}` : fingerprint;
}
function formatUptime(startedAt: string): string {
const startedMs = Date.parse(startedAt);
if (Number.isNaN(startedMs)) return startedAt;
const elapsedSec = Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
if (elapsedSec < 60) return `${elapsedSec}s`;
const min = Math.floor(elapsedSec / 60);
if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60);
return `${hr}h ${min % 60}m`;
}
function formatExecutorType(type: string): string {
switch (type) {
case "local_pc":
case "worktree":
return "Local (worktree)";
case "local_docker":
return "Local Docker";
case "sprites":
return "Sprites sandbox";
case "remote_docker":
return "Remote Docker";
case "ssh":
return "SSH";
default:
return type || "Unknown executor";
}
}