// frontend\src\components\ui\PerformanceMonitor.tsx import { useState, useEffect, useRef } from 'react' import { subscribeSSE } from '../../lib/sseSingleton' type Props = { mode?: 'inline' | 'floating' className?: string /** Server perf poll Intervall */ pollMs?: number /** kompakter, header-freundlicher Modus */ compact?: boolean } const clamp01 = (v: number) => Math.max(0, Math.min(1, v)) function barTone(t: 'good' | 'warn' | 'bad') { if (t === 'good') return 'bg-emerald-500' if (t === 'warn') return 'bg-amber-500' return 'bg-red-500' } function formatMs(v: number | null) { if (v == null) return '–' return `${Math.round(v)}ms` } function formatPct(v: number | null) { if (v == null) return '–' return `${Math.round(v)}%` } function formatBytes(bytes: number | null) { if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return '–' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let v = bytes let i = 0 while (v >= 1024 && i < units.length - 1) { v /= 1024 i++ } const digits = i === 0 ? 0 : v >= 10 ? 1 : 2 return `${v.toFixed(digits)} ${units[i]}` } function formatBytesCompact(bytes: number | null) { if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return '–' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let v = bytes let i = 0 while (v >= 1024 && i < units.length - 1) { v /= 1024 i++ } const rounded = i === 0 ? Math.round(v) : v >= 100 ? Math.round(v) : v >= 10 ? Number(v.toFixed(0)) : Number(v.toFixed(1)) return `${rounded} ${units[i]}` } function useFps(sampleMs = 1000) { const [fps, setFps] = useState(null) useEffect(() => { let raf = 0 let last = performance.now() let frames = 0 let alive = true let running = false const loop = (t: number) => { if (!alive || !running) return frames += 1 const dt = t - last if (dt >= sampleMs) { const next = Math.round((frames * 1000) / dt) setFps(next) frames = 0 last = t } raf = requestAnimationFrame(loop) } const start = () => { if (!alive) return if (document.hidden) return if (running) return running = true last = performance.now() frames = 0 raf = requestAnimationFrame(loop) } const stop = () => { running = false cancelAnimationFrame(raf) } const onVis = () => { if (document.hidden) stop() else start() } start() document.addEventListener('visibilitychange', onVis) return () => { alive = false document.removeEventListener('visibilitychange', onVis) stop() } }, [sampleMs]) return fps } export default function PerformanceMonitor({ mode = 'inline', className, pollMs = 3000, compact = false, }: Props) { const fps = useFps(1000) const [ping, setPing] = useState(null) const [cpu, setCpu] = useState(null) const [diskFreeBytes, setDiskFreeBytes] = useState(null) const [diskTotalBytes, setDiskTotalBytes] = useState(null) const [diskUsedPercent, setDiskUsedPercent] = useState(null) const LOW_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB const RESET_BYTES = 8 * 1024 * 1024 * 1024 // Hysterese (8 GB) const emergencyRef = useRef(false) useEffect(() => { const url = `/api/perf/stream?ms=${encodeURIComponent(String(pollMs))}` const unsub = subscribeSSE(url, 'perf', (data) => { const v = typeof data?.cpuPercent === 'number' ? data.cpuPercent : null const free = typeof data?.diskFreeBytes === 'number' ? data.diskFreeBytes : null const total = typeof data?.diskTotalBytes === 'number' ? data.diskTotalBytes : null const usedPct = typeof data?.diskUsedPercent === 'number' ? data.diskUsedPercent : null setCpu(v) setDiskFreeBytes(free) setDiskTotalBytes(total) setDiskUsedPercent(usedPct) const serverMs = typeof data?.serverMs === 'number' ? data.serverMs : null setPing(serverMs != null ? Math.max(0, Date.now() - serverMs) : null) }) return () => unsub() }, [pollMs]) // ------------------------- // Meter config // ------------------------- const pingTone: 'good' | 'warn' | 'bad' = ping == null ? 'bad' : ping <= 120 ? 'good' : ping <= 300 ? 'warn' : 'bad' const fpsTone: 'good' | 'warn' | 'bad' = fps == null ? 'bad' : fps >= 55 ? 'good' : fps >= 30 ? 'warn' : 'bad' const cpuTone: 'good' | 'warn' | 'bad' = cpu == null ? 'bad' : cpu <= 60 ? 'good' : cpu <= 85 ? 'warn' : 'bad' // Balken-Füllstände const pingFill = clamp01(((ping ?? 999) as number) / 500) // 0..500ms const fpsFill = clamp01(((fps ?? 0) as number) / 60) // 0..60fps const cpuFill = clamp01(((cpu ?? 0) as number) / 100) // 0..100% const freePct = diskFreeBytes != null && diskTotalBytes != null && diskTotalBytes > 0 ? diskFreeBytes / diskTotalBytes : null // "Used %" Anzeige: vom Server, sonst selbst aus freePct berechnen const usedPctShown = diskUsedPercent != null ? diskUsedPercent : freePct != null ? (1 - freePct) * 100 : null const usedFill = clamp01(((usedPctShown ?? 0) as number) / 100) // 0..1 // Disk "emergency" Hysterese (absolute Bytes) // - wenn free <= LOW_FREE_BYTES => emergency an // - bleibt an bis free >= RESET_BYTES if (diskFreeBytes == null) { emergencyRef.current = false } else if (emergencyRef.current) { if (diskFreeBytes >= RESET_BYTES) emergencyRef.current = false } else { if (diskFreeBytes <= LOW_FREE_BYTES) emergencyRef.current = true } const diskTone: 'good' | 'warn' | 'bad' = diskFreeBytes == null ? 'bad' : emergencyRef.current ? 'bad' : freePct == null ? 'bad' : freePct >= 0.15 ? 'good' : freePct >= 0.07 ? 'warn' : 'bad' const diskTitle = diskFreeBytes == null ? 'Disk: –' : `Free: ${formatBytes(diskFreeBytes)} / Total: ${formatBytes(diskTotalBytes)} · Used: ${formatPct(usedPctShown)}` const wrapperClass = mode === 'floating' ? 'fixed bottom-4 right-4 z-[80]' : compact ? 'min-w-0' : 'w-full min-w-0' const panelClass = compact ? ` w-full min-w-0 rounded-lg border border-gray-200/70 bg-white/70 px-1.5 py-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 sm:flex sm:h-[47px] sm:items-center sm:rounded-xl sm:px-2 sm:py-0 ` : ` rounded-lg border border-gray-200/70 bg-white/70 px-2.5 py-1.5 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 grid grid-cols-2 gap-x-3 gap-y-2 sm:flex sm:items-center sm:gap-3 ` if (compact) { const metrics = [ { key: 'disk', label: 'Disk', value: formatBytesCompact(diskFreeBytes), fill: usedFill, tone: diskTone, title: diskTitle, }, { key: 'ping', label: 'Ping', value: formatMs(ping), fill: pingFill, tone: pingTone, title: `Ping: ${formatMs(ping)}`, }, { key: 'fps', label: 'FPS', value: fps != null ? String(fps) : '–', fill: fpsFill, tone: fpsTone, title: `FPS: ${fps != null ? fps : '–'}`, }, { key: 'cpu', label: 'CPU', value: formatPct(cpu), fill: cpuFill, tone: cpuTone, title: `CPU: ${formatPct(cpu)}`, }, ] return (
{metrics.map((item) => (
{item.label} {item.value}
))}
) } return (
{/* DISK */}
Disk
{formatBytes(diskFreeBytes)}
{/* PING */}
Ping
{formatMs(ping)}
{/* FPS */}
FPS
{fps != null ? fps : '–'}
{/* CPU */}
CPU
{formatPct(cpu)}
) }