Phase 20: per-container inspect + start/stop/restart, local + agent (0.26.0)
Stack Overview now renders each service as an expandable ContainerCard with a
curated single-container inspect view and admin start/stop/restart buttons,
both for local stacks (GET/POST /api/containers/{id}[/{action}]) and remote
stacks (proxied via /api/agents/{id}/containers/* to the agent's new
/agent/containers/* endpoints). Only compose-managed containers are exposed.
Also bumps version 0.23.0 -> 0.26.0 (the bumps for the already-committed
Phase 18 image-prune / Phase 19 compose-validate were missed) and backfills
README sections for Phase 18/19/20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9c4d319f8f
commit
2f63247fc1
@@ -0,0 +1,151 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Play, Square, RotateCw, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
|
||||
import { containersApi, type ContainerAction } from "@/api/containers";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import type { ContainerInfo } from "@/types";
|
||||
|
||||
export function ContainerCard({
|
||||
container,
|
||||
agentId,
|
||||
host,
|
||||
isAdmin,
|
||||
onChanged,
|
||||
}: {
|
||||
container: ContainerInfo;
|
||||
agentId?: number;
|
||||
host?: string;
|
||||
isAdmin: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const running = container.state === "running";
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ["container", agentId ?? "local", container.id],
|
||||
queryFn: () => containersApi.inspect(container.id, agentId),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const act = async (action: ContainerAction) => {
|
||||
setBusy(true);
|
||||
const t = toast.loading(`${action} ${container.service}…`);
|
||||
try {
|
||||
await containersApi.action(container.id, action, agentId);
|
||||
toast.success(`${container.service}: ${action} ok`, { id: t });
|
||||
onChanged?.();
|
||||
if (open) detail.refetch();
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e), { id: t });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 p-3">
|
||||
<button
|
||||
className="flex min-w-0 items-center gap-3 text-left"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
)}
|
||||
<StatusDot status={running ? "running" : "stopped"} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{container.service}</p>
|
||||
<p className="truncate font-mono text-xs text-slate-500">{container.image}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||
{container.health && <Badge>{container.health}</Badge>}
|
||||
<span>{container.status}</span>
|
||||
<ContainerPorts ports={container.ports} host={host} />
|
||||
{isAdmin && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" className="px-2 py-1" onClick={() => act("start")} disabled={busy || running}>
|
||||
<Play className="h-3.5 w-3.5 text-green-500" />
|
||||
</Button>
|
||||
<Button variant="outline" className="px-2 py-1" onClick={() => act("stop")} disabled={busy || !running}>
|
||||
<Square className="h-3.5 w-3.5 text-red-500" />
|
||||
</Button>
|
||||
<Button variant="outline" className="px-2 py-1" onClick={() => act("restart")} disabled={busy || !running}>
|
||||
<RotateCw className="h-3.5 w-3.5 text-sky-500" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-slate-200 p-3 text-xs dark:border-slate-700">
|
||||
{detail.isLoading ? (
|
||||
<Spinner />
|
||||
) : detail.data ? (
|
||||
<div className="grid gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||
<Field label="Container">
|
||||
<span className="font-mono">{detail.data.name}</span>
|
||||
</Field>
|
||||
<Field label="ID">
|
||||
<span className="font-mono">{detail.data.id.slice(0, 12)}</span>
|
||||
</Field>
|
||||
<Field label="State">
|
||||
{detail.data.state}
|
||||
{detail.data.exit_code != null && detail.data.state !== "running"
|
||||
? ` (exit ${detail.data.exit_code})`
|
||||
: ""}
|
||||
</Field>
|
||||
<Field label="Restarts">{detail.data.restart_count}</Field>
|
||||
{detail.data.started_at && (
|
||||
<Field label="Started">
|
||||
{new Date(detail.data.started_at).toLocaleString()}
|
||||
</Field>
|
||||
)}
|
||||
{detail.data.networks.length > 0 && (
|
||||
<Field label="Networks">{detail.data.networks.join(", ")}</Field>
|
||||
)}
|
||||
{detail.data.mounts.length > 0 && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="mb-1 font-medium text-slate-500">Mounts</p>
|
||||
<ul className="space-y-0.5 font-mono">
|
||||
{detail.data.mounts.map((m, i) => (
|
||||
<li key={i} className="truncate">
|
||||
{m.source} → {m.destination} {m.rw ? "(rw)" : "(ro)"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{detail.data.env.length > 0 && (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="mb-1 font-medium text-slate-500">Environment</p>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap font-mono">
|
||||
{detail.data.env.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500">{apiErrorMessage(detail.error)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<span className="w-20 shrink-0 font-medium text-slate-500">{label}</span>
|
||||
<span className="min-w-0 break-words">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user