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:
menzelj
2026-06-09 12:12:58 +00:00
co-authored by Claude Opus 4.8
parent 9c4d319f8f
commit 2f63247fc1
11 changed files with 460 additions and 38 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.23.0",
"version": "0.26.0",
"type": "module",
"scripts": {
"dev": "vite",
+43
View File
@@ -0,0 +1,43 @@
import api from "./client";
export interface ContainerDetail {
id: string;
name: string;
service: string;
stack: string | null;
image: string;
command: string[] | null;
entrypoint: string[] | null;
state: string;
status: string;
health?: string | null;
restart_count: number;
exit_code?: number | null;
created?: string | null;
started_at?: string | null;
finished_at?: string | null;
env: string[];
mounts: {
type?: string;
source?: string;
destination?: string;
mode?: string;
rw?: boolean;
}[];
ports: { container: string; host_ip?: string; host_port?: string | null }[];
networks: string[];
labels: Record<string, string>;
}
export type ContainerAction = "start" | "stop" | "restart";
// Base path for the local host or, when agentId is given, a remote agent.
const base = (agentId?: number) =>
agentId != null ? `/api/agents/${agentId}/containers` : "/api/containers";
export const containersApi = {
inspect: (id: string, agentId?: number) =>
api.get<ContainerDetail>(`${base(agentId)}/${id}`).then((r) => r.data),
action: (id: string, action: ContainerAction, agentId?: number) =>
api.post(`${base(agentId)}/${id}/${action}`).then((r) => r.data),
};
@@ -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>
);
}
+32 -17
View File
@@ -15,11 +15,12 @@ import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { HostDot } from "@/components/hosts/HostDot";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
import { ContainerCard } from "@/components/stacks/ContainerCard";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import type { ContainerInfo } from "@/types";
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
type Tab = (typeof TABS)[number];
@@ -129,7 +130,15 @@ export function RemoteStackDetail() {
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && <Overview containers={data.containers} host={agentHost} />}
{tab === "Overview" && (
<Overview
containers={data.containers}
host={agentHost}
agentId={aid}
isAdmin={isAdmin}
onChanged={() => qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] })}
/>
)}
{tab === "Logs" && (
<Card className="h-full overflow-hidden">
<LogViewer stackId={id} agentId={aid} />
@@ -160,7 +169,19 @@ export function RemoteStackDetail() {
);
}
function Overview({ containers, host }: { containers: any[]; host?: string }) {
function Overview({
containers,
host,
agentId,
isAdmin,
onChanged,
}: {
containers: ContainerInfo[];
host?: string;
agentId: number;
isAdmin: boolean;
onChanged: () => void;
}) {
return (
<div className="space-y-2 overflow-auto">
{containers.length === 0 && (
@@ -169,20 +190,14 @@ function Overview({ containers, host }: { containers: any[]; host?: string }) {
</Card>
)}
{containers.map((c) => (
<Card key={c.id} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<StatusDot status={c.state === "running" ? "running" : "stopped"} />
<div>
<p className="font-medium">{c.service}</p>
<p className="font-mono text-xs text-slate-500">{c.image}</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{c.health && <Badge>{c.health}</Badge>}
<span>{c.status}</span>
<ContainerPorts ports={c.ports ?? []} host={host} />
</div>
</Card>
<ContainerCard
key={c.id}
container={c}
agentId={agentId}
host={host}
isAdmin={isAdmin}
onChanged={onChanged}
/>
))}
</div>
);
+21 -18
View File
@@ -15,12 +15,13 @@ import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
import { ContainerCard } from "@/components/stacks/ContainerCard";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
import type { ContainerInfo } from "@/types";
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
type Tab = (typeof TABS)[number];
@@ -30,6 +31,7 @@ export function StackDetail() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [tab, setTab] = useState<Tab>("Overview");
const actions = useStackActions();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["stack", id],
@@ -102,7 +104,13 @@ export function StackDetail() {
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && <Overview data={data} />}
{tab === "Overview" && (
<Overview
data={data}
isAdmin={isAdmin}
onChanged={() => queryClient.invalidateQueries({ queryKey: ["stack", id] })}
/>
)}
{tab === "Logs" && <LogViewer stackId={id} />}
{tab === "Environment" && <EnvView env={data.env} />}
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
@@ -111,7 +119,15 @@ export function StackDetail() {
);
}
function Overview({ data }: { data: ReturnType<typeof Object> & any }) {
function Overview({
data,
isAdmin,
onChanged,
}: {
data: ReturnType<typeof Object> & any;
isAdmin: boolean;
onChanged: () => void;
}) {
return (
<div className="space-y-2 overflow-auto">
{data.containers.length === 0 && (
@@ -121,21 +137,8 @@ function Overview({ data }: { data: ReturnType<typeof Object> & any }) {
</p>
</Card>
)}
{data.containers.map((c: any) => (
<Card key={c.id} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<StatusDot status={c.state === "running" ? "running" : "stopped"} />
<div>
<p className="font-medium">{c.service}</p>
<p className="font-mono text-xs text-slate-500">{c.image}</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{c.health && <Badge>{c.health}</Badge>}
<span>{c.status}</span>
<ContainerPorts ports={c.ports} />
</div>
</Card>
{data.containers.map((c: ContainerInfo) => (
<ContainerCard key={c.id} container={c} isAdmin={isAdmin} onChanged={onChanged} />
))}
</div>
);