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
+27
View File
@@ -153,6 +153,33 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
container or connect any container on the host (`POST /api/networks/{id}/connect`
/ `/disconnect`).
### Phase 20 — Container management
- The stack **Overview** tab now renders each service as an expandable
**container card** instead of a static row. Expanding it fetches a curated
single-container inspect view (image, state + exit code, restart count,
started-at, networks, mounts, and environment) via
`GET /api/containers/{id}`.
- Admins get **per-container start / stop / restart** buttons directly on the
card (`POST /api/containers/{id}/{action}`), so a single misbehaving service
can be bounced without touching the rest of the stack.
- Works for **remote stacks** too — the same card is used on the remote stack
detail page, proxied through `/api/agents/{id}/containers/*` to the agent's
new `/agent/containers/*` endpoints.
- Only containers carrying the `com.docker.compose.project` label are exposed,
so this never becomes a generic "control any container on the host" backdoor.
### Phase 19 — Compose validate & diff
- The editor can **validate** a compose file (`docker compose config`) before
deploying and show a **diff against the currently deployed** definition, so
you can see exactly what a re-deploy will change.
### Phase 18 — Image prune
- **Prune images** (dangling, or all unused) from the Images page, on the local
host and on each agent.
### Phase 17 — Multi-host dashboard
- The dashboard now shows, **per host** (local + each registered agent, online
+17 -1
View File
@@ -40,6 +40,7 @@ from docker_client import DockerError, get_client, safe_call
from services import (
backup_service,
compose_service,
container_service,
device_service,
file_service,
image_service,
@@ -62,7 +63,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler
AGENT_VERSION = "0.23.0"
AGENT_VERSION = "0.26.0"
# --------------------------------------------------------------------------- #
@@ -504,6 +505,21 @@ def image_prune(all_unused: bool = Query(False, alias="all")) -> dict:
return image_service.prune_images(all_unused)
# --------------------------------------------------------------------------- #
# Containers (single-container inspect + lifecycle)
# --------------------------------------------------------------------------- #
@app.get("/agent/containers/{container_id}", dependencies=[Depends(verify_token)])
def inspect_container(container_id: str) -> dict:
return container_service.inspect_container(container_id)
@app.post("/agent/containers/{container_id}/{action}", dependencies=[Depends(verify_token)])
def container_action(container_id: str, action: str) -> dict:
return container_service.container_action(container_id, action)
# --------------------------------------------------------------------------- #
# Volumes
# --------------------------------------------------------------------------- #
+3 -1
View File
@@ -18,6 +18,7 @@ from routers import (
audit,
auth,
backups,
containers,
destinations,
editor,
files,
@@ -55,7 +56,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.23.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.26.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -76,6 +77,7 @@ async def docker_error_handler(_request: Request, exc: DockerError):
app.include_router(auth.router)
app.include_router(stacks.router)
app.include_router(containers.router)
app.include_router(system.router)
app.include_router(volumes.router)
app.include_router(editor.router)
+36
View File
@@ -947,3 +947,39 @@ async def agent_files_upload(
target=f"{agent.name}:{path}", detail=rel_path or file.filename, ip=_ip(request),
)
return result
# --------------------------------------------------------------------------- #
# Containers (proxied)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/containers/{container_id}")
async def agent_container_inspect(
agent_id: int,
container_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", f"/agent/containers/{container_id}")
@router.post("/{agent_id}/containers/{container_id}/{action}")
async def agent_container_action(
agent_id: int,
container_id: str,
action: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
result = await _proxy(
session, agent, "POST", f"/agent/containers/{container_id}/{action}"
)
audit_service.record(
session, user=user.username, action=f"agent.container.{action}",
target=f"{agent.name}/{container_id[:12]}", ip=_ip(request),
)
return result
+37
View File
@@ -0,0 +1,37 @@
"""Single-container inspect + lifecycle for compose-managed containers."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from auth import get_current_user, require_admin
from database import get_session
from models.user import User
from services import audit_service, container_service
router = APIRouter(prefix="/api/containers", tags=["containers"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
@router.get("/{container_id}")
def inspect(container_id: str, _user: User = Depends(get_current_user)) -> dict:
return container_service.inspect_container(container_id)
@router.post("/{container_id}/{action}")
def action(
container_id: str,
action: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
result = container_service.container_action(container_id, action)
audit_service.record(
session, user=user.username, action=f"container.{action}",
target=container_id[:12], ip=_ip(request),
)
return result
+92
View File
@@ -0,0 +1,92 @@
"""Single-container inspect + lifecycle — shared by the central app and agent.
Only containers that belong to a compose-managed stack (i.e. carry the
``com.docker.compose.project`` label) are exposed, so this never becomes a
generic "control any container on the host" backdoor.
"""
from __future__ import annotations
from docker_client import DockerError, get_client, safe_call
COMPOSE_LABEL = "com.docker.compose.project"
SERVICE_LABEL = "com.docker.compose.service"
ACTIONS = {"start", "stop", "restart"}
def _get_managed(container_id: str):
client = get_client()
container = safe_call(client.containers.get, container_id)
if COMPOSE_LABEL not in (container.labels or {}):
raise DockerError("not_managed", "container is not part of a managed stack")
return container
def inspect_container(container_id: str) -> dict:
"""Return a curated inspect view for a single managed container."""
c = _get_managed(container_id)
attrs = c.attrs
state = attrs.get("State", {}) or {}
config = attrs.get("Config", {}) or {}
health = (state.get("Health") or {}).get("Status")
network_settings = attrs.get("NetworkSettings", {}) or {}
networks = network_settings.get("Networks", {}) or {}
mounts = []
for m in attrs.get("Mounts", []) or []:
mounts.append(
{
"type": m.get("Type"),
"source": m.get("Source") or m.get("Name"),
"destination": m.get("Destination"),
"mode": m.get("Mode"),
"rw": m.get("RW"),
}
)
ports = []
for container_port, bindings in (network_settings.get("Ports") or {}).items():
if bindings:
for b in bindings:
ports.append(
{"container": container_port, "host_ip": b.get("HostIp"), "host_port": b.get("HostPort")}
)
else:
ports.append({"container": container_port, "host_port": None})
return {
"id": c.id,
"name": c.name,
"service": c.labels.get(SERVICE_LABEL, c.name),
"stack": c.labels.get(COMPOSE_LABEL),
"image": config.get("Image", "") or attrs.get("Image", ""),
"command": config.get("Cmd"),
"entrypoint": config.get("Entrypoint"),
"state": state.get("Status", c.status),
"status": state.get("Status", c.status),
"health": health,
"restart_count": attrs.get("RestartCount", 0),
"exit_code": state.get("ExitCode"),
"created": attrs.get("Created"),
"started_at": state.get("StartedAt"),
"finished_at": state.get("FinishedAt"),
"env": config.get("Env") or [],
"mounts": mounts,
"ports": ports,
"networks": sorted(networks.keys()),
"labels": c.labels or {},
}
def container_action(container_id: str, action: str) -> dict:
"""Start / stop / restart a single managed container."""
if action not in ACTIONS:
raise DockerError("bad_action", f"unsupported action '{action}'")
c = _get_managed(container_id)
if action == "start":
safe_call(c.start)
elif action == "stop":
safe_call(c.stop)
else:
safe_call(c.restart)
return {"ok": True, "action": action, "id": c.id}
+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>
);