Feat: System-Status als poliertes Live-Chart (Recharts) - Legende, Hover-Tooltip, Spline

Auf Wunsch im Stil der Referenz-Dashboards: unified Live-Chart (CPU/RAM/GPU/Disk) mit
glatten Flaechen (monotone), Gradient-Fill, Legende mit Live-Werten + GB-Detail, und
gestyltem Hover-Tooltip (alle Serien an der Cursor-Position). Dynamische Y-Achse
(skaliert in 25er-Schritten auf den Peak), damit Linien auch bei idle-Box den Chart
ausfuellen. Reines Live (kein Monats-/Jahres-Dropdown), rollende 40 Punkte (~2 Min).

- recharts als Dependency
- Sparkline.tsx entfernt (durch Recharts ersetzt)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 16:34:53 +02:00
parent 3dc878f012
commit ea256fca0d
9 changed files with 979 additions and 489 deletions
@@ -1,40 +0,0 @@
import { cn } from "@/lib/utils"
/** Schlanker Live-Verlaufsgraph (SVG Area+Linie) für 0..100-Prozentwerte.
* preserveAspectRatio=none streckt auf die Containerbreite; non-scaling-stroke
* hält die Linienstärke konstant. tone = Tailwind-Textfarbe (currentColor). */
export function Sparkline({ data, tone, max = 100 }: { data: number[]; tone: string; max?: number }) {
const w = 100, h = 40
const n = data.length
const clamp = (v: number) => Math.min(Math.max(v, 0), max)
const pts = data.map((v, i) => {
const x = n <= 1 ? 0 : (i / (n - 1)) * w
const y = h - (clamp(v) / max) * h
return [x, y] as const
})
const line = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" ")
const area = n > 1 ? `${line} L${w},${h} L0,${h} Z` : ""
const last = pts[pts.length - 1]
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className={cn("h-full w-full overflow-visible", tone)}>
{/* Baseline */}
<line x1="0" y1={h - 0.5} x2={w} y2={h - 0.5} className="stroke-border/40" strokeWidth="1" vectorEffect="non-scaling-stroke" />
{n > 1 && <path d={area} fill="currentColor" opacity={0.12} stroke="none" />}
{n > 1 && (
<path
d={line}
fill="none"
stroke="currentColor"
strokeWidth={1.75}
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{last && (
<circle cx={last[0]} cy={last[1]} r={2} fill="currentColor" vectorEffect="non-scaling-stroke" />
)}
</svg>
)
}
@@ -1,87 +1,142 @@
import { useEffect, useState } from "react"
import { Cpu } from "lucide-react"
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import { useSystemStatus } from "@/lib/queries"
import { gb } from "@/lib/format"
import { cn } from "@/lib/utils"
import { Sparkline } from "./Sparkline"
const MAX_POINTS = 40 // bei 3s-Poll ~2 Min Verlauf
const MAX_POINTS = 40 // bei 3s-Poll ~2 Min Live-Verlauf
function tone(value: number): string {
return value > 90 ? "text-red-500" : value > 75 ? "text-amber-500" : "text-primary"
}
type Sample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
function MetricChart({ value, label, detail, data }: {
value: number; label: string; detail?: string; data: number[]
}) {
const t = tone(value)
const SERIES = [
{ key: "cpu", label: "CPU", color: "#2dd4bf" },
{ key: "ram", label: "RAM", color: "#38bdf8" },
{ key: "gpu", label: "GPU", color: "#a78bfa" },
{ key: "disk", label: "Disk", color: "#fbbf24" },
] as const
const GRID = "rgba(130,130,150,0.14)"
const AXIS = "rgba(130,130,150,0.85)"
function ChartTooltip({ active, payload }: any) {
if (!active || !payload?.length) return null
return (
<div className="flex flex-col gap-1.5 rounded-xl border border-border/40 bg-background/20 p-2.5">
<div className="flex items-baseline justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
<span className={cn("font-mono text-xs font-bold tabular-nums", t)}>{Math.round(value)}%</span>
<div className="rounded-lg border border-border/70 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur">
<div className="space-y-1">
{payload.map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2 text-[11px] font-mono">
<span className="h-2 w-2 rounded-full" style={{ background: p.color }} />
<span className="uppercase tracking-wider text-muted-foreground">{p.name}</span>
<span className="ml-auto pl-3 font-bold tabular-nums text-foreground">{Math.round(p.value)}%</span>
</div>
))}
</div>
<div className="h-12 w-full">
<Sparkline data={data} tone={t} />
</div>
{detail && <span className="truncate font-mono text-[10px] text-muted-foreground/80">{detail}</span>}
</div>
)
}
export function SystemStatusCard() {
const { data: sys, dataUpdatedAt } = useSystemStatus(3_000)
const [hist, setHist] = useState<{ cpu: number[]; ram: number[]; gpu: number[]; disk: number[] }>(
{ cpu: [], ram: [], gpu: [], disk: [] }
)
const [hist, setHist] = useState<Sample[]>([])
// Bei jedem Poll (dataUpdatedAt ändert sich pro Fetch) einen Messpunkt anhängen.
// Bei jedem Poll (dataUpdatedAt ändert sich pro Fetch) einen Live-Messpunkt anhängen.
useEffect(() => {
if (!sys) return
const push = (arr: number[], v: number | null | undefined) =>
v == null ? arr : [...arr, v].slice(-MAX_POINTS)
setHist((h) => ({
cpu: push(h.cpu, sys.cpu?.percent),
ram: push(h.ram, sys.ram?.percent),
gpu: push(h.gpu, sys.gpu?.busy_percent),
disk: push(h.disk, sys.disk?.percent),
}))
setHist((h) => [
...h,
{
t: Date.now(),
cpu: sys.cpu?.percent ?? 0,
ram: sys.ram?.percent ?? 0,
gpu: sys.gpu?.busy_percent ?? null,
disk: sys.disk?.percent ?? null,
},
].slice(-MAX_POINTS))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataUpdatedAt])
const hasGpu = sys?.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null
const hasGpu = !!(sys?.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null)
const activeSeries = SERIES.filter((s) => s.key !== "gpu" || hasGpu)
// Dynamische Y-Achse: skaliert auf den jüngsten Peak (in 25er-Schritten), damit die
// Linien auch bei idle-Box (<20%) den Chart ausfüllen statt unten zu kleben.
const peak = hist.reduce((m, s) => Math.max(m, s.cpu, s.ram, s.gpu ?? 0, s.disk ?? 0), 0)
const yMax = Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25))
const current: Record<string, number | null | undefined> = {
cpu: sys?.cpu?.percent, ram: sys?.ram?.percent,
gpu: hasGpu ? sys!.gpu!.busy_percent : null, disk: sys?.disk?.percent,
}
const detail: Record<string, string> = {
cpu: sys?.cpu?.cores ? `${sys.cpu.cores} Cores` : "",
ram: sys ? `${gb(sys.ram.used)}/${gb(sys.ram.total)} GB` : "",
gpu: hasGpu ? `${gb(sys!.gpu!.gtt_used!)}/${gb(sys!.gpu!.gtt_total!)} GB` : "",
disk: sys?.disk ? `${gb(sys.disk.used)}/${gb(sys.disk.total)} GB` : "",
}
return (
<div className="md:col-span-2 flex flex-col justify-between rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
<div>
<div className="flex items-center gap-2 mb-4">
<div className="mb-3 flex items-center gap-2">
<Cpu className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">System-Status</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">System-Status</h2>
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
</span>
</div>
{sys ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MetricChart value={sys.cpu.percent} label="CPU" data={hist.cpu}
detail={sys.cpu.cores ? `${sys.cpu.cores} Cores` : undefined} />
<MetricChart value={sys.ram.percent} label="RAM" data={hist.ram}
detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
{hasGpu && (
<MetricChart value={sys.gpu!.busy_percent!} label="GPU" data={hist.gpu}
detail={`${gb(sys.gpu!.gtt_used!)} / ${gb(sys.gpu!.gtt_total!)} GB`} />
)}
{sys.disk && (
<MetricChart value={sys.disk.percent} label="Disk" data={hist.disk}
detail={`${gb(sys.disk.used)} / ${gb(sys.disk.total)} GB`} />
)}
</div>
<>
{/* Legende mit Live-Werten */}
<div className="mb-1 flex flex-wrap gap-x-5 gap-y-1.5">
{activeSeries.map((s) => (
<div key={s.key} className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
<span className="font-mono text-xs font-bold tabular-nums text-foreground">{Math.round(current[s.key] ?? 0)}%</span>
{detail[s.key] && <span className="font-mono text-[10px] text-muted-foreground/60">{detail[s.key]}</span>}
</div>
))}
</div>
<div className="h-44 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={hist} margin={{ top: 8, right: 6, bottom: 0, left: -18 }}>
<defs>
{SERIES.map((s) => (
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color} stopOpacity={0.22} />
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} stroke={GRID} />
<XAxis dataKey="t" hide />
<YAxis
domain={[0, yMax]} ticks={[0, yMax / 2, yMax]} tickFormatter={(v) => `${v}%`}
width={34} axisLine={false} tickLine={false}
tick={{ fontSize: 10, fill: AXIS }}
/>
<Tooltip content={<ChartTooltip />} cursor={{ stroke: AXIS, strokeOpacity: 0.4, strokeDasharray: "3 3" }} />
{activeSeries.map((s) => (
<Area
key={s.key} type="monotone" dataKey={s.key} name={s.label}
stroke={s.color} strokeWidth={2} fill={`url(#grad-${s.key})`}
dot={false} activeDot={{ r: 3, strokeWidth: 0 }}
isAnimationActive={false} connectNulls
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
</>
) : (
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Systemdaten</div>
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">Lade Systemdaten</div>
)}
</div>
{sys?.temp && (sys.temp.cpu || sys.temp.gpu) && (
<div className="mt-4 flex gap-4 text-xs font-mono text-muted-foreground/80 border-t border-border/30 pt-3">
<div className="mt-3 flex gap-4 border-t border-border/30 pt-3 font-mono text-xs text-muted-foreground/80">
{sys.temp.cpu != null && <span>CPU Temp: {sys.temp.cpu} °C</span>}
{sys.temp.gpu != null && <span>GPU Temp: {sys.temp.gpu} °C</span>}
</div>