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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c5f591749f
commit
19cc92dc94
@@ -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"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user