// frontend\src\components\ui\PerformanceMonitor.tsx import React from 'react' import { subscribeSSE } from '../../lib/sseSingleton' type Props = { mode?: 'inline' | 'floating' className?: string /** Server perf poll Intervall */ pollMs?: number } 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 useFps(sampleMs = 1000) { const [fps, setFps] = React.useState(null) React.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, }: Props) { const fps = useFps(1000) const [ping, setPing] = React.useState(null) const [cpu, setCpu] = React.useState(null) const [diskFreeBytes, setDiskFreeBytes] = React.useState(null) const [diskTotalBytes, setDiskTotalBytes] = React.useState(null) const [diskUsedPercent, setDiskUsedPercent] = React.useState(null) const LOW_FREE_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB const RESET_BYTES = 8 * 1024 * 1024 * 1024 // Hysterese (8 GB) const emergencyRef = React.useRef(false) React.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]' : 'flex items-center' // inline: sichtbar, Caller entscheidet per className return (
{/* DISK */}
Disk
{formatBytes(diskFreeBytes)}
{/* PING */}
Ping
{formatMs(ping)}
{/* FPS */}
FPS
{fps != null ? fps : '–'}
{/* CPU */}
CPU
{formatPct(cpu)}
) }