Dashboard: rebuild into an operator cockpit (0.38.0)
Replace the analytics-style dashboard (stack-health funnel, uptime %, operations/day grid, AI pill) with an attention-driven fleet cockpit: - New /api/dashboard/fleet endpoint: server-side fan-out across the local host and every agent into one payload — a prioritized "needs attention" list, headline KPIs, an honest stack-status breakdown and a per-host resource rollup. Each agent uses its own DB session so the fan-out is concurrency-safe; failures degrade to "offline" instead of stalling. - New frontend: AttentionStrip, FleetKpiRow, StackStatusBar and HostResourceTable; Dashboard.tsx rewritten around them. - Remove the funnel/summary endpoints, the uptime sampler loop and the ops-activity machinery; delete the now-unused chart components. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5c46e40866
commit
c830d28b65
@@ -1,97 +0,0 @@
|
||||
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,101 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ServerOff,
|
||||
HeartPulse,
|
||||
ArrowUpCircle,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
Archive,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import type { AttentionItem } from "@/api/dashboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const KIND_ICON: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
agent_offline: ServerOff,
|
||||
unhealthy: HeartPulse,
|
||||
stack_error: AlertTriangle,
|
||||
stack_partial: AlertCircle,
|
||||
updates: ArrowUpCircle,
|
||||
disk_pressure: HardDrive,
|
||||
mem_pressure: MemoryStick,
|
||||
backup_failed: Archive,
|
||||
backup_overdue: Archive,
|
||||
};
|
||||
|
||||
export function AttentionStrip({
|
||||
items,
|
||||
loading,
|
||||
}: {
|
||||
items?: AttentionItem[];
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="sp-card p-4">
|
||||
<div className="sp-skeleton h-5 w-40" />
|
||||
<div className="sp-skeleton mt-3 h-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-card border border-sp-green/40 bg-sp-green/10 px-4 py-3 text-sp-green">
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0" />
|
||||
<p className="text-sm font-medium">All systems healthy — nothing needs your attention.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errors = items.filter((i) => i.severity === "error").length;
|
||||
|
||||
return (
|
||||
<div className="sp-card overflow-hidden p-0">
|
||||
<div className="flex items-center justify-between border-b border-sp-border px-4 py-2.5">
|
||||
<h2 className="sp-label">Needs attention</h2>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-pill px-2 py-0.5 text-xs font-semibold",
|
||||
errors > 0
|
||||
? "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300"
|
||||
: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300"
|
||||
)}
|
||||
>
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-sp-border">
|
||||
{items.map((item, i) => {
|
||||
const Icon = KIND_ICON[item.kind] ?? AlertCircle;
|
||||
const isError = item.severity === "error";
|
||||
return (
|
||||
<li key={`${item.kind}-${item.host}-${i}`}>
|
||||
<Link
|
||||
to={item.link}
|
||||
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-sp-surface-2"
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
isError ? "text-red-600 dark:text-red-400" : "text-sp-amber"
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-sp-text-1">{item.title}</p>
|
||||
{item.detail && (
|
||||
<p className="truncate text-xs text-sp-text-3">{item.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-sp-text-3" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Server, Boxes, Container, HeartPulse, ArrowUpCircle, Archive } from "lucide-react";
|
||||
import type { FleetKpis } from "@/api/dashboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tone = "default" | "warn" | "error";
|
||||
|
||||
function Kpi({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
tone = "default",
|
||||
to,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
sub?: string;
|
||||
tone?: Tone;
|
||||
to?: string;
|
||||
}) {
|
||||
const toneText =
|
||||
tone === "error"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: tone === "warn"
|
||||
? "text-sp-amber"
|
||||
: "text-sp-text-1";
|
||||
const inner = (
|
||||
<div className="sp-card flex h-full flex-col gap-1 p-4">
|
||||
<div className="flex items-center gap-1.5 text-sp-text-3">
|
||||
<span className="[&>svg]:h-3.5 [&>svg]:w-3.5">{icon}</span>
|
||||
<span className="sp-label">{label}</span>
|
||||
</div>
|
||||
<p className={cn("sp-display text-3xl leading-none", toneText)}>{value}</p>
|
||||
{sub && <p className="text-xs text-sp-text-3">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
return to ? (
|
||||
<Link to={to} className="block transition-transform hover:-translate-y-0.5">
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
inner
|
||||
);
|
||||
}
|
||||
|
||||
export function FleetKpiRow({ kpis }: { kpis: FleetKpis }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<Kpi
|
||||
icon={<Server />}
|
||||
label="Hosts"
|
||||
value={`${kpis.hosts_online}/${kpis.hosts_total}`}
|
||||
sub="online"
|
||||
tone={kpis.hosts_online < kpis.hosts_total ? "error" : "default"}
|
||||
/>
|
||||
<Kpi
|
||||
icon={<Boxes />}
|
||||
label="Stacks"
|
||||
value={`${kpis.stacks_running}/${kpis.stacks_total}`}
|
||||
sub={kpis.stacks_partial > 0 ? `${kpis.stacks_partial} partial` : "running"}
|
||||
tone={kpis.stacks_partial > 0 ? "warn" : "default"}
|
||||
/>
|
||||
<Kpi
|
||||
icon={<Container />}
|
||||
label="Containers"
|
||||
value={`${kpis.containers_running}/${kpis.containers_total}`}
|
||||
sub="running"
|
||||
/>
|
||||
<Kpi
|
||||
icon={<HeartPulse />}
|
||||
label="Unhealthy"
|
||||
value={kpis.unhealthy}
|
||||
sub={kpis.unhealthy > 0 ? "need a look" : "all healthy"}
|
||||
tone={kpis.unhealthy > 0 ? "error" : "default"}
|
||||
/>
|
||||
<Kpi
|
||||
icon={<ArrowUpCircle />}
|
||||
label="Updates"
|
||||
value={kpis.updates_available}
|
||||
sub={kpis.updates_available > 0 ? "available" : "up to date"}
|
||||
tone={kpis.updates_available > 0 ? "warn" : "default"}
|
||||
to={kpis.updates_available > 0 ? "/images" : undefined}
|
||||
/>
|
||||
<Kpi
|
||||
icon={<Archive />}
|
||||
label="Backups"
|
||||
value={kpis.backups_failing > 0 ? kpis.backups_failing : "OK"}
|
||||
sub={kpis.backups_failing > 0 ? "failing/overdue" : "on schedule"}
|
||||
tone={kpis.backups_failing > 0 ? "error" : "default"}
|
||||
to={kpis.backups_failing > 0 ? "/settings" : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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,100 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { FleetHost } from "@/api/dashboard";
|
||||
import { cn, formatBytes } from "@/lib/utils";
|
||||
|
||||
const DISK_PRESSURE = 85;
|
||||
const MEM_PRESSURE = 90;
|
||||
|
||||
function Meter({ used, total, pressure }: { used: number; total: number; pressure: number }) {
|
||||
const pct = total > 0 ? Math.round((used / total) * 100) : 0;
|
||||
const hot = pct >= pressure;
|
||||
return (
|
||||
<div className="min-w-[7rem]">
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className={cn("font-semibold", hot ? "text-red-600 dark:text-red-400" : "text-sp-text-1")}>
|
||||
{pct}%
|
||||
</span>
|
||||
<span className="text-sp-text-3">
|
||||
{formatBytes(used)} / {formatBytes(total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-pill bg-sp-surface-2">
|
||||
<div
|
||||
className={cn("h-1.5 rounded-pill", hot ? "bg-red-500" : "bg-sp-blue")}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostResourceTable({ hosts }: { hosts: FleetHost[] }) {
|
||||
return (
|
||||
<div className="sp-card overflow-hidden p-0">
|
||||
<div className="border-b border-sp-border px-4 py-2.5">
|
||||
<h2 className="sp-label">Hosts</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-sp-text-3">
|
||||
<th className="px-4 py-2 font-medium">Host</th>
|
||||
<th className="px-4 py-2 font-medium">CPU</th>
|
||||
<th className="px-4 py-2 font-medium">Memory</th>
|
||||
<th className="px-4 py-2 font-medium">Disk</th>
|
||||
<th className="px-4 py-2 font-medium">Containers</th>
|
||||
<th className="px-4 py-2 font-medium">Stacks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-sp-border">
|
||||
{hosts.map((h) => (
|
||||
<tr key={String(h.id)} className="align-middle">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0 rounded-full",
|
||||
h.online ? "bg-sp-green" : "bg-slate-400"
|
||||
)}
|
||||
title={h.status}
|
||||
/>
|
||||
<span className="font-medium text-sp-text-1">{h.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
{h.online ? (
|
||||
<>
|
||||
<td className="px-4 py-3 text-sp-text-2">{h.cpu_cores || "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Meter used={h.mem_used} total={h.mem_total} pressure={MEM_PRESSURE} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Meter used={h.disk_used} total={h.disk_total} pressure={DISK_PRESSURE} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sp-text-2">
|
||||
{h.containers_running}/{h.containers_total}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sp-text-2">
|
||||
{h.stacks.running}/{h.stacks.total}
|
||||
{h.unhealthy > 0 && (
|
||||
<span className="ml-1.5 text-xs font-semibold text-red-600 dark:text-red-400">
|
||||
· {h.unhealthy} unhealthy
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<td className="px-4 py-3 text-sp-text-3" colSpan={5}>
|
||||
{h.status} —{" "}
|
||||
<Link to="/settings" className="text-sp-blue hover:underline">
|
||||
check under Settings
|
||||
</Link>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/** 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,66 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatusTotals {
|
||||
running: number;
|
||||
partial: number;
|
||||
stopped: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
const SEGMENTS: { key: keyof StatusTotals; label: string; bar: string; dot: string }[] = [
|
||||
{ key: "running", label: "Running", bar: "bg-sp-green", dot: "bg-sp-green" },
|
||||
{ key: "partial", label: "Partial", bar: "bg-sp-amber", dot: "bg-sp-amber" },
|
||||
{ key: "stopped", label: "Stopped", bar: "bg-slate-400", dot: "bg-slate-400" },
|
||||
{ key: "error", label: "Error", bar: "bg-red-500", dot: "bg-red-500" },
|
||||
];
|
||||
|
||||
export function StackStatusBar({
|
||||
totals,
|
||||
unhealthy,
|
||||
}: {
|
||||
totals: StatusTotals;
|
||||
unhealthy: number;
|
||||
}) {
|
||||
const total = SEGMENTS.reduce((s, seg) => s + totals[seg.key], 0);
|
||||
|
||||
return (
|
||||
<div className="sp-card flex flex-col gap-4 p-4 sm:p-5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="sp-label">Stack status</h2>
|
||||
<span className="text-xs text-sp-text-3">{total} stacks</span>
|
||||
</div>
|
||||
|
||||
<div className="flex h-3 w-full overflow-hidden rounded-pill bg-sp-surface-2">
|
||||
{total === 0 ? null : (
|
||||
SEGMENTS.map((seg) =>
|
||||
totals[seg.key] > 0 ? (
|
||||
<div
|
||||
key={seg.key}
|
||||
className={cn("h-full", seg.bar)}
|
||||
style={{ width: `${(totals[seg.key] / total) * 100}%` }}
|
||||
title={`${seg.label}: ${totals[seg.key]}`}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
{SEGMENTS.map((seg) => (
|
||||
<div key={seg.key} className="flex items-center gap-1.5">
|
||||
<span className={cn("h-2.5 w-2.5 rounded-full", seg.dot)} />
|
||||
<span className="text-sm font-semibold text-sp-text-1">{totals[seg.key]}</span>
|
||||
<span className="text-xs text-sp-text-3">{seg.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{unhealthy > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-red-200 dark:ring-red-900/50" />
|
||||
<span className="text-sm font-semibold text-red-600 dark:text-red-400">{unhealthy}</span>
|
||||
<span className="text-xs text-sp-text-3">Unhealthy</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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