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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-10 10:54:06 +00:00
co-authored by Claude Fable 5
parent 6464e0677c
commit 34cb215266
37 changed files with 1551 additions and 222 deletions
+12 -2
View File
@@ -1,13 +1,14 @@
{
"name": "stackpilot-frontend",
"version": "0.26.0",
"version": "0.29.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stackpilot-frontend",
"version": "0.26.0",
"version": "0.29.0",
"dependencies": {
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^5.62.7",
"@xterm/addon-fit": "^0.10.0",
@@ -346,6 +347,15 @@
"node": ">=12"
}
},
"node_modules/@fontsource-variable/schibsted-grotesk": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/schibsted-grotesk/-/schibsted-grotesk-5.2.8.tgz",
"integrity": "sha512-nZAorDrFue4dXZbI613WdvAhu5DPH1UJYNn5fWl7Poa+nl/s9o3VUcQImIiVZpQnYG/jkPVzHsTE7WZbSDvvkw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.29.0",
"version": "0.30.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^5.62.7",
"@xterm/addon-fit": "^0.10.0",
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router-dom";
import { Layout } from "@/components/layout/Layout";
import { AppShell } from "@/components/layout/AppShell";
import { Login } from "@/pages/Login";
import { Dashboard } from "@/pages/Dashboard";
import { Stacks } from "@/pages/Stacks";
@@ -40,7 +40,7 @@ export default function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<RequireAuth />}>
<Route element={<Layout />}>
<Route element={<AppShell />}>
<Route path="/" element={<Dashboard />} />
<Route path="/stacks" element={<Stacks />} />
<Route path="/stacks/new" element={<StackEditor />} />
+35
View File
@@ -0,0 +1,35 @@
import api from "./client";
export interface FunnelData {
discovered: number;
running: number;
healthy: number;
updated: number;
monitored: number;
as_of: string;
}
export interface UptimePoint {
date: string;
value: number | null;
}
export interface OpsPoint {
date: string;
count: number;
}
export interface DashboardSummary {
total_containers: number;
containers_total: number;
uptime_series: UptimePoint[];
ops_last_30d: OpsPoint[];
ops_peak_day: string | null;
as_of: string;
}
export const dashboardApi = {
funnel: (refresh = false) =>
api.get<FunnelData>(`/api/dashboard/funnel${refresh ? "?refresh=true" : ""}`).then((r) => r.data),
summary: () => api.get<DashboardSummary>("/api/dashboard/summary").then((r) => r.data),
};
@@ -0,0 +1,97 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronDown, CornerDownLeft } from "lucide-react";
import { cn } from "@/lib/utils";
const CONTEXT_TAGS = [
{ tag: "/running", filter: "running" },
{ tag: "/stopped", filter: "stopped" },
{ tag: "/attention", filter: "attention" },
];
function SparkleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 1.5 9.4 6 14 7.5 9.4 9 8 13.5 6.6 9 2 7.5 6.6 6 8 1.5Z"
fill="var(--sp-amber)"
/>
<path d="M13 11l.6 1.7L15.3 13l-1.7.6L13 15.3l-.6-1.7-1.7-.6 1.7-.6.6-1.7Z" fill="var(--sp-amber)" fillOpacity="0.7" />
</svg>
);
}
/** Inline explore-prompt: typed (or tag-clicked) queries jump to the Stacks
* page with a matching filter — no LLM behind it (yet). */
export function AiPromptBar({ onSubmit }: { onSubmit?: (query: string) => void }) {
const [open, setOpen] = useState(true);
const [value, setValue] = useState("");
const navigate = useNavigate();
const submit = (raw: string) => {
const query = raw.trim();
if (!query) return;
if (onSubmit) {
onSubmit(query);
return;
}
const tag = CONTEXT_TAGS.find((t) => query.startsWith(t.tag));
if (tag) navigate(`/stacks?filter=${tag.filter}`);
else navigate(`/stacks?q=${encodeURIComponent(query)}`);
};
return (
<div className="rounded-2xl border border-sp-border bg-sp-surface-2">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-4 py-3 text-left"
aria-expanded={open}
>
<SparkleIcon />
<span className="text-sm font-medium text-sp-text-1">
What would you like to explore next?
</span>
<ChevronDown
className={cn("ml-auto h-4 w-4 text-sp-text-3 transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div className="px-4 pb-3.5">
<form
onSubmit={(e) => {
e.preventDefault();
submit(value);
}}
className="flex items-center gap-2 rounded-xl border border-sp-border bg-sp-surface px-3 py-2"
>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Show me stacks that are…"
className="min-w-0 flex-1 bg-transparent text-sm text-sp-text-1 placeholder:text-sp-text-3 focus:outline-none"
/>
<button
type="submit"
className="rounded-lg p-1.5 text-sp-text-3 hover:bg-sp-surface-2 hover:text-sp-text-1"
title="Go"
aria-label="Submit"
>
<CornerDownLeft className="h-4 w-4" />
</button>
</form>
<div className="mt-2 flex flex-wrap gap-1.5">
{CONTEXT_TAGS.map(({ tag }) => (
<button
key={tag}
onClick={() => submit(tag)}
className="rounded-chip bg-sp-amber/15 px-2 py-0.5 font-mono text-xs font-semibold text-sp-amber hover:bg-sp-amber/25"
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,146 @@
import { useId, useState } from "react";
export interface FunnelStage {
label: string;
value: number;
}
/** SVG waterfall funnel: bars alternate gradient / diagonal-hatch fill,
* with a value chip above each bar and a conversion tooltip on hover. */
export function FunnelChart({
stages,
onHover,
}: {
stages: FunnelStage[];
onHover?: (i: number | null) => void;
}) {
const uid = useId().replace(/:/g, "");
const [hover, setHover] = useState<number | null>(null);
const W = 640;
const H = 250;
const chipZone = 34; // space above bars for value chips
const max = Math.max(...stages.map((s) => s.value), 1);
const n = stages.length;
const gap = 14;
const colW = (W - gap * (n - 1)) / n;
const setHovered = (i: number | null) => {
setHover(i);
onHover?.(i);
};
const gradId = `sp-funnel-grad-${uid}`;
const hatchId = `sp-funnel-hatch-${uid}`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
role="img"
aria-label={`Funnel: ${stages.map((s) => `${s.label} ${s.value}`).join(", ")}`}
onMouseLeave={() => setHovered(null)}
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--sp-blue)" />
<stop offset="100%" stopColor="var(--sp-blue-light)" />
</linearGradient>
<pattern
id={hatchId}
width="7"
height="7"
patternTransform="rotate(45)"
patternUnits="userSpaceOnUse"
>
<rect width="7" height="7" fill="var(--sp-blue)" fillOpacity="0.13" />
<line x1="0" y1="0" x2="0" y2="7" stroke="var(--sp-blue)" strokeWidth="2.5" strokeOpacity="0.75" />
</pattern>
</defs>
{stages.map((stage, i) => {
const x = i * (colW + gap);
const h = Math.max((stage.value / max) * (H - chipZone), stage.value > 0 ? 6 : 2);
const y = H - h;
const chipText = String(stage.value);
const chipW = chipText.length * 8.5 + 18;
const conv = stages[0].value > 0 ? Math.round((stage.value / stages[0].value) * 100) : 0;
const prev = i > 0 ? stages[i - 1].value : stage.value;
const drop = i > 0 && prev > 0 ? Math.round(((prev - stage.value) / prev) * 100) : 0;
const isHover = hover === i;
return (
<g key={stage.label}>
<rect
x={x}
y={y}
width={colW}
height={h}
rx={10}
fill={i % 2 === 1 ? `url(#${gradId})` : `url(#${hatchId})`}
opacity={hover === null || isHover ? 1 : 0.45}
style={{ transition: "opacity 150ms" }}
/>
{/* Value chip above the bar */}
<g opacity={hover === null || isHover ? 1 : 0.45} style={{ transition: "opacity 150ms" }}>
<rect
x={x + colW / 2 - chipW / 2}
y={Math.max(y - 28, 0)}
width={chipW}
height={22}
rx={11}
fill="var(--sp-surface)"
stroke="var(--sp-border-color)"
/>
<text
x={x + colW / 2}
y={Math.max(y - 28, 0) + 15}
textAnchor="middle"
fontSize="12.5"
fontWeight="700"
fill="var(--sp-text-1)"
>
{chipText}
</text>
</g>
{/* Hover tooltip */}
{isHover && (
<g pointerEvents="none">
<rect
x={Math.min(Math.max(x + colW / 2 - 105, 4), W - 214)}
y={4}
width={210}
height={26}
rx={13}
fill="var(--sp-pill-bg)"
/>
<text
x={Math.min(Math.max(x + colW / 2, 109), W - 109)}
y={21}
textAnchor="middle"
fontSize="12"
fontWeight="600"
fill="var(--sp-pill-text)"
>
{stage.value} stacks · Conv: {conv}%{i > 0 ? ` · Drop-off: ${drop}%` : ""}
</text>
</g>
)}
{/* Transparent hit-target for the whole column.
pointer-events must be the SVG attribute (not CSS) for
fill="none" elements to receive events in Firefox. */}
<rect
x={x}
y={0}
width={colW}
height={H}
fill="none"
pointerEvents="all"
onMouseEnter={() => setHovered(i)}
/>
</g>
);
})}
</svg>
);
}
@@ -0,0 +1,46 @@
/** Contribution-grid style dot matrix: one column per day, cells light up
* bottom-to-top with the day's normalised activity. Fully deterministic —
* derived only from `data`, no randomness. */
export function OpsGrid({
data,
cols = 30,
rows = 5,
}: {
data: number[]; // normalised 01 per day
cols?: number;
rows?: number;
}) {
const days = data.slice(-cols);
const cell = 10;
const gap = 3;
const W = cols * (cell + gap) - gap;
const H = rows * (cell + gap) - gap;
// 4 opacity tiers, like a contribution graph.
const tier = (v: number) => (v <= 0 ? 0.08 : v < 0.34 ? 0.28 : v < 0.67 ? 0.58 : 1);
return (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="Operations activity grid">
{days.map((v, day) => {
const lit = Math.min(rows, Math.ceil(Math.max(0, Math.min(v, 1)) * rows));
const x = day * (cell + gap);
return Array.from({ length: rows }, (_, r) => {
const y = (rows - 1 - r) * (cell + gap);
const on = r < lit;
return (
<rect
key={`${day}-${r}`}
x={x}
y={y}
width={cell}
height={cell}
rx={2.5}
fill="var(--sp-blue)"
fillOpacity={on ? tier(v) : 0.08}
/>
);
});
})}
</svg>
);
}
@@ -0,0 +1,69 @@
import { useId } from "react";
/** Smooth SVG line chart with a soft gradient area fill underneath.
* `data` values are percentages (0100); nulls (days before monitoring
* began) are rendered as a flat lead-in at the first known value. */
export function UptimeChart({
data,
color = "var(--sp-pink)",
}: {
data: (number | null)[];
color?: string;
}) {
const uid = useId().replace(/:/g, "");
const gradId = `sp-uptime-grad-${uid}`;
const W = 320;
const H = 96;
const pad = 6;
const firstKnown = data.find((v): v is number => v !== null) ?? 100;
const values = data.map((v) => v ?? firstKnown);
if (values.length === 0) values.push(100);
if (values.length === 1) values.push(values[0]);
const min = Math.min(...values);
const lo = Math.max(0, Math.min(min - 5, 90));
const span = 100 - lo || 1;
const stepX = (W - pad * 2) / (values.length - 1);
const pts = values.map((v, i) => ({
x: pad + i * stepX,
y: pad + (1 - (v - lo) / span) * (H - pad * 2),
}));
// Catmull-Rom → cubic bezier for a smooth line through every point.
let line = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, pts.length - 1)];
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
line += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
}
const area = `${line} L ${pts[pts.length - 1].x} ${H} L ${pts[0].x} ${H} Z`;
return (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="Uptime trend">
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.12" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#${gradId})`} />
<path d={line} fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
<circle
cx={pts[pts.length - 1].x}
cy={pts[pts.length - 1].y}
r="3.5"
fill={color}
stroke="var(--sp-surface)"
strokeWidth="2"
/>
</svg>
);
}
@@ -0,0 +1,33 @@
import { Outlet, useLocation } from "react-router-dom";
import { TopNav } from "./TopNav";
/** Top-level routes get a display-weight title here; the Dashboard ("/")
* and detail pages render their own headers. */
const TITLES: Record<string, string> = {
"/stacks": "Stacks",
"/networks": "Networks",
"/images": "Images",
"/volumes": "Volumes",
"/files": "Files",
"/templates": "Templates",
"/audit": "Audit log",
"/settings": "Settings",
};
/** Design System v2 layout: fixed 60px TopNav, content below. */
export function AppShell() {
const { pathname } = useLocation();
const title = TITLES[pathname];
return (
<div className="min-h-screen bg-sp-bg text-slate-900 dark:text-slate-100">
<TopNav />
<main className="mx-auto max-w-[1480px] px-4 pb-10 pt-[76px] sm:px-6">
{title && (
<h1 className="sp-display mb-6 text-[40px] leading-none sm:text-[50px]">{title}</h1>
)}
<Outlet />
</main>
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
export function Layout() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<div className="flex h-screen bg-bg text-slate-900 dark:bg-bg-dark dark:text-slate-100">
<Sidebar mobileOpen={mobileOpen} onClose={() => setMobileOpen(false)} />
<div className="flex min-w-0 flex-1 flex-col">
<Topbar onMenu={() => setMobileOpen(true)} />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
<Outlet />
</main>
</div>
</div>
);
}
-132
View File
@@ -1,132 +0,0 @@
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Boxes,
Network,
Image,
Database,
FolderTree,
LayoutTemplate,
ScrollText,
Settings,
Moon,
Sun,
LogOut,
Ship,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
const nav = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/stacks", label: "Stacks", icon: Boxes },
{ to: "/networks", label: "Networks", icon: Network },
{ to: "/images", label: "Images", icon: Image },
{ to: "/volumes", label: "Volumes", icon: Database },
{ to: "/files", label: "Files", icon: FolderTree },
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
{ to: "/audit", label: "Audit log", icon: ScrollText },
{ to: "/settings", label: "Settings", icon: Settings },
];
export function Sidebar({
mobileOpen = false,
onClose,
}: {
mobileOpen?: boolean;
onClose?: () => void;
}) {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const { theme, toggle } = useThemeStore();
return (
<>
{/* Mobile backdrop */}
{mobileOpen && (
<div
className="fixed inset-0 z-30 bg-black/50 md:hidden"
onClick={onClose}
/>
)}
<aside
className={cn(
"z-40 flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark",
// Off-canvas on mobile, static on desktop.
"fixed inset-y-0 left-0 transform transition-transform md:static md:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex items-center justify-between px-5 py-5">
<div className="flex items-center gap-2">
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
<span className="text-lg font-bold">StackPilot</span>
</div>
<button
onClick={onClose}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
title="Close menu"
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex-1 space-y-1 px-3">
{nav.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
onClick={onClose}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
<div className="flex items-center justify-between px-2">
<span className="text-sm text-slate-500 dark:text-slate-400">
{user?.username ?? "—"}
{user?.role === "admin" && (
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
admin
</span>
)}
</span>
<button
onClick={toggle}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
title="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
</div>
<button
onClick={logout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</aside>
</>
);
}
+259
View File
@@ -0,0 +1,259 @@
import { useEffect, useRef, useState } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
LayoutDashboard,
Boxes,
Network,
Image,
Database,
FolderTree,
LayoutTemplate,
ScrollText,
Settings,
Moon,
Sun,
LogOut,
Menu,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
import { agentsApi } from "@/api/agents";
export const NAV_ITEMS = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/stacks", label: "Stacks", icon: Boxes },
{ to: "/networks", label: "Networks", icon: Network },
{ to: "/images", label: "Images", icon: Image },
{ to: "/volumes", label: "Volumes", icon: Database },
{ to: "/files", label: "Files", icon: FolderTree },
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
{ to: "/audit", label: "Audit", icon: ScrollText },
{ to: "/settings", label: "Settings", icon: Settings },
];
function LogoMark() {
return (
<svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true">
<defs>
<linearGradient id="sp-logo-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--sp-blue)" />
<stop offset="100%" stopColor="var(--sp-blue-light)" />
</linearGradient>
</defs>
<rect width="30" height="30" rx="9" fill="url(#sp-logo-grad)" />
{/* Stacked-layers glyph */}
<path
d="M15 7.5 22 11.25 15 15 8 11.25 15 7.5Z"
fill="#fff"
fillOpacity="0.95"
/>
<path
d="M9.6 14.4 15 17.3l5.4-2.9L22 15.25 15 19 8 15.25l1.6-.85Z"
fill="#fff"
fillOpacity="0.6"
/>
<path
d="M9.6 18.4 15 21.3l5.4-2.9L22 19.25 15 23 8 19.25l1.6-.85Z"
fill="#fff"
fillOpacity="0.32"
/>
</svg>
);
}
function initials(name: string | undefined): string {
if (!name) return "?";
return name.slice(0, 2).toUpperCase();
}
export function TopNav() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const { theme, toggle } = useThemeStore();
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 30000,
});
const agentCount = agents.data?.length ?? 0;
const agentsOnline = agents.data?.filter((a) => a.status === "online").length ?? 0;
// Close avatar menu on outside click.
useEffect(() => {
if (!menuOpen) return;
const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [menuOpen]);
return (
<>
<header className="fixed inset-x-0 top-0 z-40 h-[60px] border-b border-sp-border bg-sp-bg/80 backdrop-blur-md">
<div className="mx-auto flex h-full max-w-[1480px] items-center gap-3 px-4 sm:px-6">
{/* Hamburger (mobile / narrow) */}
<button
onClick={() => setDrawerOpen(true)}
className="rounded-lg p-2 text-sp-text-2 hover:bg-sp-surface lg:hidden"
title="Open menu"
aria-label="Open navigation menu"
>
<Menu className="h-5 w-5" />
</button>
{/* Logo */}
<NavLink to="/" className="flex shrink-0 items-center gap-2.5">
<LogoMark />
<span className="sp-heading hidden text-[17px] sm:inline">StackPilot</span>
</NavLink>
{/* Pill nav */}
<nav
role="navigation"
aria-label="Primary"
className="mx-auto hidden items-center gap-0.5 rounded-pill border border-sp-border bg-sp-surface p-1 lg:flex"
>
{NAV_ITEMS.map(({ to, label, end }) => (
<NavLink key={to} to={to} end={end}>
{({ isActive }) => (
<span
aria-current={isActive ? "page" : undefined}
className={cn(
"block rounded-pill px-3.5 py-1.5 text-[13px] font-medium transition-colors",
isActive
? "bg-sp-pill text-sp-pill-text"
: "text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1"
)}
>
{label}
</span>
)}
</NavLink>
))}
</nav>
{/* Right cluster */}
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0">
<span className="sp-label hidden rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 sm:inline">
v{__APP_VERSION__}
</span>
{agentCount > 0 && (
<button
onClick={() => navigate("/settings")}
className="hidden items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 text-xs font-medium text-sp-text-2 hover:text-sp-text-1 sm:flex"
title={`${agentsOnline}/${agentCount} remote hosts online`}
>
<span
className={cn(
"h-2 w-2 rounded-full",
agentsOnline === agentCount ? "bg-sp-green" : "bg-sp-amber"
)}
/>
{agentsOnline}/{agentCount}
</button>
)}
<button
onClick={toggle}
className="rounded-pill border border-sp-border bg-sp-surface p-2 text-sp-text-2 hover:text-sp-text-1"
title="Toggle theme"
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen((v) => !v)}
className="flex h-9 w-9 items-center justify-center rounded-full bg-sp-pill text-xs font-bold text-sp-pill-text"
title={user?.username}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
{initials(user?.username)}
</button>
{menuOpen && (
<div className="sp-card absolute right-0 top-11 z-50 w-48 p-2 shadow-lg">
<div className="px-3 py-2">
<p className="truncate text-sm font-semibold text-sp-text-1">
{user?.username ?? "—"}
</p>
{user?.role === "admin" && <p className="sp-label mt-0.5">admin</p>}
</div>
<button
onClick={logout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
)}
</div>
</div>
</div>
</header>
{/* Off-canvas drawer (narrow screens) */}
{drawerOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setDrawerOpen(false)}
/>
)}
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex w-64 transform flex-col bg-sp-surface transition-transform lg:hidden",
drawerOpen ? "translate-x-0" : "-translate-x-full"
)}
aria-hidden={!drawerOpen}
>
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-2.5">
<LogoMark />
<span className="sp-heading text-[17px]">StackPilot</span>
</div>
<button
onClick={() => setDrawerOpen(false)}
className="rounded-lg p-1.5 text-sp-text-2 hover:bg-sp-surface-2"
title="Close menu"
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3" aria-label="Primary">
{NAV_ITEMS.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
onClick={() => setDrawerOpen(false)}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-sp-pill text-sp-pill-text"
: "text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1"
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
<div className="border-t border-sp-border p-4">
<span className="sp-label">v{__APP_VERSION__}</span>
</div>
</aside>
</>
);
}
-31
View File
@@ -1,31 +0,0 @@
import { useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
const titles: Record<string, string> = {
"": "Dashboard",
stacks: "Stacks",
networks: "Networks",
images: "Images",
templates: "Templates",
audit: "Audit log",
settings: "Settings",
};
export function Topbar({ onMenu }: { onMenu?: () => void }) {
const { pathname } = useLocation();
const segment = pathname.split("/")[1] ?? "";
const title = titles[segment] ?? "StackPilot";
return (
<header className="flex h-14 items-center gap-3 border-b border-slate-200 bg-card px-4 dark:border-slate-700 dark:bg-card-dark sm:px-6">
<button
onClick={onMenu}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
title="Open menu"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="text-base font-semibold">{title}</h1>
</header>
);
}
@@ -108,7 +108,7 @@ export function BackupButton({
</Button>
{open && (
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Back up {stackId}</h2>
<h2 className="mb-3 sp-heading text-lg">Back up {stackId}</h2>
<div className="space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Destination</span>
@@ -225,7 +225,7 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
</Button>
{open && (
<Modal onClose={() => !busy && setOpen(false)}>
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
<h2 className="mb-3 sp-heading text-lg">Restore from backup</h2>
<div className="mb-3 flex gap-1 rounded-lg bg-slate-100 p-1 text-sm dark:bg-slate-800">
{(["upload", "destination"] as const).map((m) => (
@@ -127,7 +127,7 @@ export function ContainerTerminal({
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark">
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 dark:border-slate-700">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">Terminal {service}</h2>
<h2 className="sp-heading text-lg">Terminal {service}</h2>
<span
className={
status === "open"
@@ -79,7 +79,7 @@ export function DeployConsole({
{(phase === "failed" || phase === "error") && (
<XCircle className="h-5 w-5 text-red-500" />
)}
<h2 className="text-lg font-semibold">
<h2 className="sp-heading text-lg">
{phase === "running" && `Deploying ${stackId}`}
{phase === "success" && `Deployed ${stackId}`}
{phase === "failed" && `Deploy of ${stackId} failed`}
@@ -16,7 +16,7 @@ export function PortConflictDialog({
<div className="w-full max-w-lg rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
<div className="mb-3 flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold">Port conflicts detected</h2>
<h2 className="sp-heading text-lg">Port conflicts detected</h2>
</div>
<ul className="mb-4 space-y-2">
{conflicts.map((c, i) => (
+1 -1
View File
@@ -29,7 +29,7 @@ export function ConfirmDialog({
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold">{title}</h2>
<h2 className="sp-heading text-lg">{title}</h2>
{message && <p className="mt-2 text-sm text-slate-500">{message}</p>}
{children && <div className="mt-3">{children}</div>}
<div className="mt-4 flex justify-end gap-2">
+1 -5
View File
@@ -12,11 +12,7 @@ export function Card({
}) {
return (
<div
className={cn(
"rounded-xl border border-slate-200 bg-card p-4 shadow-sm",
"dark:border-slate-700 dark:bg-card-dark",
className
)}
className={cn("sp-card p-4", className)}
>
{children}
</div>
+5 -1
View File
@@ -1,3 +1,6 @@
@import "@fontsource-variable/schibsted-grotesk";
@import "./styles/tokens.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -14,7 +17,8 @@ body,
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-family: var(--sp-font);
background: var(--sp-bg);
}
/* Custom scrollbar */
+304 -7
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Cpu,
MemoryStick,
@@ -8,6 +8,7 @@ import {
Container,
Clock,
ArrowUpCircle,
RefreshCw,
Server,
Database,
} from "lucide-react";
@@ -15,13 +16,18 @@ import { toast } from "sonner";
import { Card } from "@/components/ui";
import { HostHeader } from "@/components/hosts/HostHeader";
import { StacksTable } from "@/components/stacks/StacksTable";
import { FunnelChart } from "@/components/dashboard/FunnelChart";
import { UptimeChart } from "@/components/dashboard/UptimeChart";
import { OpsGrid } from "@/components/dashboard/OpsGrid";
import { AiPromptBar } from "@/components/dashboard/AiPromptBar";
import { stacksApi } from "@/api/stacks";
import { systemApi } from "@/api/system";
import { imagesApi } from "@/api/images";
import { agentsApi } from "@/api/agents";
import { volumesApi } from "@/api/volumes";
import { dashboardApi } from "@/api/dashboard";
import { apiErrorMessage } from "@/api/client";
import { formatBytes, relativeTime } from "@/lib/utils";
import { cn, formatBytes, relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
import type { Agent } from "@/types";
@@ -29,9 +35,23 @@ import type { Agent } from "@/types";
const sumSizes = (m: Record<string, number | null>) =>
Object.values(m).reduce<number>((a, b) => a + (b ?? 0), 0);
type RangeDays = 7 | 30;
export function Dashboard() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { busyId, start, stop, restart } = useStackActions();
const [range, setRange] = useState<RangeDays>(30);
const funnel = useQuery({
queryKey: ["dashboard-funnel"],
queryFn: () => dashboardApi.funnel(),
refetchInterval: 30000,
});
const summary = useQuery({
queryKey: ["dashboard-summary"],
queryFn: dashboardApi.summary,
refetchInterval: 60000,
});
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 });
@@ -50,23 +70,74 @@ export function Dashboard() {
const hasAgents = (agents.data?.length ?? 0) > 0;
return (
<div className="space-y-6">
<div className="space-y-8">
{/* ---- Overview header ---- */}
<div className="flex flex-wrap items-end justify-between gap-4">
<h1 className="sp-display text-[40px] leading-none sm:text-[50px]">Overview</h1>
<div className="flex items-center gap-1.5">
{([30, 7] as RangeDays[]).map((d) => (
<button
key={d}
onClick={() => setRange(d)}
className={cn(
"rounded-pill px-3.5 py-1.5 text-[13px] font-medium transition-colors",
range === d
? "bg-sp-pill text-sp-pill-text"
: "border border-sp-border bg-sp-surface text-sp-text-2 hover:text-sp-text-1"
)}
>
Last {d} days
</button>
))}
</div>
</div>
{updateCount > 0 && (
<Link
to="/images"
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
className="flex items-center gap-2 rounded-card border border-sp-amber/40 bg-sp-amber/10 px-4 py-3 text-sm text-sp-amber hover:bg-sp-amber/20"
>
<ArrowUpCircle className="h-5 w-5" />
{updateCount} image update{updateCount > 1 ? "s" : ""} available view on the Images page.
</Link>
)}
{/* Local host */}
{/* ---- Analytics row: funnel + container count ---- */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px]">
<StackHealthCard funnel={funnel.data} loading={funnel.isLoading} />
<ContainerCountCard
localRunning={summary.data?.total_containers}
loading={summary.isLoading}
agents={agents.data ?? []}
healthyRate={
funnel.data && funnel.data.running > 0
? Math.round((funnel.data.healthy / funnel.data.running) * 100)
: null
}
/>
</div>
{/* ---- Bottom row: uptime + ops ---- */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<UptimeCard
series={summary.data?.uptime_series.slice(-range)}
loading={summary.isLoading}
range={range}
/>
<OpsCard
series={summary.data?.ops_last_30d.slice(-range)}
peakDay={summary.data?.ops_peak_day ?? null}
loading={summary.isLoading}
range={range}
/>
</div>
{/* ---- Local host ---- */}
<section>
{hasAgents ? (
<HostHeader />
) : (
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">This host</h2>
<h2 className="sp-label mb-3">This host</h2>
)}
<ResourceBar
cpuCores={info.data?.cpu_cores ?? 0}
@@ -101,7 +172,7 @@ export function Dashboard() {
{/* Recent activity */}
<section>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<h2 className="sp-label mb-3 flex items-center gap-2">
<Clock className="h-4 w-4" /> Recent activity
</h2>
<Card>
@@ -127,6 +198,232 @@ export function Dashboard() {
);
}
/* ---------------------------------------------------------------------- */
/* Analytics cards */
/* ---------------------------------------------------------------------- */
const FUNNEL_STAGES = [
{ key: "discovered", label: "Discovered" },
{ key: "running", label: "Running" },
{ key: "healthy", label: "Healthy" },
{ key: "updated", label: "Up to date" },
{ key: "monitored", label: "Monitored" },
] as const;
function StackHealthCard({
funnel,
loading,
}: {
funnel?: import("@/api/dashboard").FunnelData;
loading: boolean;
}) {
const qc = useQueryClient();
const [refreshing, setRefreshing] = useState(false);
const [hovered, setHovered] = useState<number | null>(null);
const refresh = async () => {
setRefreshing(true);
try {
const data = await dashboardApi.funnel(true);
qc.setQueryData(["dashboard-funnel"], data);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setRefreshing(false);
}
};
return (
<div className="sp-card sp-rise flex flex-col gap-5 p-5 sm:p-6">
<div className="flex items-center justify-between">
<h2 className="sp-label">Stack health</h2>
<button
onClick={refresh}
className="flex items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface-2 px-2.5 py-1 text-xs text-sp-text-2 hover:text-sp-text-1"
title="Refresh now"
>
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
{funnel ? relativeTime(funnel.as_of) : "…"}
</button>
</div>
{loading || !funnel ? (
<div className="space-y-4">
<div className="sp-skeleton h-12" />
<div className="sp-skeleton h-52" />
</div>
) : (
<>
<div className="grid grid-cols-5 gap-2">
{FUNNEL_STAGES.map(({ key, label }, i) => (
<div
key={key}
className={cn(
"min-w-0 transition-opacity",
hovered !== null && hovered !== i && "opacity-40"
)}
>
<p className="sp-label truncate">{label}</p>
<p className="sp-display mt-0.5 text-2xl sm:text-3xl">{funnel[key]}</p>
</div>
))}
</div>
<FunnelChart
stages={FUNNEL_STAGES.map(({ key, label }) => ({ label, value: funnel[key] }))}
onHover={setHovered}
/>
</>
)}
<AiPromptBar />
</div>
);
}
function ContainerCountCard({
localRunning,
loading,
agents,
healthyRate,
}: {
localRunning?: number;
loading: boolean;
agents: Agent[];
healthyRate: number | null;
}) {
const online = agents.filter((a) => a.status === "online");
const remote = useQueries({
queries: online.map((a) => ({
queryKey: ["agent-system", a.id],
queryFn: () => agentsApi.system(a.id),
refetchInterval: 30000,
})),
});
const hosts: { name: string; count: number }[] = [
{ name: "local", count: localRunning ?? 0 },
...online.map((a, i) => ({ name: a.name, count: remote[i].data?.containers_running ?? 0 })),
];
const total = hosts.reduce((s, h) => s + h.count, 0);
const max = Math.max(...hosts.map((h) => h.count), 1);
return (
<div className="sp-card sp-rise flex flex-col p-5 sm:p-6" style={{ animationDelay: "60ms" }}>
<h2 className="sp-label">Containers running</h2>
{loading ? (
<div className="mt-3 space-y-3">
<div className="sp-skeleton h-14 w-28" />
<div className="sp-skeleton h-20" />
</div>
) : (
<>
<p className="sp-display mt-1 text-5xl">{total}</p>
<div className="mt-4 space-y-2.5">
{hosts.map((h) => (
<div key={h.name}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="truncate font-medium text-sp-text-2">{h.name}</span>
<span className="font-semibold text-sp-text-1">{h.count}</span>
</div>
<div className="h-1.5 rounded-pill bg-sp-surface-2">
<div
className="h-1.5 rounded-pill bg-sp-blue"
style={{ width: `${(h.count / max) * 100}%` }}
/>
</div>
</div>
))}
</div>
</>
)}
<div className="mt-auto pt-5">
<div className="flex items-center gap-2.5 rounded-2xl bg-sp-pill px-3.5 py-3 text-sp-pill-text">
<span className="text-base leading-none"></span>
<p className="text-xs font-medium leading-snug">
{healthyRate === null
? "Insights appear once stacks are running."
: `${healthyRate}% of running stacks are healthy.`}
</p>
</div>
</div>
</div>
);
}
function UptimeCard({
series,
loading,
range,
}: {
series?: { date: string; value: number | null }[];
loading: boolean;
range: RangeDays;
}) {
const values = series?.map((p) => p.value) ?? [];
const latest = [...values].reverse().find((v): v is number => v !== null);
return (
<div className="sp-card sp-rise p-5 sm:p-6" style={{ animationDelay: "120ms" }}>
<div className="flex items-start justify-between">
<div>
<h2 className="sp-label">Uptime</h2>
<p className="sp-display mt-1 text-4xl">
{latest === undefined ? "—" : `${latest}%`}
</p>
</div>
<span className="sp-label">{range}d</span>
</div>
<div className="mt-4">
{loading || !series ? (
<div className="sp-skeleton h-24" />
) : (
<UptimeChart data={values} />
)}
</div>
<p className="mt-2 text-xs text-sp-text-3">Daily share of compose containers running, this host.</p>
</div>
);
}
function OpsCard({
series,
peakDay,
loading,
range,
}: {
series?: { date: string; count: number }[];
peakDay: string | null;
loading: boolean;
range: RangeDays;
}) {
const total = series?.reduce((s, p) => s + p.count, 0) ?? 0;
const max = Math.max(...(series?.map((p) => p.count) ?? []), 1);
return (
<div className="sp-card sp-rise p-5 sm:p-6" style={{ animationDelay: "180ms" }}>
<div className="flex items-start justify-between">
<div>
<h2 className="sp-label">Operations</h2>
<p className="sp-display mt-1 text-4xl">{total}</p>
</div>
<span className="sp-label">{range}d</span>
</div>
<div className="mt-4">
{loading || !series ? (
<div className="sp-skeleton h-16" />
) : (
<OpsGrid data={series.map((p) => p.count / max)} cols={range} />
)}
</div>
<p className="mt-2 text-xs text-sp-text-3">
{peakDay ? `Busiest day: ${peakDay}.` : "Audit-log actions per day."}
</p>
</div>
);
}
/* ---------------------------------------------------------------------- */
/* Host sections (pre-Phase-24, retained) */
/* ---------------------------------------------------------------------- */
function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
const qc = useQueryClient();
const online = agent.status === "online";
+1 -1
View File
@@ -51,7 +51,7 @@ export function Login() {
<Card className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center gap-2">
<Ship className="h-10 w-10 text-accent dark:text-accent-dark" />
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
<h1 className="sp-heading text-xl text-slate-900 dark:text-slate-100">
StackPilot
</h1>
<p className="text-sm text-slate-500">
+1 -1
View File
@@ -377,7 +377,7 @@ function CreateNetworkDialog({
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
onClick={(e) => e.stopPropagation()}
>
<h2 className="mb-3 text-lg font-semibold">Create network</h2>
<h2 className="mb-3 sp-heading text-lg">Create network</h2>
<div className="space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Name</span>
+1 -1
View File
@@ -82,7 +82,7 @@ export function RemoteStackDetail() {
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="text-xl font-bold">{data.name}</h1>
<h1 className="sp-heading text-xl">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
<p className="mt-1 flex items-center gap-1 text-sm text-slate-500">
+1 -1
View File
@@ -40,7 +40,7 @@ export function Settings() {
return (
<Card className="flex flex-col items-center gap-3 py-16 text-center">
<ShieldCheck className="h-10 w-10 text-slate-400" />
<h2 className="text-lg font-semibold">Admin only</h2>
<h2 className="sp-heading text-lg">Admin only</h2>
<p className="max-w-md text-sm text-slate-500">
Settings are available to administrators only.
</p>
+1 -1
View File
@@ -50,7 +50,7 @@ export function StackDetail() {
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="text-xl font-bold">{data.name}</h1>
<h1 className="sp-heading text-xl">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
{data.description && (
+32 -3
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search, HardDrive } from "lucide-react";
import { Button, Input } from "@/components/ui";
@@ -13,11 +13,23 @@ import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
type SortKey = "name" | "status" | "updated";
type StatusFilter = "all" | "running" | "stopped" | "attention";
const STATUS_FILTERS: Record<string, StatusFilter> = {
running: "running",
stopped: "stopped",
attention: "attention",
};
export function Stacks() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { busyId, start, stop, restart } = useStackActions();
const [q, setQ] = useState("");
// The dashboard explore bar deep-links here with ?q= / ?filter=.
const [params] = useSearchParams();
const [q, setQ] = useState(params.get("q") ?? "");
const [statusFilter, setStatusFilter] = useState<StatusFilter>(
STATUS_FILTERS[params.get("filter") ?? ""] ?? "all"
);
const [sort, setSort] = useState<SortKey>("name");
const { data, isLoading } = useQuery({
@@ -50,13 +62,20 @@ export function Stacks() {
s.name.toLowerCase().includes(q.toLowerCase()) ||
s.id.toLowerCase().includes(q.toLowerCase())
);
if (statusFilter !== "all") {
list = list.filter((s) =>
statusFilter === "attention"
? s.status === "error" || s.status === "partial"
: s.status === statusFilter
);
}
list = [...list].sort((a, b) => {
if (sort === "name") return a.name.localeCompare(b.name);
if (sort === "status") return a.status.localeCompare(b.status);
return b.updated_at.localeCompare(a.updated_at);
});
return list;
}, [data, q, sort]);
}, [data, q, statusFilter, sort]);
return (
<div className="space-y-4">
@@ -70,6 +89,16 @@ export function Stacks() {
onChange={(e) => setQ(e.target.value)}
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option value="all">All statuses</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
<option value="attention">Needs attention</option>
</select>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
+1 -1
View File
@@ -110,7 +110,7 @@ function UseTemplateDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[85vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
<h2 className="mb-3 text-lg font-semibold">Use {template.name}</h2>
<h2 className="mb-3 sp-heading text-lg">Use {template.name}</h2>
<div className="space-y-3">
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Stack name</span>
+153
View File
@@ -0,0 +1,153 @@
/* StackPilot Design System v2 — analytics-grade tokens (Phase 24).
Light is the canonical theme; dark overrides live under `.dark`
(the app toggles dark mode by class, not media query). */
:root {
/* Surfaces */
--sp-bg: #ebebeb;
--sp-surface: #ffffff;
--sp-surface-2: #f7f7fb;
/* Borders */
--sp-border-color: #e3e3e3;
--sp-border: 1px solid var(--sp-border-color);
/* Text */
--sp-text-1: #0d0d0d;
--sp-text-2: #5a5a5a;
--sp-text-3: #9a9a9a;
/* Brand + signals */
--sp-blue: #2741ce;
--sp-blue-rgb: 39 65 206;
--sp-blue-light: #7b9bfa;
--sp-green: #16a34a;
--sp-pink: #db2777;
--sp-amber: #d97706;
--sp-amber-rgb: 217 119 6;
/* Nav pill */
--sp-pill-bg: #1a1a1a;
--sp-pill-text: #ffffff;
/* Radii */
--sp-r-card: 18px;
--sp-r-pill: 99px;
--sp-r-chip: 4px;
/* Type */
--sp-font: "Schibsted Grotesk Variable", "Schibsted Grotesk", ui-sans-serif,
system-ui, -apple-system, "Segoe UI", sans-serif;
}
.dark {
--sp-bg: #101014;
--sp-surface: #18181d;
--sp-surface-2: #1f1f26;
--sp-border-color: #2a2a33;
--sp-text-1: #f4f4f5;
--sp-text-2: #a3a3ad;
--sp-text-3: #66666f;
--sp-blue: #6d86f4;
--sp-blue-rgb: 109 134 244;
--sp-blue-light: #3650d6;
--sp-green: #34d399;
--sp-pink: #f472b6;
--sp-amber: #fbbf24;
--sp-amber-rgb: 251 191 36;
/* Pill inverts: light chip on dark chrome */
--sp-pill-bg: #f4f4f5;
--sp-pill-text: #101014;
}
@layer components {
/* Display numerals / hero headings: 800 weight, tight tracking */
.sp-display {
font-weight: 800;
letter-spacing: -0.035em;
color: var(--sp-text-1);
font-feature-settings: "tnum";
}
/* Section / card headings */
.sp-heading {
font-weight: 600;
letter-spacing: -0.01em;
color: var(--sp-text-1);
}
/* Small uppercase labels */
.sp-label {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.6875rem;
color: var(--sp-text-3);
}
/* Shared card chrome */
.sp-card {
background: var(--sp-surface);
border: var(--sp-border);
border-radius: var(--sp-r-card);
}
/* Skeleton shimmer for loading cards */
.sp-skeleton {
position: relative;
overflow: hidden;
background: var(--sp-surface-2);
border-radius: var(--sp-r-chip);
}
.sp-skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.55),
transparent
);
animation: sp-shimmer 1.6s infinite;
}
.dark .sp-skeleton::after {
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.07),
transparent
);
}
}
@keyframes sp-shimmer {
100% {
transform: translateX(100%);
}
}
/* Staggered card entrance on dashboard load */
@keyframes sp-rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sp-rise {
animation: sp-rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.sp-rise {
animation: none;
}
.sp-skeleton::after {
animation: none;
}
}
+4
View File
@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
/** Injected by Vite `define` from package.json at build time. */
declare const __APP_VERSION__: string;
+30 -3
View File
@@ -6,9 +6,36 @@ export default {
theme: {
extend: {
colors: {
bg: { DEFAULT: "#f8fafc", dark: "#0f172a" },
card: { DEFAULT: "#ffffff", dark: "#1e293b" },
accent: { DEFAULT: "#0284c7", dark: "#38bdf8" },
// Legacy aliases now resolve to the v2 tokens so every existing
// page picks up the new palette without per-file edits. The CSS
// variables themselves flip under `.dark`, so the `-dark` variants
// point at the same vars.
bg: { DEFAULT: "var(--sp-bg)", dark: "var(--sp-bg)" },
card: { DEFAULT: "var(--sp-surface)", dark: "var(--sp-surface)" },
accent: {
DEFAULT: "rgb(var(--sp-blue-rgb) / <alpha-value>)",
dark: "rgb(var(--sp-blue-rgb) / <alpha-value>)",
},
// Design System v2 tokens
"sp-bg": "var(--sp-bg)",
"sp-surface": "var(--sp-surface)",
"sp-surface-2": "var(--sp-surface-2)",
"sp-border": "var(--sp-border-color)",
"sp-text-1": "var(--sp-text-1)",
"sp-text-2": "var(--sp-text-2)",
"sp-text-3": "var(--sp-text-3)",
"sp-blue": "var(--sp-blue)",
"sp-blue-light": "var(--sp-blue-light)",
"sp-green": "var(--sp-green)",
"sp-pink": "var(--sp-pink)",
"sp-amber": "rgb(var(--sp-amber-rgb) / <alpha-value>)",
"sp-pill": "var(--sp-pill-bg)",
"sp-pill-text": "var(--sp-pill-text)",
},
borderRadius: {
card: "var(--sp-r-card)",
pill: "var(--sp-r-pill)",
chip: "var(--sp-r-chip)",
},
},
},
+6
View File
@@ -1,9 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { readFileSync } from "fs";
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "package.json"), "utf-8"));
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},