From 34cb215266eb48fe06e5c9fc12a6b822a559cc17 Mon Sep 17 00:00:00 2001 From: menzelj Date: Wed, 10 Jun 2026 10:54:06 +0000 Subject: [PATCH] =?UTF-8?q?Phase=2024:=20Design=20System=20v2=20=E2=80=94?= =?UTF-8?q?=20analytics-style=20UI=20(0.30.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 35 +- backend/agent_app.py | 2 +- backend/main.py | 4 +- backend/routers/dashboard.py | 29 ++ backend/services/dashboard_service.py | 236 +++++++++++++ frontend/package-lock.json | 14 +- frontend/package.json | 3 +- frontend/src/App.tsx | 4 +- frontend/src/api/dashboard.ts | 35 ++ .../src/components/dashboard/AiPromptBar.tsx | 97 ++++++ .../src/components/dashboard/FunnelChart.tsx | 146 ++++++++ frontend/src/components/dashboard/OpsGrid.tsx | 46 +++ .../src/components/dashboard/UptimeChart.tsx | 69 ++++ frontend/src/components/layout/AppShell.tsx | 33 ++ frontend/src/components/layout/Layout.tsx | 20 -- frontend/src/components/layout/Sidebar.tsx | 132 -------- frontend/src/components/layout/TopNav.tsx | 259 +++++++++++++++ frontend/src/components/layout/Topbar.tsx | 31 -- .../src/components/stacks/BackupRestore.tsx | 4 +- .../components/stacks/ContainerTerminal.tsx | 2 +- .../src/components/stacks/DeployConsole.tsx | 2 +- .../components/stacks/PortConflictDialog.tsx | 2 +- frontend/src/components/ui/ConfirmDialog.tsx | 2 +- frontend/src/components/ui/index.tsx | 6 +- frontend/src/index.css | 6 +- frontend/src/pages/Dashboard.tsx | 311 +++++++++++++++++- frontend/src/pages/Login.tsx | 2 +- frontend/src/pages/Networks.tsx | 2 +- frontend/src/pages/RemoteStackDetail.tsx | 2 +- frontend/src/pages/Settings.tsx | 2 +- frontend/src/pages/StackDetail.tsx | 2 +- frontend/src/pages/Stacks.tsx | 35 +- frontend/src/pages/Templates.tsx | 2 +- frontend/src/styles/tokens.css | 153 +++++++++ frontend/src/vite-env.d.ts | 4 + frontend/tailwind.config.ts | 33 +- frontend/vite.config.ts | 6 + 37 files changed, 1551 insertions(+), 222 deletions(-) create mode 100644 backend/routers/dashboard.py create mode 100644 backend/services/dashboard_service.py create mode 100644 frontend/src/api/dashboard.ts create mode 100644 frontend/src/components/dashboard/AiPromptBar.tsx create mode 100644 frontend/src/components/dashboard/FunnelChart.tsx create mode 100644 frontend/src/components/dashboard/OpsGrid.tsx create mode 100644 frontend/src/components/dashboard/UptimeChart.tsx create mode 100644 frontend/src/components/layout/AppShell.tsx delete mode 100644 frontend/src/components/layout/Layout.tsx delete mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/layout/TopNav.tsx delete mode 100644 frontend/src/components/layout/Topbar.tsx create mode 100644 frontend/src/styles/tokens.css create mode 100644 frontend/src/vite-env.d.ts diff --git a/README.md b/README.md index 2d6a955..d05b22d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/agent_app.py b/backend/agent_app.py index 504a706..c358b5b 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -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" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index 55ec4ad..e07e1be 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py new file mode 100644 index 0000000..a294763 --- /dev/null +++ b/backend/routers/dashboard.py @@ -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) diff --git a/backend/services/dashboard_service.py b/backend/services/dashboard_service.py new file mode 100644 index 0000000..8bfba94 --- /dev/null +++ b/backend/services/dashboard_service.py @@ -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(), + } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 559cb90..0de7a08 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index a911b76..a9329ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4517bd7..8053391 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> }> - }> + }> } /> } /> } /> diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..ac32883 --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -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(`/api/dashboard/funnel${refresh ? "?refresh=true" : ""}`).then((r) => r.data), + summary: () => api.get("/api/dashboard/summary").then((r) => r.data), +}; diff --git a/frontend/src/components/dashboard/AiPromptBar.tsx b/frontend/src/components/dashboard/AiPromptBar.tsx new file mode 100644 index 0000000..83f54da --- /dev/null +++ b/frontend/src/components/dashboard/AiPromptBar.tsx @@ -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 ( + + ); +} + +/** Inline explore-prompt: typed (or tag-clicked) queries jump to the Stacks + * page with a matching filter — no LLM behind it (yet). */ +export function AiPromptBar({ onSubmit }: { onSubmit?: (query: string) => void }) { + const [open, setOpen] = useState(true); + const [value, setValue] = useState(""); + const navigate = useNavigate(); + + const submit = (raw: string) => { + const query = raw.trim(); + if (!query) return; + if (onSubmit) { + onSubmit(query); + return; + } + const tag = CONTEXT_TAGS.find((t) => query.startsWith(t.tag)); + if (tag) navigate(`/stacks?filter=${tag.filter}`); + else navigate(`/stacks?q=${encodeURIComponent(query)}`); + }; + + return ( +
+ + {open && ( +
+
{ + e.preventDefault(); + submit(value); + }} + className="flex items-center gap-2 rounded-xl border border-sp-border bg-sp-surface px-3 py-2" + > + setValue(e.target.value)} + placeholder="Show me stacks that are…" + className="min-w-0 flex-1 bg-transparent text-sm text-sp-text-1 placeholder:text-sp-text-3 focus:outline-none" + /> + +
+
+ {CONTEXT_TAGS.map(({ tag }) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/FunnelChart.tsx b/frontend/src/components/dashboard/FunnelChart.tsx new file mode 100644 index 0000000..88b9e32 --- /dev/null +++ b/frontend/src/components/dashboard/FunnelChart.tsx @@ -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(null); + + const W = 640; + const H = 250; + const chipZone = 34; // space above bars for value chips + const max = Math.max(...stages.map((s) => s.value), 1); + const n = stages.length; + const gap = 14; + const colW = (W - gap * (n - 1)) / n; + + const setHovered = (i: number | null) => { + setHover(i); + onHover?.(i); + }; + + const gradId = `sp-funnel-grad-${uid}`; + const hatchId = `sp-funnel-hatch-${uid}`; + + return ( + `${s.label} ${s.value}`).join(", ")}`} + onMouseLeave={() => setHovered(null)} + > + + + + + + + + + + + + {stages.map((stage, i) => { + const x = i * (colW + gap); + const h = Math.max((stage.value / max) * (H - chipZone), stage.value > 0 ? 6 : 2); + const y = H - h; + const chipText = String(stage.value); + const chipW = chipText.length * 8.5 + 18; + const conv = stages[0].value > 0 ? Math.round((stage.value / stages[0].value) * 100) : 0; + const prev = i > 0 ? stages[i - 1].value : stage.value; + const drop = i > 0 && prev > 0 ? Math.round(((prev - stage.value) / prev) * 100) : 0; + const isHover = hover === i; + + return ( + + + {/* Value chip above the bar */} + + + + {chipText} + + + {/* Hover tooltip */} + {isHover && ( + + + + {stage.value} stacks · Conv: {conv}%{i > 0 ? ` · Drop-off: −${drop}%` : ""} + + + )} + {/* Transparent hit-target for the whole column. + pointer-events must be the SVG attribute (not CSS) for + fill="none" elements to receive events in Firefox. */} + setHovered(i)} + /> + + ); + })} + + ); +} diff --git a/frontend/src/components/dashboard/OpsGrid.tsx b/frontend/src/components/dashboard/OpsGrid.tsx new file mode 100644 index 0000000..0776dd5 --- /dev/null +++ b/frontend/src/components/dashboard/OpsGrid.tsx @@ -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 ( + + {days.map((v, day) => { + const lit = Math.min(rows, Math.ceil(Math.max(0, Math.min(v, 1)) * rows)); + const x = day * (cell + gap); + return Array.from({ length: rows }, (_, r) => { + const y = (rows - 1 - r) * (cell + gap); + const on = r < lit; + return ( + + ); + }); + })} + + ); +} diff --git a/frontend/src/components/dashboard/UptimeChart.tsx b/frontend/src/components/dashboard/UptimeChart.tsx new file mode 100644 index 0000000..1f47c09 --- /dev/null +++ b/frontend/src/components/dashboard/UptimeChart.tsx @@ -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 ( + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..2ccd2c8 --- /dev/null +++ b/frontend/src/components/layout/AppShell.tsx @@ -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 = { + "/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 ( +
+ +
+ {title && ( +

{title}

+ )} + +
+
+ ); +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx deleted file mode 100644 index 67ae4ce..0000000 --- a/frontend/src/components/layout/Layout.tsx +++ /dev/null @@ -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 ( -
- setMobileOpen(false)} /> -
- setMobileOpen(true)} /> -
- -
-
-
- ); -} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx deleted file mode 100644 index 89e75f2..0000000 --- a/frontend/src/components/layout/Sidebar.tsx +++ /dev/null @@ -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 && ( -
- )} - - - - ); -} diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx new file mode 100644 index 0000000..0094011 --- /dev/null +++ b/frontend/src/components/layout/TopNav.tsx @@ -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 ( + + ); +} + +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(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 ( + <> +
+
+ {/* Hamburger (mobile / narrow) */} + + + {/* Logo */} + + + StackPilot + + + {/* Pill nav */} + + + {/* Right cluster */} +
+ + v{__APP_VERSION__} + + {agentCount > 0 && ( + + )} + +
+ + {menuOpen && ( +
+
+

+ {user?.username ?? "—"} +

+ {user?.role === "admin" &&

admin

} +
+ +
+ )} +
+
+
+
+ + {/* Off-canvas drawer (narrow screens) */} + {drawerOpen && ( +
setDrawerOpen(false)} + /> + )} + + + ); +} diff --git a/frontend/src/components/layout/Topbar.tsx b/frontend/src/components/layout/Topbar.tsx deleted file mode 100644 index ab89f35..0000000 --- a/frontend/src/components/layout/Topbar.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useLocation } from "react-router-dom"; -import { Menu } from "lucide-react"; - -const titles: Record = { - "": "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 ( -
- -

{title}

-
- ); -} diff --git a/frontend/src/components/stacks/BackupRestore.tsx b/frontend/src/components/stacks/BackupRestore.tsx index 5632020..880c01f 100644 --- a/frontend/src/components/stacks/BackupRestore.tsx +++ b/frontend/src/components/stacks/BackupRestore.tsx @@ -108,7 +108,7 @@ export function BackupButton({ {open && ( !busy && setOpen(false)}> -

Back up “{stackId}”

+

Back up “{stackId}”