From 19cc92dc9490be230a6dd8f79d9a1da3f7de42e3 Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 12:20:52 +0000 Subject: [PATCH] Phase 15: dashboard stack resource usage (0.16.0) The dashboard now lists stacks in a table with live CPU and memory usage per stack. Usage is sampled from docker stats (one-shot read per running container, using the daemon-provided precpu for the CPU delta) and aggregated by compose project. - services/stats_service.py + GET /api/stacks/stats: per-stack cpu_used (cores), mem_used (bytes minus reclaimable cache), and the summed assigned cpu/mem limits (null when none set), read concurrently across containers. - Dashboard: stacks render as a table with a CPU and a Memory meter. When a limit is assigned the bar fills toward it (used / limit + %); otherwise it fills toward the host total. Inline start/stop/restart per row for admins. Co-Authored-By: Claude Opus 4.8 --- README.md | 13 +- backend/agent_app.py | 2 +- backend/main.py | 2 +- backend/routers/stacks.py | 8 +- backend/services/stats_service.py | 106 +++++++++++++++ frontend/package.json | 2 +- frontend/src/api/stacks.ts | 3 +- frontend/src/pages/Dashboard.tsx | 218 +++++++++++++++++++++++++++--- frontend/src/types/index.ts | 8 ++ 9 files changed, 338 insertions(+), 24 deletions(-) create mode 100644 backend/services/stats_service.py diff --git a/README.md b/README.md index 6b5b091..ccab944 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups) > + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX & > network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks & -> images) + Phase 14 (Multi-host file browser) complete. +> images) + Phase 14 (Multi-host file browser) + Phase 15 (Dashboard stack +> resource usage) complete. ## What works today (Phase 1) @@ -146,6 +147,16 @@ 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 15 — Dashboard stack resource usage + +- **The dashboard now lists stacks in a table** (status, services) with live + **CPU** and **memory** usage per stack, sampled from `docker stats` and + aggregated by compose project. +- When a stack has `deploy.resources.limits` assigned, the bar fills toward that + limit and shows usage vs the limit (e.g. `0.42 / 1 cores`, `310 MB / 512 MB`); + otherwise it shows absolute usage against the host total. Inline start/stop/ + restart actions per row for admins. New endpoint `GET /api/stacks/stats`. + ### Phase 14 — Multi-host file browser - **The Files page now has a host switcher.** When agents are registered, a diff --git a/backend/agent_app.py b/backend/agent_app.py index 5608349..5093471 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -59,7 +59,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.15.0" +AGENT_VERSION = "0.16.0" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index da627d3..c5a4535 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.15.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.16.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/stacks.py b/backend/routers/stacks.py index 369adc3..169c2f8 100644 --- a/backend/routers/stacks.py +++ b/backend/routers/stacks.py @@ -26,7 +26,7 @@ from models.setting import ( EVENT_STACK_STOP, ) from models.user import User -from services import audit_service, compose_service, notify_service +from services import audit_service, compose_service, notify_service, stats_service from services.convert_service import convert_docker_run router = APIRouter(prefix="/api/stacks", tags=["stacks"]) @@ -116,6 +116,12 @@ def create_stack( return _stack_summary(stack) +@router.get("/stats") +def stacks_stats(_user: User = Depends(get_current_user)) -> dict: + """Live CPU (cores) and memory usage per stack, with assigned limits.""" + return stats_service.stack_stats() + + @router.get("/{stack_id}") def get_stack( stack_id: str, diff --git a/backend/services/stats_service.py b/backend/services/stats_service.py new file mode 100644 index 0000000..3d8ffd5 --- /dev/null +++ b/backend/services/stats_service.py @@ -0,0 +1,106 @@ +"""Live CPU/memory stats for running containers, aggregated per compose stack. + +Reads a one-shot ``docker stats`` sample per running container (the daemon +includes ``precpu_stats`` so a single read yields a usable CPU delta) and sums +them by ``com.docker.compose.project`` label, which equals the stack id. +""" +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor + +from docker_client import DockerError, get_client, safe_call + +COMPOSE_PROJECT_LABEL = "com.docker.compose.project" + + +def _container_stats(container) -> dict | None: + try: + s = container.stats(stream=False) + except Exception: # noqa: BLE001 - skip containers that fail to report + return None + + cpu = s.get("cpu_stats", {}) or {} + pre = s.get("precpu_stats", {}) or {} + cpu_delta = cpu.get("cpu_usage", {}).get("total_usage", 0) - pre.get("cpu_usage", {}).get( + "total_usage", 0 + ) + sys_delta = cpu.get("system_cpu_usage", 0) - pre.get("system_cpu_usage", 0) + online = ( + cpu.get("online_cpus") + or len(cpu.get("cpu_usage", {}).get("percpu_usage") or []) + or 1 + ) + cores_used = (cpu_delta / sys_delta) * online if sys_delta > 0 and cpu_delta > 0 else 0.0 + + mem = s.get("memory_stats", {}) or {} + usage = mem.get("usage", 0) or 0 + detail = mem.get("stats", {}) or {} + # Match `docker stats`: exclude reclaimable page cache from "used". + inactive = detail.get("inactive_file") or detail.get("total_inactive_file") or 0 + mem_used = max(usage - inactive, 0) + + hc = container.attrs.get("HostConfig", {}) or {} + nano = hc.get("NanoCpus") or 0 + quota = hc.get("CpuQuota") or 0 + period = hc.get("CpuPeriod") or 0 + if nano: + cpu_limit = nano / 1e9 + elif quota and period: + cpu_limit = quota / period + else: + cpu_limit = None + mem_limit = hc.get("Memory") or 0 + + return { + "project": (container.labels or {}).get(COMPOSE_PROJECT_LABEL), + "cores_used": cores_used, + "cpu_limit": cpu_limit, + "mem_used": mem_used, + "mem_limit": mem_limit or None, + } + + +def stack_stats() -> dict: + """Return {stack_id: {cpu_used, cpu_limit, mem_used, mem_limit, containers}}. + + Limits are the summed assigned limits across the stack's containers, or null + when none of them have that limit set. + """ + try: + client = get_client() + containers = safe_call(client.containers.list) # running only + except DockerError: + return {} + + with ThreadPoolExecutor(max_workers=8) as pool: + samples = list(pool.map(_container_stats, containers)) + + agg: dict[str, dict] = {} + for st in samples: + if not st or not st["project"]: + continue + a = agg.setdefault( + st["project"], + {"cpu_used": 0.0, "cpu_limit": 0.0, "has_cpu": False, + "mem_used": 0, "mem_limit": 0, "has_mem": False, "containers": 0}, + ) + a["cpu_used"] += st["cores_used"] + a["mem_used"] += st["mem_used"] + a["containers"] += 1 + if st["cpu_limit"]: + a["cpu_limit"] += st["cpu_limit"] + a["has_cpu"] = True + if st["mem_limit"]: + a["mem_limit"] += st["mem_limit"] + a["has_mem"] = True + + return { + proj: { + "cpu_used": round(a["cpu_used"], 3), + "cpu_limit": round(a["cpu_limit"], 3) if a["has_cpu"] else None, + "mem_used": a["mem_used"], + "mem_limit": a["mem_limit"] if a["has_mem"] else None, + "containers": a["containers"], + } + for proj, a in agg.items() + } diff --git a/frontend/package.json b/frontend/package.json index d52d89e..184f7e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.15.0", + "version": "0.16.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/stacks.ts b/frontend/src/api/stacks.ts index 14cb610..3889c43 100644 --- a/frontend/src/api/stacks.ts +++ b/frontend/src/api/stacks.ts @@ -1,8 +1,9 @@ import api from "./client"; -import type { StackDetail, StackSummary } from "@/types"; +import type { StackDetail, StackStats, StackSummary } from "@/types"; export const stacksApi = { list: () => api.get("/api/stacks").then((r) => r.data), + stats: () => api.get>("/api/stacks/stats").then((r) => r.data), get: (id: string) => api.get(`/api/stacks/${id}`).then((r) => r.data), create: (body: { name: string; description?: string; yaml?: string; env?: string }) => diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b8f527a..844f98e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,25 +1,38 @@ import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { Cpu, MemoryStick, HardDrive, Container, Clock, ArrowUpCircle } from "lucide-react"; -import { Card, Spinner } from "@/components/ui"; -import { StackCard } from "@/components/stacks/StackCard"; +import { + Cpu, + MemoryStick, + HardDrive, + Container, + Clock, + ArrowUpCircle, + Play, + Square, + RotateCw, +} from "lucide-react"; +import { Card, Spinner, StatusDot, Badge } from "@/components/ui"; import { stacksApi } from "@/api/stacks"; import { systemApi } from "@/api/system"; import { imagesApi } from "@/api/images"; -import { formatBytes, formatUptime, relativeTime } from "@/lib/utils"; +import { formatBytes, relativeTime } from "@/lib/utils"; import { useAuthStore } from "@/store/auth"; import { useStackActions } from "@/hooks/useStackActions"; +import type { StackStats, StackSummary } from "@/types"; export function Dashboard() { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const { busyId, start, stop, restart } = useStackActions(); const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 }); + const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 }); const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 }); const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 }); const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 }); const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length; + const hostCpus = info.data?.cpu_cores ?? 0; + const hostMem = info.data?.ram.total ?? 0; return (
@@ -61,7 +74,7 @@ export function Dashboard() { />
- {/* Stacks grid */} + {/* Stacks list */}

Stacks @@ -69,19 +82,34 @@ export function Dashboard() { {stacks.isLoading ? ( ) : stacks.data && stacks.data.length > 0 ? ( -
- {stacks.data.map((s) => ( - - ))} -
+ + + + + + + + {isAdmin && } + + + + {stacks.data.map((s) => ( + + ))} + +
StackCPUMemory
+
) : (

@@ -123,6 +151,160 @@ export function Dashboard() { ); } +function StackRow({ + stack, + stats, + hostCpus, + hostMem, + isAdmin, + busy, + onStart, + onStop, + onRestart, +}: { + stack: StackSummary; + stats?: StackStats; + hostCpus: number; + hostMem: number; + isAdmin: boolean; + busy: boolean; + onStart: (id: string) => void; + onStop: (id: string) => void; + onRestart: (id: string) => void; +}) { + const running = stack.running_count > 0; + + return ( + + + + + {stack.name} + {stack.status} + + {stack.running_count}/{stack.service_count} svc + + + + + {running && stats ? ( + + ) : ( + + )} + + + {running && stats ? ( + + ) : ( + + )} + + {isAdmin && ( + +

+ {running ? ( + <> + onRestart(stack.id)} disabled={busy}> + + + onStop(stack.id)} disabled={busy}> + + + + ) : ( + onStart(stack.id)} disabled={busy}> + + + )} +
+ + )} + + ); +} + +function IconBtn({ + title, + onClick, + disabled, + children, +}: { + title: string; + onClick: () => void; + disabled?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +/** A compact usage bar. When a limit is set the bar fills toward the limit; + * otherwise it fills toward the host total as a faint reference. */ +function Meter({ + used, + limit, + hostMax, + label, +}: { + used: number; + limit: number | null; + hostMax: number; + label: string; +}) { + const denom = limit ?? (hostMax || 0); + const pct = denom > 0 ? Math.min((used / denom) * 100, 100) : 0; + const over = limit != null && used > limit * 1.001; + const bar = + over || pct >= 90 + ? "bg-red-500" + : pct >= 75 + ? "bg-amber-500" + : limit != null + ? "bg-sky-500" + : "bg-slate-400"; + + return ( +
+
+ {label} + {denom > 0 && ( + {Math.round(pct)}% + )} +
+
+
+
+
+ ); +} + function Stat({ icon, label, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5d85d3f..f0dd3f7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -20,6 +20,14 @@ export interface StackSummary { agent_name?: string; } +export interface StackStats { + cpu_used: number; // cores in use (1.0 = one full core) + cpu_limit: number | null; // summed assigned CPU limit, or null + mem_used: number; // bytes + mem_limit: number | null; // summed assigned memory limit in bytes, or null + containers: number; +} + export interface Agent { id: number; name: string;