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:
+1
File diff suppressed because one or more lines are too long
+17
-17
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-Pcp4609T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cp70TbqQ.css">
|
||||
<script type="module" crossorigin src="/assets/index-CVpN0_0g.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BtqbLPzA.css">
|
||||
</head>
|
||||
<body>
|
||||
<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 { useSystemStatus } from "@/lib/queries"
|
||||
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() {
|
||||
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 (
|
||||
<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">
|
||||
<Cpu className="h-4.5 w-4.5 text-primary" />
|
||||
<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>
|
||||
{sys ? (
|
||||
<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} />
|
||||
<RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
|
||||
{sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && (
|
||||
<RadialGauge
|
||||
value={sys.gpu.busy_percent}
|
||||
label="GPU"
|
||||
detail={`${gb(sys.gpu.gtt_used)} / ${gb(sys.gpu.gtt_total)} GB`}
|
||||
/>
|
||||
<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 && (
|
||||
<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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user