From ddd29aad24bd01239b66f8d08eab17fea053ce77 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 22 Sep 2026 17:05:34 +0200 Subject: [PATCH] feat(frontend): restyle monitoring charts on the Beszel school MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports Beszel's chart design language onto our recharts primitives: titled card grid, area fills on a true time axis, unit-aware Y ticks, sorted dotted tooltips, and legend chips. System detail gains stacked per-container Docker CPU/memory, per-sensor temperature lines, load 1/5/15, and GPU usage/VRAM — all from data the agent already sends. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 + apps/frontend/src/components/chart.tsx | 236 ++++++++++++++++++ apps/frontend/src/i18n/cs.ts | 29 ++- apps/frontend/src/i18n/en.ts | 29 ++- apps/frontend/src/index.css | 20 ++ apps/frontend/src/pages/MonitorDetailPage.tsx | 56 ++--- apps/frontend/src/pages/SystemDetailPage.tsx | 168 ++++++++----- apps/frontend/src/pages/SystemsPage.tsx | 5 +- 8 files changed, 454 insertions(+), 98 deletions(-) create mode 100644 apps/frontend/src/components/chart.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2650723..0abe91c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to Dash. Format follows ## [Unreleased] +### Changed + +- Monitoring charts restyled to the Beszel school: titled chart cards with + subtitles, soft area fills on a true time-scaled axis, unit-aware Y ticks, + sorted dotted tooltips, and legend chips on multi-series charts +- System detail gains stacked per-container Docker CPU/memory charts, + per-sensor temperature lines, load 1/5/15, and GPU usage/VRAM charts — + all from data the agent already sends + ## [2.0.0] — 2026-09-22 ### Added diff --git a/apps/frontend/src/components/chart.tsx b/apps/frontend/src/components/chart.tsx new file mode 100644 index 0000000..d2013bc --- /dev/null +++ b/apps/frontend/src/components/chart.tsx @@ -0,0 +1,236 @@ +// Shared time-series chart primitives — the Beszel-grade look on our tokens: +// titled cards with a subtitle, soft area fills over a real time axis, +// unit-aware Y ticks, a dotted tooltip sorted by value, and legend chips. +import type { ReactNode } from "react"; +import { + Area, + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { cn } from "@/lib/utils"; +import { t } from "@/i18n"; + +/** One chart row: epoch ms plus named series values. */ +export type ChartRow = { t: number } & Record; + +export interface Series { + key: string; + label: string; + color: string; + /** Shared value → stacked areas (container charts). */ + stack?: string; + /** Render as a bare line, no fill (temperatures). */ + line?: boolean; +} + +export const CHART_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", + "var(--chart-6)", + "var(--chart-7)", + "var(--chart-8)", +]; +export const chartColor = (i: number) => CHART_COLORS[i % CHART_COLORS.length] ?? "var(--accent)"; + +const axisTick = { fontSize: 10, fill: "var(--text-faint)" }; + +/** Auto Y-domain with headroom, rounding to a readable ceiling. */ +export function niceMax(dataMax: number): number { + if (!Number.isFinite(dataMax) || dataMax <= 0) return 1; + if (dataMax > 10) return Math.ceil(dataMax * 1.05); + if (dataMax > 1) return Math.ceil(dataMax * 10.5) / 10; + return Math.ceil(dataMax * 105) / 100; +} + +/** Tick format by range: HH:mm up to 2 days, day+time to 2 weeks, then dates. */ +export function xTick(spanMs: number): (v: number) => string { + if (spanMs <= 48 * 3600_000) + return (v) => new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + if (spanMs <= 14 * 86400_000) + return (v) => + new Date(v).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + return (v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" }); +} + +const fullTime = (ms: number) => + new Date(ms).toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + +interface TipItem { + name?: string; + value?: number | string; + color?: string; + stroke?: string; + dataKey?: string | number; + payload?: ChartRow; +} + +/** Dotted tooltip: timestamp header, per-series rows sorted by value desc. */ +function ChartTip({ + active, + payload, + fmt, +}: { + active?: boolean; + payload?: TipItem[]; + fmt: (v: number) => string; +}) { + if (!active || !payload?.length) return null; + const t0 = payload[0]?.payload?.t; + const rows = payload + .filter((p): p is TipItem & { value: number } => typeof p.value === "number") + .sort((a, b) => b.value - a.value); + if (!rows.length) return null; + return ( +
+ {t0 !== undefined &&
{fullTime(t0)}
} +
+ {rows.map((p) => ( +
+ + {p.name} + {fmt(p.value)} +
+ ))} +
+
+ ); +} + +/** Area/line chart on a time-scaled X axis. Empty data renders a hint, not a blank box. */ +export function MetricChart({ + rows, + series, + fmt, + domain, + spanMs, +}: { + rows: ChartRow[]; + series: Series[]; + fmt: (v: number) => string; + domain?: [number, number] | undefined; + spanMs: number; +}) { + if (!rows.length) { + return ( +
+ {t("chart.noData")} +
+ ); + } + return ( + + + + + fmt(Number(v))} + /> + } cursor={{ stroke: "var(--border-strong)" }} /> + {series.map((s) => + s.line ? ( + + ) : ( + + ), + )} + + + ); +} + +/** Titled chart card in the Beszel layout: title + subtitle on top, legend chips below. */ +export function ChartCard({ + title, + hint, + actions, + series, + height = 160, + children, +}: { + title: string; + hint?: string; + actions?: ReactNode; + series?: Series[]; + height?: number; + children: ReactNode; +}) { + return ( +
+
+
+

{title}

+ {hint ?

{hint}

: null} +
+ {actions} +
+
{children}
+ {series && series.length > 1 && ( +
+ {series.map((s) => ( + + + {s.label} + + ))} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/i18n/cs.ts b/apps/frontend/src/i18n/cs.ts index ad870a2..84a6375 100644 --- a/apps/frontend/src/i18n/cs.ts +++ b/apps/frontend/src/i18n/cs.ts @@ -323,10 +323,29 @@ export const cs: Partial> = { "systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.", "systems.notFound": "Systém nenalezen.", "systems.usage": "Využití", - "systems.cpuPct": "CPU %", - "systems.memPct": "Paměť %", - "systems.netKbs": "Síť KB/s", - "systems.load1m": "Load 1m", + "systems.chartCpu": "Využití CPU", + "systems.chartCpuHint": "Průměrné využití CPU celého systému", + "systems.chartMem": "Využití paměti", + "systems.chartMemHint": "Přesné využití v zaznamenaný čas", + "systems.chartUsed": "obsazeno", + "systems.chartDisk": "Využití disku", + "systems.chartDiskHint": "Využití kořenového oddílu", + "systems.chartNet": "Šířka pásma", + "systems.chartNetHint": "Síťový provoz veřejných rozhraní", + "systems.chartRx": "přijato", + "systems.chartTx": "odesláno", + "systems.chartLoad": "Průměrné zatížení", + "systems.chartLoadHint": "Zátěž za 1, 5 a 15 minut", + "systems.chartTemp": "Teplota", + "systems.chartTempHint": "Systémové senzory", + "systems.chartDockerCpu": "CPU Dockeru", + "systems.chartDockerCpuHint": "CPU na kontejner, procent jednoho jádra", + "systems.chartDockerMem": "Paměť Dockeru", + "systems.chartDockerMemHint": "Paměť na kontejner", + "systems.chartGpu": "Využití GPU", + "systems.chartGpuHint": "Využití na GPU", + "systems.chartVram": "VRAM GPU", + "systems.chartVramHint": "Paměť na GPU", "systems.temperatures": "Teploty", "systems.drivesSmart": "Disky (SMART)", "systems.agentCmd": "Příkaz agenta", @@ -335,6 +354,8 @@ export const cs: Partial> = { "systems.failing": "selhává", "systems.uptime": "Doba běhu", + "chart.noData": "Zatím žádná data.", + "incidents.title": "Incidenty", "incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.", "incidents.new": "Nový incident", diff --git a/apps/frontend/src/i18n/en.ts b/apps/frontend/src/i18n/en.ts index 78a9e22..bc28c29 100644 --- a/apps/frontend/src/i18n/en.ts +++ b/apps/frontend/src/i18n/en.ts @@ -322,10 +322,29 @@ export const en = { "systems.addDesc": "Creates a bearer token — run dash-agent with it.", "systems.notFound": "System not found.", "systems.usage": "Usage", - "systems.cpuPct": "CPU %", - "systems.memPct": "Memory %", - "systems.netKbs": "Network KB/s", - "systems.load1m": "Load 1m", + "systems.chartCpu": "CPU Usage", + "systems.chartCpuHint": "Average system-wide CPU utilization", + "systems.chartMem": "Memory Usage", + "systems.chartMemHint": "Precise utilization at the recorded time", + "systems.chartUsed": "used", + "systems.chartDisk": "Disk Usage", + "systems.chartDiskHint": "Usage of the root partition", + "systems.chartNet": "Bandwidth", + "systems.chartNetHint": "Network traffic of public interfaces", + "systems.chartRx": "received", + "systems.chartTx": "sent", + "systems.chartLoad": "Load Average", + "systems.chartLoadHint": "System load over 1, 5, and 15 minutes", + "systems.chartTemp": "Temperature", + "systems.chartTempHint": "System sensors", + "systems.chartDockerCpu": "Docker CPU Usage", + "systems.chartDockerCpuHint": "Per-container CPU, percent of one core", + "systems.chartDockerMem": "Docker Memory", + "systems.chartDockerMemHint": "Per-container memory usage", + "systems.chartGpu": "GPU Usage", + "systems.chartGpuHint": "Per-GPU utilization", + "systems.chartVram": "GPU VRAM", + "systems.chartVramHint": "Per-GPU memory usage", "systems.temperatures": "Temperatures", "systems.drivesSmart": "Drives (SMART)", "systems.agentCmd": "Agent command", @@ -334,6 +353,8 @@ export const en = { "systems.failing": "failing", "systems.uptime": "Uptime", + "chart.noData": "No data yet.", + "incidents.title": "Incidents", "incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.", "incidents.new": "New incident", diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 39fdcec..458febc 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -25,6 +25,16 @@ --icon-bg: #1c1c1e; --popover: #141414; + /* chart series palette (monitoring graphs) */ + --chart-1: #3b82f6; + --chart-2: #10b981; + --chart-3: #8b5cf6; + --chart-4: #f59e0b; + --chart-5: #22d3ee; + --chart-6: #fb7185; + --chart-7: #a3e635; + --chart-8: #c084fc; + --background: var(--bg); --foreground: var(--text); --card: var(--surface); @@ -57,6 +67,16 @@ --icon-bg: #f4f4f5; --popover: #ffffff; + /* chart series palette (monitoring graphs) — darker for light bg */ + --chart-1: #2563eb; + --chart-2: #059669; + --chart-3: #7c3aed; + --chart-4: #d97706; + --chart-5: #0891b2; + --chart-6: #e11d48; + --chart-7: #65a30d; + --chart-8: #9333ea; + --background: var(--bg); --foreground: var(--text); --card: var(--surface); diff --git a/apps/frontend/src/pages/MonitorDetailPage.tsx b/apps/frontend/src/pages/MonitorDetailPage.tsx index dfaca5c..ff92043 100644 --- a/apps/frontend/src/pages/MonitorDetailPage.tsx +++ b/apps/frontend/src/pages/MonitorDetailPage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { Link } from "wouter"; import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react"; -import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { api } from "@/api"; +import { ChartCard, MetricChart, chartColor, type ChartRow } from "@/components/chart"; import type { Heartbeat, MonitorView } from "@/types"; import { MonitorDialog } from "@/components/MonitorDialog"; import { Button } from "@/components/ui/button"; @@ -32,10 +32,9 @@ export function MonitorDetailPage({ id }: { id: string }) { if (!m) return
{t("common.loading")}
; - const chart = hbs.map((h) => ({ - t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + const rows: ChartRow[] = hbs.map((h) => ({ + t: new Date(h.checkedAt ?? "").getTime(), ping: h.pingMs, - up: h.status === "up", })); return ( @@ -94,31 +93,30 @@ export function MonitorDetailPage({ id }: { id: string }) { -
-

{t("monitors.responseTime")}

-
- {[24, 168, 720].map((h) => ( - - ))} -
-
-
- - - - - - - - - +
+ + {[24, 168, 720].map((h) => ( + + ))} +
+ } + > + `${Math.round(v)} ms`} + spanMs={hours * 3600_000} + /> +

{t("monitors.recentChecks")}

diff --git a/apps/frontend/src/pages/SystemDetailPage.tsx b/apps/frontend/src/pages/SystemDetailPage.tsx index 9d3f670..b4d4c6e 100644 --- a/apps/frontend/src/pages/SystemDetailPage.tsx +++ b/apps/frontend/src/pages/SystemDetailPage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { Link } from "wouter"; import { ArrowLeft, Copy, Trash2 } from "lucide-react"; -import { Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { api } from "@/api"; +import { ChartCard, MetricChart, chartColor, type ChartRow, type Series } from "@/components/chart"; import type { System, SystemStat } from "@/types"; import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage"; import { Button } from "@/components/ui/button"; @@ -40,16 +40,73 @@ export function SystemDetailPage({ id }: { id: string }) { if (!sys) return
{t("common.loading")}
; const l = sys.latest; - const chart = stats.map((s) => ({ - t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), - cpu: s.cpu, - memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined, - rx: (s.netRx ?? 0) / 1024, - tx: (s.netTx ?? 0) / 1024, - load: s.load1, - })); - const axisTick = { fontSize: 10, fill: "var(--text-faint)" }; - const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }; + const spanMs = hours * 3600_000; + + // Rows carry fixed keys plus dynamic `c:`/`m:` (containers), `s:` (sensors), + // `g:`/`v:` (GPU util/VRAM) keys; series pick them up by name. + const rows: ChartRow[] = stats.map((s) => { + const r: ChartRow = { + t: new Date(s.ts ?? "").getTime(), + cpu: s.cpu, + mem: s.memUsed, + swap: s.swapUsed, + disk: s.diskUsed, + rx: s.netRx, + tx: s.netTx, + l1: s.load1, + l5: s.load5, + l15: s.load15, + }; + for (const c of s.containers ?? []) { + if (c.name) { + r[`c:${c.name}`] = c.cpu ?? 0; + r[`m:${c.name}`] = c.memUsed ?? 0; + } + } + for (const [k, v] of Object.entries(s.temps ?? {})) r[`s:${k}`] = v; + for (const g of s.gpu ?? []) { + if (g.name) { + r[`g:${g.name}`] = g.utilPct; + r[`v:${g.name}`] = g.memUsed; + } + } + return r; + }); + + const names = (ns: (string | undefined)[]) => + [...new Set(ns.filter((n): n is string => n !== undefined))]; + const containerNames = names(stats.flatMap((s) => (s.containers ?? []).map((c) => c.name))); + const tempNames = names(stats.flatMap((s) => Object.keys(s.temps ?? {}))); + const gpuNames = names(stats.flatMap((s) => (s.gpu ?? []).map((g) => g.name))); + // Zero-fill absent containers so stacked areas stay flush over restarts. + for (const r of rows) { + for (const n of containerNames) { + r[`c:${n}`] ??= 0; + r[`m:${n}`] ??= 0; + } + } + + const pct = (v: number) => `${v.toFixed(1)}%`; + const cpuSeries: Series[] = [{ key: "cpu", label: "cpu", color: chartColor(0) }]; + const dockerCpuSeries: Series[] = containerNames.map((n, i) => ({ key: `c:${n}`, label: n, color: chartColor(i), stack: "a" })); + const memSeries: Series[] = [ + { key: "mem", label: t("systems.chartUsed"), color: chartColor(1) }, + { key: "swap", label: "swap", color: chartColor(3) }, + ]; + const dockerMemSeries: Series[] = containerNames.map((n, i) => ({ key: `m:${n}`, label: n, color: chartColor(i), stack: "a" })); + const diskSeries: Series[] = [{ key: "disk", label: t("systems.chartUsed"), color: chartColor(2) }]; + const netSeries: Series[] = [ + { key: "rx", label: t("systems.chartRx"), color: chartColor(4) }, + { key: "tx", label: t("systems.chartTx"), color: chartColor(5) }, + ]; + const loadSeries: Series[] = [ + { key: "l1", label: "1 min", color: chartColor(0) }, + { key: "l5", label: "5 min", color: chartColor(3) }, + { key: "l15", label: "15 min", color: chartColor(5) }, + ]; + const tempSeries: Series[] = tempNames.map((n, i) => ({ key: `s:${n}`, label: n, color: chartColor(i), line: true })); + const gpuSeries: Series[] = gpuNames.map((n, i) => ({ key: `g:${n}`, label: n, color: chartColor(i) })); + const vramSeries: Series[] = gpuNames.map((n, i) => ({ key: `v:${n}`, label: n, color: chartColor(i) })); return (
@@ -111,55 +168,46 @@ export function SystemDetailPage({ id }: { id: string }) {
-
-
{t("systems.cpuPct")}
- - - - - - - - - -
-
-
{t("systems.memPct")}
- - - - - - - - - -
-
-
{t("systems.netKbs")}
- - - - - - - - - - -
-
-
{t("systems.load1m")}
- - - - - - - - - -
+ + + + {dockerCpuSeries.length > 0 && ( + + + + )} + + + + {dockerMemSeries.length > 0 && ( + + + + )} + + + + + `${fmtBytes(v)}/s`} spanMs={spanMs} /> + + + v.toFixed(2)} spanMs={spanMs} /> + + {tempSeries.length > 0 && ( + + `${v.toFixed(0)}°C`} spanMs={spanMs} /> + + )} + {gpuSeries.length > 0 && ( + + + + )} + {vramSeries.length > 0 && ( + + + + )}
diff --git a/apps/frontend/src/pages/SystemsPage.tsx b/apps/frontend/src/pages/SystemsPage.tsx index f109355..2651107 100644 --- a/apps/frontend/src/pages/SystemsPage.tsx +++ b/apps/frontend/src/pages/SystemsPage.tsx @@ -15,11 +15,14 @@ import { t } from "@/i18n"; const TB = 2 ** 40; const GB = 2 ** 30; const MB = 2 ** 20; +const KB = 2 ** 10; export function fmtBytes(b: number) { if (b >= TB) return `${(b / TB).toFixed(1)} TB`; if (b >= GB) return `${(b / GB).toFixed(1)} GB`; - return `${(b / MB).toFixed(0)} MB`; + if (b >= MB) return `${(b / MB).toFixed(1)} MB`; + if (b >= KB) return `${(b / KB).toFixed(0)} KB`; + return `${Math.round(b)} B`; } export function fmtUptime(s: number) {