From 2f63247fc1de317579bf663e18c5d66918711da7 Mon Sep 17 00:00:00 2001 From: menzelj Date: Tue, 9 Jun 2026 12:12:58 +0000 Subject: [PATCH] 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 --- README.md | 27 ++++ backend/agent_app.py | 18 ++- backend/main.py | 4 +- backend/routers/agents.py | 36 +++++ backend/routers/containers.py | 37 +++++ backend/services/container_service.py | 92 +++++++++++ frontend/package.json | 2 +- frontend/src/api/containers.ts | 43 +++++ .../src/components/stacks/ContainerCard.tsx | 151 ++++++++++++++++++ frontend/src/pages/RemoteStackDetail.tsx | 49 ++++-- frontend/src/pages/StackDetail.tsx | 39 ++--- 11 files changed, 460 insertions(+), 38 deletions(-) create mode 100644 backend/routers/containers.py create mode 100644 backend/services/container_service.py create mode 100644 frontend/src/api/containers.ts create mode 100644 frontend/src/components/stacks/ContainerCard.tsx diff --git a/README.md b/README.md index a69b764..0868e2d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/agent_app.py b/backend/agent_app.py index 68abeaa..eadc97a 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -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 # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index b99adef..57c8507 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routers/agents.py b/backend/routers/agents.py index 9be01e3..8eb4e3e 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -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 diff --git a/backend/routers/containers.py b/backend/routers/containers.py new file mode 100644 index 0000000..1f567fa --- /dev/null +++ b/backend/routers/containers.py @@ -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 diff --git a/backend/services/container_service.py b/backend/services/container_service.py new file mode 100644 index 0000000..ca5c6b1 --- /dev/null +++ b/backend/services/container_service.py @@ -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} diff --git a/frontend/package.json b/frontend/package.json index 39533be..214bfbb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.23.0", + "version": "0.26.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/containers.ts b/frontend/src/api/containers.ts new file mode 100644 index 0000000..5fadf4a --- /dev/null +++ b/frontend/src/api/containers.ts @@ -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; +} + +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(`${base(agentId)}/${id}`).then((r) => r.data), + action: (id: string, action: ContainerAction, agentId?: number) => + api.post(`${base(agentId)}/${id}/${action}`).then((r) => r.data), +}; diff --git a/frontend/src/components/stacks/ContainerCard.tsx b/frontend/src/components/stacks/ContainerCard.tsx new file mode 100644 index 0000000..dfea757 --- /dev/null +++ b/frontend/src/components/stacks/ContainerCard.tsx @@ -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 ( + +
+ +
+ {container.health && {container.health}} + {container.status} + + {isAdmin && ( +
+ + + +
+ )} +
+
+ + {open && ( +
+ {detail.isLoading ? ( + + ) : detail.data ? ( +
+ + {detail.data.name} + + + {detail.data.id.slice(0, 12)} + + + {detail.data.state} + {detail.data.exit_code != null && detail.data.state !== "running" + ? ` (exit ${detail.data.exit_code})` + : ""} + + {detail.data.restart_count} + {detail.data.started_at && ( + + {new Date(detail.data.started_at).toLocaleString()} + + )} + {detail.data.networks.length > 0 && ( + {detail.data.networks.join(", ")} + )} + {detail.data.mounts.length > 0 && ( +
+

Mounts

+
    + {detail.data.mounts.map((m, i) => ( +
  • + {m.source} → {m.destination} {m.rw ? "(rw)" : "(ro)"} +
  • + ))} +
+
+ )} + {detail.data.env.length > 0 && ( +
+

Environment

+
+                    {detail.data.env.join("\n")}
+                  
+
+ )} +
+ ) : ( +

{apiErrorMessage(detail.error)}

+ )} +
+ )} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/frontend/src/pages/RemoteStackDetail.tsx b/frontend/src/pages/RemoteStackDetail.tsx index 3f66752..7e2b0ec 100644 --- a/frontend/src/pages/RemoteStackDetail.tsx +++ b/frontend/src/pages/RemoteStackDetail.tsx @@ -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() {
- {tab === "Overview" && } + {tab === "Overview" && ( + qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] })} + /> + )} {tab === "Logs" && ( @@ -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 (
{containers.length === 0 && ( @@ -169,20 +190,14 @@ function Overview({ containers, host }: { containers: any[]; host?: string }) { )} {containers.map((c) => ( - -
- -
-

{c.service}

-

{c.image}

-
-
-
- {c.health && {c.health}} - {c.status} - -
-
+ ))}
); diff --git a/frontend/src/pages/StackDetail.tsx b/frontend/src/pages/StackDetail.tsx index bc5bcae..95ea6c4 100644 --- a/frontend/src/pages/StackDetail.tsx +++ b/frontend/src/pages/StackDetail.tsx @@ -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("Overview"); const actions = useStackActions(); + const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ["stack", id], @@ -102,7 +104,13 @@ export function StackDetail() {
- {tab === "Overview" && } + {tab === "Overview" && ( + queryClient.invalidateQueries({ queryKey: ["stack", id] })} + /> + )} {tab === "Logs" && } {tab === "Environment" && } {tab === "Compose" && } @@ -111,7 +119,15 @@ export function StackDetail() { ); } -function Overview({ data }: { data: ReturnType & any }) { +function Overview({ + data, + isAdmin, + onChanged, +}: { + data: ReturnType & any; + isAdmin: boolean; + onChanged: () => void; +}) { return (
{data.containers.length === 0 && ( @@ -121,21 +137,8 @@ function Overview({ data }: { data: ReturnType & any }) {

)} - {data.containers.map((c: any) => ( - -
- -
-

{c.service}

-

{c.image}

-
-
-
- {c.health && {c.health}} - {c.status} - -
-
+ {data.containers.map((c: ContainerInfo) => ( + ))}
);