Phase 24: Design System v2 — analytics-style UI (0.30.0)
- New /api/dashboard/funnel (5-stage stack health, 30s TTL cache) and /api/dashboard/summary (containers, daily uptime jsonl, ops activity) - Token system (tokens.css + Tailwind sp-* aliases); legacy bg/card/accent remapped onto the tokens; Schibsted Grotesk bundled via fontsource - TopNav pill navigation + AppShell replace the sidebar layout (off-canvas drawer below 1024px); central display-weight page titles - Dashboard redesign: FunnelChart (gradient/hatch SVG waterfall), container count card with per-host bars + Insights chip, UptimeChart, OpsGrid, AiPromptBar; 30/7-day range selector; host sections retained below - Stacks page honours ?q= / ?filter= deep links + new status-filter select Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6464e0677c
commit
34cb215266
@@ -0,0 +1,97 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, CornerDownLeft } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CONTEXT_TAGS = [
|
||||
{ tag: "/running", filter: "running" },
|
||||
{ tag: "/stopped", filter: "stopped" },
|
||||
{ tag: "/attention", filter: "attention" },
|
||||
];
|
||||
|
||||
function SparkleIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8 1.5 9.4 6 14 7.5 9.4 9 8 13.5 6.6 9 2 7.5 6.6 6 8 1.5Z"
|
||||
fill="var(--sp-amber)"
|
||||
/>
|
||||
<path d="M13 11l.6 1.7L15.3 13l-1.7.6L13 15.3l-.6-1.7-1.7-.6 1.7-.6.6-1.7Z" fill="var(--sp-amber)" fillOpacity="0.7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline explore-prompt: typed (or tag-clicked) queries jump to the Stacks
|
||||
* page with a matching filter — no LLM behind it (yet). */
|
||||
export function AiPromptBar({ onSubmit }: { onSubmit?: (query: string) => void }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [value, setValue] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submit = (raw: string) => {
|
||||
const query = raw.trim();
|
||||
if (!query) return;
|
||||
if (onSubmit) {
|
||||
onSubmit(query);
|
||||
return;
|
||||
}
|
||||
const tag = CONTEXT_TAGS.find((t) => query.startsWith(t.tag));
|
||||
if (tag) navigate(`/stacks?filter=${tag.filter}`);
|
||||
else navigate(`/stacks?q=${encodeURIComponent(query)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-sp-border bg-sp-surface-2">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<SparkleIcon />
|
||||
<span className="text-sm font-medium text-sp-text-1">
|
||||
What would you like to explore next?
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("ml-auto h-4 w-4 text-sp-text-3 transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 pb-3.5">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit(value);
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-xl border border-sp-border bg-sp-surface px-3 py-2"
|
||||
>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Show me stacks that are…"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-sp-text-1 placeholder:text-sp-text-3 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg p-1.5 text-sp-text-3 hover:bg-sp-surface-2 hover:text-sp-text-1"
|
||||
title="Go"
|
||||
aria-label="Submit"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{CONTEXT_TAGS.map(({ tag }) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => submit(tag)}
|
||||
className="rounded-chip bg-sp-amber/15 px-2 py-0.5 font-mono text-xs font-semibold text-sp-amber hover:bg-sp-amber/25"
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useId, useState } from "react";
|
||||
|
||||
export interface FunnelStage {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** SVG waterfall funnel: bars alternate gradient / diagonal-hatch fill,
|
||||
* with a value chip above each bar and a conversion tooltip on hover. */
|
||||
export function FunnelChart({
|
||||
stages,
|
||||
onHover,
|
||||
}: {
|
||||
stages: FunnelStage[];
|
||||
onHover?: (i: number | null) => void;
|
||||
}) {
|
||||
const uid = useId().replace(/:/g, "");
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
|
||||
const W = 640;
|
||||
const H = 250;
|
||||
const chipZone = 34; // space above bars for value chips
|
||||
const max = Math.max(...stages.map((s) => s.value), 1);
|
||||
const n = stages.length;
|
||||
const gap = 14;
|
||||
const colW = (W - gap * (n - 1)) / n;
|
||||
|
||||
const setHovered = (i: number | null) => {
|
||||
setHover(i);
|
||||
onHover?.(i);
|
||||
};
|
||||
|
||||
const gradId = `sp-funnel-grad-${uid}`;
|
||||
const hatchId = `sp-funnel-hatch-${uid}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
width="100%"
|
||||
role="img"
|
||||
aria-label={`Funnel: ${stages.map((s) => `${s.label} ${s.value}`).join(", ")}`}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--sp-blue)" />
|
||||
<stop offset="100%" stopColor="var(--sp-blue-light)" />
|
||||
</linearGradient>
|
||||
<pattern
|
||||
id={hatchId}
|
||||
width="7"
|
||||
height="7"
|
||||
patternTransform="rotate(45)"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<rect width="7" height="7" fill="var(--sp-blue)" fillOpacity="0.13" />
|
||||
<line x1="0" y1="0" x2="0" y2="7" stroke="var(--sp-blue)" strokeWidth="2.5" strokeOpacity="0.75" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{stages.map((stage, i) => {
|
||||
const x = i * (colW + gap);
|
||||
const h = Math.max((stage.value / max) * (H - chipZone), stage.value > 0 ? 6 : 2);
|
||||
const y = H - h;
|
||||
const chipText = String(stage.value);
|
||||
const chipW = chipText.length * 8.5 + 18;
|
||||
const conv = stages[0].value > 0 ? Math.round((stage.value / stages[0].value) * 100) : 0;
|
||||
const prev = i > 0 ? stages[i - 1].value : stage.value;
|
||||
const drop = i > 0 && prev > 0 ? Math.round(((prev - stage.value) / prev) * 100) : 0;
|
||||
const isHover = hover === i;
|
||||
|
||||
return (
|
||||
<g key={stage.label}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={colW}
|
||||
height={h}
|
||||
rx={10}
|
||||
fill={i % 2 === 1 ? `url(#${gradId})` : `url(#${hatchId})`}
|
||||
opacity={hover === null || isHover ? 1 : 0.45}
|
||||
style={{ transition: "opacity 150ms" }}
|
||||
/>
|
||||
{/* Value chip above the bar */}
|
||||
<g opacity={hover === null || isHover ? 1 : 0.45} style={{ transition: "opacity 150ms" }}>
|
||||
<rect
|
||||
x={x + colW / 2 - chipW / 2}
|
||||
y={Math.max(y - 28, 0)}
|
||||
width={chipW}
|
||||
height={22}
|
||||
rx={11}
|
||||
fill="var(--sp-surface)"
|
||||
stroke="var(--sp-border-color)"
|
||||
/>
|
||||
<text
|
||||
x={x + colW / 2}
|
||||
y={Math.max(y - 28, 0) + 15}
|
||||
textAnchor="middle"
|
||||
fontSize="12.5"
|
||||
fontWeight="700"
|
||||
fill="var(--sp-text-1)"
|
||||
>
|
||||
{chipText}
|
||||
</text>
|
||||
</g>
|
||||
{/* Hover tooltip */}
|
||||
{isHover && (
|
||||
<g pointerEvents="none">
|
||||
<rect
|
||||
x={Math.min(Math.max(x + colW / 2 - 105, 4), W - 214)}
|
||||
y={4}
|
||||
width={210}
|
||||
height={26}
|
||||
rx={13}
|
||||
fill="var(--sp-pill-bg)"
|
||||
/>
|
||||
<text
|
||||
x={Math.min(Math.max(x + colW / 2, 109), W - 109)}
|
||||
y={21}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="600"
|
||||
fill="var(--sp-pill-text)"
|
||||
>
|
||||
{stage.value} stacks · Conv: {conv}%{i > 0 ? ` · Drop-off: −${drop}%` : ""}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
{/* Transparent hit-target for the whole column.
|
||||
pointer-events must be the SVG attribute (not CSS) for
|
||||
fill="none" elements to receive events in Firefox. */}
|
||||
<rect
|
||||
x={x}
|
||||
y={0}
|
||||
width={colW}
|
||||
height={H}
|
||||
fill="none"
|
||||
pointerEvents="all"
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Contribution-grid style dot matrix: one column per day, cells light up
|
||||
* bottom-to-top with the day's normalised activity. Fully deterministic —
|
||||
* derived only from `data`, no randomness. */
|
||||
export function OpsGrid({
|
||||
data,
|
||||
cols = 30,
|
||||
rows = 5,
|
||||
}: {
|
||||
data: number[]; // normalised 0–1 per day
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}) {
|
||||
const days = data.slice(-cols);
|
||||
const cell = 10;
|
||||
const gap = 3;
|
||||
const W = cols * (cell + gap) - gap;
|
||||
const H = rows * (cell + gap) - gap;
|
||||
|
||||
// 4 opacity tiers, like a contribution graph.
|
||||
const tier = (v: number) => (v <= 0 ? 0.08 : v < 0.34 ? 0.28 : v < 0.67 ? 0.58 : 1);
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="Operations activity grid">
|
||||
{days.map((v, day) => {
|
||||
const lit = Math.min(rows, Math.ceil(Math.max(0, Math.min(v, 1)) * rows));
|
||||
const x = day * (cell + gap);
|
||||
return Array.from({ length: rows }, (_, r) => {
|
||||
const y = (rows - 1 - r) * (cell + gap);
|
||||
const on = r < lit;
|
||||
return (
|
||||
<rect
|
||||
key={`${day}-${r}`}
|
||||
x={x}
|
||||
y={y}
|
||||
width={cell}
|
||||
height={cell}
|
||||
rx={2.5}
|
||||
fill="var(--sp-blue)"
|
||||
fillOpacity={on ? tier(v) : 0.08}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useId } from "react";
|
||||
|
||||
/** Smooth SVG line chart with a soft gradient area fill underneath.
|
||||
* `data` values are percentages (0–100); nulls (days before monitoring
|
||||
* began) are rendered as a flat lead-in at the first known value. */
|
||||
export function UptimeChart({
|
||||
data,
|
||||
color = "var(--sp-pink)",
|
||||
}: {
|
||||
data: (number | null)[];
|
||||
color?: string;
|
||||
}) {
|
||||
const uid = useId().replace(/:/g, "");
|
||||
const gradId = `sp-uptime-grad-${uid}`;
|
||||
|
||||
const W = 320;
|
||||
const H = 96;
|
||||
const pad = 6;
|
||||
|
||||
const firstKnown = data.find((v): v is number => v !== null) ?? 100;
|
||||
const values = data.map((v) => v ?? firstKnown);
|
||||
if (values.length === 0) values.push(100);
|
||||
if (values.length === 1) values.push(values[0]);
|
||||
|
||||
const min = Math.min(...values);
|
||||
const lo = Math.max(0, Math.min(min - 5, 90));
|
||||
const span = 100 - lo || 1;
|
||||
const stepX = (W - pad * 2) / (values.length - 1);
|
||||
const pts = values.map((v, i) => ({
|
||||
x: pad + i * stepX,
|
||||
y: pad + (1 - (v - lo) / span) * (H - pad * 2),
|
||||
}));
|
||||
|
||||
// Catmull-Rom → cubic bezier for a smooth line through every point.
|
||||
let line = `M ${pts[0].x} ${pts[0].y}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(i - 1, 0)];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[Math.min(i + 2, pts.length - 1)];
|
||||
const c1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const c1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const c2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const c2y = p2.y - (p3.y - p1.y) / 6;
|
||||
line += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
|
||||
}
|
||||
const area = `${line} L ${pts[pts.length - 1].x} ${H} L ${pts[0].x} ${H} Z`;
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" role="img" aria-label="Uptime trend">
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.12" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill={`url(#${gradId})`} />
|
||||
<path d={line} fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle
|
||||
cx={pts[pts.length - 1].x}
|
||||
cy={pts[pts.length - 1].y}
|
||||
r="3.5"
|
||||
fill={color}
|
||||
stroke="var(--sp-surface)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { TopNav } from "./TopNav";
|
||||
|
||||
/** Top-level routes get a display-weight title here; the Dashboard ("/")
|
||||
* and detail pages render their own headers. */
|
||||
const TITLES: Record<string, string> = {
|
||||
"/stacks": "Stacks",
|
||||
"/networks": "Networks",
|
||||
"/images": "Images",
|
||||
"/volumes": "Volumes",
|
||||
"/files": "Files",
|
||||
"/templates": "Templates",
|
||||
"/audit": "Audit log",
|
||||
"/settings": "Settings",
|
||||
};
|
||||
|
||||
/** Design System v2 layout: fixed 60px TopNav, content below. */
|
||||
export function AppShell() {
|
||||
const { pathname } = useLocation();
|
||||
const title = TITLES[pathname];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-sp-bg text-slate-900 dark:text-slate-100">
|
||||
<TopNav />
|
||||
<main className="mx-auto max-w-[1480px] px-4 pb-10 pt-[76px] sm:px-6">
|
||||
{title && (
|
||||
<h1 className="sp-display mb-6 text-[40px] leading-none sm:text-[50px]">{title}</h1>
|
||||
)}
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
export function Layout() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-bg text-slate-900 dark:bg-bg-dark dark:text-slate-100">
|
||||
<Sidebar mobileOpen={mobileOpen} onClose={() => setMobileOpen(false)} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar onMenu={() => setMobileOpen(true)} />
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
Network,
|
||||
Image,
|
||||
Database,
|
||||
FolderTree,
|
||||
LayoutTemplate,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Moon,
|
||||
Sun,
|
||||
LogOut,
|
||||
Ship,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
|
||||
const nav = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||
{ to: "/stacks", label: "Stacks", icon: Boxes },
|
||||
{ to: "/networks", label: "Networks", icon: Network },
|
||||
{ to: "/images", label: "Images", icon: Image },
|
||||
{ to: "/volumes", label: "Volumes", icon: Database },
|
||||
{ to: "/files", label: "Files", icon: FolderTree },
|
||||
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
|
||||
{ to: "/audit", label: "Audit log", icon: ScrollText },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
mobileOpen = false,
|
||||
onClose,
|
||||
}: {
|
||||
mobileOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const { theme, toggle } = useThemeStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/50 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark",
|
||||
// Off-canvas on mobile, static on desktop.
|
||||
"fixed inset-y-0 left-0 transform transition-transform md:static md:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
|
||||
<span className="text-lg font-bold">StackPilot</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
|
||||
title="Close menu"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{nav.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
|
||||
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{user?.username ?? "—"}
|
||||
{user?.role === "admin" && (
|
||||
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
|
||||
admin
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NavLink, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
Network,
|
||||
Image,
|
||||
Database,
|
||||
FolderTree,
|
||||
LayoutTemplate,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Moon,
|
||||
Sun,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||
{ to: "/stacks", label: "Stacks", icon: Boxes },
|
||||
{ to: "/networks", label: "Networks", icon: Network },
|
||||
{ to: "/images", label: "Images", icon: Image },
|
||||
{ to: "/volumes", label: "Volumes", icon: Database },
|
||||
{ to: "/files", label: "Files", icon: FolderTree },
|
||||
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
|
||||
{ to: "/audit", label: "Audit", icon: ScrollText },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
function LogoMark() {
|
||||
return (
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="sp-logo-grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="var(--sp-blue)" />
|
||||
<stop offset="100%" stopColor="var(--sp-blue-light)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="30" height="30" rx="9" fill="url(#sp-logo-grad)" />
|
||||
{/* Stacked-layers glyph */}
|
||||
<path
|
||||
d="M15 7.5 22 11.25 15 15 8 11.25 15 7.5Z"
|
||||
fill="#fff"
|
||||
fillOpacity="0.95"
|
||||
/>
|
||||
<path
|
||||
d="M9.6 14.4 15 17.3l5.4-2.9L22 15.25 15 19 8 15.25l1.6-.85Z"
|
||||
fill="#fff"
|
||||
fillOpacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M9.6 18.4 15 21.3l5.4-2.9L22 19.25 15 23 8 19.25l1.6-.85Z"
|
||||
fill="#fff"
|
||||
fillOpacity="0.32"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function initials(name: string | undefined): string {
|
||||
if (!name) return "?";
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function TopNav() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const { theme, toggle } = useThemeStore();
|
||||
|
||||
const agents = useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => agentsApi.list(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
const agentCount = agents.data?.length ?? 0;
|
||||
const agentsOnline = agents.data?.filter((a) => a.status === "online").length ?? 0;
|
||||
|
||||
// Close avatar menu on outside click.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="fixed inset-x-0 top-0 z-40 h-[60px] border-b border-sp-border bg-sp-bg/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-full max-w-[1480px] items-center gap-3 px-4 sm:px-6">
|
||||
{/* Hamburger (mobile / narrow) */}
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="rounded-lg p-2 text-sp-text-2 hover:bg-sp-surface lg:hidden"
|
||||
title="Open menu"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<NavLink to="/" className="flex shrink-0 items-center gap-2.5">
|
||||
<LogoMark />
|
||||
<span className="sp-heading hidden text-[17px] sm:inline">StackPilot</span>
|
||||
</NavLink>
|
||||
|
||||
{/* Pill nav */}
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="Primary"
|
||||
className="mx-auto hidden items-center gap-0.5 rounded-pill border border-sp-border bg-sp-surface p-1 lg:flex"
|
||||
>
|
||||
{NAV_ITEMS.map(({ to, label, end }) => (
|
||||
<NavLink key={to} to={to} end={end}>
|
||||
{({ isActive }) => (
|
||||
<span
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"block rounded-pill px-3.5 py-1.5 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-sp-pill text-sp-pill-text"
|
||||
: "text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Right cluster */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0">
|
||||
<span className="sp-label hidden rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 sm:inline">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
{agentCount > 0 && (
|
||||
<button
|
||||
onClick={() => navigate("/settings")}
|
||||
className="hidden items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 text-xs font-medium text-sp-text-2 hover:text-sp-text-1 sm:flex"
|
||||
title={`${agentsOnline}/${agentCount} remote hosts online`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
agentsOnline === agentCount ? "bg-sp-green" : "bg-sp-amber"
|
||||
)}
|
||||
/>
|
||||
{agentsOnline}/{agentCount}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="rounded-pill border border-sp-border bg-sp-surface p-2 text-sp-text-2 hover:text-sp-text-1"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-sp-pill text-xs font-bold text-sp-pill-text"
|
||||
title={user?.username}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{initials(user?.username)}
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="sp-card absolute right-0 top-11 z-50 w-48 p-2 shadow-lg">
|
||||
<div className="px-3 py-2">
|
||||
<p className="truncate text-sm font-semibold text-sp-text-1">
|
||||
{user?.username ?? "—"}
|
||||
</p>
|
||||
{user?.role === "admin" && <p className="sp-label mt-0.5">admin</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Off-canvas drawer (narrow screens) */}
|
||||
{drawerOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex w-64 transform flex-col bg-sp-surface transition-transform lg:hidden",
|
||||
drawerOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
aria-hidden={!drawerOpen}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LogoMark />
|
||||
<span className="sp-heading text-[17px]">StackPilot</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
className="rounded-lg p-1.5 text-sp-text-2 hover:bg-sp-surface-2"
|
||||
title="Close menu"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-3" aria-label="Primary">
|
||||
{NAV_ITEMS.map(({ to, label, icon: Icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-sp-pill text-sp-pill-text"
|
||||
: "text-sp-text-2 hover:bg-sp-surface-2 hover:text-sp-text-1"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-sp-border p-4">
|
||||
<span className="sp-label">v{__APP_VERSION__}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
"": "Dashboard",
|
||||
stacks: "Stacks",
|
||||
networks: "Networks",
|
||||
images: "Images",
|
||||
templates: "Templates",
|
||||
audit: "Audit log",
|
||||
settings: "Settings",
|
||||
};
|
||||
|
||||
export function Topbar({ onMenu }: { onMenu?: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const segment = pathname.split("/")[1] ?? "";
|
||||
const title = titles[segment] ?? "StackPilot";
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-3 border-b border-slate-200 bg-card px-4 dark:border-slate-700 dark:bg-card-dark sm:px-6">
|
||||
<button
|
||||
onClick={onMenu}
|
||||
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 md:hidden"
|
||||
title="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-base font-semibold">{title}</h1>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export function BackupButton({
|
||||
</Button>
|
||||
{open && (
|
||||
<Modal onClose={() => !busy && setOpen(false)}>
|
||||
<h2 className="mb-3 text-lg font-semibold">Back up “{stackId}”</h2>
|
||||
<h2 className="mb-3 sp-heading text-lg">Back up “{stackId}”</h2>
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Destination</span>
|
||||
@@ -225,7 +225,7 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
|
||||
</Button>
|
||||
{open && (
|
||||
<Modal onClose={() => !busy && setOpen(false)}>
|
||||
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
|
||||
<h2 className="mb-3 sp-heading text-lg">Restore from backup</h2>
|
||||
|
||||
<div className="mb-3 flex gap-1 rounded-lg bg-slate-100 p-1 text-sm dark:bg-slate-800">
|
||||
{(["upload", "destination"] as const).map((m) => (
|
||||
|
||||
@@ -127,7 +127,7 @@ export function ContainerTerminal({
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">Terminal — {service}</h2>
|
||||
<h2 className="sp-heading text-lg">Terminal — {service}</h2>
|
||||
<span
|
||||
className={
|
||||
status === "open"
|
||||
|
||||
@@ -79,7 +79,7 @@ export function DeployConsole({
|
||||
{(phase === "failed" || phase === "error") && (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">
|
||||
<h2 className="sp-heading text-lg">
|
||||
{phase === "running" && `Deploying ${stackId}…`}
|
||||
{phase === "success" && `Deployed ${stackId} ✓`}
|
||||
{phase === "failed" && `Deploy of ${stackId} failed`}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function PortConflictDialog({
|
||||
<div className="w-full max-w-lg rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||
<div className="mb-3 flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">Port conflicts detected</h2>
|
||||
<h2 className="sp-heading text-lg">Port conflicts detected</h2>
|
||||
</div>
|
||||
<ul className="mb-4 space-y-2">
|
||||
{conflicts.map((c, i) => (
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ConfirmDialog({
|
||||
className="w-full max-w-md rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<h2 className="sp-heading text-lg">{title}</h2>
|
||||
{message && <p className="mt-2 text-sm text-slate-500">{message}</p>}
|
||||
{children && <div className="mt-3">{children}</div>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
|
||||
@@ -12,11 +12,7 @@ export function Card({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-slate-200 bg-card p-4 shadow-sm",
|
||||
"dark:border-slate-700 dark:bg-card-dark",
|
||||
className
|
||||
)}
|
||||
className={cn("sp-card p-4", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user