Dashboard: rebuild into an operator cockpit (0.38.0)
Replace the analytics-style dashboard (stack-health funnel, uptime %, operations/day grid, AI pill) with an attention-driven fleet cockpit: - New /api/dashboard/fleet endpoint: server-side fan-out across the local host and every agent into one payload — a prioritized "needs attention" list, headline KPIs, an honest stack-status breakdown and a per-host resource rollup. Each agent uses its own DB session so the fan-out is concurrency-safe; failures degrade to "offline" instead of stalling. - New frontend: AttentionStrip, FleetKpiRow, StackStatusBar and HostResourceTable; Dashboard.tsx rewritten around them. - Remove the funnel/summary endpoints, the uptime sampler loop and the ops-activity machinery; delete the now-unused chart components. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5c46e40866
commit
c830d28b65
@@ -1,40 +1,38 @@
|
||||
"""Aggregated dashboard data: stack-health funnel + summary widgets.
|
||||
"""Fleet aggregate for the dashboard cockpit.
|
||||
|
||||
Everything here is read-only and cheap by construction: one container
|
||||
*summary* list (no per-container inspect) feeds the whole funnel, image
|
||||
freshness comes from the cache the update-service background loop already
|
||||
maintains, and the daily uptime sample is appended lazily on read.
|
||||
One read-only call rolls up every host (local + agents) into a "needs
|
||||
attention" list, headline KPIs and a per-host resource view. It is cheap by
|
||||
construction: a single container *summary* list (no per-container inspect)
|
||||
drives the local figures, image freshness comes from the cache the
|
||||
update-service background loop already maintains, and per agent it makes a
|
||||
small bounded set of HTTP fetches that degrade gracefully on failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from config import settings
|
||||
from database import engine
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from models.audit import AuditLog
|
||||
from models.auto_update import AutoUpdate
|
||||
from services import compose_service, update_service
|
||||
from models.agent import Agent
|
||||
from models.backup_schedule import BackupSchedule
|
||||
from services import agent_service, compose_service, update_service
|
||||
|
||||
COMPOSE_LABEL = compose_service.COMPOSE_LABEL
|
||||
DOCKER_TIMEOUT = 5.0 # seconds — a slow daemon must not stall the dashboard
|
||||
FUNNEL_TTL = 30.0
|
||||
|
||||
UPTIME_FILE = os.path.join(settings.DATA_DIR, "uptime.jsonl")
|
||||
UPTIME_DAYS = 30
|
||||
UPTIME_SAMPLE_INTERVAL = 300.0 # seconds between uptime samples
|
||||
# Fleet aggregate: cached briefly because each call may fan out to every agent.
|
||||
FLEET_TTL = 25.0
|
||||
DISK_PRESSURE = 0.85 # disk used fraction above which a host needs attention
|
||||
MEM_PRESSURE = 0.90 # memory used fraction above which a host needs attention
|
||||
|
||||
_funnel_cache: dict = {"data": None, "ts": 0.0}
|
||||
_fleet_cache: dict = {"data": None, "ts": 0.0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Container summary (single Docker round-trip)
|
||||
# Container summary (single Docker round-trip) + shared classifiers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@@ -43,10 +41,6 @@ def _list_containers() -> list[dict]:
|
||||
return safe_call(client.api.containers, all=True)
|
||||
|
||||
|
||||
async def _containers_with_timeout() -> list[dict]:
|
||||
return await asyncio.wait_for(asyncio.to_thread(_list_containers), timeout=DOCKER_TIMEOUT)
|
||||
|
||||
|
||||
def _group_by_project(raw: list[dict]) -> dict[str, list[dict]]:
|
||||
by_project: dict[str, list[dict]] = {}
|
||||
for c in raw:
|
||||
@@ -78,226 +72,281 @@ def _is_updated(containers: list[dict], cache: dict[str, dict]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _auto_managed_ids(session: Session) -> set[str]:
|
||||
"""Local stacks with an enabled auto-update policy."""
|
||||
rows = session.exec(
|
||||
select(AutoUpdate.stack_id).where(
|
||||
AutoUpdate.enabled == True, # noqa: E712 — SQL expression, not identity
|
||||
AutoUpdate.agent_id == None, # noqa: E711
|
||||
)
|
||||
).all()
|
||||
return set(rows)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fleet aggregate (one call → "needs attention" + KPIs across every host)
|
||||
#
|
||||
# The dashboard used to poll each agent individually and recombine the numbers
|
||||
# client-side. ``compute_fleet`` does the fan-out server-side instead: one local
|
||||
# Docker pass plus, per online agent, a small set of system/stacks/updates
|
||||
# fetches — all wrapped so a slow or broken agent degrades to "offline" rather
|
||||
# than stalling the whole view. Cached for FLEET_TTL because the fan-out is not
|
||||
# free.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Stack-status buckets the status bar / KPIs are built from. Anything reporting
|
||||
# "error" or "dead" containers counts as a problem stack.
|
||||
_PROBLEM_STATUSES = {"error", "dead"}
|
||||
|
||||
|
||||
async def compute_funnel(session: Session, refresh: bool = False) -> dict:
|
||||
now = time.time()
|
||||
if not refresh and _funnel_cache["data"] and now - _funnel_cache["ts"] < FUNNEL_TTL:
|
||||
return _funnel_cache["data"]
|
||||
def _bucket_statuses(statuses: list[str]) -> dict[str, int]:
|
||||
return {
|
||||
"running": sum(1 for s in statuses if s == "running"),
|
||||
"partial": sum(1 for s in statuses if s == "partial"),
|
||||
"stopped": sum(1 for s in statuses if s in ("stopped", "exited")),
|
||||
"error": sum(1 for s in statuses if s in _PROBLEM_STATUSES),
|
||||
"total": len(statuses),
|
||||
}
|
||||
|
||||
discovered_ids = compose_service.discover_stacks()
|
||||
|
||||
def _attn(severity: str, kind: str, host: str, title: str, detail: str, link: str) -> dict:
|
||||
return {
|
||||
"severity": severity,
|
||||
"kind": kind,
|
||||
"host": host,
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
"link": link,
|
||||
}
|
||||
|
||||
|
||||
def _resource_attention(name: str, link: str, mem_used: int, mem_total: int,
|
||||
disk_used: int, disk_total: int) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
if mem_total and mem_used / mem_total >= MEM_PRESSURE:
|
||||
pct = round(mem_used / mem_total * 100)
|
||||
items.append(_attn("warn", "mem_pressure", name,
|
||||
f"{name}: memory at {pct}%", "Free memory or move stacks.", link))
|
||||
if disk_total and disk_used / disk_total >= DISK_PRESSURE:
|
||||
pct = round(disk_used / disk_total * 100)
|
||||
items.append(_attn("warn", "disk_pressure", name,
|
||||
f"{name}: disk at {pct}%", "Prune images/volumes or add capacity.", link))
|
||||
return items
|
||||
|
||||
|
||||
def _local_host() -> tuple[dict, list[dict]]:
|
||||
"""Local host card + attention items from a single Docker container pass."""
|
||||
discovered = compose_service.discover_stacks()
|
||||
try:
|
||||
raw = await _containers_with_timeout()
|
||||
except (DockerError, asyncio.TimeoutError):
|
||||
raw = _list_containers()
|
||||
except DockerError:
|
||||
raw = []
|
||||
by_project = _group_by_project(raw)
|
||||
update_cache = update_service.get_cache()
|
||||
auto_managed = _auto_managed_ids(session)
|
||||
|
||||
running = healthy = updated = monitored = 0
|
||||
for stack_id in discovered_ids:
|
||||
statuses: list[str] = []
|
||||
unhealthy: list[str] = []
|
||||
updates = 0
|
||||
for stack_id in discovered:
|
||||
containers = by_project.get(stack_id, [])
|
||||
states = [c.get("State", "") for c in containers]
|
||||
if not states or any(s != "running" for s in states):
|
||||
continue
|
||||
running += 1
|
||||
if not _is_healthy(containers):
|
||||
continue
|
||||
healthy += 1
|
||||
status = compose_service._status_from_states([c.get("State", "") for c in containers])
|
||||
statuses.append(status)
|
||||
if status == "running" and not _is_healthy(containers):
|
||||
unhealthy.append(stack_id)
|
||||
if not _is_updated(containers, update_cache):
|
||||
continue
|
||||
updated += 1
|
||||
# Final stage: the stack also keeps itself fresh (auto-update enabled).
|
||||
if stack_id in auto_managed:
|
||||
monitored += 1
|
||||
updates += 1
|
||||
|
||||
buckets = _bucket_statuses(statuses)
|
||||
labelled = [c for c in raw if (c.get("Labels") or {}).get(COMPOSE_LABEL)]
|
||||
|
||||
# Local resource figures (lazy import keeps dashboard_service free of a
|
||||
# router dependency at module load time).
|
||||
from routers.system import _cpu_count, _disk_usage, _mem_info
|
||||
mem = _mem_info()
|
||||
disk = _disk_usage()
|
||||
|
||||
host = {
|
||||
"id": "local",
|
||||
"name": "local",
|
||||
"online": True,
|
||||
"status": "online",
|
||||
"cpu_cores": _cpu_count(),
|
||||
"mem_used": mem["used"], "mem_total": mem["total"],
|
||||
"disk_used": disk["used"], "disk_total": disk["total"],
|
||||
"stacks": buckets,
|
||||
"containers_running": sum(1 for c in labelled if c.get("State") == "running"),
|
||||
"containers_total": len(labelled),
|
||||
"unhealthy": len(unhealthy),
|
||||
"updates_available": updates,
|
||||
}
|
||||
|
||||
attention: list[dict] = []
|
||||
for sid in unhealthy:
|
||||
attention.append(_attn("error", "unhealthy", "local",
|
||||
f"{sid} is unhealthy", "A container is failing its healthcheck.",
|
||||
f"/stacks/{sid}"))
|
||||
if buckets["error"]:
|
||||
attention.append(_attn("error", "stack_error", "local",
|
||||
f"{buckets['error']} stack(s) in error", "Containers are dead.", "/stacks"))
|
||||
if buckets["partial"]:
|
||||
attention.append(_attn("warn", "stack_partial", "local",
|
||||
f"{buckets['partial']} stack(s) partially running",
|
||||
"Some services are down.", "/stacks"))
|
||||
if updates:
|
||||
attention.append(_attn("warn", "updates", "local",
|
||||
f"{updates} stack(s) have image updates", "Pull the newer images.", "/images"))
|
||||
attention += _resource_attention("local", "/", mem["used"], mem["total"],
|
||||
disk["used"], disk["total"])
|
||||
return host, attention
|
||||
|
||||
|
||||
def _offline_host(agent_id: int, name: str, status: str) -> dict:
|
||||
return {"id": agent_id, "name": name, "online": False, "status": status,
|
||||
"cpu_cores": 0, "mem_used": 0, "mem_total": 0, "disk_used": 0, "disk_total": 0,
|
||||
"stacks": _bucket_statuses([]), "containers_running": 0, "containers_total": 0,
|
||||
"unhealthy": 0, "updates_available": 0}
|
||||
|
||||
|
||||
async def _agent_host(agent_id: int) -> tuple[dict, list[dict]]:
|
||||
"""One agent's host card + attention items, tolerant of partial failure.
|
||||
|
||||
Opens its own Session so the fan-out across agents stays concurrency-safe
|
||||
(a shared SQLModel session is not), and fetches sequentially within the
|
||||
agent because :func:`agent_service.call` commits a status update each time.
|
||||
"""
|
||||
with Session(engine) as session:
|
||||
agent = session.get(Agent, agent_id)
|
||||
if agent is None:
|
||||
return _offline_host(agent_id, str(agent_id), "unknown"), []
|
||||
name = agent.name
|
||||
# Agent stacks live in the host section of the dashboard, not a route
|
||||
# of their own, so aggregate agent items deep-link back to it.
|
||||
link = "/"
|
||||
|
||||
if agent.status != "online":
|
||||
return _offline_host(agent_id, name, agent.status), [
|
||||
_attn("error", "agent_offline", name,
|
||||
f"{name} is {agent.status}", "Check it under Settings → Remote hosts.",
|
||||
"/settings")]
|
||||
|
||||
async def _fetch(path: str):
|
||||
try:
|
||||
return await agent_service.call(session, agent, "GET", path)
|
||||
except Exception: # AgentError or transport — degrade gracefully
|
||||
return None
|
||||
|
||||
sys_data = await _fetch("/agent/system")
|
||||
stacks = await _fetch("/agent/stacks")
|
||||
updates = await _fetch("/agent/stacks/updates")
|
||||
|
||||
if sys_data is None and stacks is None:
|
||||
# Couldn't reach it at all — agent_service.call already marked it offline.
|
||||
return _offline_host(agent_id, name, "offline"), [
|
||||
_attn("error", "agent_offline", name,
|
||||
f"{name} is unreachable", "Check it under Settings → Remote hosts.",
|
||||
"/settings")]
|
||||
|
||||
sys_data = sys_data or {}
|
||||
statuses = [s.get("status", "") for s in (stacks or [])]
|
||||
buckets = _bucket_statuses(statuses)
|
||||
update_count = sum(1 for v in (updates or {}).values()
|
||||
if isinstance(v, dict) and v.get("update_available"))
|
||||
|
||||
host = {
|
||||
"id": agent_id,
|
||||
"name": name,
|
||||
"online": True,
|
||||
"status": "online",
|
||||
"cpu_cores": sys_data.get("cpu_cores", 0),
|
||||
"mem_used": sys_data.get("mem_used", 0), "mem_total": sys_data.get("mem_total", 0),
|
||||
"disk_used": sys_data.get("disk_used", 0), "disk_total": sys_data.get("disk_total", 0),
|
||||
"stacks": buckets,
|
||||
"containers_running": sys_data.get("compose_running", sys_data.get("containers_running", 0)),
|
||||
"containers_total": sys_data.get("containers_total", 0),
|
||||
"unhealthy": buckets["error"], # agents expose no healthcheck rollup; error is the proxy
|
||||
"updates_available": update_count,
|
||||
}
|
||||
|
||||
attention: list[dict] = []
|
||||
if buckets["error"]:
|
||||
attention.append(_attn("error", "stack_error", name,
|
||||
f"{name}: {buckets['error']} stack(s) in error",
|
||||
"Containers are dead.", link))
|
||||
if buckets["partial"]:
|
||||
attention.append(_attn("warn", "stack_partial", name,
|
||||
f"{name}: {buckets['partial']} stack(s) partially running",
|
||||
"Some services are down.", link))
|
||||
if update_count:
|
||||
attention.append(_attn("warn", "updates", name,
|
||||
f"{name}: {update_count} stack(s) have image updates",
|
||||
"Pull the newer images.", link))
|
||||
attention += _resource_attention(name, link,
|
||||
host["mem_used"], host["mem_total"],
|
||||
host["disk_used"], host["disk_total"])
|
||||
return host, attention
|
||||
|
||||
|
||||
def _backup_attention(session: Session, agent_names: dict[int, str]) -> list[dict]:
|
||||
"""Flag enabled backup schedules whose last run failed or is overdue."""
|
||||
now = datetime.now(timezone.utc)
|
||||
items: list[dict] = []
|
||||
for sch in session.exec(select(BackupSchedule).where(BackupSchedule.enabled == True)).all(): # noqa: E712
|
||||
host = agent_names.get(sch.agent_id, "local") if sch.agent_id else "local"
|
||||
status = (sch.last_status or "").lower()
|
||||
if status and not status.startswith("ok"):
|
||||
items.append(_attn("error", "backup_failed", host,
|
||||
f"Backup of {sch.stack_id} failed", sch.last_status or "",
|
||||
"/settings"))
|
||||
elif sch.next_run and _aware(sch.next_run) < now - timedelta(hours=1):
|
||||
items.append(_attn("warn", "backup_overdue", host,
|
||||
f"Backup of {sch.stack_id} is overdue",
|
||||
"Scheduled run did not happen.", "/settings"))
|
||||
return items
|
||||
|
||||
|
||||
def _aware(dt: datetime) -> datetime:
|
||||
"""Treat naive DB timestamps as UTC (they're stored that way)."""
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
_SEVERITY_ORDER = {"error": 0, "warn": 1}
|
||||
|
||||
|
||||
async def compute_fleet(session: Session, refresh: bool = False) -> dict:
|
||||
now = time.time()
|
||||
if not refresh and _fleet_cache["data"] and now - _fleet_cache["ts"] < FLEET_TTL:
|
||||
return _fleet_cache["data"]
|
||||
|
||||
local_host, attention = await asyncio.to_thread(_local_host)
|
||||
|
||||
agents = session.exec(select(Agent)).all()
|
||||
agent_names = {a.id: a.name for a in agents}
|
||||
agent_ids = [a.id for a in agents]
|
||||
agent_results = await asyncio.gather(*[_agent_host(aid) for aid in agent_ids])
|
||||
|
||||
hosts = [local_host]
|
||||
for host, items in agent_results:
|
||||
hosts.append(host)
|
||||
attention += items
|
||||
attention += _backup_attention(session, agent_names)
|
||||
|
||||
attention.sort(key=lambda a: _SEVERITY_ORDER.get(a["severity"], 9))
|
||||
|
||||
kpis = {
|
||||
"hosts_online": sum(1 for h in hosts if h["online"]),
|
||||
"hosts_total": len(hosts),
|
||||
"stacks_running": sum(h["stacks"]["running"] for h in hosts),
|
||||
"stacks_partial": sum(h["stacks"]["partial"] for h in hosts),
|
||||
"stacks_total": sum(h["stacks"]["total"] for h in hosts),
|
||||
"containers_running": sum(h["containers_running"] for h in hosts),
|
||||
"containers_total": sum(h["containers_total"] for h in hosts),
|
||||
"unhealthy": sum(h["unhealthy"] for h in hosts),
|
||||
"updates_available": sum(h["updates_available"] for h in hosts),
|
||||
"backups_failing": sum(1 for a in attention if a["kind"] in ("backup_failed", "backup_overdue")),
|
||||
}
|
||||
status_totals = {
|
||||
"running": kpis["stacks_running"],
|
||||
"partial": kpis["stacks_partial"],
|
||||
"stopped": sum(h["stacks"]["stopped"] for h in hosts),
|
||||
"error": sum(h["stacks"]["error"] for h in hosts),
|
||||
}
|
||||
|
||||
data = {
|
||||
"discovered": len(discovered_ids),
|
||||
"running": running,
|
||||
"healthy": healthy,
|
||||
"updated": updated,
|
||||
"monitored": monitored,
|
||||
"as_of": datetime.now(timezone.utc).isoformat(),
|
||||
"hosts": hosts,
|
||||
"kpis": kpis,
|
||||
"status_totals": status_totals,
|
||||
"attention": attention,
|
||||
}
|
||||
_funnel_cache["data"] = data
|
||||
_funnel_cache["ts"] = now
|
||||
_fleet_cache["data"] = data
|
||||
_fleet_cache["ts"] = now
|
||||
return data
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uptime series (sampled every few minutes, aggregated to a daily mean)
|
||||
#
|
||||
# Each sample is one JSONL line {"ts": iso, "value": pct}; a background loop
|
||||
# samples every UPTIME_SAMPLE_INTERVAL and summary reads top up opportunistically,
|
||||
# so the daily value approximates real uptime instead of a once-a-day snapshot.
|
||||
# Legacy pre-0.31.1 lines {"date": d, "value": pct} still count as one sample.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _read_uptime() -> list[dict]:
|
||||
if not os.path.isfile(UPTIME_FILE):
|
||||
return []
|
||||
entries = []
|
||||
with open(UPTIME_FILE, "r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def _append_uptime(entry: dict) -> None:
|
||||
os.makedirs(settings.DATA_DIR, exist_ok=True)
|
||||
with open(UPTIME_FILE, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(entry) + "\n")
|
||||
|
||||
|
||||
def _entry_date(e: dict) -> Optional[str]:
|
||||
if "date" in e:
|
||||
return e["date"]
|
||||
ts = e.get("ts")
|
||||
return ts[:10] if isinstance(ts, str) and len(ts) >= 10 else None
|
||||
|
||||
|
||||
def _latest_sample_ts(entries: list[dict]) -> float:
|
||||
latest = 0.0
|
||||
for e in entries:
|
||||
ts = e.get("ts")
|
||||
if not isinstance(ts, str):
|
||||
continue
|
||||
try:
|
||||
latest = max(latest, datetime.fromisoformat(ts).timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
return latest
|
||||
|
||||
|
||||
def _sample_uptime(raw: list[dict]) -> Optional[dict]:
|
||||
"""Record a sample if the last one is older than the sample interval.
|
||||
|
||||
Uptime% = share of compose containers currently running. With no compose
|
||||
containers at all there is nothing to be up — skip rather than fake 100%.
|
||||
"""
|
||||
entries = _read_uptime()
|
||||
now = datetime.now(timezone.utc)
|
||||
if now.timestamp() - _latest_sample_ts(entries) < UPTIME_SAMPLE_INTERVAL:
|
||||
return None
|
||||
labelled = [c for c in raw if (c.get("Labels") or {}).get(COMPOSE_LABEL)]
|
||||
if not labelled:
|
||||
return None
|
||||
running = sum(1 for c in labelled if c.get("State") == "running")
|
||||
value = round(running / len(labelled) * 100, 1)
|
||||
entry = {"ts": now.isoformat(timespec="seconds"), "value": value}
|
||||
_append_uptime(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def prune_uptime_file() -> None:
|
||||
"""Drop samples older than twice the chart window (called at startup)."""
|
||||
entries = _read_uptime()
|
||||
if not entries:
|
||||
return
|
||||
cutoff = (datetime.now(timezone.utc).date() - timedelta(days=UPTIME_DAYS * 2)).isoformat()
|
||||
kept = [e for e in entries if (_entry_date(e) or cutoff) >= cutoff]
|
||||
if len(kept) == len(entries):
|
||||
return
|
||||
os.makedirs(settings.DATA_DIR, exist_ok=True)
|
||||
tmp = UPTIME_FILE + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
for e in kept:
|
||||
fh.write(json.dumps(e) + "\n")
|
||||
os.replace(tmp, UPTIME_FILE)
|
||||
|
||||
|
||||
def uptime_series(raw: list[dict]) -> list[dict]:
|
||||
_sample_uptime(raw)
|
||||
by_date: dict[str, list[float]] = {}
|
||||
for e in _read_uptime():
|
||||
day = _entry_date(e)
|
||||
value = e.get("value")
|
||||
if day and isinstance(value, (int, float)):
|
||||
by_date.setdefault(day, []).append(float(value))
|
||||
series = []
|
||||
today = datetime.now(timezone.utc).date()
|
||||
last_value: Optional[float] = None
|
||||
for i in range(UPTIME_DAYS - 1, -1, -1):
|
||||
day = (today - timedelta(days=i)).isoformat()
|
||||
samples = by_date.get(day)
|
||||
if samples:
|
||||
last_value = round(sum(samples) / len(samples), 1)
|
||||
# Days before monitoring started (or gaps) reuse the last known value
|
||||
# so the chart doesn't show artificial dips.
|
||||
series.append({"date": day, "value": last_value})
|
||||
return series
|
||||
|
||||
|
||||
async def uptime_sampler_loop() -> None:
|
||||
"""Background task: keep the uptime series fed even when nobody is
|
||||
looking at the dashboard."""
|
||||
prune_uptime_file()
|
||||
while True:
|
||||
try:
|
||||
raw = await _containers_with_timeout()
|
||||
await asyncio.to_thread(_sample_uptime, raw)
|
||||
except (DockerError, asyncio.TimeoutError, OSError):
|
||||
pass
|
||||
await asyncio.sleep(UPTIME_SAMPLE_INTERVAL)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Ops (audit-log) activity
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
|
||||
def ops_activity(session: Session) -> tuple[list[dict], Optional[str]]:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
cutoff = datetime.combine(today - timedelta(days=UPTIME_DAYS - 1), datetime.min.time(), timezone.utc)
|
||||
timestamps = session.exec(
|
||||
select(AuditLog.timestamp).where(AuditLog.timestamp >= cutoff)
|
||||
).all()
|
||||
|
||||
per_day: dict[str, int] = {}
|
||||
per_weekday = [0] * 7
|
||||
for ts in timestamps:
|
||||
per_day[ts.date().isoformat()] = per_day.get(ts.date().isoformat(), 0) + 1
|
||||
per_weekday[ts.weekday()] += 1
|
||||
|
||||
series = []
|
||||
for i in range(UPTIME_DAYS - 1, -1, -1):
|
||||
day = (today - timedelta(days=i)).isoformat()
|
||||
series.append({"date": day, "count": per_day.get(day, 0)})
|
||||
|
||||
peak = _WEEKDAYS[per_weekday.index(max(per_weekday))] if any(per_weekday) else None
|
||||
return series, peak
|
||||
|
||||
|
||||
async def compute_summary(session: Session) -> dict:
|
||||
try:
|
||||
raw = await _containers_with_timeout()
|
||||
except (DockerError, asyncio.TimeoutError):
|
||||
raw = []
|
||||
labelled = [c for c in raw if (c.get("Labels") or {}).get(COMPOSE_LABEL)]
|
||||
ops_series, peak = ops_activity(session)
|
||||
return {
|
||||
"total_containers": sum(1 for c in labelled if c.get("State") == "running"),
|
||||
"containers_total": len(labelled),
|
||||
"uptime_series": uptime_series(raw),
|
||||
"ops_last_30d": ops_series,
|
||||
"ops_peak_day": peak,
|
||||
"as_of": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user