feat(docker): the Docker page leads with a verdict, lists services and containers as filterable tables, and each service has its own page with logs across its tasks (#5298–#5302)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m4s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped

- #5298 "Docker overview: verdict line; only the problem tiles that aren't zero"
- #5299 "Docker overview: swarm services as a filterable, sortable table with a Stack column"
- #5300 "Docker overview: failures listed once, each with a link to the failed task's logs"
- #5301 "Docker overview: standalone containers in one table with a Host column"
- #5302 "Service page: replicas, image, task history, resource history and logs across all its tasks"

overview.py holds the rows, filters and sort as plain functions. The filter
bar sits outside the polled fragment and the URL keeps its state. A service's
page reads every task container Steward still holds anything for, the live
ones and removed ones known by their events, so a failed task's logs are
reachable after Docker has removed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:08:38 -04:00
co-authored by Claude Opus 5.5
parent 7dccb2f303
commit 2093f399e6
13 changed files with 1339 additions and 268 deletions
+9 -3
View File
@@ -94,7 +94,8 @@ def last_task_error(tasks: list[dict]) -> dict | None:
if t.get("state") in FAILED_TASK_STATES or t.get("error"):
return {"state": t.get("state"), "error": t.get("error") or None,
"message": t.get("message") or None, "exit_code": t.get("exit_code"),
"node_id": t.get("node_id") or None, "at": t.get("at") or None}
"node_id": t.get("node_id") or None, "at": t.get("at") or None,
"task_id": t.get("task_id") or None, "slot": t.get("slot")}
return None
@@ -233,11 +234,12 @@ def build_swarm_services(containers, services, node_hostname: dict, host_name: d
*, now: datetime | None = None, crash_loop_deaths: int = 3) -> dict:
"""Return {"services": [...], "standalone": [...], "superseded": {(host_id, name)}}.
services[i] = {name, mode, desired, running, state, replicas:[...],
services[i] = {name, mode, image, desired, running, state, replicas:[...],
previous_tasks, tasks_known, failures_1h, failures_24h,
crash_looping, last_error}
real replica = {ghost:False, host, host_id, name, status, cpu_pct, mem_pct,
health, restart_count} # current tasks only
mem_bytes, started_at, image, health, restart_count}
# current tasks only
ghost replica = {ghost:True, host, count} # placement with no local row
previous_tasks = task containers Swarm replaced, left out of replicas
failures_* = tasks that failed or were rejected in the window (None
@@ -288,6 +290,9 @@ def build_swarm_services(containers, services, node_hostname: dict, host_name: d
"status": getattr(c, "status", "unknown"),
"cpu_pct": getattr(c, "cpu_pct", None),
"mem_pct": getattr(c, "mem_pct", None),
"mem_bytes": getattr(c, "mem_usage_bytes", None),
"started_at": getattr(c, "started_at", None),
"image": getattr(c, "image", None) or None,
"health": getattr(c, "health", None),
"restart_count": getattr(c, "restart_count", 0) or 0,
})
@@ -329,6 +334,7 @@ def build_swarm_services(containers, services, node_hostname: dict, host_name: d
out_services.append({
"name": name,
"mode": getattr(svc, "mode", "replicated") if svc is not None else "replicated",
"image": getattr(svc, "image", None) if svc is not None else None,
"running": running,
"desired": desired,
"state": state,