diff --git a/backend/main.py b/backend/main.py index aae98c7..cb1e6a5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -36,7 +36,7 @@ from routers import ( volumes, ws, ) -from services import dashboard_service, schedule_service, template_service, update_service +from services import schedule_service, template_service, update_service logging.basicConfig(level=logging.INFO) logger = logging.getLogger("stackpilot") @@ -59,12 +59,10 @@ 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=APP_VERSION, lifespan=lifespan) diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py index a294763..466d286 100644 --- a/backend/routers/dashboard.py +++ b/backend/routers/dashboard.py @@ -1,4 +1,4 @@ -"""Dashboard aggregation endpoints (funnel + summary widgets).""" +"""Dashboard aggregation endpoint (fleet-wide cockpit data).""" from __future__ import annotations from fastapi import APIRouter, Depends @@ -12,18 +12,12 @@ from services import dashboard_service router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) -@router.get("/funnel") -async def funnel( +@router.get("/fleet") +async def fleet( 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) + """Fleet-wide 'needs attention' list, KPIs and per-host rollup across the + local host and every agent — the data behind the operator cockpit.""" + return await dashboard_service.compute_fleet(session, refresh=refresh) diff --git a/backend/services/dashboard_service.py b/backend/services/dashboard_service.py index e492a76..e314ba7 100644 --- a/backend/services/dashboard_service.py +++ b/backend/services/dashboard_service.py @@ -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(), - } diff --git a/backend/version.py b/backend/version.py index 143041b..a779e43 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.37.7" +APP_VERSION = "0.38.0" diff --git a/frontend/package.json b/frontend/package.json index e75a12d..018c50a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.37.7", + "version": "0.38.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index ac32883..a475e42 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -1,35 +1,65 @@ import api from "./client"; -export interface FunnelData { - discovered: number; +export interface StackBuckets { running: number; - healthy: number; - updated: number; - monitored: number; - as_of: string; + partial: number; + stopped: number; + error: number; + total: number; } -export interface UptimePoint { - date: string; - value: number | null; -} - -export interface OpsPoint { - date: string; - count: number; -} - -export interface DashboardSummary { - total_containers: number; +export interface FleetHost { + id: string | number; // "local" or an agent id + name: string; + online: boolean; + status: string; + cpu_cores: number; + mem_used: number; + mem_total: number; + disk_used: number; + disk_total: number; + stacks: StackBuckets; + containers_running: number; containers_total: number; - uptime_series: UptimePoint[]; - ops_last_30d: OpsPoint[]; - ops_peak_day: string | null; + unhealthy: number; + updates_available: number; +} + +export interface FleetKpis { + hosts_online: number; + hosts_total: number; + stacks_running: number; + stacks_partial: number; + stacks_total: number; + containers_running: number; + containers_total: number; + unhealthy: number; + updates_available: number; + backups_failing: number; +} + +export type AttentionSeverity = "error" | "warn"; + +export interface AttentionItem { + severity: AttentionSeverity; + kind: string; + host: string; + title: string; + detail: string; + link: string; +} + +export interface FleetData { as_of: string; + hosts: FleetHost[]; + kpis: FleetKpis; + status_totals: { running: number; partial: number; stopped: number; error: number }; + attention: AttentionItem[]; } export const dashboardApi = { - funnel: (refresh = false) => - api.get(`/api/dashboard/funnel${refresh ? "?refresh=true" : ""}`).then((r) => r.data), - summary: () => api.get("/api/dashboard/summary").then((r) => r.data), + fleet: (refresh = false) => + api + .get(`/api/dashboard/fleet${refresh ? "?refresh=true" : ""}`) + .then((r) => r.data), }; diff --git a/frontend/src/components/dashboard/AiPromptBar.tsx b/frontend/src/components/dashboard/AiPromptBar.tsx deleted file mode 100644 index 83f54da..0000000 --- a/frontend/src/components/dashboard/AiPromptBar.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { ChevronDown, CornerDownLeft } from "lucide-react"; -import { cn } from "@/lib/utils"; - -const CONTEXT_TAGS = [ - { tag: "/running", filter: "running" }, - { tag: "/stopped", filter: "stopped" }, - { tag: "/attention", filter: "attention" }, -]; - -function SparkleIcon() { - return ( - - ); -} - -/** Inline explore-prompt: typed (or tag-clicked) queries jump to the Stacks - * page with a matching filter — no LLM behind it (yet). */ -export function AiPromptBar({ onSubmit }: { onSubmit?: (query: string) => void }) { - const [open, setOpen] = useState(true); - const [value, setValue] = useState(""); - const navigate = useNavigate(); - - const submit = (raw: string) => { - const query = raw.trim(); - if (!query) return; - if (onSubmit) { - onSubmit(query); - return; - } - const tag = CONTEXT_TAGS.find((t) => query.startsWith(t.tag)); - if (tag) navigate(`/stacks?filter=${tag.filter}`); - else navigate(`/stacks?q=${encodeURIComponent(query)}`); - }; - - return ( -
- - {open && ( -
-
{ - e.preventDefault(); - submit(value); - }} - className="flex items-center gap-2 rounded-xl border border-sp-border bg-sp-surface px-3 py-2" - > - setValue(e.target.value)} - placeholder="Show me stacks that are…" - className="min-w-0 flex-1 bg-transparent text-sm text-sp-text-1 placeholder:text-sp-text-3 focus:outline-none" - /> - -
-
- {CONTEXT_TAGS.map(({ tag }) => ( - - ))} -
-
- )} -
- ); -} diff --git a/frontend/src/components/dashboard/AttentionStrip.tsx b/frontend/src/components/dashboard/AttentionStrip.tsx new file mode 100644 index 0000000..1886364 --- /dev/null +++ b/frontend/src/components/dashboard/AttentionStrip.tsx @@ -0,0 +1,101 @@ +import { Link } from "react-router-dom"; +import { + AlertTriangle, + AlertCircle, + CheckCircle2, + ServerOff, + HeartPulse, + ArrowUpCircle, + HardDrive, + MemoryStick, + Archive, + ChevronRight, +} from "lucide-react"; +import type { AttentionItem } from "@/api/dashboard"; +import { cn } from "@/lib/utils"; + +const KIND_ICON: Record> = { + agent_offline: ServerOff, + unhealthy: HeartPulse, + stack_error: AlertTriangle, + stack_partial: AlertCircle, + updates: ArrowUpCircle, + disk_pressure: HardDrive, + mem_pressure: MemoryStick, + backup_failed: Archive, + backup_overdue: Archive, +}; + +export function AttentionStrip({ + items, + loading, +}: { + items?: AttentionItem[]; + loading: boolean; +}) { + if (loading) { + return ( +
+
+
+
+ ); + } + + if (!items || items.length === 0) { + return ( +
+ +

All systems healthy — nothing needs your attention.

+
+ ); + } + + const errors = items.filter((i) => i.severity === "error").length; + + return ( +
+
+

Needs attention

+ 0 + ? "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300" + : "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300" + )} + > + {items.length} + +
+
    + {items.map((item, i) => { + const Icon = KIND_ICON[item.kind] ?? AlertCircle; + const isError = item.severity === "error"; + return ( +
  • + + +
    +

    {item.title}

    + {item.detail && ( +

    {item.detail}

    + )} +
    + + +
  • + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/FleetKpiRow.tsx b/frontend/src/components/dashboard/FleetKpiRow.tsx new file mode 100644 index 0000000..85a7566 --- /dev/null +++ b/frontend/src/components/dashboard/FleetKpiRow.tsx @@ -0,0 +1,96 @@ +import { Link } from "react-router-dom"; +import { Server, Boxes, Container, HeartPulse, ArrowUpCircle, Archive } from "lucide-react"; +import type { FleetKpis } from "@/api/dashboard"; +import { cn } from "@/lib/utils"; + +type Tone = "default" | "warn" | "error"; + +function Kpi({ + icon, + label, + value, + sub, + tone = "default", + to, +}: { + icon: React.ReactNode; + label: string; + value: React.ReactNode; + sub?: string; + tone?: Tone; + to?: string; +}) { + const toneText = + tone === "error" + ? "text-red-600 dark:text-red-400" + : tone === "warn" + ? "text-sp-amber" + : "text-sp-text-1"; + const inner = ( +
+
+ {icon} + {label} +
+

{value}

+ {sub &&

{sub}

} +
+ ); + return to ? ( + + {inner} + + ) : ( + inner + ); +} + +export function FleetKpiRow({ kpis }: { kpis: FleetKpis }) { + return ( +
+ } + label="Hosts" + value={`${kpis.hosts_online}/${kpis.hosts_total}`} + sub="online" + tone={kpis.hosts_online < kpis.hosts_total ? "error" : "default"} + /> + } + label="Stacks" + value={`${kpis.stacks_running}/${kpis.stacks_total}`} + sub={kpis.stacks_partial > 0 ? `${kpis.stacks_partial} partial` : "running"} + tone={kpis.stacks_partial > 0 ? "warn" : "default"} + /> + } + label="Containers" + value={`${kpis.containers_running}/${kpis.containers_total}`} + sub="running" + /> + } + label="Unhealthy" + value={kpis.unhealthy} + sub={kpis.unhealthy > 0 ? "need a look" : "all healthy"} + tone={kpis.unhealthy > 0 ? "error" : "default"} + /> + } + label="Updates" + value={kpis.updates_available} + sub={kpis.updates_available > 0 ? "available" : "up to date"} + tone={kpis.updates_available > 0 ? "warn" : "default"} + to={kpis.updates_available > 0 ? "/images" : undefined} + /> + } + label="Backups" + value={kpis.backups_failing > 0 ? kpis.backups_failing : "OK"} + sub={kpis.backups_failing > 0 ? "failing/overdue" : "on schedule"} + tone={kpis.backups_failing > 0 ? "error" : "default"} + to={kpis.backups_failing > 0 ? "/settings" : undefined} + /> +
+ ); +} diff --git a/frontend/src/components/dashboard/FunnelChart.tsx b/frontend/src/components/dashboard/FunnelChart.tsx deleted file mode 100644 index 88b9e32..0000000 --- a/frontend/src/components/dashboard/FunnelChart.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useId, useState } from "react"; - -export interface FunnelStage { - label: string; - value: number; -} - -/** SVG waterfall funnel: bars alternate gradient / diagonal-hatch fill, - * with a value chip above each bar and a conversion tooltip on hover. */ -export function FunnelChart({ - stages, - onHover, -}: { - stages: FunnelStage[]; - onHover?: (i: number | null) => void; -}) { - const uid = useId().replace(/:/g, ""); - const [hover, setHover] = useState(null); - - const W = 640; - const H = 250; - const chipZone = 34; // space above bars for value chips - const max = Math.max(...stages.map((s) => s.value), 1); - const n = stages.length; - const gap = 14; - const colW = (W - gap * (n - 1)) / n; - - const setHovered = (i: number | null) => { - setHover(i); - onHover?.(i); - }; - - const gradId = `sp-funnel-grad-${uid}`; - const hatchId = `sp-funnel-hatch-${uid}`; - - return ( - `${s.label} ${s.value}`).join(", ")}`} - onMouseLeave={() => setHovered(null)} - > - - - - - - - - - - - - {stages.map((stage, i) => { - const x = i * (colW + gap); - const h = Math.max((stage.value / max) * (H - chipZone), stage.value > 0 ? 6 : 2); - const y = H - h; - const chipText = String(stage.value); - const chipW = chipText.length * 8.5 + 18; - const conv = stages[0].value > 0 ? Math.round((stage.value / stages[0].value) * 100) : 0; - const prev = i > 0 ? stages[i - 1].value : stage.value; - const drop = i > 0 && prev > 0 ? Math.round(((prev - stage.value) / prev) * 100) : 0; - const isHover = hover === i; - - return ( - - - {/* Value chip above the bar */} - - - - {chipText} - - - {/* Hover tooltip */} - {isHover && ( - - - - {stage.value} stacks · Conv: {conv}%{i > 0 ? ` · Drop-off: −${drop}%` : ""} - - - )} - {/* Transparent hit-target for the whole column. - pointer-events must be the SVG attribute (not CSS) for - fill="none" elements to receive events in Firefox. */} - setHovered(i)} - /> - - ); - })} - - ); -} diff --git a/frontend/src/components/dashboard/HostResourceTable.tsx b/frontend/src/components/dashboard/HostResourceTable.tsx new file mode 100644 index 0000000..0ac55c4 --- /dev/null +++ b/frontend/src/components/dashboard/HostResourceTable.tsx @@ -0,0 +1,100 @@ +import { Link } from "react-router-dom"; +import type { FleetHost } from "@/api/dashboard"; +import { cn, formatBytes } from "@/lib/utils"; + +const DISK_PRESSURE = 85; +const MEM_PRESSURE = 90; + +function Meter({ used, total, pressure }: { used: number; total: number; pressure: number }) { + const pct = total > 0 ? Math.round((used / total) * 100) : 0; + const hot = pct >= pressure; + return ( +
+
+ + {pct}% + + + {formatBytes(used)} / {formatBytes(total)} + +
+
+
+
+
+ ); +} + +export function HostResourceTable({ hosts }: { hosts: FleetHost[] }) { + return ( +
+
+

Hosts

+
+
+ + + + + + + + + + + + + {hosts.map((h) => ( + + + {h.online ? ( + <> + + + + + + + ) : ( + + )} + + ))} + +
HostCPUMemoryDiskContainersStacks
+
+ + {h.name} +
+
{h.cpu_cores || "—"} + + + + + {h.containers_running}/{h.containers_total} + + {h.stacks.running}/{h.stacks.total} + {h.unhealthy > 0 && ( + + · {h.unhealthy} unhealthy + + )} + + {h.status} —{" "} + + check under Settings + +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/OpsGrid.tsx b/frontend/src/components/dashboard/OpsGrid.tsx deleted file mode 100644 index 0776dd5..0000000 --- a/frontend/src/components/dashboard/OpsGrid.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** Contribution-grid style dot matrix: one column per day, cells light up - * bottom-to-top with the day's normalised activity. Fully deterministic — - * derived only from `data`, no randomness. */ -export function OpsGrid({ - data, - cols = 30, - rows = 5, -}: { - data: number[]; // normalised 0–1 per day - cols?: number; - rows?: number; -}) { - const days = data.slice(-cols); - const cell = 10; - const gap = 3; - const W = cols * (cell + gap) - gap; - const H = rows * (cell + gap) - gap; - - // 4 opacity tiers, like a contribution graph. - const tier = (v: number) => (v <= 0 ? 0.08 : v < 0.34 ? 0.28 : v < 0.67 ? 0.58 : 1); - - return ( - - {days.map((v, day) => { - const lit = Math.min(rows, Math.ceil(Math.max(0, Math.min(v, 1)) * rows)); - const x = day * (cell + gap); - return Array.from({ length: rows }, (_, r) => { - const y = (rows - 1 - r) * (cell + gap); - const on = r < lit; - return ( - - ); - }); - })} - - ); -} diff --git a/frontend/src/components/dashboard/StackStatusBar.tsx b/frontend/src/components/dashboard/StackStatusBar.tsx new file mode 100644 index 0000000..62052d2 --- /dev/null +++ b/frontend/src/components/dashboard/StackStatusBar.tsx @@ -0,0 +1,66 @@ +import { cn } from "@/lib/utils"; + +interface StatusTotals { + running: number; + partial: number; + stopped: number; + error: number; +} + +const SEGMENTS: { key: keyof StatusTotals; label: string; bar: string; dot: string }[] = [ + { key: "running", label: "Running", bar: "bg-sp-green", dot: "bg-sp-green" }, + { key: "partial", label: "Partial", bar: "bg-sp-amber", dot: "bg-sp-amber" }, + { key: "stopped", label: "Stopped", bar: "bg-slate-400", dot: "bg-slate-400" }, + { key: "error", label: "Error", bar: "bg-red-500", dot: "bg-red-500" }, +]; + +export function StackStatusBar({ + totals, + unhealthy, +}: { + totals: StatusTotals; + unhealthy: number; +}) { + const total = SEGMENTS.reduce((s, seg) => s + totals[seg.key], 0); + + return ( +
+
+

Stack status

+ {total} stacks +
+ +
+ {total === 0 ? null : ( + SEGMENTS.map((seg) => + totals[seg.key] > 0 ? ( +
+ ) : null + ) + )} +
+ +
+ {SEGMENTS.map((seg) => ( +
+ + {totals[seg.key]} + {seg.label} +
+ ))} + {unhealthy > 0 && ( +
+ + {unhealthy} + Unhealthy +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/UptimeChart.tsx b/frontend/src/components/dashboard/UptimeChart.tsx deleted file mode 100644 index 1f47c09..0000000 --- a/frontend/src/components/dashboard/UptimeChart.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useId } from "react"; - -/** Smooth SVG line chart with a soft gradient area fill underneath. - * `data` values are percentages (0–100); nulls (days before monitoring - * began) are rendered as a flat lead-in at the first known value. */ -export function UptimeChart({ - data, - color = "var(--sp-pink)", -}: { - data: (number | null)[]; - color?: string; -}) { - const uid = useId().replace(/:/g, ""); - const gradId = `sp-uptime-grad-${uid}`; - - const W = 320; - const H = 96; - const pad = 6; - - const firstKnown = data.find((v): v is number => v !== null) ?? 100; - const values = data.map((v) => v ?? firstKnown); - if (values.length === 0) values.push(100); - if (values.length === 1) values.push(values[0]); - - const min = Math.min(...values); - const lo = Math.max(0, Math.min(min - 5, 90)); - const span = 100 - lo || 1; - const stepX = (W - pad * 2) / (values.length - 1); - const pts = values.map((v, i) => ({ - x: pad + i * stepX, - y: pad + (1 - (v - lo) / span) * (H - pad * 2), - })); - - // Catmull-Rom → cubic bezier for a smooth line through every point. - let line = `M ${pts[0].x} ${pts[0].y}`; - for (let i = 0; i < pts.length - 1; i++) { - const p0 = pts[Math.max(i - 1, 0)]; - const p1 = pts[i]; - const p2 = pts[i + 1]; - const p3 = pts[Math.min(i + 2, pts.length - 1)]; - const c1x = p1.x + (p2.x - p0.x) / 6; - const c1y = p1.y + (p2.y - p0.y) / 6; - const c2x = p2.x - (p3.x - p1.x) / 6; - const c2y = p2.y - (p3.y - p1.y) / 6; - line += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`; - } - const area = `${line} L ${pts[pts.length - 1].x} ${H} L ${pts[0].x} ${H} Z`; - - return ( - - - - - - - - - - - - ); -} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 28ef576..049e76a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,155 +1,102 @@ import { useState } from "react"; -import { Link } from "react-router-dom"; -import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Cpu, - MemoryStick, - HardDrive, - Container, - Clock, - ArrowUpCircle, - RefreshCw, - Server, - Database, -} from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Clock, RefreshCw } from "lucide-react"; import { toast } from "sonner"; import { Card } from "@/components/ui"; import { HostHeader } from "@/components/hosts/HostHeader"; import { StacksTable } from "@/components/stacks/StacksTable"; -import { FunnelChart } from "@/components/dashboard/FunnelChart"; -import { UptimeChart } from "@/components/dashboard/UptimeChart"; -import { OpsGrid } from "@/components/dashboard/OpsGrid"; -import { AiPromptBar } from "@/components/dashboard/AiPromptBar"; +import { AttentionStrip } from "@/components/dashboard/AttentionStrip"; +import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow"; +import { StackStatusBar } from "@/components/dashboard/StackStatusBar"; +import { HostResourceTable } from "@/components/dashboard/HostResourceTable"; import { stacksApi } from "@/api/stacks"; import { systemApi } from "@/api/system"; -import { imagesApi } from "@/api/images"; import { agentsApi } from "@/api/agents"; -import { volumesApi } from "@/api/volumes"; import { dashboardApi } from "@/api/dashboard"; import { apiErrorMessage } from "@/api/client"; -import { cn, formatBytes, relativeTime } from "@/lib/utils"; +import { cn, relativeTime } from "@/lib/utils"; import { useAuthStore } from "@/store/auth"; import { useStackActions } from "@/hooks/useStackActions"; import type { Agent } from "@/types"; -const sumSizes = (m: Record) => - Object.values(m).reduce((a, b) => a + (b ?? 0), 0); - -type RangeDays = 7 | 30; - export function Dashboard() { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const { busyId, start, stop, restart } = useStackActions(); - const [range, setRange] = useState(30); + const qc = useQueryClient(); + const [refreshing, setRefreshing] = useState(false); - const funnel = useQuery({ - queryKey: ["dashboard-funnel"], - queryFn: () => dashboardApi.funnel(), - refetchInterval: 30000, - }); - const summary = useQuery({ - queryKey: ["dashboard-summary"], - queryFn: dashboardApi.summary, - refetchInterval: 60000, + const fleet = useQuery({ + queryKey: ["dashboard-fleet"], + queryFn: () => dashboardApi.fleet(), + refetchInterval: 20000, }); const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 }); const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 }); - const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 }); + const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 30000 }); const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 }); - const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 }); const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 }); - // Volumes total is from `docker system df` (slow, cached ~60s server-side). - const volSize = useQuery({ - queryKey: ["volumes-size", "local"], - queryFn: () => volumesApi.sizes().then(sumSizes), - refetchInterval: 60000, - }); - const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length; const hasAgents = (agents.data?.length ?? 0) > 0; + const refreshFleet = async () => { + setRefreshing(true); + try { + const data = await dashboardApi.fleet(true); + qc.setQueryData(["dashboard-fleet"], data); + } catch (e) { + toast.error(apiErrorMessage(e)); + } finally { + setRefreshing(false); + } + }; + return (
- {/* ---- Overview header ---- */} + {/* ---- Header ---- */}

Overview

-
- {([30, 7] as RangeDays[]).map((d) => ( - + +
+ + {/* ---- Needs attention ---- */} + + + {/* ---- Fleet KPIs ---- */} + {fleet.data ? ( + + ) : ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
))}
-
- - {updateCount > 0 && ( - - - {updateCount} image update{updateCount > 1 ? "s" : ""} available — view on the Images page. - )} - {/* ---- Analytics row: funnel + container count ---- */} -
- - 0 - ? Math.round((funnel.data.healthy / funnel.data.running) * 100) - : null - } - /> -
- - {/* ---- Bottom row: uptime + ops ---- */} -
- - -
- - {/* ---- Local host ---- */} -
- {hasAgents ? ( - + {/* ---- Status + hosts ---- */} +
+ {fleet.data ? ( + ) : ( -

This host

+
)} - + {fleet.data ? ( + + ) : ( +
+ )} +
+ + {/* ---- Local host stacks ---- */} +
+ {hasAgents ? :

This host

}
- {/* Remote hosts */} + {/* ---- Remote host stacks ---- */} {agents.data?.map((agent) => ( ))} - {/* Recent activity */} + {/* ---- Recent activity ---- */}

Recent activity @@ -199,236 +146,7 @@ export function Dashboard() { } /* ---------------------------------------------------------------------- */ -/* Analytics cards */ -/* ---------------------------------------------------------------------- */ - -const FUNNEL_STAGES = [ - { key: "discovered", label: "Discovered" }, - { key: "running", label: "Running" }, - { key: "healthy", label: "Healthy" }, - { key: "updated", label: "Up to date" }, - { key: "monitored", label: "Auto-managed" }, -] as const; - -function StackHealthCard({ - funnel, - loading, -}: { - funnel?: import("@/api/dashboard").FunnelData; - loading: boolean; -}) { - const qc = useQueryClient(); - const [refreshing, setRefreshing] = useState(false); - const [hovered, setHovered] = useState(null); - - const refresh = async () => { - setRefreshing(true); - try { - const data = await dashboardApi.funnel(true); - qc.setQueryData(["dashboard-funnel"], data); - } catch (e) { - toast.error(apiErrorMessage(e)); - } finally { - setRefreshing(false); - } - }; - - return ( -
-
-

Stack health

- -
- - {loading || !funnel ? ( -
-
-
-
- ) : ( - <> -
- {FUNNEL_STAGES.map(({ key, label }, i) => ( -
-

{label}

-

{funnel[key]}

-
- ))} -
- ({ label, value: funnel[key] }))} - onHover={setHovered} - /> - - )} - - -
- ); -} - -function ContainerCountCard({ - localRunning, - loading, - agents, - healthyRate, -}: { - localRunning?: number; - loading: boolean; - agents: Agent[]; - healthyRate: number | null; -}) { - const online = agents.filter((a) => a.status === "online"); - const remote = useQueries({ - queries: online.map((a) => ({ - queryKey: ["agent-system", a.id], - queryFn: () => agentsApi.system(a.id), - refetchInterval: 30000, - })), - }); - - const hosts: { name: string; count: number }[] = [ - { name: "local", count: localRunning ?? 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 ( -
-

Compose containers running

- {loading ? ( -
-
-
-
- ) : ( - <> -

{total}

-
- {hosts.map((h) => ( -
-
- {h.name} - {h.count} -
-
-
-
-
- ))} -
- - )} -
-
- -

- {healthyRate === null - ? "Insights appear once stacks are running." - : `${healthyRate}% of running stacks are healthy.`} -

-
-
-
- ); -} - -function UptimeCard({ - series, - loading, - range, -}: { - series?: { date: string; value: number | null }[]; - loading: boolean; - range: RangeDays; -}) { - const values = series?.map((p) => p.value) ?? []; - const latest = [...values].reverse().find((v): v is number => v !== null); - return ( -
-
-
-

Uptime

-

- {latest === undefined ? "—" : `${latest}%`} -

-
- {range}d -
-
- {loading || !series ? ( -
- ) : ( - - )} -
-

- Share of compose containers running on this host, sampled every 5 min (daily average). -

-
- ); -} - -function OpsCard({ - series, - peakDay, - loading, - range, -}: { - series?: { date: string; count: number }[]; - peakDay: string | null; - loading: boolean; - range: RangeDays; -}) { - const total = series?.reduce((s, p) => s + p.count, 0) ?? 0; - const max = Math.max(...(series?.map((p) => p.count) ?? []), 1); - return ( -
-
-
-

Operations

-

{total}

-
- {range}d -
-
- {loading || !series ? ( -
- ) : ( - p.count / max)} cols={range} /> - )} -
-

- {peakDay ? `Busiest day: ${peakDay}.` : "Audit-log actions per day."} -

-
- ); -} - -/* ---------------------------------------------------------------------- */ -/* Host sections (pre-Phase-24, retained) */ +/* Remote host stacks section */ /* ---------------------------------------------------------------------- */ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) { @@ -454,12 +172,6 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool enabled: online, refetchInterval: 30000, }); - const volSize = useQuery({ - queryKey: ["volumes-size", agent.id], - queryFn: () => volumesApi.sizes(false, agent.id).then(sumSizes), - enabled: online, - refetchInterval: 60000, - }); const run = async (action: string, label: string, id: string) => { setBusyId(id); @@ -486,18 +198,6 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool

) : ( - <> - run("restart", "Restarting", id)} emptyText="No stacks on this host." /> - )}

); } - -function ResourceBar({ - cpuCores, - memUsed, - memTotal, - diskUsed, - diskTotal, - volumesSize, - containersRunning, - containersTotal, - dockerVersion, -}: { - cpuCores: number; - memUsed: number; - memTotal: number; - diskUsed: number; - diskTotal: number; - volumesSize?: number; - containersRunning: number; - containersTotal: number; - dockerVersion: string; -}) { - return ( -
- } label="CPU cores" value={cpuCores || "—"} /> - } - label="Memory" - value={memTotal ? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}` : "—"} - /> - } - label="Disk" - value={diskTotal ? `${formatBytes(diskUsed)} / ${formatBytes(diskTotal)}` : "—"} - /> - } - label="Volumes" - value={volumesSize === undefined ? "…" : formatBytes(volumesSize)} - /> - } - label="Containers (all)" - value={`${containersRunning} / ${containersTotal}`} - /> - } label="Docker" value={dockerVersion || "—"} /> -
- ); -} - -function Stat({ - icon, - label, - value, -}: { - icon: React.ReactNode; - label: string; - value: React.ReactNode; -}) { - return ( - -
- {icon} -
-
-

{label}

-

{value}

-
-
- ); -}