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:
menzelj
2026-06-24 11:04:57 +00:00
co-authored by Claude Opus 4.8
parent 5c46e40866
commit c830d28b65
15 changed files with 771 additions and 1067 deletions
+1 -3
View File
@@ -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)
+6 -12
View File
@@ -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)
+280 -231
View File
@@ -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(),
}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.37.7"
APP_VERSION = "0.38.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.37.7",
"version": "0.38.0",
"type": "module",
"scripts": {
"dev": "vite",
+54 -24
View File
@@ -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<FunnelData>(`/api/dashboard/funnel${refresh ? "?refresh=true" : ""}`).then((r) => r.data),
summary: () => api.get<DashboardSummary>("/api/dashboard/summary").then((r) => r.data),
fleet: (refresh = false) =>
api
.get<FleetData>(`/api/dashboard/fleet${refresh ? "?refresh=true" : ""}`)
.then((r) => r.data),
};
@@ -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 (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 1.5 9.4 6 14 7.5 9.4 9 8 13.5 6.6 9 2 7.5 6.6 6 8 1.5Z"
fill="var(--sp-amber)"
/>
<path d="M13 11l.6 1.7L15.3 13l-1.7.6L13 15.3l-.6-1.7-1.7-.6 1.7-.6.6-1.7Z" fill="var(--sp-amber)" fillOpacity="0.7" />
</svg>
);
}
/** 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 (
<div className="rounded-2xl border border-sp-border bg-sp-surface-2">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-4 py-3 text-left"
aria-expanded={open}
>
<SparkleIcon />
<span className="text-sm font-medium text-sp-text-1">
What would you like to explore next?
</span>
<ChevronDown
className={cn("ml-auto h-4 w-4 text-sp-text-3 transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div className="px-4 pb-3.5">
<form
onSubmit={(e) => {
e.preventDefault();
submit(value);
}}
className="flex items-center gap-2 rounded-xl border border-sp-border bg-sp-surface px-3 py-2"
>
<input
value={value}
onChange={(e) => 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"
/>
<button
type="submit"
className="rounded-lg p-1.5 text-sp-text-3 hover:bg-sp-surface-2 hover:text-sp-text-1"
title="Go"
aria-label="Submit"
>
<CornerDownLeft className="h-4 w-4" />
</button>
</form>
<div className="mt-2 flex flex-wrap gap-1.5">
{CONTEXT_TAGS.map(({ tag }) => (
<button
key={tag}
onClick={() => submit(tag)}
className="rounded-chip bg-sp-amber/15 px-2 py-0.5 font-mono text-xs font-semibold text-sp-amber hover:bg-sp-amber/25"
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -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<string, React.ComponentType<{ className?: string }>> = {
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 (
<div className="sp-card p-4">
<div className="sp-skeleton h-5 w-40" />
<div className="sp-skeleton mt-3 h-10" />
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="flex items-center gap-3 rounded-card border border-sp-green/40 bg-sp-green/10 px-4 py-3 text-sp-green">
<CheckCircle2 className="h-5 w-5 shrink-0" />
<p className="text-sm font-medium">All systems healthy nothing needs your attention.</p>
</div>
);
}
const errors = items.filter((i) => i.severity === "error").length;
return (
<div className="sp-card overflow-hidden p-0">
<div className="flex items-center justify-between border-b border-sp-border px-4 py-2.5">
<h2 className="sp-label">Needs attention</h2>
<span
className={cn(
"rounded-pill px-2 py-0.5 text-xs font-semibold",
errors > 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}
</span>
</div>
<ul className="divide-y divide-sp-border">
{items.map((item, i) => {
const Icon = KIND_ICON[item.kind] ?? AlertCircle;
const isError = item.severity === "error";
return (
<li key={`${item.kind}-${item.host}-${i}`}>
<Link
to={item.link}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-sp-surface-2"
>
<Icon
className={cn(
"h-4 w-4 shrink-0",
isError ? "text-red-600 dark:text-red-400" : "text-sp-amber"
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-sp-text-1">{item.title}</p>
{item.detail && (
<p className="truncate text-xs text-sp-text-3">{item.detail}</p>
)}
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-sp-text-3" />
</Link>
</li>
);
})}
</ul>
</div>
);
}
@@ -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 = (
<div className="sp-card flex h-full flex-col gap-1 p-4">
<div className="flex items-center gap-1.5 text-sp-text-3">
<span className="[&>svg]:h-3.5 [&>svg]:w-3.5">{icon}</span>
<span className="sp-label">{label}</span>
</div>
<p className={cn("sp-display text-3xl leading-none", toneText)}>{value}</p>
{sub && <p className="text-xs text-sp-text-3">{sub}</p>}
</div>
);
return to ? (
<Link to={to} className="block transition-transform hover:-translate-y-0.5">
{inner}
</Link>
) : (
inner
);
}
export function FleetKpiRow({ kpis }: { kpis: FleetKpis }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Kpi
icon={<Server />}
label="Hosts"
value={`${kpis.hosts_online}/${kpis.hosts_total}`}
sub="online"
tone={kpis.hosts_online < kpis.hosts_total ? "error" : "default"}
/>
<Kpi
icon={<Boxes />}
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"}
/>
<Kpi
icon={<Container />}
label="Containers"
value={`${kpis.containers_running}/${kpis.containers_total}`}
sub="running"
/>
<Kpi
icon={<HeartPulse />}
label="Unhealthy"
value={kpis.unhealthy}
sub={kpis.unhealthy > 0 ? "need a look" : "all healthy"}
tone={kpis.unhealthy > 0 ? "error" : "default"}
/>
<Kpi
icon={<ArrowUpCircle />}
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}
/>
<Kpi
icon={<Archive />}
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}
/>
</div>
);
}
@@ -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<number | null>(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 (
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
role="img"
aria-label={`Funnel: ${stages.map((s) => `${s.label} ${s.value}`).join(", ")}`}
onMouseLeave={() => setHovered(null)}
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--sp-blue)" />
<stop offset="100%" stopColor="var(--sp-blue-light)" />
</linearGradient>
<pattern
id={hatchId}
width="7"
height="7"
patternTransform="rotate(45)"
patternUnits="userSpaceOnUse"
>
<rect width="7" height="7" fill="var(--sp-blue)" fillOpacity="0.13" />
<line x1="0" y1="0" x2="0" y2="7" stroke="var(--sp-blue)" strokeWidth="2.5" strokeOpacity="0.75" />
</pattern>
</defs>
{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 (
<g key={stage.label}>
<rect
x={x}
y={y}
width={colW}
height={h}
rx={10}
fill={i % 2 === 1 ? `url(#${gradId})` : `url(#${hatchId})`}
opacity={hover === null || isHover ? 1 : 0.45}
style={{ transition: "opacity 150ms" }}
/>
{/* Value chip above the bar */}
<g opacity={hover === null || isHover ? 1 : 0.45} style={{ transition: "opacity 150ms" }}>
<rect
x={x + colW / 2 - chipW / 2}
y={Math.max(y - 28, 0)}
width={chipW}
height={22}
rx={11}
fill="var(--sp-surface)"
stroke="var(--sp-border-color)"
/>
<text
x={x + colW / 2}
y={Math.max(y - 28, 0) + 15}
textAnchor="middle"
fontSize="12.5"
fontWeight="700"
fill="var(--sp-text-1)"
>
{chipText}
</text>
</g>
{/* Hover tooltip */}
{isHover && (
<g pointerEvents="none">
<rect
x={Math.min(Math.max(x + colW / 2 - 105, 4), W - 214)}
y={4}
width={210}
height={26}
rx={13}
fill="var(--sp-pill-bg)"
/>
<text
x={Math.min(Math.max(x + colW / 2, 109), W - 109)}
y={21}
textAnchor="middle"
fontSize="12"
fontWeight="600"
fill="var(--sp-pill-text)"
>
{stage.value} stacks · Conv: {conv}%{i > 0 ? ` · Drop-off: ${drop}%` : ""}
</text>
</g>
)}
{/* 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. */}
<rect
x={x}
y={0}
width={colW}
height={H}
fill="none"
pointerEvents="all"
onMouseEnter={() => setHovered(i)}
/>
</g>
);
})}
</svg>
);
}
@@ -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 (
<div className="min-w-[7rem]">
<div className="mb-1 flex items-center justify-between text-xs">
<span className={cn("font-semibold", hot ? "text-red-600 dark:text-red-400" : "text-sp-text-1")}>
{pct}%
</span>
<span className="text-sp-text-3">
{formatBytes(used)} / {formatBytes(total)}
</span>
</div>
<div className="h-1.5 rounded-pill bg-sp-surface-2">
<div
className={cn("h-1.5 rounded-pill", hot ? "bg-red-500" : "bg-sp-blue")}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
);
}
export function HostResourceTable({ hosts }: { hosts: FleetHost[] }) {
return (
<div className="sp-card overflow-hidden p-0">
<div className="border-b border-sp-border px-4 py-2.5">
<h2 className="sp-label">Hosts</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-sp-text-3">
<th className="px-4 py-2 font-medium">Host</th>
<th className="px-4 py-2 font-medium">CPU</th>
<th className="px-4 py-2 font-medium">Memory</th>
<th className="px-4 py-2 font-medium">Disk</th>
<th className="px-4 py-2 font-medium">Containers</th>
<th className="px-4 py-2 font-medium">Stacks</th>
</tr>
</thead>
<tbody className="divide-y divide-sp-border">
{hosts.map((h) => (
<tr key={String(h.id)} className="align-middle">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span
className={cn(
"h-2.5 w-2.5 shrink-0 rounded-full",
h.online ? "bg-sp-green" : "bg-slate-400"
)}
title={h.status}
/>
<span className="font-medium text-sp-text-1">{h.name}</span>
</div>
</td>
{h.online ? (
<>
<td className="px-4 py-3 text-sp-text-2">{h.cpu_cores || "—"}</td>
<td className="px-4 py-3">
<Meter used={h.mem_used} total={h.mem_total} pressure={MEM_PRESSURE} />
</td>
<td className="px-4 py-3">
<Meter used={h.disk_used} total={h.disk_total} pressure={DISK_PRESSURE} />
</td>
<td className="px-4 py-3 text-sp-text-2">
{h.containers_running}/{h.containers_total}
</td>
<td className="px-4 py-3 text-sp-text-2">
{h.stacks.running}/{h.stacks.total}
{h.unhealthy > 0 && (
<span className="ml-1.5 text-xs font-semibold text-red-600 dark:text-red-400">
· {h.unhealthy} unhealthy
</span>
)}
</td>
</>
) : (
<td className="px-4 py-3 text-sp-text-3" colSpan={5}>
{h.status} {" "}
<Link to="/settings" className="text-sp-blue hover:underline">
check under Settings
</Link>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -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 01 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 (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="Operations activity grid">
{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 (
<rect
key={`${day}-${r}`}
x={x}
y={y}
width={cell}
height={cell}
rx={2.5}
fill="var(--sp-blue)"
fillOpacity={on ? tier(v) : 0.08}
/>
);
});
})}
</svg>
);
}
@@ -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 (
<div className="sp-card flex flex-col gap-4 p-4 sm:p-5">
<div className="flex items-baseline justify-between">
<h2 className="sp-label">Stack status</h2>
<span className="text-xs text-sp-text-3">{total} stacks</span>
</div>
<div className="flex h-3 w-full overflow-hidden rounded-pill bg-sp-surface-2">
{total === 0 ? null : (
SEGMENTS.map((seg) =>
totals[seg.key] > 0 ? (
<div
key={seg.key}
className={cn("h-full", seg.bar)}
style={{ width: `${(totals[seg.key] / total) * 100}%` }}
title={`${seg.label}: ${totals[seg.key]}`}
/>
) : null
)
)}
</div>
<div className="flex flex-wrap gap-x-5 gap-y-2">
{SEGMENTS.map((seg) => (
<div key={seg.key} className="flex items-center gap-1.5">
<span className={cn("h-2.5 w-2.5 rounded-full", seg.dot)} />
<span className="text-sm font-semibold text-sp-text-1">{totals[seg.key]}</span>
<span className="text-xs text-sp-text-3">{seg.label}</span>
</div>
))}
{unhealthy > 0 && (
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-red-200 dark:ring-red-900/50" />
<span className="text-sm font-semibold text-red-600 dark:text-red-400">{unhealthy}</span>
<span className="text-xs text-sp-text-3">Unhealthy</span>
</div>
)}
</div>
</div>
);
}
@@ -1,69 +0,0 @@
import { useId } from "react";
/** Smooth SVG line chart with a soft gradient area fill underneath.
* `data` values are percentages (0100); 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 (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="Uptime trend">
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.12" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#${gradId})`} />
<path d={line} fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
<circle
cx={pts[pts.length - 1].x}
cy={pts[pts.length - 1].y}
r="3.5"
fill={color}
stroke="var(--sp-surface)"
strokeWidth="2"
/>
</svg>
);
}
+65 -437
View File
@@ -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<string, number | null>) =>
Object.values(m).reduce<number>((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<RangeDays>(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 (
<div className="space-y-8">
{/* ---- Overview header ---- */}
{/* ---- Header ---- */}
<div className="flex flex-wrap items-end justify-between gap-4">
<h1 className="sp-display text-[40px] leading-none sm:text-[50px]">Overview</h1>
<div className="flex items-center gap-1.5">
{([30, 7] as RangeDays[]).map((d) => (
<button
key={d}
onClick={() => setRange(d)}
className={cn(
"rounded-pill px-3.5 py-1.5 text-[13px] font-medium transition-colors",
range === d
? "bg-sp-pill text-sp-pill-text"
: "border border-sp-border bg-sp-surface text-sp-text-2 hover:text-sp-text-1"
)}
>
Last {d} days
</button>
<button
onClick={refreshFleet}
className="flex items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface px-3 py-1.5 text-xs text-sp-text-2 hover:text-sp-text-1"
title="Refresh now"
>
<RefreshCw className={cn("h-3.5 w-3.5", refreshing && "animate-spin")} />
{fleet.data ? relativeTime(fleet.data.as_of) : "…"}
</button>
</div>
{/* ---- Needs attention ---- */}
<AttentionStrip items={fleet.data?.attention} loading={fleet.isLoading} />
{/* ---- Fleet KPIs ---- */}
{fleet.data ? (
<FleetKpiRow kpis={fleet.data.kpis} />
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="sp-skeleton h-24" />
))}
</div>
</div>
{updateCount > 0 && (
<Link
to="/images"
className="flex items-center gap-2 rounded-card border border-sp-amber/40 bg-sp-amber/10 px-4 py-3 text-sm text-sp-amber hover:bg-sp-amber/20"
>
<ArrowUpCircle className="h-5 w-5" />
{updateCount} image update{updateCount > 1 ? "s" : ""} available view on the Images page.
</Link>
)}
{/* ---- Analytics row: funnel + container count ---- */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px]">
<StackHealthCard funnel={funnel.data} loading={funnel.isLoading} />
<ContainerCountCard
localRunning={summary.data?.total_containers}
loading={summary.isLoading}
agents={agents.data ?? []}
healthyRate={
funnel.data && funnel.data.running > 0
? Math.round((funnel.data.healthy / funnel.data.running) * 100)
: null
}
/>
</div>
{/* ---- Bottom row: uptime + ops ---- */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<UptimeCard
series={summary.data?.uptime_series.slice(-range)}
loading={summary.isLoading}
range={range}
/>
<OpsCard
series={summary.data?.ops_last_30d.slice(-range)}
peakDay={summary.data?.ops_peak_day ?? null}
loading={summary.isLoading}
range={range}
/>
</div>
{/* ---- Local host ---- */}
<section>
{hasAgents ? (
<HostHeader />
{/* ---- Status + hosts ---- */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[340px_1fr]">
{fleet.data ? (
<StackStatusBar totals={fleet.data.status_totals} unhealthy={fleet.data.kpis.unhealthy} />
) : (
<h2 className="sp-label mb-3">This host</h2>
<div className="sp-skeleton h-40" />
)}
<ResourceBar
cpuCores={info.data?.cpu_cores ?? 0}
memUsed={info.data?.ram.used ?? 0}
memTotal={info.data?.ram.total ?? 0}
diskUsed={info.data?.disk.used ?? 0}
diskTotal={info.data?.disk.total ?? 0}
volumesSize={volSize.data}
containersRunning={info.data?.containers_running ?? 0}
containersTotal={info.data?.containers_total ?? 0}
dockerVersion={info.data?.docker_version ?? ""}
/>
{fleet.data ? (
<HostResourceTable hosts={fleet.data.hosts} />
) : (
<div className="sp-skeleton h-40" />
)}
</div>
{/* ---- Local host stacks ---- */}
<section>
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
<StacksTable
stacks={stacks.data}
stats={stats.data}
@@ -165,12 +112,12 @@ export function Dashboard() {
/>
</section>
{/* Remote hosts */}
{/* ---- Remote host stacks ---- */}
{agents.data?.map((agent) => (
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))}
{/* Recent activity */}
{/* ---- Recent activity ---- */}
<section>
<h2 className="sp-label mb-3 flex items-center gap-2">
<Clock className="h-4 w-4" /> 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<number | null>(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 (
<div className="sp-card sp-rise flex flex-col gap-5 p-5 sm:p-6">
<div className="flex items-center justify-between">
<h2 className="sp-label">Stack health</h2>
<button
onClick={refresh}
className="flex items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface-2 px-2.5 py-1 text-xs text-sp-text-2 hover:text-sp-text-1"
title="Refresh now"
>
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
{funnel ? relativeTime(funnel.as_of) : "…"}
</button>
</div>
{loading || !funnel ? (
<div className="space-y-4">
<div className="sp-skeleton h-12" />
<div className="sp-skeleton h-52" />
</div>
) : (
<>
<div className="grid grid-cols-5 gap-2">
{FUNNEL_STAGES.map(({ key, label }, i) => (
<div
key={key}
className={cn(
"min-w-0 transition-opacity",
hovered !== null && hovered !== i && "opacity-40"
)}
>
<p className="sp-label truncate">{label}</p>
<p className="sp-display mt-0.5 text-2xl sm:text-3xl">{funnel[key]}</p>
</div>
))}
</div>
<FunnelChart
stages={FUNNEL_STAGES.map(({ key, label }) => ({ label, value: funnel[key] }))}
onHover={setHovered}
/>
</>
)}
<AiPromptBar />
</div>
);
}
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 (
<div className="sp-card sp-rise flex flex-col p-5 sm:p-6" style={{ animationDelay: "60ms" }}>
<h2 className="sp-label">Compose containers running</h2>
{loading ? (
<div className="mt-3 space-y-3">
<div className="sp-skeleton h-14 w-28" />
<div className="sp-skeleton h-20" />
</div>
) : (
<>
<p className="sp-display mt-1 text-5xl">{total}</p>
<div className="mt-4 space-y-2.5">
{hosts.map((h) => (
<div key={h.name}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="truncate font-medium text-sp-text-2">{h.name}</span>
<span className="font-semibold text-sp-text-1">{h.count}</span>
</div>
<div className="h-1.5 rounded-pill bg-sp-surface-2">
<div
className="h-1.5 rounded-pill bg-sp-blue"
style={{ width: `${(h.count / max) * 100}%` }}
/>
</div>
</div>
))}
</div>
</>
)}
<div className="mt-auto pt-5">
<div className="flex items-center gap-2.5 rounded-2xl bg-sp-pill px-3.5 py-3 text-sp-pill-text">
<span className="text-base leading-none"></span>
<p className="text-xs font-medium leading-snug">
{healthyRate === null
? "Insights appear once stacks are running."
: `${healthyRate}% of running stacks are healthy.`}
</p>
</div>
</div>
</div>
);
}
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 (
<div className="sp-card sp-rise p-5 sm:p-6" style={{ animationDelay: "120ms" }}>
<div className="flex items-start justify-between">
<div>
<h2 className="sp-label">Uptime</h2>
<p className="sp-display mt-1 text-4xl">
{latest === undefined ? "—" : `${latest}%`}
</p>
</div>
<span className="sp-label">{range}d</span>
</div>
<div className="mt-4">
{loading || !series ? (
<div className="sp-skeleton h-24" />
) : (
<UptimeChart data={values} />
)}
</div>
<p className="mt-2 text-xs text-sp-text-3">
Share of compose containers running on this host, sampled every 5 min (daily average).
</p>
</div>
);
}
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 (
<div className="sp-card sp-rise p-5 sm:p-6" style={{ animationDelay: "180ms" }}>
<div className="flex items-start justify-between">
<div>
<h2 className="sp-label">Operations</h2>
<p className="sp-display mt-1 text-4xl">{total}</p>
</div>
<span className="sp-label">{range}d</span>
</div>
<div className="mt-4">
{loading || !series ? (
<div className="sp-skeleton h-16" />
) : (
<OpsGrid data={series.map((p) => p.count / max)} cols={range} />
)}
</div>
<p className="mt-2 text-xs text-sp-text-3">
{peakDay ? `Busiest day: ${peakDay}.` : "Audit-log actions per day."}
</p>
</div>
);
}
/* ---------------------------------------------------------------------- */
/* 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
</p>
</Card>
) : (
<>
<ResourceBar
cpuCores={sys.data?.cpu_cores ?? 0}
memUsed={sys.data?.mem_used ?? 0}
memTotal={sys.data?.mem_total ?? 0}
diskUsed={sys.data?.disk_used ?? 0}
diskTotal={sys.data?.disk_total ?? 0}
volumesSize={volSize.data}
containersRunning={sys.data?.containers_running ?? 0}
containersTotal={sys.data?.containers_total ?? 0}
dockerVersion={sys.data?.docker_version ?? ""}
/>
<StacksTable
stacks={stacks.data}
stats={stats.data}
@@ -512,79 +212,7 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool
onRestart={(id) => run("restart", "Restarting", id)}
emptyText="No stacks on this host."
/>
</>
)}
</section>
);
}
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 (
<div className="mb-4 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={cpuCores || "—"} />
<Stat
icon={<MemoryStick className="h-5 w-5" />}
label="Memory"
value={memTotal ? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}` : "—"}
/>
<Stat
icon={<HardDrive className="h-5 w-5" />}
label="Disk"
value={diskTotal ? `${formatBytes(diskUsed)} / ${formatBytes(diskTotal)}` : "—"}
/>
<Stat
icon={<Database className="h-5 w-5" />}
label="Volumes"
value={volumesSize === undefined ? "…" : formatBytes(volumesSize)}
/>
<Stat
icon={<Container className="h-5 w-5" />}
label="Containers (all)"
value={`${containersRunning} / ${containersTotal}`}
/>
<Stat icon={<Server className="h-5 w-5" />} label="Docker" value={dockerVersion || "—"} />
</div>
);
}
function Stat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
}) {
return (
<Card className="flex items-center gap-3">
<div className="rounded-lg bg-accent/10 p-2 text-accent dark:bg-accent-dark/10 dark:text-accent-dark">
{icon}
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500">{label}</p>
<p className="truncate text-sm font-semibold">{value}</p>
</div>
</Card>
);
}