feat(frontend): restyle monitoring charts on the Beszel school

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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-22 17:05:34 +02:00
1 parent 35602c2e52
commit ddd29aad24
8 files changed
+443 -87

No files matched your search

+9
View File
@@ -6,6 +6,15 @@ All notable changes to Dash. Format follows
## [Unreleased] ## [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 ## [2.0.0] — 2026-09-22
### Added ### Added
+236
View File
@@ -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<string, number | undefined>;
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 (
<div className="min-w-28 rounded-lg border border-border-strong bg-popover px-2.5 py-1.5 text-[11px] shadow-xl">
{t0 !== undefined && <div className="mb-1 font-medium text-text-dim">{fullTime(t0)}</div>}
<div className="grid gap-1">
{rows.map((p) => (
<div key={String(p.dataKey ?? p.name)} className="flex items-center gap-2">
<span
className="size-2 shrink-0 rounded-[2px]"
style={{ background: p.color ?? p.stroke ?? "var(--text-faint)" }}
/>
<span className="flex-1 truncate text-text-faint">{p.name}</span>
<span className="font-mono font-medium">{fmt(p.value)}</span>
</div>
))}
</div>
</div>
);
}
/** 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 (
<div className="flex h-full items-center justify-center text-[11px] text-text-faint">
{t("chart.noData")}
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tick={axisTick}
tickLine={false}
axisLine={false}
tickFormatter={xTick(spanMs)}
minTickGap={28}
/>
<YAxis
tick={axisTick}
tickLine={false}
axisLine={false}
width="auto"
domain={domain ?? [0, niceMax]}
tickFormatter={(v) => fmt(Number(v))}
/>
<Tooltip content={<ChartTip fmt={fmt} />} cursor={{ stroke: "var(--border-strong)" }} />
{series.map((s) =>
s.line ? (
<Line
key={s.key}
dataKey={s.key}
name={s.label}
type="monotone"
stroke={s.color}
strokeWidth={1.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
) : (
<Area
key={s.key}
dataKey={s.key}
name={s.label}
type="monotone"
stroke={s.color}
fill={s.color}
fillOpacity={s.stack ? 0.65 : 0.14}
strokeWidth={1.5}
dot={false}
{...(s.stack !== undefined ? { stackId: s.stack } : {})}
isAnimationActive={false}
/>
),
)}
</ComposedChart>
</ResponsiveContainer>
);
}
/** 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 (
<div className="rounded-[10px] border border-border bg-surface p-3 pt-3.5">
<div className="mb-2 flex items-start justify-between gap-2 px-1">
<div>
<h3 className="text-[13px] font-medium tracking-tight">{title}</h3>
{hint ? <p className="text-[10.5px] text-text-faint">{hint}</p> : null}
</div>
{actions}
</div>
<div style={{ height }}>{children}</div>
{series && series.length > 1 && (
<div className="mt-2 flex flex-wrap gap-x-3.5 gap-y-1 px-1">
{series.map((s) => (
<span
key={s.key}
className="flex items-center gap-1.5 font-mono text-[10px] text-text-faint"
>
<span className="size-2 rounded-[2px]" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
)}
</div>
);
}
+25 -4
View File
@@ -323,10 +323,29 @@ export const cs: Partial<Record<Key, string>> = {
"systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.", "systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.",
"systems.notFound": "Systém nenalezen.", "systems.notFound": "Systém nenalezen.",
"systems.usage": "Využití", "systems.usage": "Využití",
"systems.cpuPct": "CPU %", "systems.chartCpu": "Využití CPU",
"systems.memPct": "Paměť %", "systems.chartCpuHint": "Průměrné využití CPU celého systému",
"systems.netKbs": "Síť KB/s", "systems.chartMem": "Využití paměti",
"systems.load1m": "Load 1m", "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.temperatures": "Teploty",
"systems.drivesSmart": "Disky (SMART)", "systems.drivesSmart": "Disky (SMART)",
"systems.agentCmd": "Příkaz agenta", "systems.agentCmd": "Příkaz agenta",
@@ -335,6 +354,8 @@ export const cs: Partial<Record<Key, string>> = {
"systems.failing": "selhává", "systems.failing": "selhává",
"systems.uptime": "Doba běhu", "systems.uptime": "Doba běhu",
"chart.noData": "Zatím žádná data.",
"incidents.title": "Incidenty", "incidents.title": "Incidenty",
"incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.", "incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.",
"incidents.new": "Nový incident", "incidents.new": "Nový incident",
+25 -4
View File
@@ -322,10 +322,29 @@ export const en = {
"systems.addDesc": "Creates a bearer token — run dash-agent with it.", "systems.addDesc": "Creates a bearer token — run dash-agent with it.",
"systems.notFound": "System not found.", "systems.notFound": "System not found.",
"systems.usage": "Usage", "systems.usage": "Usage",
"systems.cpuPct": "CPU %", "systems.chartCpu": "CPU Usage",
"systems.memPct": "Memory %", "systems.chartCpuHint": "Average system-wide CPU utilization",
"systems.netKbs": "Network KB/s", "systems.chartMem": "Memory Usage",
"systems.load1m": "Load 1m", "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.temperatures": "Temperatures",
"systems.drivesSmart": "Drives (SMART)", "systems.drivesSmart": "Drives (SMART)",
"systems.agentCmd": "Agent command", "systems.agentCmd": "Agent command",
@@ -334,6 +353,8 @@ export const en = {
"systems.failing": "failing", "systems.failing": "failing",
"systems.uptime": "Uptime", "systems.uptime": "Uptime",
"chart.noData": "No data yet.",
"incidents.title": "Incidents", "incidents.title": "Incidents",
"incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.", "incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.",
"incidents.new": "New incident", "incidents.new": "New incident",
+20
View File
@@ -25,6 +25,16 @@
--icon-bg: #1c1c1e; --icon-bg: #1c1c1e;
--popover: #141414; --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); --background: var(--bg);
--foreground: var(--text); --foreground: var(--text);
--card: var(--surface); --card: var(--surface);
@@ -57,6 +67,16 @@
--icon-bg: #f4f4f5; --icon-bg: #f4f4f5;
--popover: #ffffff; --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); --background: var(--bg);
--foreground: var(--text); --foreground: var(--text);
--card: var(--surface); --card: var(--surface);
+17 -19
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter"; import { Link } from "wouter";
import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react"; import { ArrowLeft, Pause, Play, RefreshCw, Trash2 } from "lucide-react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { api } from "@/api"; import { api } from "@/api";
import { ChartCard, MetricChart, chartColor, type ChartRow } from "@/components/chart";
import type { Heartbeat, MonitorView } from "@/types"; import type { Heartbeat, MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog"; import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -32,10 +32,9 @@ export function MonitorDetailPage({ id }: { id: string }) {
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>; if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const chart = hbs.map((h) => ({ const rows: ChartRow[] = hbs.map((h) => ({
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), t: new Date(h.checkedAt ?? "").getTime(),
ping: h.pingMs, ping: h.pingMs,
up: h.status === "up",
})); }));
return ( return (
@@ -94,8 +93,12 @@ export function MonitorDetailPage({ id }: { id: string }) {
<UptimeBar hbs={hbs} hours={hours} /> <UptimeBar hbs={hbs} hours={hours} />
</div> </div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-6">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.responseTime")}</h2> <ChartCard
title={t("monitors.responseTime")}
height={208}
series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
actions={
<div className="flex gap-1"> <div className="flex gap-1">
{[24, 168, 720].map((h) => ( {[24, 168, 720].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)} <button key={h} type="button" onClick={() => setHours(h)}
@@ -105,20 +108,15 @@ export function MonitorDetailPage({ id }: { id: string }) {
</button> </button>
))} ))}
</div> </div>
</div> }
<div className="mb-6 h-56 rounded-[10px] border border-border bg-surface p-3"> >
<ResponsiveContainer width="100%" height="100%"> <MetricChart
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}> rows={rows}
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} /> series={[{ key: "ping", label: "ms", color: chartColor(0) }]}
<XAxis dataKey="t" tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} minTickGap={40} /> fmt={(v) => `${Math.round(v)} ms`}
<YAxis tick={{ fontSize: 10, fill: "var(--text-faint)" }} tickLine={false} axisLine={false} unit=" ms" width={62} /> spanMs={hours * 3600_000}
<Tooltip
contentStyle={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }}
labelStyle={{ color: "var(--text-faint)" }}
/> />
<Area type="monotone" dataKey="ping" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} /> </ChartCard>
</AreaChart>
</ResponsiveContainer>
</div> </div>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2> <h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
+107 -59
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter"; import { Link } from "wouter";
import { ArrowLeft, Copy, Trash2 } from "lucide-react"; import { ArrowLeft, Copy, Trash2 } from "lucide-react";
import { Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { api } from "@/api"; import { api } from "@/api";
import { ChartCard, MetricChart, chartColor, type ChartRow, type Series } from "@/components/chart";
import type { System, SystemStat } from "@/types"; import type { System, SystemStat } from "@/types";
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage"; import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -40,16 +40,73 @@ export function SystemDetailPage({ id }: { id: string }) {
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>; if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const l = sys.latest; const l = sys.latest;
const chart = stats.map((s) => ({ const spanMs = hours * 3600_000;
t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
// 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, cpu: s.cpu,
memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined, mem: s.memUsed,
rx: (s.netRx ?? 0) / 1024, swap: s.swapUsed,
tx: (s.netTx ?? 0) / 1024, disk: s.diskUsed,
load: s.load1, rx: s.netRx,
})); tx: s.netTx,
const axisTick = { fontSize: 10, fill: "var(--text-faint)" }; l1: s.load1,
const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 }; 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 ( return (
<main className="mx-auto w-full max-w-5xl px-7 py-8"> <main className="mx-auto w-full max-w-5xl px-7 py-8">
@@ -111,55 +168,46 @@ export function SystemDetailPage({ id }: { id: string }) {
</div> </div>
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1"> <div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
<div className="h-44 rounded-[10px] border border-border bg-surface p-3"> <ChartCard title={t("systems.chartCpu")} hint={t("systems.chartCpuHint")}>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.cpuPct")}</div> <MetricChart rows={rows} series={cpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
<ResponsiveContainer width="100%" height="85%"> </ChartCard>
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}> {dockerCpuSeries.length > 0 && (
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} /> <ChartCard title={t("systems.chartDockerCpu")} hint={t("systems.chartDockerCpuHint")} series={dockerCpuSeries}>
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} /> <MetricChart rows={rows} series={dockerCpuSeries} fmt={pct} spanMs={spanMs} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} /> </ChartCard>
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} /> )}
<Area type="monotone" dataKey="cpu" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} /> <ChartCard title={t("systems.chartMem")} hint={t("systems.chartMemHint")} series={memSeries}>
</AreaChart> <MetricChart rows={rows} series={memSeries} fmt={fmtBytes} spanMs={spanMs} />
</ResponsiveContainer> </ChartCard>
</div> {dockerMemSeries.length > 0 && (
<div className="h-44 rounded-[10px] border border-border bg-surface p-3"> <ChartCard title={t("systems.chartDockerMem")} hint={t("systems.chartDockerMemHint")} series={dockerMemSeries}>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.memPct")}</div> <MetricChart rows={rows} series={dockerMemSeries} fmt={fmtBytes} spanMs={spanMs} />
<ResponsiveContainer width="100%" height="85%"> </ChartCard>
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}> )}
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} /> <ChartCard title={t("systems.chartDisk")} hint={t("systems.chartDiskHint")}>
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} /> <MetricChart rows={rows} series={diskSeries} fmt={fmtBytes} domain={l?.diskTotal ? [0, l.diskTotal] : undefined} spanMs={spanMs} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} /> </ChartCard>
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} /> <ChartCard title={t("systems.chartNet")} hint={t("systems.chartNetHint")} series={netSeries}>
<Area type="monotone" dataKey="memPct" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} /> <MetricChart rows={rows} series={netSeries} fmt={(v) => `${fmtBytes(v)}/s`} spanMs={spanMs} />
</AreaChart> </ChartCard>
</ResponsiveContainer> <ChartCard title={t("systems.chartLoad")} hint={t("systems.chartLoadHint")} series={loadSeries}>
</div> <MetricChart rows={rows} series={loadSeries} fmt={(v) => v.toFixed(2)} spanMs={spanMs} />
<div className="h-44 rounded-[10px] border border-border bg-surface p-3"> </ChartCard>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.netKbs")}</div> {tempSeries.length > 0 && (
<ResponsiveContainer width="100%" height="85%"> <ChartCard title={t("systems.chartTemp")} hint={t("systems.chartTempHint")} series={tempSeries}>
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}> <MetricChart rows={rows} series={tempSeries} fmt={(v) => `${v.toFixed(0)}°C`} spanMs={spanMs} />
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} /> </ChartCard>
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} /> )}
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={58} /> {gpuSeries.length > 0 && (
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} /> <ChartCard title={t("systems.chartGpu")} hint={t("systems.chartGpuHint")} series={gpuSeries}>
<Line type="monotone" dataKey="rx" name="rx" stroke="var(--up)" strokeWidth={1.5} dot={false} isAnimationActive={false} /> <MetricChart rows={rows} series={gpuSeries} fmt={pct} domain={[0, 100]} spanMs={spanMs} />
<Line type="monotone" dataKey="tx" name="tx" stroke="var(--text-faint)" strokeWidth={1.5} dot={false} isAnimationActive={false} /> </ChartCard>
</LineChart> )}
</ResponsiveContainer> {vramSeries.length > 0 && (
</div> <ChartCard title={t("systems.chartVram")} hint={t("systems.chartVramHint")} series={vramSeries}>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3"> <MetricChart rows={rows} series={vramSeries} fmt={fmtBytes} spanMs={spanMs} />
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.load1m")}</div> </ChartCard>
<ResponsiveContainer width="100%" height="85%"> )}
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="load" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1"> <div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
+4 -1
View File
@@ -15,11 +15,14 @@ import { t } from "@/i18n";
const TB = 2 ** 40; const TB = 2 ** 40;
const GB = 2 ** 30; const GB = 2 ** 30;
const MB = 2 ** 20; const MB = 2 ** 20;
const KB = 2 ** 10;
export function fmtBytes(b: number) { export function fmtBytes(b: number) {
if (b >= TB) return `${(b / TB).toFixed(1)} TB`; if (b >= TB) return `${(b / TB).toFixed(1)} TB`;
if (b >= GB) return `${(b / GB).toFixed(1)} GB`; 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) { export function fmtUptime(s: number) {