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
@@ -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>