Phase 24: Design System v2 — analytics-style UI (0.30.0)

- New /api/dashboard/funnel (5-stage stack health, 30s TTL cache) and
  /api/dashboard/summary (containers, daily uptime jsonl, ops activity)
- Token system (tokens.css + Tailwind sp-* aliases); legacy bg/card/accent
  remapped onto the tokens; Schibsted Grotesk bundled via fontsource
- TopNav pill navigation + AppShell replace the sidebar layout (off-canvas
  drawer below 1024px); central display-weight page titles
- Dashboard redesign: FunnelChart (gradient/hatch SVG waterfall), container
  count card with per-host bars + Insights chip, UptimeChart, OpsGrid,
  AiPromptBar; 30/7-day range selector; host sections retained below
- Stacks page honours ?q= / ?filter= deep links + new status-filter select

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-10 10:54:06 +00:00
co-authored by Claude Fable 5
parent 6464e0677c
commit 34cb215266
37 changed files with 1551 additions and 222 deletions
+34 -1
View File
@@ -12,7 +12,8 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
> resource usage) + Phase 16 (Volumes page, multi-host) + Phase 17 (Multi-host
> dashboard) + Phase 18 (Image prune) + Phase 19 (Compose validate & diff) +
> Phase 20 (Container management) + Phase 21 (Container terminal) + Phase 22
> (Auto-update) + Phase 23 (Secrets & configs) complete.
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
> complete.
## What works today (Phase 1)
@@ -155,6 +156,31 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
container or connect any container on the host (`POST /api/networks/{id}/connect`
/ `/disconnect`).
### Phase 24 — Design System v2 (analytics-style UI)
- **New shell**: the sidebar is gone — a fixed 60px top bar carries a pill
navigation (active route = dark pill), the logo mark, a version badge, a
remote-host online indicator, theme toggle and an avatar menu. Narrow
screens get an off-canvas drawer.
- **Design tokens** (`frontend/src/styles/tokens.css`): one CSS-variable set
for surfaces, borders, text tiers, brand colours, radii and type scales,
with class-based dark-mode overrides. Existing Tailwind aliases
(`bg`/`card`/`accent`) are remapped onto the tokens so all pages reskin
consistently. Typeface: Schibsted Grotesk (bundled, offline-friendly).
- **Stack Health funnel** — the dashboard centrepiece. Five stages
(`discovered → running → healthy → updated → monitored`) from
`GET /api/dashboard/funnel` (30 s server cache, `?refresh=true` to bust).
SVG waterfall with alternating gradient / diagonal-hatch bars, value chips
and hover conversion/drop-off tooltips.
- **Summary widgets** from `GET /api/dashboard/summary`: containers-running
card with per-host breakdown and an "Insights" chip (healthy-rate %), a
30-day uptime line chart (daily samples appended to `DATA_DIR/uptime.jsonl`),
and an ops contribution grid from audit-log activity with the peak weekday.
A 30/7-day range selector slices both series client-side.
- **Explore prompt bar** under the funnel: typed queries or `/running`,
`/stopped`, `/attention` tags deep-link to the Stacks page, which now
honours `?q=` and `?filter=` (plus a new status-filter select).
### Phase 23 — Secrets & configs (compose file-based)
- A **Secrets** tab on the stack detail page manages per-stack Docker
@@ -482,6 +508,13 @@ POST /api/agents/{id}/stacks (create a stack on a remote h
POST /api/templates/{id}/instantiate {agent_id} (instantiate a template onto a remote host)
```
### Phase 24 endpoints
```
GET /api/dashboard/funnel[?refresh=true] (stack-health funnel, 30s TTL cache)
GET /api/dashboard/summary (containers, uptime series, ops activity)
```
## Security notes
- The Docker socket is only ever touched by the backend process; it is never
+1 -1
View File
@@ -66,7 +66,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler
AGENT_VERSION = "0.29.0"
AGENT_VERSION = "0.30.0"
# --------------------------------------------------------------------------- #
+3 -1
View File
@@ -19,6 +19,7 @@ from routers import (
auth,
backups,
containers,
dashboard,
destinations,
editor,
files,
@@ -57,7 +58,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.29.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.30.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -80,6 +81,7 @@ app.include_router(auth.router)
app.include_router(stacks.router)
app.include_router(secrets.router)
app.include_router(containers.router)
app.include_router(dashboard.router)
app.include_router(system.router)
app.include_router(volumes.router)
app.include_router(editor.router)
+29
View File
@@ -0,0 +1,29 @@
"""Dashboard aggregation endpoints (funnel + summary widgets)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session
from auth import get_current_user
from database import get_session
from models.user import User
from services import dashboard_service
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/funnel")
async def funnel(
refresh: bool = False,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
return await dashboard_service.compute_funnel(session, refresh=refresh)
@router.get("/summary")
async def summary(
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
return await dashboard_service.compute_summary(session)
+236
View File
@@ -0,0 +1,236 @@
"""Aggregated dashboard data: stack-health funnel + summary widgets.
Everything here is read-only and cheap by construction: one container
*summary* list (no per-container inspect) feeds the whole funnel, image
freshness comes from the cache the update-service background loop already
maintains, and the daily uptime sample is appended lazily on read.
"""
from __future__ import annotations
import asyncio
import json
import os
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlmodel import Session, select
from config import settings
from docker_client import DockerError, get_client, safe_call
from models.audit import AuditLog
from models.setting import Webhook
from services import compose_service, update_service
COMPOSE_LABEL = compose_service.COMPOSE_LABEL
DOCKER_TIMEOUT = 5.0 # seconds — a slow daemon must not stall the dashboard
FUNNEL_TTL = 30.0
UPTIME_FILE = os.path.join(settings.DATA_DIR, "uptime.jsonl")
UPTIME_DAYS = 30
_funnel_cache: dict = {"data": None, "ts": 0.0}
# --------------------------------------------------------------------------- #
# Container summary (single Docker round-trip)
# --------------------------------------------------------------------------- #
def _list_containers() -> list[dict]:
client = get_client()
return safe_call(client.api.containers, all=True)
async def _containers_with_timeout() -> list[dict]:
return await asyncio.wait_for(asyncio.to_thread(_list_containers), timeout=DOCKER_TIMEOUT)
def _group_by_project(raw: list[dict]) -> dict[str, list[dict]]:
by_project: dict[str, list[dict]] = {}
for c in raw:
project = (c.get("Labels") or {}).get(COMPOSE_LABEL)
if project:
by_project.setdefault(project, []).append(c)
return by_project
def _is_healthy(containers: list[dict]) -> bool:
"""All containers that *have* a healthcheck report healthy.
The summary ``Status`` string carries the health suffix — "(healthy)",
"(unhealthy)" or "(health: starting)" — only for containers with a
healthcheck configured, so its absence simply means "no healthcheck".
"""
for c in containers:
status = c.get("Status", "") or ""
if "(unhealthy)" in status or "(health:" in status:
return False
return True
def _is_updated(containers: list[dict], cache: dict[str, dict]) -> bool:
for c in containers:
st = cache.get(c.get("Image", ""))
if st and st.get("update_available"):
return False
return True
def _has_notify_target(session: Session) -> bool:
if settings.NOTIFY_WEBHOOKS:
return True
for wh in session.exec(select(Webhook)).all():
if wh.enabled:
return True
return False
async def compute_funnel(session: Session, refresh: bool = False) -> dict:
now = time.time()
if not refresh and _funnel_cache["data"] and now - _funnel_cache["ts"] < FUNNEL_TTL:
return _funnel_cache["data"]
discovered_ids = compose_service.discover_stacks()
try:
raw = await _containers_with_timeout()
except (DockerError, asyncio.TimeoutError):
raw = []
by_project = _group_by_project(raw)
update_cache = update_service.get_cache()
notify_configured = _has_notify_target(session)
running = healthy = updated = monitored = 0
for stack_id in discovered_ids:
containers = by_project.get(stack_id, [])
states = [c.get("State", "") for c in containers]
if not states or any(s != "running" for s in states):
continue
running += 1
if not _is_healthy(containers):
continue
healthy += 1
if not _is_updated(containers, update_cache):
continue
updated += 1
if notify_configured:
monitored += 1
data = {
"discovered": len(discovered_ids),
"running": running,
"healthy": healthy,
"updated": updated,
"monitored": monitored,
"as_of": datetime.now(timezone.utc).isoformat(),
}
_funnel_cache["data"] = data
_funnel_cache["ts"] = now
return data
# --------------------------------------------------------------------------- #
# Uptime series (one sample per day, JSONL on disk)
# --------------------------------------------------------------------------- #
def _read_uptime() -> list[dict]:
if not os.path.isfile(UPTIME_FILE):
return []
entries = []
with open(UPTIME_FILE, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def _append_uptime(entry: dict) -> None:
os.makedirs(settings.DATA_DIR, exist_ok=True)
with open(UPTIME_FILE, "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry) + "\n")
def _sample_uptime(raw: list[dict]) -> Optional[dict]:
"""Append today's sample if not yet recorded. Uptime% = share of compose
containers currently running."""
today = datetime.now(timezone.utc).date().isoformat()
entries = _read_uptime()
if any(e.get("date") == today for e in entries):
return None
labelled = [c for c in raw if (c.get("Labels") or {}).get(COMPOSE_LABEL)]
total = len(labelled)
running = sum(1 for c in labelled if c.get("State") == "running")
value = round(running / total * 100, 1) if total else 100.0
entry = {"date": today, "value": value}
_append_uptime(entry)
return entry
def uptime_series(raw: list[dict]) -> list[dict]:
_sample_uptime(raw)
entries = _read_uptime()
by_date = {e["date"]: e for e in entries if "date" in e}
series = []
today = datetime.now(timezone.utc).date()
last_value: Optional[float] = None
for i in range(UPTIME_DAYS - 1, -1, -1):
day = (today - timedelta(days=i)).isoformat()
e = by_date.get(day)
if e is not None:
last_value = e.get("value")
# Days before monitoring started (or gaps) reuse the last known value
# so the chart doesn't show artificial dips.
series.append({"date": day, "value": last_value})
return series
# --------------------------------------------------------------------------- #
# Ops (audit-log) activity
# --------------------------------------------------------------------------- #
_WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def ops_activity(session: Session) -> tuple[list[dict], Optional[str]]:
today = datetime.now(timezone.utc).date()
cutoff = datetime.combine(today - timedelta(days=UPTIME_DAYS - 1), datetime.min.time(), timezone.utc)
timestamps = session.exec(
select(AuditLog.timestamp).where(AuditLog.timestamp >= cutoff)
).all()
per_day: dict[str, int] = {}
per_weekday = [0] * 7
for ts in timestamps:
per_day[ts.date().isoformat()] = per_day.get(ts.date().isoformat(), 0) + 1
per_weekday[ts.weekday()] += 1
series = []
for i in range(UPTIME_DAYS - 1, -1, -1):
day = (today - timedelta(days=i)).isoformat()
series.append({"date": day, "count": per_day.get(day, 0)})
peak = _WEEKDAYS[per_weekday.index(max(per_weekday))] if any(per_weekday) else None
return series, peak
async def compute_summary(session: Session) -> dict:
try:
raw = await _containers_with_timeout()
except (DockerError, asyncio.TimeoutError):
raw = []
labelled = [c for c in raw if (c.get("Labels") or {}).get(COMPOSE_LABEL)]
ops_series, peak = ops_activity(session)
return {
"total_containers": sum(1 for c in labelled if c.get("State") == "running"),
"containers_total": len(labelled),
"uptime_series": uptime_series(raw),
"ops_last_30d": ops_series,
"ops_peak_day": peak,
"as_of": datetime.now(timezone.utc).isoformat(),
}
+12 -2
View File
@@ -1,13 +1,14 @@
{
"name": "stackpilot-frontend",
"version": "0.26.0",
"version": "0.29.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stackpilot-frontend",
"version": "0.26.0",
"version": "0.29.0",
"dependencies": {
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^5.62.7",
"@xterm/addon-fit": "^0.10.0",
@@ -346,6 +347,15 @@
"node": ">=12"
}
},
"node_modules/@fontsource-variable/schibsted-grotesk": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/schibsted-grotesk/-/schibsted-grotesk-5.2.8.tgz",
"integrity": "sha512-nZAorDrFue4dXZbI613WdvAhu5DPH1UJYNn5fWl7Poa+nl/s9o3VUcQImIiVZpQnYG/jkPVzHsTE7WZbSDvvkw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.29.0",
"version": "0.30.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^5.62.7",
"@xterm/addon-fit": "^0.10.0",
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import { AppShell } from "@/components/layout/AppShell";
import { Login } from "@/pages/Login";
import { Dashboard } from "@/pages/Dashboard";
import { Stacks } from "@/pages/Stacks";
@@ -40,7 +40,7 @@ export default function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<RequireAuth />}>
<Route element={<Layout />}>
<Route element={<AppShell />}>
<Route path="/" element={<Dashboard />} />
<Route path="/stacks" element={<Stacks />} />
<Route path="/stacks/new" element={<StackEditor />} />
+35
View File
@@ -0,0 +1,35 @@
import api from "./client";
export interface FunnelData {
discovered: number;
running: number;
healthy: number;
updated: number;
monitored: number;
as_of: string;
}
export interface UptimePoint {
date: string;
value: number | null;
}
export interface OpsPoint {
date: string;
count: number;
}
export interface DashboardSummary {
total_containers: number;
containers_total: number;
uptime_series: UptimePoint[];
ops_last_30d: OpsPoint[];
ops_peak_day: string | null;
as_of: string;
}
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),
};
@@ -0,0 +1,97 @@
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,146 @@
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,46 @@
/** 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,69 @@
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>
);
}
@@ -0,0 +1,33 @@
import { Outlet, useLocation } from "react-router-dom";
import { TopNav } from "./TopNav";
/** Top-level routes get a display-weight title here; the Dashboard ("/")
* and detail pages render their own headers. */
const TITLES: Record<string, string> = {
"/stacks": "Stacks",
"/networks": "Networks",
"/images": "Images",
"/volumes": "Volumes",
"/files": "Files",
"/templates": "Templates",
"/audit": "Audit log",
"/settings": "Settings",
};
/** Design System v2 layout: fixed 60px TopNav, content below. */
export function AppShell() {
const { pathname } = useLocation();
const title = TITLES[pathname];
return (
<div className="min-h-screen bg-sp-bg text-slate-900 dark:text-slate-100">
<TopNav />
<main className="mx-auto max-w-[1480px] px-4 pb-10 pt-[76px] sm:px-6">
{title && (
<h1 className="sp-display mb-6 text-[40px] leading-none sm:text-[50px]">{title}</h1>
)}
<Outlet />
</main>
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
export function Layout() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<div className="flex h-screen bg-bg text-slate-900 dark:bg-bg-dark dark:text-slate-100">
<Sidebar mobileOpen={mobileOpen} onClose={() => setMobileOpen(false)} />
<div className="flex min-w-0 flex-1 flex-col">
<Topbar onMenu={() => setMobileOpen(true)} />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
<Outlet />
</main>
</div>
</div>
);
}
-132
View File
@@ -1,132 +0,0 @@
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Boxes,
Network,
Image,
Database,
FolderTree,
LayoutTemplate,
ScrollText,
Settings,
Moon,
Sun,
LogOut,
Ship,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
const nav = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/stacks", label: "Stacks", icon: Boxes },
{ to: "/networks", label: "Networks", icon: Network },
{ to: "/images", label: "Images", icon: Image },
{ to: "/volumes", label: "Volumes", icon: Database },
{ to: "/files", label: "Files", icon: FolderTree },
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
{ to: "/audit", label: "Audit log", icon: ScrollText },
{ to: "/settings", label: "Settings", icon: Settings },
];
export function Sidebar({
mobileOpen = false,
onClose,
}: {
mobileOpen?: boolean;
onClose?: () => void;
}) {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const { theme, toggle } = useThemeStore();
return (
<>
{/* Mobile backdrop */}
{mobileOpen && (
<div
className="fixed inset-0 z-30 bg-black/50 md:hidden"
onClick={onClose}
/>
)}
<aside
className={cn(
"z-40 flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark",
// Off-canvas on mobile, static on desktop.
"fixed inset-y-0 left-0 transform transition-transform md:static md:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex items-center justify-between px-5 py-5">
<div className="flex items-center gap-2">
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
<span className="text-lg font-bold">StackPilot</span>
</div>
<button
onClick={onClose}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
title="Close menu"
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex-1 space-y-1 px-3">
{nav.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
onClick={onClose}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
<div className="flex items-center justify-between px-2">
<span className="text-sm text-slate-500 dark:text-slate-400">
{user?.username ?? "—"}
{user?.role === "admin" && (
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
admin
</span>
)}
</span>
<button
onClick={toggle}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
title="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
</div>
<button
onClick={logout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</aside>
</>
);
}
+259
View File
@@ -0,0 +1,259 @@
import { useEffect, useRef, useState } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
LayoutDashboard,
Boxes,
Network,
Image,
Database,
FolderTree,
LayoutTemplate,
ScrollText,
Settings,
Moon,
Sun,
LogOut,
Menu,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
import { agentsApi } from "@/api/agents";
export const NAV_ITEMS = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/stacks", label: "Stacks", icon: Boxes },
{ to: "/networks", label: "Networks", icon: Network },
{ to: "/images", label: "Images", icon: Image },
{ to: "/volumes", label: "Volumes", icon: Database },
{ to: "/files", label: "Files", icon: FolderTree },
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
{ to: "/audit", label: "Audit", icon: ScrollText },
{ to: "/settings", label: "Settings", icon: Settings },
];
function LogoMark() {
return (
<svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true">
<defs>
<linearGradient id="sp-logo-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--sp-blue)" />
<stop offset="100%" stopColor="var(--sp-blue-light)" />
</linearGradient>
</defs>
<rect width="30" height="30" rx="9" fill="url(#sp-logo-grad)" />
{/* Stacked-layers glyph */}
<path
d="M15 7.5 22 11.25 15 15 8 11.25 15 7.5Z"
fill="#fff"
fillOpacity="0.95"
/>
<path
d="M9.6 14.4 15 17.3l5.4-2.9L22 15.25 15 19 8 15.25l1.6-.85Z"
fill="#fff"
fillOpacity="0.6"
/>
<path
d="M9.6 18.4 15 21.3l5.4-2.9L22 19.25 15 23 8 19.25l1.6-.85Z"
fill="#fff"
fillOpacity="0.32"
/>
</svg>
);
}
function initials(name: string | undefined): string {
if (!name) return "?";
return name.slice(0, 2).toUpperCase();
}
export function TopNav() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const { theme, toggle } = useThemeStore();
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 30000,
});
const agentCount = agents.data?.length ?? 0;
const agentsOnline = agents.data?.filter((a) => a.status === "online").length ?? 0;
// Close avatar menu on outside click.
useEffect(() => {
if (!menuOpen) return;
const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [menuOpen]);
return (
<>
<header className="fixed inset-x-0 top-0 z-40 h-[60px] border-b border-sp-border bg-sp-bg/80 backdrop-blur-md">
<div className="mx-auto flex h-full max-w-[1480px] items-center gap-3 px-4 sm:px-6">
{/* Hamburger (mobile / narrow) */}
<button
onClick={() => setDrawerOpen(true)}
className="rounded-lg p-2 text-sp-text-2 hover:bg-sp-surface lg:hidden"
title="Open menu"
aria-label="Open navigation menu"
>
<Menu className="h-5 w-5" />
</button>
{/* Logo */}
<NavLink to="/" className="flex shrink-0 items-center gap-2.5">
<LogoMark />
<span className="sp-heading hidden text-[17px] sm:inline">StackPilot</span>
</NavLink>
{/* Pill nav */}
<nav
role="navigation"
aria-label="Primary"
className="mx-auto hidden items-center gap-0.5 rounded-pill border border-sp-border bg-sp-surface p-1 lg:flex"
>
{NAV_ITEMS.map(({ to, label, end }) => (
<NavLink key={to} to={to} end={end}>
{({ isActive }) => (
<span
aria-current={isActive ? "page" : undefined}
className={cn(
"block rounded-pill px-3.5 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "bg-sp-pill text-sp-pill-text"
: "text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1"
)}
>
{label}
</span>
)}
</NavLink>
))}
</nav>
{/* Right cluster */}
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0">
<span className="sp-label hidden rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 sm:inline">
v{__APP_VERSION__}
</span>
{agentCount > 0 && (
<button
onClick={() => navigate("/settings")}
className="hidden items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 text-xs font-medium text-sp-text-2 hover:text-sp-text-1 sm:flex"
title={`${agentsOnline}/${agentCount} remote hosts online`}
>
<span
className={cn(
"h-2 w-2 rounded-full",
agentsOnline === agentCount ? "bg-sp-green" : "bg-sp-amber"
)}
/>
{agentsOnline}/{agentCount}
</button>
)}
<button
onClick={toggle}
className="rounded-pill border border-sp-border bg-sp-surface p-2 text-sp-text-2 hover:text-sp-text-1"
title="Toggle theme"
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen((v) => !v)}
className="flex h-9 w-9 items-center justify-center rounded-full bg-sp-pill text-xs font-bold text-sp-pill-text"
title={user?.username}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
{initials(user?.username)}
</button>
{menuOpen && (
<div className="sp-card absolute right-0 top-11 z-50 w-48 p-2 shadow-lg">
<div className="px-3 py-2">
<p className="truncate text-sm font-semibold text-sp-text-1">
{user?.username ?? "—"}
</p>
{user?.role === "admin" && <p className="sp-label mt-0.5">admin</p>}
</div>
<button
onClick={logout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
)}
</div>
</div>
</div>
</header>
{/* Off-canvas drawer (narrow screens) */}
{drawerOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setDrawerOpen(false)}
/>
)}
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex w-64 transform flex-col bg-sp-surface transition-transform lg:hidden",
drawerOpen ? "translate-x-0" : "-translate-x-full"
)}
aria-hidden={!drawerOpen}
>
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-2.5">
<LogoMark />
<span className="sp-heading text-[17px]">StackPilot</span>
</div>
<button
onClick={() => setDrawerOpen(false)}
className="rounded-lg p-1.5 text-sp-text-2 hover:bg-sp-surface-2"
title="Close menu"
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3" aria-label="Primary">
{NAV_ITEMS.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
onClick={() => setDrawerOpen(false)}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-sp-pill text-sp-pill-text"
: "text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1"
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
<div className="border-t border-sp-border p-4">
<span className="sp-label">v{__APP_VERSION__}</span>
</div>
</aside>
</>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
const titles: Record<string, string> = {
"": "Dashboard",
stacks: "Stacks",
networks: "Networks",
images: "Images",
templates: "Templates",
audit: "Audit log",
settings: "Settings",
};
export function Topbar({ onMenu }: { onMenu?: () => void }) {
const { pathname } = useLocation();
const segment = pathname.split("/")[1] ?? "";
const title = titles[segment] ?? "StackPilot";
return (
<header className="flex h-14 items-center gap-3 border-b border-slate-200 bg-card px-4 dark:border-slate-700 dark:bg-card-dark sm:px-6">
<button
onClick={onMenu}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
title="Open menu"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="text-base font-semibold">{title}</h1>
</header>
);
}
@@ -108,7 +108,7 @@ export function BackupButton({
</Button>
{open && (
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Back up {stackId}</h2>
<h2 className="mb-3 sp-heading text-lg">Back up {stackId}</h2>
<div className="space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Destination</span>
@@ -225,7 +225,7 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
</Button>
{open && (
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
<h2 className="mb-3 sp-heading text-lg">Restore from backup</h2>
<div className="mb-3 flex gap-1 rounded-lg bg-slate-100 p-1 text-sm dark:bg-slate-800">
{(["upload", "destination"] as const).map((m) => (
@@ -127,7 +127,7 @@ export function ContainerTerminal({
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark">
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 dark:border-slate-700">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">Terminal {service}</h2>
<h2 className="sp-heading text-lg">Terminal {service}</h2>
<span
className={
status === "open"
@@ -79,7 +79,7 @@ export function DeployConsole({
{(phase === "failed" || phase === "error") && (
<XCircle className="h-5 w-5 text-red-500" />
)}
<h2 className="text-lg font-semibold">
<h2 className="sp-heading text-lg">
{phase === "running" && `Deploying ${stackId}`}
{phase === "success" && `Deployed ${stackId}`}
{phase === "failed" && `Deploy of ${stackId} failed`}
@@ -16,7 +16,7 @@ export function PortConflictDialog({
<div className="w-full max-w-lg rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
<div className="mb-3 flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold">Port conflicts detected</h2>
<h2 className="sp-heading text-lg">Port conflicts detected</h2>
</div>
<ul className="mb-4 space-y-2">
{conflicts.map((c, i) => (
+1 -1
View File
@@ -29,7 +29,7 @@ export function ConfirmDialog({
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold">{title}</h2>
<h2 className="sp-heading text-lg">{title}</h2>
{message && <p className="mt-2 text-sm text-slate-500">{message}</p>}
{children && <div className="mt-3">{children}</div>}
<div className="mt-4 flex justify-end gap-2">
+1 -5
View File
@@ -12,11 +12,7 @@ export function Card({
}) {
return (
<div
className={cn(
"rounded-xl border border-slate-200 bg-card p-4 shadow-sm",
"dark:border-slate-700 dark:bg-card-dark",
className
)}
className={cn("sp-card p-4", className)}
>
{children}
</div>
+5 -1
View File
@@ -1,3 +1,6 @@
@import "@fontsource-variable/schibsted-grotesk";
@import "./styles/tokens.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -14,7 +17,8 @@ body,
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-family: var(--sp-font);
background: var(--sp-bg);
}
/* Custom scrollbar */
+304 -7
View File
@@ -1,6 +1,6 @@
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 {
Cpu,
MemoryStick,
@@ -8,6 +8,7 @@ import {
Container,
Clock,
ArrowUpCircle,
RefreshCw,
Server,
Database,
} from "lucide-react";
@@ -15,13 +16,18 @@ 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 { 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 { formatBytes, relativeTime } from "@/lib/utils";
import { cn, formatBytes, relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
import type { Agent } from "@/types";
@@ -29,9 +35,23 @@ 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 funnel = useQuery({
queryKey: ["dashboard-funnel"],
queryFn: () => dashboardApi.funnel(),
refetchInterval: 30000,
});
const summary = useQuery({
queryKey: ["dashboard-summary"],
queryFn: dashboardApi.summary,
refetchInterval: 60000,
});
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 });
@@ -50,23 +70,74 @@ export function Dashboard() {
const hasAgents = (agents.data?.length ?? 0) > 0;
return (
<div className="space-y-6">
<div className="space-y-8">
{/* ---- Overview 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>
))}
</div>
</div>
{updateCount > 0 && (
<Link
to="/images"
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
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>
)}
{/* Local host */}
{/* ---- 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 />
) : (
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">This host</h2>
<h2 className="sp-label mb-3">This host</h2>
)}
<ResourceBar
cpuCores={info.data?.cpu_cores ?? 0}
@@ -101,7 +172,7 @@ export function Dashboard() {
{/* Recent activity */}
<section>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<h2 className="sp-label mb-3 flex items-center gap-2">
<Clock className="h-4 w-4" /> Recent activity
</h2>
<Card>
@@ -127,6 +198,232 @@ 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: "Monitored" },
] 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 },
...online.map((a, i) => ({ name: a.name, count: 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">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">Daily share of compose containers running, this host.</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 }) {
const qc = useQueryClient();
const online = agent.status === "online";
+1 -1
View File
@@ -51,7 +51,7 @@ export function Login() {
<Card className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center gap-2">
<Ship className="h-10 w-10 text-accent dark:text-accent-dark" />
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
<h1 className="sp-heading text-xl text-slate-900 dark:text-slate-100">
StackPilot
</h1>
<p className="text-sm text-slate-500">
+1 -1
View File
@@ -377,7 +377,7 @@ function CreateNetworkDialog({
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-3 text-lg font-semibold">Create network</h2>
<h2 className="mb-3 sp-heading text-lg">Create network</h2>
<div className="space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Name</span>
+1 -1
View File
@@ -82,7 +82,7 @@ export function RemoteStackDetail() {
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="text-xl font-bold">{data.name}</h1>
<h1 className="sp-heading text-xl">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
<p className="mt-1 flex items-center gap-1 text-sm text-slate-500">
+1 -1
View File
@@ -40,7 +40,7 @@ export function Settings() {
return (
<Card className="flex flex-col items-center gap-3 py-16 text-center">
<ShieldCheck className="h-10 w-10 text-slate-400" />
<h2 className="text-lg font-semibold">Admin only</h2>
<h2 className="sp-heading text-lg">Admin only</h2>
<p className="max-w-md text-sm text-slate-500">
Settings are available to administrators only.
</p>
+1 -1
View File
@@ -50,7 +50,7 @@ export function StackDetail() {
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="text-xl font-bold">{data.name}</h1>
<h1 className="sp-heading text-xl">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
{data.description && (
+32 -3
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search, HardDrive } from "lucide-react";
import { Button, Input } from "@/components/ui";
@@ -13,11 +13,23 @@ import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
type SortKey = "name" | "status" | "updated";
type StatusFilter = "all" | "running" | "stopped" | "attention";
const STATUS_FILTERS: Record<string, StatusFilter> = {
running: "running",
stopped: "stopped",
attention: "attention",
};
export function Stacks() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { busyId, start, stop, restart } = useStackActions();
const [q, setQ] = useState("");
// The dashboard explore bar deep-links here with ?q= / ?filter=.
const [params] = useSearchParams();
const [q, setQ] = useState(params.get("q") ?? "");
const [statusFilter, setStatusFilter] = useState<StatusFilter>(
STATUS_FILTERS[params.get("filter") ?? ""] ?? "all"
);
const [sort, setSort] = useState<SortKey>("name");
const { data, isLoading } = useQuery({
@@ -50,13 +62,20 @@ export function Stacks() {
s.name.toLowerCase().includes(q.toLowerCase()) ||
s.id.toLowerCase().includes(q.toLowerCase())
);
if (statusFilter !== "all") {
list = list.filter((s) =>
statusFilter === "attention"
? s.status === "error" || s.status === "partial"
: s.status === statusFilter
);
}
list = [...list].sort((a, b) => {
if (sort === "name") return a.name.localeCompare(b.name);
if (sort === "status") return a.status.localeCompare(b.status);
return b.updated_at.localeCompare(a.updated_at);
});
return list;
}, [data, q, sort]);
}, [data, q, statusFilter, sort]);
return (
<div className="space-y-4">
@@ -70,6 +89,16 @@ export function Stacks() {
onChange={(e) => setQ(e.target.value)}
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option value="all">All statuses</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
<option value="attention">Needs attention</option>
</select>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
+1 -1
View File
@@ -110,7 +110,7 @@ function UseTemplateDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[85vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
<h2 className="mb-3 text-lg font-semibold">Use {template.name}</h2>
<h2 className="mb-3 sp-heading text-lg">Use {template.name}</h2>
<div className="space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Stack name</span>
+153
View File
@@ -0,0 +1,153 @@
/* StackPilot Design System v2 analytics-grade tokens (Phase 24).
Light is the canonical theme; dark overrides live under `.dark`
(the app toggles dark mode by class, not media query). */
:root {
/* Surfaces */
--sp-bg: #ebebeb;
--sp-surface: #ffffff;
--sp-surface-2: #f7f7fb;
/* Borders */
--sp-border-color: #e3e3e3;
--sp-border: 1px solid var(--sp-border-color);
/* Text */
--sp-text-1: #0d0d0d;
--sp-text-2: #5a5a5a;
--sp-text-3: #9a9a9a;
/* Brand + signals */
--sp-blue: #2741ce;
--sp-blue-rgb: 39 65 206;
--sp-blue-light: #7b9bfa;
--sp-green: #16a34a;
--sp-pink: #db2777;
--sp-amber: #d97706;
--sp-amber-rgb: 217 119 6;
/* Nav pill */
--sp-pill-bg: #1a1a1a;
--sp-pill-text: #ffffff;
/* Radii */
--sp-r-card: 18px;
--sp-r-pill: 99px;
--sp-r-chip: 4px;
/* Type */
--sp-font: "Schibsted Grotesk Variable", "Schibsted Grotesk", ui-sans-serif,
system-ui, -apple-system, "Segoe UI", sans-serif;
}
.dark {
--sp-bg: #101014;
--sp-surface: #18181d;
--sp-surface-2: #1f1f26;
--sp-border-color: #2a2a33;
--sp-text-1: #f4f4f5;
--sp-text-2: #a3a3ad;
--sp-text-3: #66666f;
--sp-blue: #6d86f4;
--sp-blue-rgb: 109 134 244;
--sp-blue-light: #3650d6;
--sp-green: #34d399;
--sp-pink: #f472b6;
--sp-amber: #fbbf24;
--sp-amber-rgb: 251 191 36;
/* Pill inverts: light chip on dark chrome */
--sp-pill-bg: #f4f4f5;
--sp-pill-text: #101014;
}
@layer components {
/* Display numerals / hero headings: 800 weight, tight tracking */
.sp-display {
font-weight: 800;
letter-spacing: -0.035em;
color: var(--sp-text-1);
font-feature-settings: "tnum";
}
/* Section / card headings */
.sp-heading {
font-weight: 600;
letter-spacing: -0.01em;
color: var(--sp-text-1);
}
/* Small uppercase labels */
.sp-label {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.6875rem;
color: var(--sp-text-3);
}
/* Shared card chrome */
.sp-card {
background: var(--sp-surface);
border: var(--sp-border);
border-radius: var(--sp-r-card);
}
/* Skeleton shimmer for loading cards */
.sp-skeleton {
position: relative;
overflow: hidden;
background: var(--sp-surface-2);
border-radius: var(--sp-r-chip);
}
.sp-skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.55),
transparent
);
animation: sp-shimmer 1.6s infinite;
}
.dark .sp-skeleton::after {
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.07),
transparent
);
}
}
@keyframes sp-shimmer {
100% {
transform: translateX(100%);
}
}
/* Staggered card entrance on dashboard load */
@keyframes sp-rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sp-rise {
animation: sp-rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.sp-rise {
animation: none;
}
.sp-skeleton::after {
animation: none;
}
}
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
/** Injected by Vite `define` from package.json at build time. */
declare const __APP_VERSION__: string;
+30 -3
View File
@@ -6,9 +6,36 @@ export default {
theme: {
extend: {
colors: {
bg: { DEFAULT: "#f8fafc", dark: "#0f172a" },
card: { DEFAULT: "#ffffff", dark: "#1e293b" },
accent: { DEFAULT: "#0284c7", dark: "#38bdf8" },
// Legacy aliases now resolve to the v2 tokens so every existing
// page picks up the new palette without per-file edits. The CSS
// variables themselves flip under `.dark`, so the `-dark` variants
// point at the same vars.
bg: { DEFAULT: "var(--sp-bg)", dark: "var(--sp-bg)" },
card: { DEFAULT: "var(--sp-surface)", dark: "var(--sp-surface)" },
accent: {
DEFAULT: "rgb(var(--sp-blue-rgb) / <alpha-value>)",
dark: "rgb(var(--sp-blue-rgb) / <alpha-value>)",
},
// Design System v2 tokens
"sp-bg": "var(--sp-bg)",
"sp-surface": "var(--sp-surface)",
"sp-surface-2": "var(--sp-surface-2)",
"sp-border": "var(--sp-border-color)",
"sp-text-1": "var(--sp-text-1)",
"sp-text-2": "var(--sp-text-2)",
"sp-text-3": "var(--sp-text-3)",
"sp-blue": "var(--sp-blue)",
"sp-blue-light": "var(--sp-blue-light)",
"sp-green": "var(--sp-green)",
"sp-pink": "var(--sp-pink)",
"sp-amber": "rgb(var(--sp-amber-rgb) / <alpha-value>)",
"sp-pill": "var(--sp-pill-bg)",
"sp-pill-text": "var(--sp-pill-text)",
},
borderRadius: {
card: "var(--sp-r-card)",
pill: "var(--sp-r-pill)",
chip: "var(--sp-r-chip)",
},
},
},
+6
View File
@@ -1,9 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { readFileSync } from "fs";
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "package.json"), "utf-8"));
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},