"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 type { KubernetesSession } from "@/lib/types/http-kubernetes"; import { formatDateTime } from "@/lib/i18n/formats"; import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; import { getExecutorIcon } from "@/lib/executor-icons"; import { resolveExecutorEnvironmentStatus, type StatusTone } from "./executor-environment-status"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; const TONE_CLASSES: Record = { 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", }; const KubernetesPodIcon = getExecutorIcon("k8s"); const KUBERNETES_POD_KEY = "executors:kubernetesPod"; export function EnvironmentInfo({ env, container, ssh, kubernetes, kubernetesLoaded = false, kubernetesError = null, loading, kubernetesActions, }: { env: TaskEnvironment | null; container: ContainerLiveStatus | null; ssh?: SSHLiveStatus | null; kubernetes?: KubernetesSession | null; kubernetesLoaded?: boolean; kubernetesError?: string | null; loading: boolean; kubernetesActions?: React.ReactNode; }) { const { t } = useTranslation(); if (loading && !env) { return (
); } if (!env) { return (

{t("task:noEnvironmentYet")}

{t("task:anEnvironmentIsCreatedWhenYou")}

); } if (env.executor_type === "k8s") { return ( ); } return (
{formatExecutorType(env.executor_type)}
); } function StatusBadge({ env, container, kubernetes, kubernetesLoaded, kubernetesError, }: { env: TaskEnvironment; container: ContainerLiveStatus | null; kubernetes: KubernetesSession | null; kubernetesLoaded: boolean; kubernetesError: string | 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, env.executor_type === "k8s" ? { session: kubernetes, loaded: kubernetesLoaded, error: kubernetesError } : undefined, ); const className = TONE_CLASSES[tone]; return ( {label} ); } function KubernetesEnvironmentSummary({ env, container, session, loaded, error, actions, }: { env: TaskEnvironment; container: ContainerLiveStatus | null; session: KubernetesSession | null; loaded: boolean; error: string | null; actions?: React.ReactNode; }) { const { t } = useTranslation(); return (

{formatExecutorType(env.executor_type)} {t(KUBERNETES_POD_KEY)}

{actions}
{session ? ( <> {session.pod_name ? (

{t(KUBERNETES_POD_KEY)}

{session.pod_name}
) : null}
{session.pod_phase ? ( ) : null} {session.container_state ? ( ) : null} {session.workspace_kind ? ( ) : null} {session.created_at ? ( ) : null}
{session.failure_reason ? (

{t("task:error")}

{session.failure_reason}

) : null} ) : (

{error || (loaded ? t("task:executorEnvironmentIsUnavailable") : t("task:loadingStatus"))}

)}
); } function KubernetesFact({ label, value, className = "", testId, technical = false, tabular = false, }: { label: string; value: string; className?: string; testId?: string; technical?: boolean; tabular?: boolean; }) { return (
{label}
{value}
); } function CopyButton({ label, value }: { label: string; value: string }) { const { t } = useTranslation(); return ( ); } function EnvironmentFields({ env, container, ssh, kubernetes, kubernetesLoaded, kubernetesError, }: { env: TaskEnvironment; container: ContainerLiveStatus | null; ssh: SSHLiveStatus | null; kubernetes: KubernetesSession | null; kubernetesLoaded: boolean; kubernetesError: string | null; }) { const { t } = useTranslation(); const fields = useMemo( () => buildFields(env, container, ssh, kubernetes), [container, env, kubernetes, ssh], ); if (fields.length === 0) { if (env.executor_type === "k8s") { let message = t("task:loadingStatus"); if (kubernetesError) message = kubernetesError; else if (kubernetesLoaded) message = t("task:executorEnvironmentIsUnavailable"); return

{message}

; } return

{t("task:noResourceDetailsAvailable")}

; } return (
{fields.map((f) => ( ))}
); } function Field({ label, value, copy, copyValue, }: { label: string; value: string; copy?: boolean; copyValue?: string; }) { const { t } = useTranslation(); return (
{label}
{value} {copy && ( )}
); } // 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, kubernetes: KubernetesSession | null, ): FieldRow[] { const rows: FieldRow[] = []; if (env.worktree_path) { rows.push({ label: t("task:worktree"), value: env.worktree_path, copy: true }); } if (env.worktree_branch) { rows.push({ label: t("task:branch"), value: env.worktree_branch, copy: true }); } if (env.container_id) { const short = env.container_id.slice(0, 12); rows.push({ label: t("task: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: t("common:shell"), value: `docker exec -it ${short} sh`, copy: true, }); if (container?.started_at && container.state === "running") { rows.push({ label: t("task:uptime"), value: formatUptime(container.started_at) }); } } if (env.sandbox_id) { rows.push({ label: t("task:sprite"), value: env.sandbox_id, copy: true }); } if (env.executor_type === "ssh" && ssh) { addSshRows(rows, ssh); } if (env.executor_type === "k8s" && kubernetes) { addKubernetesRows(rows, kubernetes); } return rows; } function addKubernetesRows(rows: FieldRow[], session: KubernetesSession) { if (session.pod_name) { rows.push({ label: t(KUBERNETES_POD_KEY), value: session.pod_name, copy: true }); } if (session.pod_phase) { rows.push({ label: t("task:status"), value: session.pod_phase }); } if (session.container_state) { rows.push({ label: t("task:container"), value: session.container_state }); } rows.push({ label: t("task:restarts"), value: String(session.restarts) }); if (session.workspace_kind) { rows.push({ label: t("task:workspaceMode"), value: session.workspace_kind }); } if (session.created_at) { rows.push({ label: t("task:created"), value: formatDateTime(session.created_at) }); } if (session.failure_reason) { rows.push({ label: t("task:error"), value: session.failure_reason }); } } 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: t("task:host"), value: formatHostTarget(ssh), copy: true }); } if (ssh.remote_task_dir) { rows.push({ label: t("task:workdir"), value: ssh.remote_task_dir, copy: true }); } const agentctl = formatAgentctlSummary(ssh); if (agentctl) { rows.push({ label: t("task:agentctl"), value: agentctl }); } if (ssh.fingerprint) { rows.push({ label: t("task: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: t("common: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 t("task:executorTypeLocalWorktree"); case "local_docker": return t("task:executorTypeLocalDocker"); case "sprites": return t("task:executorTypeSpritesSandbox"); case "remote_docker": return t("task:executorTypeRemoteDocker"); case "ssh": return "SSH"; case "k8s": return t("executors:typeKubernetes"); default: return type || t("task:executorTypeUnknown"); } }