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
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Container,
|
||||
Clock,
|
||||
ArrowUpCircle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
@@ -15,13 +16,18 @@ 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 { 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 { formatBytes, relativeTime } from "@/lib/utils";
|
||||
import { cn, formatBytes, relativeTime } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useStackActions } from "@/hooks/useStackActions";
|
||||
import type { Agent } from "@/types";
|
||||
@@ -29,9 +35,23 @@ 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 funnel = useQuery({
|
||||
queryKey: ["dashboard-funnel"],
|
||||
queryFn: () => dashboardApi.funnel(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
const summary = useQuery({
|
||||
queryKey: ["dashboard-summary"],
|
||||
queryFn: dashboardApi.summary,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
||||
const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 });
|
||||
@@ -50,23 +70,74 @@ export function Dashboard() {
|
||||
const hasAgents = (agents.data?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* ---- Overview 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateCount > 0 && (
|
||||
<Link
|
||||
to="/images"
|
||||
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Local host */}
|
||||
{/* ---- 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 />
|
||||
) : (
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">This host</h2>
|
||||
<h2 className="sp-label mb-3">This host</h2>
|
||||
)}
|
||||
<ResourceBar
|
||||
cpuCores={info.data?.cpu_cores ?? 0}
|
||||
@@ -101,7 +172,7 @@ export function Dashboard() {
|
||||
|
||||
{/* Recent activity */}
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
<h2 className="sp-label mb-3 flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" /> Recent activity
|
||||
</h2>
|
||||
<Card>
|
||||
@@ -127,6 +198,232 @@ 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: "Monitored" },
|
||||
] 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 },
|
||||
...online.map((a, i) => ({ name: a.name, count: 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">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">Daily share of compose containers running, this host.</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) */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const online = agent.status === "online";
|
||||
|
||||
Reference in New Issue
Block a user