Phase 24: Design System v2 — analytics-style UI (0.30.0)
- New /api/dashboard/funnel (5-stage stack health, 30s TTL cache) and /api/dashboard/summary (containers, daily uptime jsonl, ops activity) - Token system (tokens.css + Tailwind sp-* aliases); legacy bg/card/accent remapped onto the tokens; Schibsted Grotesk bundled via fontsource - TopNav pill navigation + AppShell replace the sidebar layout (off-canvas drawer below 1024px); central display-weight page titles - Dashboard redesign: FunnelChart (gradient/hatch SVG waterfall), container count card with per-host bars + Insights chip, UptimeChart, OpsGrid, AiPromptBar; 30/7-day range selector; host sections retained below - Stacks page honours ?q= / ?filter= deep links + new status-filter select Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6464e0677c
commit
34cb215266
@@ -66,7 +66,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.29.0"
|
||||
AGENT_VERSION = "0.30.0"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
+3
-1
@@ -19,6 +19,7 @@ from routers import (
|
||||
auth,
|
||||
backups,
|
||||
containers,
|
||||
dashboard,
|
||||
destinations,
|
||||
editor,
|
||||
files,
|
||||
@@ -57,7 +58,7 @@ async def lifespan(app: FastAPI):
|
||||
schedule_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="StackPilot", version="0.29.0", lifespan=lifespan)
|
||||
app = FastAPI(title="StackPilot", version="0.30.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -80,6 +81,7 @@ app.include_router(auth.router)
|
||||
app.include_router(stacks.router)
|
||||
app.include_router(secrets.router)
|
||||
app.include_router(containers.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(volumes.router)
|
||||
app.include_router(editor.router)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Dashboard aggregation endpoints (funnel + summary widgets)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from auth import get_current_user
|
||||
from database import get_session
|
||||
from models.user import User
|
||||
from services import dashboard_service
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/funnel")
|
||||
async def funnel(
|
||||
refresh: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return await dashboard_service.compute_funnel(session, refresh=refresh)
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def summary(
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return await dashboard_service.compute_summary(session)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Aggregated dashboard data: stack-health funnel + summary widgets.
|
||||
|
||||
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.
|
||||
"""
|
||||
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 docker_client import DockerError, get_client, safe_call
|
||||
from models.audit import AuditLog
|
||||
from models.setting import Webhook
|
||||
from services import 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
|
||||
|
||||
_funnel_cache: dict = {"data": None, "ts": 0.0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Container summary (single Docker round-trip)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _list_containers() -> list[dict]:
|
||||
client = get_client()
|
||||
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:
|
||||
project = (c.get("Labels") or {}).get(COMPOSE_LABEL)
|
||||
if project:
|
||||
by_project.setdefault(project, []).append(c)
|
||||
return by_project
|
||||
|
||||
|
||||
def _is_healthy(containers: list[dict]) -> bool:
|
||||
"""All containers that *have* a healthcheck report healthy.
|
||||
|
||||
The summary ``Status`` string carries the health suffix — "(healthy)",
|
||||
"(unhealthy)" or "(health: starting)" — only for containers with a
|
||||
healthcheck configured, so its absence simply means "no healthcheck".
|
||||
"""
|
||||
for c in containers:
|
||||
status = c.get("Status", "") or ""
|
||||
if "(unhealthy)" in status or "(health:" in status:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_updated(containers: list[dict], cache: dict[str, dict]) -> bool:
|
||||
for c in containers:
|
||||
st = cache.get(c.get("Image", ""))
|
||||
if st and st.get("update_available"):
|
||||
return False
|
||||
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
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
discovered_ids = compose_service.discover_stacks()
|
||||
try:
|
||||
raw = await _containers_with_timeout()
|
||||
except (DockerError, asyncio.TimeoutError):
|
||||
raw = []
|
||||
by_project = _group_by_project(raw)
|
||||
update_cache = update_service.get_cache()
|
||||
notify_configured = _has_notify_target(session)
|
||||
|
||||
running = healthy = updated = monitored = 0
|
||||
for stack_id in discovered_ids:
|
||||
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
|
||||
if not _is_updated(containers, update_cache):
|
||||
continue
|
||||
updated += 1
|
||||
if notify_configured:
|
||||
monitored += 1
|
||||
|
||||
data = {
|
||||
"discovered": len(discovered_ids),
|
||||
"running": running,
|
||||
"healthy": healthy,
|
||||
"updated": updated,
|
||||
"monitored": monitored,
|
||||
"as_of": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
_funnel_cache["data"] = data
|
||||
_funnel_cache["ts"] = now
|
||||
return data
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uptime series (one sample per day, JSONL on disk)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
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 _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()
|
||||
entries = _read_uptime()
|
||||
if any(e.get("date") == today for e in entries):
|
||||
return None
|
||||
labelled = [c for c in raw if (c.get("Labels") or {}).get(COMPOSE_LABEL)]
|
||||
total = len(labelled)
|
||||
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}
|
||||
_append_uptime(entry)
|
||||
return entry
|
||||
|
||||
|
||||
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}
|
||||
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")
|
||||
# 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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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