0.31.1: make dashboard metrics honest

- Container card now compares compose-only counts across hosts: agents
  report compose_running in /agent/system (pre-0.31.1 agents fall back to
  the all-containers number); card retitled, ResourceBar stat labelled
  'Containers (all)'.
- Uptime is sampled every 5 min (background loop + opportunistic on read)
  and charted as daily averages instead of a once-a-day snapshot; no
  sample is written when no compose containers exist (was: fake 100%).
  Legacy daily entries in uptime.jsonl still count; file pruned at startup.
- Funnel stage 'monitored' is now per-stack and real: stacks with an
  enabled local auto-update policy (was: global webhook-exists toggle).
  Frontend label renamed to 'Auto-managed'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-12 08:13:16 +00:00
co-authored by Claude Fable 5
parent 1609b8bcc3
commit 11effdc2ca
7 changed files with 127 additions and 41 deletions
+90 -23
View File
@@ -19,7 +19,7 @@ from sqlmodel import Session, select
from config import settings
from docker_client import DockerError, get_client, safe_call
from models.audit import AuditLog
from models.setting import Webhook
from models.auto_update import AutoUpdate
from services import compose_service, update_service
COMPOSE_LABEL = compose_service.COMPOSE_LABEL
@@ -28,6 +28,7 @@ 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
_funnel_cache: dict = {"data": None, "ts": 0.0}
@@ -77,13 +78,15 @@ def _is_updated(containers: list[dict], cache: dict[str, dict]) -> bool:
return True
def _has_notify_target(session: Session) -> bool:
if settings.NOTIFY_WEBHOOKS:
return True
for wh in session.exec(select(Webhook)).all():
if wh.enabled:
return True
return False
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)
async def compute_funnel(session: Session, refresh: bool = False) -> dict:
@@ -98,7 +101,7 @@ async def compute_funnel(session: Session, refresh: bool = False) -> dict:
raw = []
by_project = _group_by_project(raw)
update_cache = update_service.get_cache()
notify_configured = _has_notify_target(session)
auto_managed = _auto_managed_ids(session)
running = healthy = updated = monitored = 0
for stack_id in discovered_ids:
@@ -113,7 +116,8 @@ async def compute_funnel(session: Session, refresh: bool = False) -> dict:
if not _is_updated(containers, update_cache):
continue
updated += 1
if notify_configured:
# Final stage: the stack also keeps itself fresh (auto-update enabled).
if stack_id in auto_managed:
monitored += 1
data = {
@@ -130,7 +134,12 @@ async def compute_funnel(session: Session, refresh: bool = False) -> dict:
# --------------------------------------------------------------------------- #
# Uptime series (one sample per day, JSONL on disk)
# 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.
# --------------------------------------------------------------------------- #
@@ -156,40 +165,98 @@ def _append_uptime(entry: dict) -> None:
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]:
"""Append today's sample if not yet recorded. Uptime% = share of compose
containers currently running."""
today = datetime.now(timezone.utc).date().isoformat()
"""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()
if any(e.get("date") == today for e in entries):
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)]
total = len(labelled)
if not labelled:
return None
running = sum(1 for c in labelled if c.get("State") == "running")
value = round(running / total * 100, 1) if total else 100.0
entry = {"date": today, "value": value}
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)
entries = _read_uptime()
by_date = {e["date"]: e for e in entries if "date" in e}
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()
e = by_date.get(day)
if e is not None:
last_value = e.get("value")
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
# --------------------------------------------------------------------------- #