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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user