Feat: Live-Charts auf Diagnose + Token-Durchsatz-Performance-Karte (Recharts)

Gemeinsame LiveAreaChart-Komponente + useSystemHistory-Hook (EINE Quelle der Wahrheit
fuer den Live-Verlauf), genutzt von Zentrale & Diagnose.

- Diagnose: die 4 statischen Balken (CPU/RAM/GPU/Disk) sind jetzt farbcodierte
  Einzel-Live-Charts (Spline, Gradient, Hover-Tooltip, dyn. Y-Achse).
- Neue TokenPerformanceCard (Zentrale): Live-Durchsatz tok/s, aus den kumulativen
  Token-Zaehlern als Rate abgeleitet (Prompt/Prefill vs. Antwort/Generierung), KPI-
  Headline (tok/s, Gesamt-Tokens, gespart EUR), dunkler Performance-Stil.
- SystemStatusCard auf die geteilte Komponente/Hook umgestellt (schlanker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 16:44:17 +02:00
parent ea256fca0d
commit 35189fde0e
11 changed files with 718 additions and 592 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
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="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-B_8UgpHG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CWa0E1B9.css">
<script type="module" crossorigin src="/assets/index-Dn30RTcO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C4fQuXTp.css">
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,87 @@
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
export type ChartSeries = { key: string; label: string; color: string }
const GRID = "rgba(130,130,150,0.14)"
const AXIS = "rgba(130,130,150,0.85)"
function niceCeil(v: number): number {
if (v <= 0) return 10
const mag = Math.pow(10, Math.floor(Math.log10(v)))
const n = v / mag
const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10
return step * mag
}
function fmt(v: number, unit: string): string {
const n = unit === "%" ? Math.round(v) : v >= 1000 ? `${(v / 1000).toFixed(1)}k` : Math.round(v).toString()
return `${n}${unit}`
}
function ChartTooltip({ active, payload, unit }: any) {
if (!active || !payload?.length) return null
return (
<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">{fmt(p.value, unit)}</span>
</div>
))}
</div>
</div>
)
}
/** Wiederverwendbarer Live-Verlaufsgraph (Recharts Area, glatte Splines, Gradient-Fill,
* Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice). */
export function LiveAreaChart({
data, series, unit = "%", yMode = "percent", height = 176,
}: {
data: any[]
series: ChartSeries[]
unit?: string
yMode?: "percent" | "auto"
height?: number
}) {
const peak = data.reduce(
(m, row) => series.reduce((mm, s) => Math.max(mm, Number(row[s.key]) || 0), m), 0
)
const yMax = yMode === "percent"
? Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25))
: Math.max(niceCeil(peak * 1.15), 10)
return (
<div style={{ height }} className="w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 6, bottom: 0, left: -12 }}>
<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) => fmt(v, unit)}
width={42} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }}
/>
<Tooltip content={<ChartTooltip unit={unit} />} cursor={{ stroke: AXIS, strokeOpacity: 0.4, strokeDasharray: "3 3" }} />
{series.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>
)
}
@@ -1,68 +1,21 @@
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 { useSystemHistory } from "@/lib/useSystemHistory"
import { gb } from "@/lib/format"
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
const MAX_POINTS = 40 // bei 3s-Poll ~2 Min Live-Verlauf
type Sample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
const SERIES = [
const SERIES: ChartSeries[] = [
{ 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="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>
)
}
]
export function SystemStatusCard() {
const { data: sys, dataUpdatedAt } = useSystemStatus(3_000)
const [hist, setHist] = useState<Sample[]>([])
// Bei jedem Poll (dataUpdatedAt ändert sich pro Fetch) einen Live-Messpunkt anhängen.
useEffect(() => {
if (!sys) return
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 { sys, hist } = useSystemHistory()
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,
@@ -87,7 +40,6 @@ export function SystemStatusCard() {
{sys ? (
<>
{/* 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">
@@ -98,37 +50,7 @@ export function SystemStatusCard() {
</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>
<LiveAreaChart data={hist} series={activeSeries} unit="%" yMode="percent" height={176} />
</>
) : (
<div className="flex h-44 items-center justify-center text-xs text-muted-foreground">Lade Systemdaten</div>
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react"
import { Activity } from "lucide-react"
import { useTokenStats } from "@/lib/queries"
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
const MAX_POINTS = 40
type RateSample = { t: number; prompt: number; completion: number }
const SERIES: ChartSeries[] = [
{ key: "prompt", label: "Prompt", color: "#f59e0b" },
{ key: "completion", label: "Antwort", color: "#2dd4bf" },
]
export function TokenPerformanceCard() {
const { data: ts, dataUpdatedAt } = useTokenStats(3_000)
const [hist, setHist] = useState<RateSample[]>([])
const prev = useRef<{ p: number; c: number; t: number } | null>(null)
// Durchsatz (tok/s) aus den kumulativen Zählern ableiten: Delta / Zeitspanne pro Poll.
useEffect(() => {
if (!ts) return
const now = Date.now()
const p = ts.prompt_tokens, c = ts.completion_tokens
if (prev.current) {
const dt = Math.max((now - prev.current.t) / 1000, 0.001)
const prompt = Math.max(0, (p - prev.current.p) / dt)
const completion = Math.max(0, (c - prev.current.c) / dt)
setHist((h) => [...h, { t: now, prompt, completion }].slice(-MAX_POINTS))
}
prev.current = { p, c, t: now }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataUpdatedAt])
const last = hist[hist.length - 1]
const curRate = last ? Math.round(last.prompt + last.completion) : 0
return (
<div className="rounded-2xl border border-border/60 bg-gradient-to-b from-card/55 to-background/30 backdrop-blur-md p-5 shadow-lg shadow-black/20">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Activity className="h-4.5 w-4.5 text-primary" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Token-Durchsatz</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>
{ts && (
<div className="mt-2 flex items-baseline gap-2">
<span className="font-space text-3xl font-bold tracking-tight text-foreground tabular-nums">
{curRate.toLocaleString("de-DE")}
</span>
<span className="text-xs text-muted-foreground">tok/s aktuell</span>
</div>
)}
{ts && (
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70">
{ts.total_tokens.toLocaleString("de-DE")} Tokens gesamt · {ts.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })} gespart
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5 pt-1">
{SERIES.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((last?.[s.key as "prompt" | "completion"]) ?? 0)}
</span>
</div>
))}
</div>
</div>
{ts ? (
<LiveAreaChart data={hist} series={SERIES} unit=" tok/s" yMode="auto" height={150} />
) : (
<div className="flex h-[150px] items-center justify-center text-xs text-muted-foreground">Lade Durchsatz</div>
)}
<div className="mt-2 border-t border-border/30 pt-2 text-[10px] text-muted-foreground/70">
Durchsatz aus dem Gateway abgeleitet (Prefill vs. Generierung). Flach bei Leerlauf.
</div>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react"
import { useSystemStatus } from "@/lib/queries"
export type SysSample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
const MAX_POINTS = 40 // bei 3s-Poll ~2 Min Live-Verlauf
/** Rollende Live-Historie der System-Metriken (CPU/RAM/GPU/Disk). Hängt bei jedem Poll
* (dataUpdatedAt) einen Messpunkt an und hält die letzten 40. Geteilt von Zentrale +
* Diagnose, damit beide denselben Verlauf aufbauen. */
export function useSystemHistory() {
const { data: sys, dataUpdatedAt, error } = useSystemStatus(3_000)
const [hist, setHist] = useState<SysSample[]>([])
useEffect(() => {
if (!sys) return
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])
return { sys, hist, error }
}
+4
View File
@@ -5,6 +5,7 @@ import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
import { RolesCard } from "@/components/dashboard/RolesCard"
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
import { TokenStatsCard } from "@/components/dashboard/TokenStatsCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
export function DashboardView() {
return (
@@ -26,6 +27,9 @@ export function DashboardView() {
<UpdatesCard />
</div>
{/* Live-Durchsatz (Performance) */}
<TokenPerformanceCard />
{/* Bottom Grid: Agent, Roles, Memory & Token Stats */}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
<AgentStatusCard />
+27 -32
View File
@@ -1,43 +1,35 @@
import { useState } from "react"
import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity } from "lucide-react"
import { api } from "@/lib/api"
import { useSystemStatus, useServices } from "@/lib/queries"
import { useServices } from "@/lib/queries"
import { useSystemHistory, type SysSample } from "@/lib/useSystemHistory"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils"
import { gb } from "@/lib/format"
import { LiveAreaChart } from "@/components/dashboard/LiveAreaChart"
function DiagnosticBar({ label, percent, detail, icon: Icon }: { label: string; percent: number; detail?: string; icon: any }) {
const barColor = percent > 90
? "bg-red-500 shadow-md shadow-red-500/20"
: percent > 75
? "bg-amber-500 shadow-md shadow-amber-500/20"
: "bg-primary shadow-md shadow-primary/20"
function MetricChartCard({ label, percent, detail, icon: Icon, color, seriesKey, data }: {
label: string; percent: number; detail?: string; icon: any
color: string; seriesKey: keyof SysSample; data: SysSample[]
}) {
return (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-3 hover:border-primary/30 transition-all duration-300">
<div className="flex items-center justify-between">
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 shadow-lg shadow-black/10 hover:border-primary/30 transition-all duration-300">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-4.5 w-4.5 text-primary" />
<Icon className="h-4.5 w-4.5" style={{ color }} />
<span className="text-xs font-semibold uppercase tracking-wider text-foreground">{label}</span>
</div>
<span className="text-xs font-mono font-bold text-foreground">{Math.round(percent)}%</span>
<span className="font-mono text-sm font-bold tabular-nums text-foreground">{Math.round(percent)}%</span>
</div>
<div className="w-full h-2 bg-background/40 rounded-full overflow-hidden border border-border/20">
<div
className={cn("h-full transition-all duration-700 ease-out", barColor)}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
{detail && <div className="text-[10px] font-mono text-muted-foreground/80">{detail}</div>}
{detail && <div className="mb-1 font-mono text-[10px] text-muted-foreground/70">{detail}</div>}
<LiveAreaChart data={data} series={[{ key: seriesKey as string, label, color }]} unit="%" yMode="percent" height={88} />
</div>
)
}
export function SystemView() {
const { data: s, error: sErr } = useSystemStatus(3_000)
const { sys: s, hist, error: sErr } = useSystemHistory()
const { data: svc } = useServices(3_000)
const { showAlert, dialogElement } = useDialog()
const error = sErr ? String(sErr) : ""
@@ -95,26 +87,29 @@ export function SystemView() {
{s && (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DiagnosticBar label="CPU" percent={s.cpu.percent} detail={s.cpu.cores ? `${s.cpu.cores} Cores` : undefined} icon={Cpu} />
<DiagnosticBar label="RAM" percent={s.ram.percent} detail={`${gb(s.ram.used)} / ${gb(s.ram.total)} GB`} icon={Activity} />
<MetricChartCard label="CPU" color="#2dd4bf" seriesKey="cpu" data={hist}
percent={s.cpu.percent} detail={s.cpu.cores ? `${s.cpu.cores} Cores` : undefined} icon={Cpu} />
<MetricChartCard label="RAM" color="#38bdf8" seriesKey="ram" data={hist}
percent={s.ram.percent} detail={`${gb(s.ram.used)} / ${gb(s.ram.total)} GB`} icon={Activity} />
{s.gpu && s.gpu.busy_percent != null && (
<DiagnosticBar
label="GPU"
percent={s.gpu.busy_percent}
<MetricChartCard
label="GPU" color="#a78bfa" seriesKey="gpu" data={hist}
percent={s.gpu.busy_percent}
detail={
s.gpu.gtt_used != null && s.gpu.gtt_total
? `${gb(s.gpu.gtt_used)} / ${gb(s.gpu.gtt_total)} GB (GTT/unified)`
: s.gpu.vram_used != null && s.gpu.vram_total
? `${gb(s.gpu.vram_used)} / ${gb(s.gpu.vram_total)} GB VRAM`
: undefined
}
icon={GpuIcon}
}
icon={GpuIcon}
/>
)}
{s.disk && (
<DiagnosticBar label="Disk" percent={s.disk.percent} detail={`${gb(s.disk.used)} / ${gb(s.disk.total)} GB`} icon={HardDrive} />
<MetricChartCard label="Disk" color="#fbbf24" seriesKey="disk" data={hist}
percent={s.disk.percent} detail={`${gb(s.disk.used)} / ${gb(s.disk.total)} GB`} icon={HardDrive} />
)}
</div>