import type { Held } from "@p2/app/v1"; import { BACKGROUND_CONTEXT } from "@p2/app/v1"; import { ChevronIcon, CopyButton, errorText, LockIcon, PlusIcon, Sheet, SlotList, useNavigation, useObject, useOptimisticCall, useReadable, } from "@p2/app/v1/web"; import { type KeyboardEvent, useEffect, useRef, useState } from "react"; import { type EnvSummary, Envs, type EnvTest, type SandboxStatus } from "./contract.ts"; import { ENV_NAME, type EnvConfig, type GondolinEnvConfig, type SandboxMount, type SshEnvConfig } from "./schema.ts"; type SshDraft = { name: string; host: string; user: string; port: string; identityFile: string; shellPath: string; loginShell: boolean; shellEnv: string; }; const EMPTY: SshDraft = { name: "", host: "", user: "", port: "", identityFile: "", shellPath: "", loginShell: false, shellEnv: "", }; function sshDraftOf(name: string, config: SshEnvConfig): SshDraft { return { name, host: config.host, user: config.user ?? "", port: config.port === undefined ? "" : String(config.port), identityFile: config.identityFile ?? "", shellPath: config.shellPath ?? "", loginShell: config.loginShell ?? false, shellEnv: Object.entries(config.shellEnv ?? {}) .map(([key, value]) => `${key}=${value}`) .join("\n"), }; } function sshConfigOf(draft: SshDraft): EnvConfig { const shellEnv: Record = {}; for (const line of draft.shellEnv.split("\n")) { if (!line.trim()) continue; const separator = line.indexOf("="); if (separator < 1) throw new Error(`Environment variables are KEY=value lines: ${line}`); shellEnv[line.slice(0, separator).trim()] = line.slice(separator + 1); } if (draft.port && !/^\d+$/.test(draft.port.trim())) throw new Error("Port must be a number"); return { type: "ssh", host: draft.host.trim(), ...(draft.user.trim() ? { user: draft.user.trim() } : {}), ...(draft.port.trim() ? { port: Number(draft.port) } : {}), ...(draft.identityFile.trim() ? { identityFile: draft.identityFile.trim() } : {}), ...(draft.shellPath.trim() ? { shellPath: draft.shellPath.trim() } : {}), ...(draft.loginShell ? { loginShell: true } : {}), ...(Object.keys(shellEnv).length ? { shellEnv } : {}), }; } /** An environment saved or deleted while its call is in flight. */ type EnvGuess = | { readonly kind: "save"; readonly name: string; readonly config: EnvConfig } | { readonly kind: "delete"; readonly name: string }; /** The list with a save (a new row, or the row's new configuration) or a deletion applied. */ function applyEnv(envs: EnvSummary[] | undefined, guess: EnvGuess): EnvSummary[] | undefined { if (!envs) return envs; if (guess.kind === "delete") return envs.filter((env) => env.name !== guess.name); const saved = envs.find((env) => env.name === guess.name); return saved ? envs.map((env) => (env === saved ? { ...env, config: guess.config } : env)) : [...envs, { name: guess.name, config: guess.config, sessions: 0 }]; } type SandboxDraft = { name: string; host: string; image: string; mounts: SandboxMount[]; allowHosts: string; cpus: string; memoryMb: string; }; function sandboxDraftOf(name: string, config: GondolinEnvConfig): SandboxDraft { return { name, host: config.host, image: config.image ?? "", mounts: config.mounts ?? [], allowHosts: (config.allowHosts ?? []).join("\n"), cpus: config.cpus === undefined ? "" : String(config.cpus), memoryMb: config.memoryMb === undefined ? "" : String(config.memoryMb), }; } function sandboxConfigOf(draft: SandboxDraft): EnvConfig { const number = (value: string, label: string) => { if (!/^\d+$/.test(value.trim())) throw new Error(`${label} must be a number`); return Number(value.trim()); }; const allowHosts = draft.allowHosts .split("\n") .map((line) => line.trim()) .filter(Boolean); return { type: "gondolin", host: draft.host, ...(draft.image.trim() ? { image: draft.image.trim() } : {}), ...(draft.mounts.length ? { mounts: draft.mounts } : {}), ...(allowHosts.length ? { allowHosts } : {}), ...(draft.cpus.trim() ? { cpus: number(draft.cpus, "CPUs") } : {}), ...(draft.memoryMb.trim() ? { memoryMb: number(draft.memoryMb, "Memory") } : {}), }; } /** The edited screen: an SSH environment or a sandbox, new or saved. */ type Editing = | { kind: "ssh"; draft: SshDraft; existing: boolean } | { kind: "gondolin"; draft: SandboxDraft; existing: boolean }; function editingOf(env: EnvSummary): Editing { return env.config.type === "ssh" ? { kind: "ssh", draft: sshDraftOf(env.name, env.config), existing: true } : { kind: "gondolin", draft: sandboxDraftOf(env.name, env.config), existing: true }; } /** Locator prefix of a new sandbox's screen, followed by its preset host (env names cannot start with `+`). */ const NEW_SANDBOX = "+sandbox:"; /** A sandbox's VM as last read; `unknown` when reading it failed. */ type VmState = SandboxStatus | { state: "unknown"; error: string }; const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; function uptime(startedAt: number): string { const minutes = Math.floor((Date.now() - startedAt) / 60_000); if (minutes < 1) return "less than a minute"; if (minutes < 60) return `${minutes} min`; if (minutes < 48 * 60) return `${Math.floor(minutes / 60)} h`; return `${Math.floor(minutes / 1440)} d`; } /** Whether an SSH env's last test found a runtime for sandboxes. */ function hostsSandboxes(env: EnvSummary): boolean { return env.test?.ok === true && env.test.sandboxes !== undefined && env.test.sandboxes.runtime !== "none"; } function TestResult({ test, busy, onAccept, onForget, }: { test: EnvTest; busy: boolean; onAccept: (lines: string[]) => void; onForget: () => void; }) { if (test.ok) return ( <>

Connected · {test.platform} {test.arch} · pi-env {test.version} · {test.latencyMs} ms

{test.home}

Files are watched {test.watchMode === "native" ? "natively" : "by polling (no native change events)"}.

{test.warnings.map((warning) => (

⚠ {warning}

))}
{test.sandboxes && (

{test.sandboxes.runtime === "none" ? ( Sandboxes: not available ) : ( <> Sandboxes: ready · Gondolin{" "} {test.sandboxes.runtime === "native" ? "natively" : "in Docker"} )}

{test.sandboxes.detail &&

{test.sandboxes.detail}

}
)} ); return (

{test.error}

{test.hostKey?.state === "unknown" && ( <>

The host presents this key. Compare its fingerprint with one obtained from the host itself (for example ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub there) before accepting it.

{test.hostKey.fingerprints.map((fingerprint) => (

{fingerprint}

))} {test.hostKey.lines.length > 0 && ( )} )} {test.hostKey?.state === "changed" && ( <>

The host's key differs from the trusted one. This happens after reinstalling the host, but also when someone intercepts the connection. Forget the old key only if you know why it changed.

)}
); } function KeySection({ service, name, onKey, }: { service: Held; name: string; onKey: (identityFile: string) => void; }) { const [publicKey, setPublicKey] = useState(); const [busy, setBusy] = useState(false); const [error, setError] = useState(); const valid = ENV_NAME.test(name); // Looked up once typing pauses (Pi Mobile waits 300 ms), not on every keystroke. useEffect(() => { let current = true; setPublicKey(undefined); if (!valid) return; const timer = setTimeout(() => { void service .publicKey({ name }, BACKGROUND_CONTEXT) .then((key) => { if (current) setPublicKey(key ?? undefined); }) .catch(() => {}); }, 300); return () => { current = false; clearTimeout(timer); }; }, [service, name, valid]); const run = async (operation: () => Promise<{ publicKey: string; identityFile: string }>) => { setBusy(true); setError(undefined); try { const key = await operation(); setPublicKey(key.publicKey); onKey(key.identityFile); } catch (reason) { setError(errorText(reason)); } finally { setBusy(false); } }; return (
SSH key {publicKey ? ( <>

Add this public key to ~/.ssh/authorized_keys on the host, or to the SSH keys of your hosting provider's console.