Files
stackpilot/backend/services/stats_service.py
T
menzeljandClaude Opus 5.5 06234c1067
CI / check (push) Successful in 4m48s
CI / build-and-push (push) Failing after 16m2s
Run compose like the terminal does, and follow changes made there (0.67.2)
StackPilot called `docker compose -p <folder> -f compose.yaml`, which is
not what `docker compose` typed in the stack's folder does. A
compose.override.yaml or a COMPOSE_FILE in .env applied in a terminal
and was dropped by the next Start in StackPilot, which recreated the
containers without it. A compose file's `name:` or a COMPOSE_PROJECT_NAME
in .env made the terminal's containers invisible: the stack showed as
stopped while running, and Start set up a second copy next to it.

Compose now runs in the stack's folder without -f, so it loads the same
files, and -p is the name compose itself picks there: COMPOSE_PROJECT_NAME
from .env, else the last `name:` among the compose files (${VAR} from
.env), else the folder name. A stack StackPilot already runs under its
folder name keeps that name while it has containers or volumes under it
and none under the declared one: switching would have brought it up
again on empty volumes. Backups record the project, and a restore takes
it from the files it has just put back.

Changes made outside StackPilot now reach every view:

- docker_watch_service reads Docker's events once for the backend. The
  dashboard rollup (cached 25s) and the stats cache are stale as soon as
  the event generation moves; the tiles followed a terminal `compose
  stop` after 15-30s, now within a second.
- Image events re-read the local digests, so a `docker compose pull` in
  a terminal clears the update badge instead of leaving it until the
  next registry check (an hour by default).
- The browser catches up when its event socket reconnects; what changed
  while it was down waited for the 60s poll.
- The stack detail carries a revision of the compose file and .env;
  saving after they changed on disk is refused with 409 and the editor
  offers to overwrite or to load the version on disk. A save used to
  replace a terminal edit without a word.
- A stack whose folder is gone is flagged "folder missing" instead of
  passing for an ordinary stopped stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu
2026-10-15 15:14:40 +02:00

153 lines
5.3 KiB
Python

"""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.
Sampling is not free: it is one blocking call to the daemon *per running
container*, and the dashboard and the stacks list both poll this every five
seconds. Two open tabs on a 40-container host meant a sustained ~16 samples a
second. Results are therefore cached for :data:`CACHE_TTL`, the same shape
``dashboard_service`` already uses for its fleet aggregate — one sweep serves
every reader in the window, and the numbers stay well inside what a
five-second poll can show.
"""
from __future__ import annotations
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from docker_client import DockerError, get_client, safe_call
from services import docker_watch_service
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
#: Slightly under the frontend's 5s poll, so a refresh usually gets fresh
#: numbers while concurrent readers still share one sweep.
CACHE_TTL = 4.0
_cache: dict = {"data": None, "ts": 0.0}
# Held across the sample so N simultaneous callers trigger one sweep, not N.
_lock = threading.Lock()
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(refresh: bool = False) -> 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. Served from a short-lived cache
unless ``refresh`` is set.
"""
def fresh() -> bool:
# Also stale once Docker reported a change (a container stopped in a
# terminal), see services/docker_watch_service.py.
return (
_cache["data"] is not None
and time.monotonic() - _cache["ts"] < CACHE_TTL
and _cache.get("gen") == docker_watch_service.generation()
)
if not refresh and fresh():
return _cache["data"]
with _lock:
# Somebody may have refreshed it while we waited for the lock.
if not refresh and fresh():
return _cache["data"]
gen = docker_watch_service.generation()
data = _sample()
_cache["data"] = data
_cache["ts"] = time.monotonic()
_cache["gen"] = gen
return data
def _sample() -> dict:
"""One full sweep across every running container."""
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()
}