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:
co-authored by
Claude Fable 5
parent
6464e0677c
commit
34cb215266
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
}
|
||||
Generated
+12
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 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,69 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** Injected by Vite `define` from package.json at build time. */
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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") },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user