nsfwapp/frontend/src/components/ui/PerformanceMonitor.tsx
2026-04-09 14:53:16 +02:00

375 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
)
}