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:
co-authored by
Claude Opus 4.8
parent
5c46e40866
commit
c830d28b65
+1
-3
@@ -36,7 +36,7 @@ from routers import (
|
|||||||
volumes,
|
volumes,
|
||||||
ws,
|
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)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("stackpilot")
|
logger = logging.getLogger("stackpilot")
|
||||||
@@ -59,12 +59,10 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning("Legacy template migration failed: %s", exc)
|
logger.warning("Legacy template migration failed: %s", exc)
|
||||||
update_task = asyncio.create_task(update_service.background_loop())
|
update_task = asyncio.create_task(update_service.background_loop())
|
||||||
schedule_task = asyncio.create_task(schedule_service.scheduler_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)
|
logger.info("StackPilot backend ready on port %s", settings.PORT)
|
||||||
yield
|
yield
|
||||||
update_task.cancel()
|
update_task.cancel()
|
||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
uptime_task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version=APP_VERSION, lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version=APP_VERSION, lifespan=lifespan)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Dashboard aggregation endpoints (funnel + summary widgets)."""
|
"""Dashboard aggregation endpoint (fleet-wide cockpit data)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -12,18 +12,12 @@ from services import dashboard_service
|
|||||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/funnel")
|
@router.get("/fleet")
|
||||||
async def funnel(
|
async def fleet(
|
||||||
refresh: bool = False,
|
refresh: bool = False,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: User = Depends(get_current_user),
|
_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return await dashboard_service.compute_funnel(session, refresh=refresh)
|
"""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)
|
||||||
@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)
|
|
||||||
|
|||||||
@@ -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
|
One read-only call rolls up every host (local + agents) into a "needs
|
||||||
*summary* list (no per-container inspect) feeds the whole funnel, image
|
attention" list, headline KPIs and a per-host resource view. It is cheap by
|
||||||
freshness comes from the cache the update-service background loop already
|
construction: a single container *summary* list (no per-container inspect)
|
||||||
maintains, and the daily uptime sample is appended lazily on read.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from config import settings
|
from database import engine
|
||||||
from docker_client import DockerError, get_client, safe_call
|
from docker_client import DockerError, get_client, safe_call
|
||||||
from models.audit import AuditLog
|
from models.agent import Agent
|
||||||
from models.auto_update import AutoUpdate
|
from models.backup_schedule import BackupSchedule
|
||||||
from services import compose_service, update_service
|
from services import agent_service, compose_service, update_service
|
||||||
|
|
||||||
COMPOSE_LABEL = compose_service.COMPOSE_LABEL
|
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")
|
# Fleet aggregate: cached briefly because each call may fan out to every agent.
|
||||||
UPTIME_DAYS = 30
|
FLEET_TTL = 25.0
|
||||||
UPTIME_SAMPLE_INTERVAL = 300.0 # seconds between uptime samples
|
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)
|
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]]:
|
def _group_by_project(raw: list[dict]) -> dict[str, list[dict]]:
|
||||||
by_project: dict[str, list[dict]] = {}
|
by_project: dict[str, list[dict]] = {}
|
||||||
for c in raw:
|
for c in raw:
|
||||||
@@ -78,226 +72,281 @@ def _is_updated(containers: list[dict], cache: dict[str, dict]) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _auto_managed_ids(session: Session) -> set[str]:
|
# --------------------------------------------------------------------------- #
|
||||||
"""Local stacks with an enabled auto-update policy."""
|
# Fleet aggregate (one call → "needs attention" + KPIs across every host)
|
||||||
rows = session.exec(
|
#
|
||||||
select(AutoUpdate.stack_id).where(
|
# The dashboard used to poll each agent individually and recombine the numbers
|
||||||
AutoUpdate.enabled == True, # noqa: E712 — SQL expression, not identity
|
# client-side. ``compute_fleet`` does the fan-out server-side instead: one local
|
||||||
AutoUpdate.agent_id == None, # noqa: E711
|
# 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
|
||||||
).all()
|
# than stalling the whole view. Cached for FLEET_TTL because the fan-out is not
|
||||||
return set(rows)
|
# 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:
|
def _bucket_statuses(statuses: list[str]) -> dict[str, int]:
|
||||||
now = time.time()
|
return {
|
||||||
if not refresh and _funnel_cache["data"] and now - _funnel_cache["ts"] < FUNNEL_TTL:
|
"running": sum(1 for s in statuses if s == "running"),
|
||||||
return _funnel_cache["data"]
|
"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:
|
try:
|
||||||
raw = await _containers_with_timeout()
|
raw = _list_containers()
|
||||||
except (DockerError, asyncio.TimeoutError):
|
except DockerError:
|
||||||
raw = []
|
raw = []
|
||||||
by_project = _group_by_project(raw)
|
by_project = _group_by_project(raw)
|
||||||
update_cache = update_service.get_cache()
|
update_cache = update_service.get_cache()
|
||||||
auto_managed = _auto_managed_ids(session)
|
|
||||||
|
|
||||||
running = healthy = updated = monitored = 0
|
statuses: list[str] = []
|
||||||
for stack_id in discovered_ids:
|
unhealthy: list[str] = []
|
||||||
|
updates = 0
|
||||||
|
for stack_id in discovered:
|
||||||
containers = by_project.get(stack_id, [])
|
containers = by_project.get(stack_id, [])
|
||||||
states = [c.get("State", "") for c in containers]
|
status = compose_service._status_from_states([c.get("State", "") for c in containers])
|
||||||
if not states or any(s != "running" for s in states):
|
statuses.append(status)
|
||||||
continue
|
if status == "running" and not _is_healthy(containers):
|
||||||
running += 1
|
unhealthy.append(stack_id)
|
||||||
if not _is_healthy(containers):
|
|
||||||
continue
|
|
||||||
healthy += 1
|
|
||||||
if not _is_updated(containers, update_cache):
|
if not _is_updated(containers, update_cache):
|
||||||
continue
|
updates += 1
|
||||||
updated += 1
|
|
||||||
# Final stage: the stack also keeps itself fresh (auto-update enabled).
|
buckets = _bucket_statuses(statuses)
|
||||||
if stack_id in auto_managed:
|
labelled = [c for c in raw if (c.get("Labels") or {}).get(COMPOSE_LABEL)]
|
||||||
monitored += 1
|
|
||||||
|
# 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 = {
|
data = {
|
||||||
"discovered": len(discovered_ids),
|
|
||||||
"running": running,
|
|
||||||
"healthy": healthy,
|
|
||||||
"updated": updated,
|
|
||||||
"monitored": monitored,
|
|
||||||
"as_of": datetime.now(timezone.utc).isoformat(),
|
"as_of": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"hosts": hosts,
|
||||||
|
"kpis": kpis,
|
||||||
|
"status_totals": status_totals,
|
||||||
|
"attention": attention,
|
||||||
}
|
}
|
||||||
_funnel_cache["data"] = data
|
_fleet_cache["data"] = data
|
||||||
_funnel_cache["ts"] = now
|
_fleet_cache["ts"] = now
|
||||||
return data
|
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
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the StackPilot release version."""
|
"""Single source of truth for the StackPilot release version."""
|
||||||
|
|
||||||
APP_VERSION = "0.37.7"
|
APP_VERSION = "0.38.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.37.7",
|
"version": "0.38.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,35 +1,65 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
|
|
||||||
export interface FunnelData {
|
export interface StackBuckets {
|
||||||
discovered: number;
|
|
||||||
running: number;
|
running: number;
|
||||||
healthy: number;
|
partial: number;
|
||||||
updated: number;
|
stopped: number;
|
||||||
monitored: number;
|
error: number;
|
||||||
as_of: string;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UptimePoint {
|
export interface FleetHost {
|
||||||
date: string;
|
id: string | number; // "local" or an agent id
|
||||||
value: number | null;
|
name: string;
|
||||||
}
|
online: boolean;
|
||||||
|
status: string;
|
||||||
export interface OpsPoint {
|
cpu_cores: number;
|
||||||
date: string;
|
mem_used: number;
|
||||||
count: number;
|
mem_total: number;
|
||||||
}
|
disk_used: number;
|
||||||
|
disk_total: number;
|
||||||
export interface DashboardSummary {
|
stacks: StackBuckets;
|
||||||
total_containers: number;
|
containers_running: number;
|
||||||
containers_total: number;
|
containers_total: number;
|
||||||
uptime_series: UptimePoint[];
|
unhealthy: number;
|
||||||
ops_last_30d: OpsPoint[];
|
updates_available: number;
|
||||||
ops_peak_day: string | null;
|
}
|
||||||
|
|
||||||
|
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;
|
as_of: string;
|
||||||
|
hosts: FleetHost[];
|
||||||
|
kpis: FleetKpis;
|
||||||
|
status_totals: { running: number; partial: number; stopped: number; error: number };
|
||||||
|
attention: AttentionItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
funnel: (refresh = false) =>
|
fleet: (refresh = false) =>
|
||||||
api.get<FunnelData>(`/api/dashboard/funnel${refresh ? "?refresh=true" : ""}`).then((r) => r.data),
|
api
|
||||||
summary: () => api.get<DashboardSummary>("/api/dashboard/summary").then((r) => r.data),
|
.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 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 (
|
|
||||||
<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 (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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,155 +1,102 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { Clock, RefreshCw } from "lucide-react";
|
||||||
import {
|
|
||||||
Cpu,
|
|
||||||
MemoryStick,
|
|
||||||
HardDrive,
|
|
||||||
Container,
|
|
||||||
Clock,
|
|
||||||
ArrowUpCircle,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Database,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Card } from "@/components/ui";
|
import { Card } from "@/components/ui";
|
||||||
import { HostHeader } from "@/components/hosts/HostHeader";
|
import { HostHeader } from "@/components/hosts/HostHeader";
|
||||||
import { StacksTable } from "@/components/stacks/StacksTable";
|
import { StacksTable } from "@/components/stacks/StacksTable";
|
||||||
import { FunnelChart } from "@/components/dashboard/FunnelChart";
|
import { AttentionStrip } from "@/components/dashboard/AttentionStrip";
|
||||||
import { UptimeChart } from "@/components/dashboard/UptimeChart";
|
import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow";
|
||||||
import { OpsGrid } from "@/components/dashboard/OpsGrid";
|
import { StackStatusBar } from "@/components/dashboard/StackStatusBar";
|
||||||
import { AiPromptBar } from "@/components/dashboard/AiPromptBar";
|
import { HostResourceTable } from "@/components/dashboard/HostResourceTable";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { systemApi } from "@/api/system";
|
import { systemApi } from "@/api/system";
|
||||||
import { imagesApi } from "@/api/images";
|
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
import { volumesApi } from "@/api/volumes";
|
|
||||||
import { dashboardApi } from "@/api/dashboard";
|
import { dashboardApi } from "@/api/dashboard";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { cn, formatBytes, relativeTime } from "@/lib/utils";
|
import { cn, relativeTime } from "@/lib/utils";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useStackActions } from "@/hooks/useStackActions";
|
import { useStackActions } from "@/hooks/useStackActions";
|
||||||
import type { Agent } from "@/types";
|
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() {
|
export function Dashboard() {
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
const { busyId, start, stop, restart } = useStackActions();
|
const { busyId, start, stop, restart } = useStackActions();
|
||||||
const [range, setRange] = useState<RangeDays>(30);
|
const qc = useQueryClient();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const funnel = useQuery({
|
const fleet = useQuery({
|
||||||
queryKey: ["dashboard-funnel"],
|
queryKey: ["dashboard-fleet"],
|
||||||
queryFn: () => dashboardApi.funnel(),
|
queryFn: () => dashboardApi.fleet(),
|
||||||
refetchInterval: 30000,
|
refetchInterval: 20000,
|
||||||
});
|
|
||||||
const summary = useQuery({
|
|
||||||
queryKey: ["dashboard-summary"],
|
|
||||||
queryFn: dashboardApi.summary,
|
|
||||||
refetchInterval: 60000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
||||||
const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, 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 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 });
|
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 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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ---- Overview header ---- */}
|
{/* ---- Header ---- */}
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h1 className="sp-display text-[40px] leading-none sm:text-[50px]">Overview</h1>
|
<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
|
<button
|
||||||
key={d}
|
onClick={refreshFleet}
|
||||||
onClick={() => setRange(d)}
|
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"
|
||||||
className={cn(
|
title="Refresh now"
|
||||||
"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
|
<RefreshCw className={cn("h-3.5 w-3.5", refreshing && "animate-spin")} />
|
||||||
|
{fleet.data ? relativeTime(fleet.data.as_of) : "…"}
|
||||||
</button>
|
</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>
|
||||||
</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 ---- */}
|
{/* ---- Status + hosts ---- */}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px]">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[340px_1fr]">
|
||||||
<StackHealthCard funnel={funnel.data} loading={funnel.isLoading} />
|
{fleet.data ? (
|
||||||
<ContainerCountCard
|
<StackStatusBar totals={fleet.data.status_totals} unhealthy={fleet.data.kpis.unhealthy} />
|
||||||
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 />
|
|
||||||
) : (
|
) : (
|
||||||
<h2 className="sp-label mb-3">This host</h2>
|
<div className="sp-skeleton h-40" />
|
||||||
)}
|
)}
|
||||||
<ResourceBar
|
{fleet.data ? (
|
||||||
cpuCores={info.data?.cpu_cores ?? 0}
|
<HostResourceTable hosts={fleet.data.hosts} />
|
||||||
memUsed={info.data?.ram.used ?? 0}
|
) : (
|
||||||
memTotal={info.data?.ram.total ?? 0}
|
<div className="sp-skeleton h-40" />
|
||||||
diskUsed={info.data?.disk.used ?? 0}
|
)}
|
||||||
diskTotal={info.data?.disk.total ?? 0}
|
</div>
|
||||||
volumesSize={volSize.data}
|
|
||||||
containersRunning={info.data?.containers_running ?? 0}
|
{/* ---- Local host stacks ---- */}
|
||||||
containersTotal={info.data?.containers_total ?? 0}
|
<section>
|
||||||
dockerVersion={info.data?.docker_version ?? ""}
|
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
|
||||||
/>
|
|
||||||
<StacksTable
|
<StacksTable
|
||||||
stacks={stacks.data}
|
stacks={stacks.data}
|
||||||
stats={stats.data}
|
stats={stats.data}
|
||||||
@@ -165,12 +112,12 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Remote hosts */}
|
{/* ---- Remote host stacks ---- */}
|
||||||
{agents.data?.map((agent) => (
|
{agents.data?.map((agent) => (
|
||||||
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
|
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Recent activity */}
|
{/* ---- Recent activity ---- */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="sp-label mb-3 flex items-center gap-2">
|
<h2 className="sp-label mb-3 flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4" /> Recent activity
|
<Clock className="h-4 w-4" /> Recent activity
|
||||||
@@ -199,236 +146,7 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------- */
|
/* ---------------------------------------------------------------------- */
|
||||||
/* Analytics cards */
|
/* Remote host stacks section */
|
||||||
/* ---------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
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) */
|
|
||||||
/* ---------------------------------------------------------------------- */
|
/* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
|
function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
|
||||||
@@ -454,12 +172,6 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool
|
|||||||
enabled: online,
|
enabled: online,
|
||||||
refetchInterval: 30000,
|
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) => {
|
const run = async (action: string, label: string, id: string) => {
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
@@ -486,18 +198,6 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool
|
|||||||
</p>
|
</p>
|
||||||
</Card>
|
</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
|
<StacksTable
|
||||||
stacks={stacks.data}
|
stacks={stacks.data}
|
||||||
stats={stats.data}
|
stats={stats.data}
|
||||||
@@ -512,79 +212,7 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool
|
|||||||
onRestart={(id) => run("restart", "Restarting", id)}
|
onRestart={(id) => run("restart", "Restarting", id)}
|
||||||
emptyText="No stacks on this host."
|
emptyText="No stacks on this host."
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user