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:
menzelj
2026-06-24 11:04:57 +00:00
co-authored by Claude Opus 4.8
parent 5c46e40866
commit c830d28b65
15 changed files with 771 additions and 1067 deletions
+54 -24
View File
@@ -1,35 +1,65 @@
import api from "./client";
export interface FunnelData {
discovered: number;
export interface StackBuckets {
running: number;
healthy: number;
updated: number;
monitored: number;
as_of: string;
partial: number;
stopped: number;
error: number;
total: number;
}
export interface UptimePoint {
date: string;
value: number | null;
}
export interface OpsPoint {
date: string;
count: number;
}
export interface DashboardSummary {
total_containers: number;
export interface FleetHost {
id: string | number; // "local" or an agent id
name: string;
online: boolean;
status: string;
cpu_cores: number;
mem_used: number;
mem_total: number;
disk_used: number;
disk_total: number;
stacks: StackBuckets;
containers_running: number;
containers_total: number;
uptime_series: UptimePoint[];
ops_last_30d: OpsPoint[];
ops_peak_day: string | null;
unhealthy: number;
updates_available: number;
}
export interface FleetKpis {
hosts_online: number;
hosts_total: number;
stacks_running: number;
stacks_partial: number;
stacks_total: number;
containers_running: number;
containers_total: number;
unhealthy: number;
updates_available: number;
backups_failing: number;
}
export type AttentionSeverity = "error" | "warn";
export interface AttentionItem {
severity: AttentionSeverity;
kind: string;
host: string;
title: string;
detail: string;
link: string;
}
export interface FleetData {
as_of: string;
hosts: FleetHost[];
kpis: FleetKpis;
status_totals: { running: number; partial: number; stopped: number; error: number };
attention: AttentionItem[];
}
export const dashboardApi = {
funnel: (refresh = false) =>
api.get<FunnelData>(`/api/dashboard/funnel${refresh ? "?refresh=true" : ""}`).then((r) => r.data),
summary: () => api.get<DashboardSummary>("/api/dashboard/summary").then((r) => r.data),
fleet: (refresh = false) =>
api
.get<FleetData>(`/api/dashboard/fleet${refresh ? "?refresh=true" : ""}`)
.then((r) => r.data),
};
@@ -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 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,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 (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>
);
}
+65 -437
View File
@@ -1,155 +1,102 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Cpu,
MemoryStick,
HardDrive,
Container,
Clock,
ArrowUpCircle,
RefreshCw,
Server,
Database,
} from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Clock, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@/components/ui";
import { HostHeader } from "@/components/hosts/HostHeader";
import { StacksTable } from "@/components/stacks/StacksTable";
import { FunnelChart } from "@/components/dashboard/FunnelChart";
import { UptimeChart } from "@/components/dashboard/UptimeChart";
import { OpsGrid } from "@/components/dashboard/OpsGrid";
import { AiPromptBar } from "@/components/dashboard/AiPromptBar";
import { AttentionStrip } from "@/components/dashboard/AttentionStrip";
import { FleetKpiRow } from "@/components/dashboard/FleetKpiRow";
import { StackStatusBar } from "@/components/dashboard/StackStatusBar";
import { HostResourceTable } from "@/components/dashboard/HostResourceTable";
import { stacksApi } from "@/api/stacks";
import { systemApi } from "@/api/system";
import { imagesApi } from "@/api/images";
import { agentsApi } from "@/api/agents";
import { volumesApi } from "@/api/volumes";
import { dashboardApi } from "@/api/dashboard";
import { apiErrorMessage } from "@/api/client";
import { cn, formatBytes, relativeTime } from "@/lib/utils";
import { cn, relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
import type { Agent } from "@/types";
const sumSizes = (m: Record<string, number | null>) =>
Object.values(m).reduce<number>((a, b) => a + (b ?? 0), 0);
type RangeDays = 7 | 30;
export function Dashboard() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { busyId, start, stop, restart } = useStackActions();
const [range, setRange] = useState<RangeDays>(30);
const qc = useQueryClient();
const [refreshing, setRefreshing] = useState(false);
const funnel = useQuery({
queryKey: ["dashboard-funnel"],
queryFn: () => dashboardApi.funnel(),
refetchInterval: 30000,
});
const summary = useQuery({
queryKey: ["dashboard-summary"],
queryFn: dashboardApi.summary,
refetchInterval: 60000,
const fleet = useQuery({
queryKey: ["dashboard-fleet"],
queryFn: () => dashboardApi.fleet(),
refetchInterval: 20000,
});
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 });
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 30000 });
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 });
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 });
// Volumes total is from `docker system df` (slow, cached ~60s server-side).
const volSize = useQuery({
queryKey: ["volumes-size", "local"],
queryFn: () => volumesApi.sizes().then(sumSizes),
refetchInterval: 60000,
});
const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
const hasAgents = (agents.data?.length ?? 0) > 0;
const refreshFleet = async () => {
setRefreshing(true);
try {
const data = await dashboardApi.fleet(true);
qc.setQueryData(["dashboard-fleet"], data);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setRefreshing(false);
}
};
return (
<div className="space-y-8">
{/* ---- Overview header ---- */}
{/* ---- Header ---- */}
<div className="flex flex-wrap items-end justify-between gap-4">
<h1 className="sp-display text-[40px] leading-none sm:text-[50px]">Overview</h1>
<div className="flex items-center gap-1.5">
{([30, 7] as RangeDays[]).map((d) => (
<button
key={d}
onClick={() => setRange(d)}
className={cn(
"rounded-pill px-3.5 py-1.5 text-[13px] font-medium transition-colors",
range === d
? "bg-sp-pill text-sp-pill-text"
: "border border-sp-border bg-sp-surface text-sp-text-2 hover:text-sp-text-1"
)}
>
Last {d} days
</button>
<button
onClick={refreshFleet}
className="flex items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface px-3 py-1.5 text-xs text-sp-text-2 hover:text-sp-text-1"
title="Refresh now"
>
<RefreshCw className={cn("h-3.5 w-3.5", refreshing && "animate-spin")} />
{fleet.data ? relativeTime(fleet.data.as_of) : "…"}
</button>
</div>
{/* ---- Needs attention ---- */}
<AttentionStrip items={fleet.data?.attention} loading={fleet.isLoading} />
{/* ---- Fleet KPIs ---- */}
{fleet.data ? (
<FleetKpiRow kpis={fleet.data.kpis} />
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="sp-skeleton h-24" />
))}
</div>
</div>
{updateCount > 0 && (
<Link
to="/images"
className="flex items-center gap-2 rounded-card border border-sp-amber/40 bg-sp-amber/10 px-4 py-3 text-sm text-sp-amber hover:bg-sp-amber/20"
>
<ArrowUpCircle className="h-5 w-5" />
{updateCount} image update{updateCount > 1 ? "s" : ""} available view on the Images page.
</Link>
)}
{/* ---- Analytics row: funnel + container count ---- */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px]">
<StackHealthCard funnel={funnel.data} loading={funnel.isLoading} />
<ContainerCountCard
localRunning={summary.data?.total_containers}
loading={summary.isLoading}
agents={agents.data ?? []}
healthyRate={
funnel.data && funnel.data.running > 0
? Math.round((funnel.data.healthy / funnel.data.running) * 100)
: null
}
/>
</div>
{/* ---- Bottom row: uptime + ops ---- */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<UptimeCard
series={summary.data?.uptime_series.slice(-range)}
loading={summary.isLoading}
range={range}
/>
<OpsCard
series={summary.data?.ops_last_30d.slice(-range)}
peakDay={summary.data?.ops_peak_day ?? null}
loading={summary.isLoading}
range={range}
/>
</div>
{/* ---- Local host ---- */}
<section>
{hasAgents ? (
<HostHeader />
{/* ---- Status + hosts ---- */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[340px_1fr]">
{fleet.data ? (
<StackStatusBar totals={fleet.data.status_totals} unhealthy={fleet.data.kpis.unhealthy} />
) : (
<h2 className="sp-label mb-3">This host</h2>
<div className="sp-skeleton h-40" />
)}
<ResourceBar
cpuCores={info.data?.cpu_cores ?? 0}
memUsed={info.data?.ram.used ?? 0}
memTotal={info.data?.ram.total ?? 0}
diskUsed={info.data?.disk.used ?? 0}
diskTotal={info.data?.disk.total ?? 0}
volumesSize={volSize.data}
containersRunning={info.data?.containers_running ?? 0}
containersTotal={info.data?.containers_total ?? 0}
dockerVersion={info.data?.docker_version ?? ""}
/>
{fleet.data ? (
<HostResourceTable hosts={fleet.data.hosts} />
) : (
<div className="sp-skeleton h-40" />
)}
</div>
{/* ---- Local host stacks ---- */}
<section>
{hasAgents ? <HostHeader /> : <h2 className="sp-label mb-3">This host</h2>}
<StacksTable
stacks={stacks.data}
stats={stats.data}
@@ -165,12 +112,12 @@ export function Dashboard() {
/>
</section>
{/* Remote hosts */}
{/* ---- Remote host stacks ---- */}
{agents.data?.map((agent) => (
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))}
{/* Recent activity */}
{/* ---- Recent activity ---- */}
<section>
<h2 className="sp-label mb-3 flex items-center gap-2">
<Clock className="h-4 w-4" /> Recent activity
@@ -199,236 +146,7 @@ export function Dashboard() {
}
/* ---------------------------------------------------------------------- */
/* Analytics cards */
/* ---------------------------------------------------------------------- */
const FUNNEL_STAGES = [
{ key: "discovered", label: "Discovered" },
{ key: "running", label: "Running" },
{ key: "healthy", label: "Healthy" },
{ key: "updated", label: "Up to date" },
{ key: "monitored", label: "Auto-managed" },
] as const;
function StackHealthCard({
funnel,
loading,
}: {
funnel?: import("@/api/dashboard").FunnelData;
loading: boolean;
}) {
const qc = useQueryClient();
const [refreshing, setRefreshing] = useState(false);
const [hovered, setHovered] = useState<number | null>(null);
const refresh = async () => {
setRefreshing(true);
try {
const data = await dashboardApi.funnel(true);
qc.setQueryData(["dashboard-funnel"], data);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setRefreshing(false);
}
};
return (
<div className="sp-card sp-rise flex flex-col gap-5 p-5 sm:p-6">
<div className="flex items-center justify-between">
<h2 className="sp-label">Stack health</h2>
<button
onClick={refresh}
className="flex items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface-2 px-2.5 py-1 text-xs text-sp-text-2 hover:text-sp-text-1"
title="Refresh now"
>
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
{funnel ? relativeTime(funnel.as_of) : "…"}
</button>
</div>
{loading || !funnel ? (
<div className="space-y-4">
<div className="sp-skeleton h-12" />
<div className="sp-skeleton h-52" />
</div>
) : (
<>
<div className="grid grid-cols-5 gap-2">
{FUNNEL_STAGES.map(({ key, label }, i) => (
<div
key={key}
className={cn(
"min-w-0 transition-opacity",
hovered !== null && hovered !== i && "opacity-40"
)}
>
<p className="sp-label truncate">{label}</p>
<p className="sp-display mt-0.5 text-2xl sm:text-3xl">{funnel[key]}</p>
</div>
))}
</div>
<FunnelChart
stages={FUNNEL_STAGES.map(({ key, label }) => ({ label, value: funnel[key] }))}
onHover={setHovered}
/>
</>
)}
<AiPromptBar />
</div>
);
}
function ContainerCountCard({
localRunning,
loading,
agents,
healthyRate,
}: {
localRunning?: number;
loading: boolean;
agents: Agent[];
healthyRate: number | null;
}) {
const online = agents.filter((a) => a.status === "online");
const remote = useQueries({
queries: online.map((a) => ({
queryKey: ["agent-system", a.id],
queryFn: () => agentsApi.system(a.id),
refetchInterval: 30000,
})),
});
const hosts: { name: string; count: number }[] = [
{ name: "local", count: localRunning ?? 0 },
// compose_running keeps the bars comparable with the local compose-only
// count; pre-0.31.1 agents only report the all-containers number.
...online.map((a, i) => ({
name: a.name,
count: remote[i].data?.compose_running ?? remote[i].data?.containers_running ?? 0,
})),
];
const total = hosts.reduce((s, h) => s + h.count, 0);
const max = Math.max(...hosts.map((h) => h.count), 1);
return (
<div className="sp-card sp-rise flex flex-col p-5 sm:p-6" style={{ animationDelay: "60ms" }}>
<h2 className="sp-label">Compose containers running</h2>
{loading ? (
<div className="mt-3 space-y-3">
<div className="sp-skeleton h-14 w-28" />
<div className="sp-skeleton h-20" />
</div>
) : (
<>
<p className="sp-display mt-1 text-5xl">{total}</p>
<div className="mt-4 space-y-2.5">
{hosts.map((h) => (
<div key={h.name}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="truncate font-medium text-sp-text-2">{h.name}</span>
<span className="font-semibold text-sp-text-1">{h.count}</span>
</div>
<div className="h-1.5 rounded-pill bg-sp-surface-2">
<div
className="h-1.5 rounded-pill bg-sp-blue"
style={{ width: `${(h.count / max) * 100}%` }}
/>
</div>
</div>
))}
</div>
</>
)}
<div className="mt-auto pt-5">
<div className="flex items-center gap-2.5 rounded-2xl bg-sp-pill px-3.5 py-3 text-sp-pill-text">
<span className="text-base leading-none"></span>
<p className="text-xs font-medium leading-snug">
{healthyRate === null
? "Insights appear once stacks are running."
: `${healthyRate}% of running stacks are healthy.`}
</p>
</div>
</div>
</div>
);
}
function UptimeCard({
series,
loading,
range,
}: {
series?: { date: string; value: number | null }[];
loading: boolean;
range: RangeDays;
}) {
const values = series?.map((p) => p.value) ?? [];
const latest = [...values].reverse().find((v): v is number => v !== null);
return (
<div className="sp-card sp-rise p-5 sm:p-6" style={{ animationDelay: "120ms" }}>
<div className="flex items-start justify-between">
<div>
<h2 className="sp-label">Uptime</h2>
<p className="sp-display mt-1 text-4xl">
{latest === undefined ? "—" : `${latest}%`}
</p>
</div>
<span className="sp-label">{range}d</span>
</div>
<div className="mt-4">
{loading || !series ? (
<div className="sp-skeleton h-24" />
) : (
<UptimeChart data={values} />
)}
</div>
<p className="mt-2 text-xs text-sp-text-3">
Share of compose containers running on this host, sampled every 5 min (daily average).
</p>
</div>
);
}
function OpsCard({
series,
peakDay,
loading,
range,
}: {
series?: { date: string; count: number }[];
peakDay: string | null;
loading: boolean;
range: RangeDays;
}) {
const total = series?.reduce((s, p) => s + p.count, 0) ?? 0;
const max = Math.max(...(series?.map((p) => p.count) ?? []), 1);
return (
<div className="sp-card sp-rise p-5 sm:p-6" style={{ animationDelay: "180ms" }}>
<div className="flex items-start justify-between">
<div>
<h2 className="sp-label">Operations</h2>
<p className="sp-display mt-1 text-4xl">{total}</p>
</div>
<span className="sp-label">{range}d</span>
</div>
<div className="mt-4">
{loading || !series ? (
<div className="sp-skeleton h-16" />
) : (
<OpsGrid data={series.map((p) => p.count / max)} cols={range} />
)}
</div>
<p className="mt-2 text-xs text-sp-text-3">
{peakDay ? `Busiest day: ${peakDay}.` : "Audit-log actions per day."}
</p>
</div>
);
}
/* ---------------------------------------------------------------------- */
/* Host sections (pre-Phase-24, retained) */
/* Remote host stacks section */
/* ---------------------------------------------------------------------- */
function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
@@ -454,12 +172,6 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool
enabled: online,
refetchInterval: 30000,
});
const volSize = useQuery({
queryKey: ["volumes-size", agent.id],
queryFn: () => volumesApi.sizes(false, agent.id).then(sumSizes),
enabled: online,
refetchInterval: 60000,
});
const run = async (action: string, label: string, id: string) => {
setBusyId(id);
@@ -486,18 +198,6 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool
</p>
</Card>
) : (
<>
<ResourceBar
cpuCores={sys.data?.cpu_cores ?? 0}
memUsed={sys.data?.mem_used ?? 0}
memTotal={sys.data?.mem_total ?? 0}
diskUsed={sys.data?.disk_used ?? 0}
diskTotal={sys.data?.disk_total ?? 0}
volumesSize={volSize.data}
containersRunning={sys.data?.containers_running ?? 0}
containersTotal={sys.data?.containers_total ?? 0}
dockerVersion={sys.data?.docker_version ?? ""}
/>
<StacksTable
stacks={stacks.data}
stats={stats.data}
@@ -512,79 +212,7 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool
onRestart={(id) => run("restart", "Restarting", id)}
emptyText="No stacks on this host."
/>
</>
)}
</section>
);
}
function ResourceBar({
cpuCores,
memUsed,
memTotal,
diskUsed,
diskTotal,
volumesSize,
containersRunning,
containersTotal,
dockerVersion,
}: {
cpuCores: number;
memUsed: number;
memTotal: number;
diskUsed: number;
diskTotal: number;
volumesSize?: number;
containersRunning: number;
containersTotal: number;
dockerVersion: string;
}) {
return (
<div className="mb-4 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={cpuCores || "—"} />
<Stat
icon={<MemoryStick className="h-5 w-5" />}
label="Memory"
value={memTotal ? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}` : "—"}
/>
<Stat
icon={<HardDrive className="h-5 w-5" />}
label="Disk"
value={diskTotal ? `${formatBytes(diskUsed)} / ${formatBytes(diskTotal)}` : "—"}
/>
<Stat
icon={<Database className="h-5 w-5" />}
label="Volumes"
value={volumesSize === undefined ? "…" : formatBytes(volumesSize)}
/>
<Stat
icon={<Container className="h-5 w-5" />}
label="Containers (all)"
value={`${containersRunning} / ${containersTotal}`}
/>
<Stat icon={<Server className="h-5 w-5" />} label="Docker" value={dockerVersion || "—"} />
</div>
);
}
function Stat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
}) {
return (
<Card className="flex items-center gap-3">
<div className="rounded-lg bg-accent/10 p-2 text-accent dark:bg-accent-dark/10 dark:text-accent-dark">
{icon}
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500">{label}</p>
<p className="truncate text-sm font-semibold">{value}</p>
</div>
</Card>
);
}