275 lines
8.5 KiB
TypeScript
275 lines
8.5 KiB
TypeScript
// 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<number | null>(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<number | null>(null)
|
||
const [cpu, setCpu] = React.useState<number | null>(null)
|
||
const [diskFreeBytes, setDiskFreeBytes] = React.useState<number | null>(null)
|
||
const [diskTotalBytes, setDiskTotalBytes] = React.useState<number | null>(null)
|
||
const [diskUsedPercent, setDiskUsedPercent] = React.useState<number | null>(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<any>(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 (
|
||
<div className={`${wrapperClass} ${className ?? ''}`}>
|
||
<div
|
||
className="
|
||
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
|
||
"
|
||
>
|
||
{/* DISK */}
|
||
<div className="flex items-center gap-2" title={diskTitle}>
|
||
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Disk</span>
|
||
<div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
|
||
<div
|
||
className={`h-full ${barTone(diskTone)}`}
|
||
style={{ width: `${Math.round(usedFill * 100)}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-[11px] w-11 tabular-nums text-gray-900 dark:text-gray-100">
|
||
{formatBytes(diskFreeBytes)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* PING */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">Ping</span>
|
||
<div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
|
||
<div
|
||
className={`h-full ${barTone(pingTone)}`}
|
||
style={{ width: `${Math.round(pingFill * 100)}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-[11px] w-10 tabular-nums text-gray-900 dark:text-gray-100">
|
||
{formatMs(ping)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* FPS */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">FPS</span>
|
||
<div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
|
||
<div
|
||
className={`h-full ${barTone(fpsTone)}`}
|
||
style={{ width: `${Math.round(fpsFill * 100)}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-[11px] w-8 tabular-nums text-gray-900 dark:text-gray-100">
|
||
{fps != null ? fps : '–'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* CPU */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">CPU</span>
|
||
<div className="hidden sm:block h-2 w-14 sm:w-16 rounded-full bg-gray-200/70 dark:bg-white/10 overflow-hidden">
|
||
<div
|
||
className={`h-full ${barTone(cpuTone)}`}
|
||
style={{ width: `${Math.round(cpuFill * 100)}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-[11px] w-10 tabular-nums text-gray-900 dark:text-gray-100">
|
||
{formatPct(cpu)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|