Feat: System-Status mit echten Live-Verlaufsgraphen statt Radial-Kreisen

Die 4 Kennzahlen (CPU/RAM/GPU/Disk) auf der Zentrale zeigen jetzt rollende Sparklines
(Area+Linie) statt statischer Radial-Gauges. SystemStatusCard sammelt pro Poll (3s,
ueber dataUpdatedAt) einen Messpunkt, haelt die letzten 40 (~2 Min Verlauf) und rendert
sie via neuer Sparkline-Komponente. Farbcodierung (gruen/amber/rot) + "live"-Indikator;
RadialGauge entfernt (war nur hier genutzt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 16:26:10 +02:00
parent 578d09ac7c
commit 3dc878f012
7 changed files with 119 additions and 58 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-Pcp4609T.js"></script> <script type="module" crossorigin src="/assets/index-CVpN0_0g.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cp70TbqQ.css"> <link rel="stylesheet" crossorigin href="/assets/index-BtqbLPzA.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -1,27 +0,0 @@
import { cn } from "@/lib/utils"
export function RadialGauge({ value, label, detail }: { value: number; label: string; detail?: string }) {
const radius = 24
const circ = 2 * Math.PI * radius
const offset = circ - (Math.min(value, 100) / 100) * circ
const strokeColor = value > 90
? "stroke-red-500"
: value > 75
? "stroke-amber-500"
: "stroke-primary"
return (
<div className="flex flex-col items-center gap-1.5 p-2 bg-background/20 rounded-xl border border-border/40">
<div className="relative flex h-16 w-16 items-center justify-center">
<svg className="absolute inset-0 h-full w-full -rotate-90">
<circle cx="32" cy="32" r={radius} className="stroke-muted fill-none" strokeWidth="4.5" />
<circle cx="32" cy="32" r={radius} className={cn("fill-none transition-all duration-700 ease-out", strokeColor)} strokeWidth="4.5" strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" />
</svg>
<span className="text-xs font-mono font-bold tracking-tight text-foreground">{Math.round(value)}%</span>
</div>
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</span>
{detail && <span className="text-[10px] font-mono text-muted-foreground/80">{detail}</span>}
</div>
)
}
@@ -0,0 +1,40 @@
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,10 +1,55 @@
import { useEffect, useState } from "react"
import { Cpu } from "lucide-react" import { Cpu } from "lucide-react"
import { useSystemStatus } from "@/lib/queries" import { useSystemStatus } from "@/lib/queries"
import { gb } from "@/lib/format" import { gb } from "@/lib/format"
import { RadialGauge } from "./RadialGauge" import { cn } from "@/lib/utils"
import { Sparkline } from "./Sparkline"
const MAX_POINTS = 40 // bei 3s-Poll ~2 Min Verlauf
function tone(value: number): string {
return value > 90 ? "text-red-500" : value > 75 ? "text-amber-500" : "text-primary"
}
function MetricChart({ value, label, detail, data }: {
value: number; label: string; detail?: string; data: number[]
}) {
const t = tone(value)
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>
<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() { export function SystemStatusCard() {
const { data: sys } = useSystemStatus(3_000) const { data: sys, dataUpdatedAt } = useSystemStatus(3_000)
const [hist, setHist] = useState<{ cpu: number[]; ram: number[]; gpu: number[]; disk: number[] }>(
{ cpu: [], ram: [], gpu: [], disk: [] }
)
// Bei jedem Poll (dataUpdatedAt ändert sich pro Fetch) einen 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),
}))
// 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
return ( 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 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">
@@ -12,20 +57,23 @@ export function SystemStatusCard() {
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Cpu className="h-4.5 w-4.5 text-primary" /> <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 tracking-wide uppercase 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> </div>
{sys ? ( {sys ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<RadialGauge value={sys.cpu.percent} label="CPU" detail={sys.cpu.cores ? `${sys.cpu.cores} Cores` : undefined} /> <MetricChart value={sys.cpu.percent} label="CPU" data={hist.cpu}
<RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} /> detail={sys.cpu.cores ? `${sys.cpu.cores} Cores` : undefined} />
{sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && ( <MetricChart value={sys.ram.percent} label="RAM" data={hist.ram}
<RadialGauge detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
value={sys.gpu.busy_percent} {hasGpu && (
label="GPU" <MetricChart value={sys.gpu!.busy_percent!} label="GPU" data={hist.gpu}
detail={`${gb(sys.gpu.gtt_used)} / ${gb(sys.gpu.gtt_total)} GB`} detail={`${gb(sys.gpu!.gtt_used!)} / ${gb(sys.gpu!.gtt_total!)} GB`} />
/>
)} )}
{sys.disk && ( {sys.disk && (
<RadialGauge value={sys.disk.percent} label="Disk" detail={`${gb(sys.disk.used)} / ${gb(sys.disk.total)} GB`} /> <MetricChart value={sys.disk.percent} label="Disk" data={hist.disk}
detail={`${gb(sys.disk.used)} / ${gb(sys.disk.total)} GB`} />
)} )}
</div> </div>
) : ( ) : (