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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user