import { useMemo, useState } from "react"; import { Button, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, Tooltip, } from "@heroui/react"; import { useAtomValue } from "jotai"; import { IconBrandDocker, IconPlayerPlayFilled, IconPlayerStopFilled, IconRefresh, IconFileText, IconTrash, } from "@tabler/icons-react"; import { filterAtom } from "../state/atoms"; import { useDockerContainerAction, useDockerContainers, useDockerStart, useDockerStatus, useDockerStop, } from "../lib/queries"; import { ago, humanSize } from "../lib/format"; import { useToast } from "../state/toast"; import { ConfirmDialog } from "./ConfirmDialog"; import { EmptyState, ViewShell } from "./ViewShell"; import { TableSkeleton } from "./Skeletons"; import { useInfiniteRows } from "../hooks/useInfiniteRows"; import ContainerLogsModal from "./ContainerLogsModal"; import type { DockerContainer, DockerStatus } from "../lib/types"; import ImageRef from "./ImageRef"; /** * Docker containers, running in bsdkrun's engine VM. * * The engine is one `docker:dind` microVM whose API is served on a host unix * socket, so these are the same containers the host's `docker ps` shows — this * view drives them, and the header says how to reach them from a terminal. */ export default function ContainersView() { const { data: status, isLoading: statusLoading } = useDockerStatus(); const running = !!status?.running; const { data: containers = [], isLoading } = useDockerContainers(true, running); const filter = useAtomValue(filterAtom).toLowerCase(); const action = useDockerContainerAction(); const [toRemove, setToRemove] = useState(null); const [logsFor, setLogsFor] = useState(null); const [pending, setPending] = useState>(new Set()); const toast = useToast(); const rows = useMemo( () => containers.filter( (c) => !filter || c.name.toLowerCase().includes(filter) || c.image.toLowerCase().includes(filter) || c.id.toLowerCase().includes(filter), ), [containers, filter], ); const { visible, sentinelRef, hasMore } = useInfiniteRows(rows.length); const visibleRows = useMemo(() => rows.slice(0, visible), [rows, visible]); const run = async (c: DockerContainer, verb: string, label: string) => { setPending((p) => new Set(p).add(c.id)); try { await action.mutateAsync({ action: verb, id: c.id }); toast("success", `${label} ${c.name || c.id}`); } catch (e) { toast("error", `Failed to ${verb} ${c.name || c.id}`, String(e)); } finally { setPending((p) => { const next = new Set(p); next.delete(c.id); return next; }); } }; const remove = async () => { if (!toRemove) return; const c = toRemove; setToRemove(null); await run(c, "rm", "Removed"); }; if (statusLoading && !status) { return ( ); } // The engine has to be up before there is anything to list — and starting it // is the only useful thing this view can offer until then. if (!running) { return ( ); } return ( c.state === "running").length} running · ${containers.length} total`} searchPlaceholder="Filter containers…" actions={} > {isLoading && containers.length === 0 ? ( ) : containers.length === 0 ? ( } title="No containers yet" hint={`The engine is running. Start one with \`docker run\` — the CLI is already pointed at it.`} /> ) : ( Name Image Status Ports Created {visibleRows.map((c) => { const up = c.state === "running"; const busy = pending.has(c.id); return (
{c.name || "—"} {c.id}
{c.status || c.state} {c.ports.length > 0 ? (
{c.ports.map((p) => ( ))}
) : ( — )}
{c.created ? ago(String(c.created)) : "—"}
{up ? ( <> ) : ( )}
); })}
)} {hasMore && (
Loading more… ({visible} of {rows.length})
)} This removes{" "} {toRemove?.name || toRemove?.id} {" "} and its writable layer. Volumes it declared go too. } confirmLabel="Remove" danger onConfirm={remove} onClose={() => setToRemove(null)} /> setLogsFor(null)} />
); } /** Trim a long image ref for the table, keeping the tag visible. */ /** * A published port, as a link. The whole point of the engine's port publisher * is that these are reachable from the host, so make them clickable. */ function PortChip({ spec }: { spec: string }) { // "8080:80/tcp" — the host port is what a browser can reach. const host = spec.split(":")[0]; return ( {spec} ); } /** Where the socket is, so a terminal can be pointed at the same engine. */ function EngineBadge({ status }: { status?: DockerStatus }) { const toast = useToast(); if (!status) return null; const hint = status.context_active ? "docker context: bsdkrun (active)" : `export DOCKER_HOST=unix://${status.socket}`; return ( ); } /** The engine is not up: explain, and offer the one action that helps. */ function EngineOffline({ status }: { status?: DockerStatus }) { const start = useDockerStart(); const stop = useDockerStop(); const toast = useToast(); const exists = !!status?.machine_id; const go = async () => { try { const s = await start.mutateAsync({}); toast( "success", "Docker engine ready", s.context_active ? "`docker ps` in a terminal talks to it too" : `DOCKER_HOST=unix://${s.socket}`, ); } catch (e) { toast("error", "Could not start the Docker engine", String(e)); } }; return ( } title={exists ? "Docker engine is stopped" : "No Docker engine yet"} hint={ exists ? "Its images and containers are still on disk — starting it brings them back." : "Runs Docker in a microVM and points your `docker` CLI at it. Images, compose and buildx all work as they do in Docker Desktop." } action={
{/* A VM that is up with a dead dockerd is the one case where stopping is the way forward, so offer it rather than leaving the user with only a button that will keep timing out. */} {status?.machine_running && ( )}
} /> ); }