375 lines
12 KiB
TypeScript
375 lines
12 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
|
||
/** 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] = 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,
|
||
compact = false,
|
||
}: 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]'
|
||
: 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 (
|
||
<div className={`${wrapperClass} ${className ?? ''}`}>
|
||
<div className={panelClass}>
|
||
<div className="grid grid-cols-2 gap-1 md:grid-cols-4 sm:h-full sm:w-full sm:items-center">
|
||
{metrics.map((item) => (
|
||
<div
|
||
key={item.key}
|
||
title={item.title}
|
||
className="min-w-0 rounded-md px-1.5 py-0.5 sm:rounded-lg sm:px-2 sm:py-1"
|
||
>
|
||
<div className="flex items-center justify-between gap-1.5 whitespace-nowrap">
|
||
<span className="min-w-0 truncate text-[9px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 sm:text-[10px]">
|
||
{item.label}
|
||
</span>
|
||
<span className="shrink-0 whitespace-nowrap text-[10px] font-semibold leading-none tabular-nums text-gray-900 dark:text-gray-100 sm:text-[11px]">
|
||
{item.value}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-1 h-[3px] overflow-hidden rounded-full bg-gray-200/70 dark:bg-white/10 sm:mt-1.5 sm:h-1">
|
||
<div
|
||
className={`h-full rounded-full ${barTone(item.tone)}`}
|
||
style={{ width: `${Math.round(item.fill * 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className={`${wrapperClass} ${className ?? ''}`}>
|
||
<div className={panelClass}>
|
||
{/* 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>
|
||
)
|
||
}
|