nsfwapp/frontend/src/components/ui/PerformanceMonitor.tsx
2026-02-20 18:18:59 +01:00

275 lines
8.5 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
}
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>
)
}