diff --git a/README.md b/README.md index a070ad9..03cba9e 100644 --- a/README.md +++ b/README.md @@ -172,14 +172,16 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. (`bg`/`card`/`accent`) are remapped onto the tokens so all pages reskin consistently. Typeface: Schibsted Grotesk (bundled, offline-friendly). - **Stack Health funnel** — the dashboard centrepiece. Five stages - (`discovered → running → healthy → updated → monitored`) from - `GET /api/dashboard/funnel` (30 s server cache, `?refresh=true` to bust). - SVG waterfall with alternating gradient / diagonal-hatch bars, value chips - and hover conversion/drop-off tooltips. -- **Summary widgets** from `GET /api/dashboard/summary`: containers-running - card with per-host breakdown and an "Insights" chip (healthy-rate %), a - 30-day uptime line chart (daily samples appended to `DATA_DIR/uptime.jsonl`), - and an ops contribution grid from audit-log activity with the peak weekday. + (`discovered → running → healthy → updated → auto-managed`) from + `GET /api/dashboard/funnel` (30 s server cache, `?refresh=true` to bust; + the last stage — API key `monitored` — counts stacks with an enabled + auto-update policy). SVG waterfall with alternating gradient / + diagonal-hatch bars, value chips and hover conversion/drop-off tooltips. +- **Summary widgets** from `GET /api/dashboard/summary`: compose-containers + card with per-host breakdown (agents report a compose-only count) and an + "Insights" chip (healthy-rate %), a 30-day uptime line chart (sampled every + 5 min into `DATA_DIR/uptime.jsonl`, charted as daily averages), and an ops + contribution grid from audit-log activity with the peak weekday. A 30/7-day range selector slices both series client-side. - **Explore prompt bar** under the funnel: typed queries or `/running`, `/stopped`, `/attention` tags deep-link to the Stacks page, which now diff --git a/backend/agent_app.py b/backend/agent_app.py index 51f6979..d3facff 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -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.31.0" +AGENT_VERSION = "0.31.1" # --------------------------------------------------------------------------- # @@ -210,7 +210,7 @@ def _disk_info() -> tuple[int, int]: def _system_info() -> dict: docker_version = "" host_os = "" - running = total = 0 + running = total = compose_running = 0 try: client = get_client() docker_version = safe_call(client.version).get("Version", "") @@ -218,6 +218,12 @@ def _system_info() -> dict: host_os = info.get("OperatingSystem", "") running = info.get("ContainersRunning", 0) total = info.get("Containers", 0) + # Running compose-managed containers — the dashboard's container card + # compares this across hosts; counting everything would include this + # agent itself and skew the bars. + compose_running = len( + safe_call(client.api.containers, filters={"label": compose_service.COMPOSE_LABEL}) + ) except DockerError as exc: docker_version = f"unavailable ({exc.error})" mem_total, mem_used = _mem_info() @@ -233,6 +239,7 @@ def _system_info() -> dict: "disk_used": disk_used, "containers_running": running, "containers_total": total, + "compose_running": compose_running, } diff --git a/backend/main.py b/backend/main.py index 940f338..19718e6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -35,7 +35,7 @@ from routers import ( volumes, ws, ) -from services import schedule_service, template_service, update_service +from services import dashboard_service, schedule_service, template_service, update_service logging.basicConfig(level=logging.INFO) logger = logging.getLogger("stackpilot") @@ -58,13 +58,15 @@ async def lifespan(app: FastAPI): logger.warning("Legacy template migration failed: %s", exc) update_task = asyncio.create_task(update_service.background_loop()) schedule_task = asyncio.create_task(schedule_service.scheduler_loop()) + uptime_task = asyncio.create_task(dashboard_service.uptime_sampler_loop()) logger.info("StackPilot backend ready on port %s", settings.PORT) yield update_task.cancel() schedule_task.cancel() + uptime_task.cancel() -app = FastAPI(title="StackPilot", version="0.31.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.31.1", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/services/dashboard_service.py b/backend/services/dashboard_service.py index 8bfba94..e492a76 100644 --- a/backend/services/dashboard_service.py +++ b/backend/services/dashboard_service.py @@ -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 # --------------------------------------------------------------------------- # diff --git a/frontend/package.json b/frontend/package.json index 7fded5b..f44253a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.31.0", + "version": "0.31.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index 494f9e5..7f293bf 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -15,6 +15,7 @@ export interface AgentSystem { disk_used: number; containers_running: number; containers_total: number; + compose_running?: number; // agents < 0.31.1 don't report it } export const agentsApi = { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e8f6331..28ef576 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -207,7 +207,7 @@ const FUNNEL_STAGES = [ { key: "running", label: "Running" }, { key: "healthy", label: "Healthy" }, { key: "updated", label: "Up to date" }, - { key: "monitored", label: "Monitored" }, + { key: "monitored", label: "Auto-managed" }, ] as const; function StackHealthCard({ @@ -302,14 +302,19 @@ function ContainerCountCard({ const hosts: { name: string; count: number }[] = [ { name: "local", count: localRunning ?? 0 }, - ...online.map((a, i) => ({ name: a.name, count: remote[i].data?.containers_running ?? 0 })), + // compose_running keeps the bars comparable with the local compose-only + // count; pre-0.31.1 agents only report the all-containers number. + ...online.map((a, i) => ({ + name: a.name, + count: remote[i].data?.compose_running ?? remote[i].data?.containers_running ?? 0, + })), ]; const total = hosts.reduce((s, h) => s + h.count, 0); const max = Math.max(...hosts.map((h) => h.count), 1); return (
Daily share of compose containers running, this host.
++ Share of compose containers running on this host, sampled every 5 min (daily average). +