feat(benchmark): end-of-run score reveal + NVIDIA GPU-util overlay (#1082)
Phase 3 of the live benchmark run experience. - ScoreReveal: replaces the abrupt run-view unmount with a deliberate end-of-run "REPORT" card -- animated NOMAD score gauge + odometer count-up number, sub-score gauges cascading in, Continue button + 5s auto-dismiss. Takes score scale as a prop so it survives Score v2. - GPU-util overlay (NVIDIA): during the AI stage, a ~1Hz nvidia-smi poll inside the Ollama container feeds live GPU utilization + VRAM into the telemetry frames; shown in the AI hero, hidden when absent (AMD/none). Poller is side-effect-only and cleared in a finally -- scored numbers unchanged. - Disk polish: reset in-test buffers on stage transition so the write stage no longer briefly shows the carried disk-read value. Browser-validated on NOMAD3 (RTX 5060): GPU overlay live (1% util, 0.2/8.0 GB VRAM during model load); reveal cascade + count-up; AI-only score 39.8 (scored path unchanged). Part of #1082 (tracker stays open). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
965474d0e4
commit
92b161d19d
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
BenchmarkType,
|
BenchmarkType,
|
||||||
BenchmarkStatus,
|
BenchmarkStatus,
|
||||||
BenchmarkProgress,
|
BenchmarkProgress,
|
||||||
|
BenchmarkTelemetry,
|
||||||
HardwareInfo,
|
HardwareInfo,
|
||||||
DiskType,
|
DiskType,
|
||||||
SystemScores,
|
SystemScores,
|
||||||
|
|
@ -525,10 +526,74 @@ export class BenchmarkService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe live NVIDIA GPU stats by exec-ing nvidia-smi inside the Ollama
|
||||||
|
* container (same approach as SystemService.getNvidiaSmiInfo). Telemetry-only:
|
||||||
|
* returns null on any failure (no Ollama container, no NVIDIA GPU / AMD,
|
||||||
|
* unparseable output) and must never affect the scored benchmark numbers.
|
||||||
|
*/
|
||||||
|
private async _probeGpuStats(): Promise<NonNullable<BenchmarkTelemetry['gpu']> | null> {
|
||||||
|
try {
|
||||||
|
const containers = await this.dockerService.docker.listContainers({ all: false })
|
||||||
|
const ollamaContainer = containers.find((c) => c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`))
|
||||||
|
if (!ollamaContainer) return null
|
||||||
|
|
||||||
|
const container = this.dockerService.docker.getContainer(ollamaContainer.Id)
|
||||||
|
const exec = await container.exec({
|
||||||
|
Cmd: [
|
||||||
|
'nvidia-smi',
|
||||||
|
'--query-gpu=utilization.gpu,memory.used,memory.total',
|
||||||
|
'--format=csv,noheader,nounits',
|
||||||
|
],
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Tty: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Read the output stream with a timeout to prevent hanging if nvidia-smi fails
|
||||||
|
const stream = await exec.start({ Tty: true })
|
||||||
|
const output = await new Promise<string>((resolve) => {
|
||||||
|
let data = ''
|
||||||
|
const timeout = setTimeout(() => resolve(data), 3000)
|
||||||
|
stream.on('data', (chunk: Buffer) => {
|
||||||
|
data += chunk.toString()
|
||||||
|
})
|
||||||
|
stream.on('end', () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(data)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove any non-printable characters and trim the output
|
||||||
|
const cleaned = Array.from(output)
|
||||||
|
.filter((character) => character.charCodeAt(0) > 8)
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
if (!cleaned || cleaned.toLowerCase().includes('error') || cleaned.toLowerCase().includes('not found')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// First GPU only: "<util>, <used>, <total>"
|
||||||
|
const parts = cleaned.split('\n')[0].split(',').map((s) => s.trim())
|
||||||
|
if (parts.length < 3) return null
|
||||||
|
const util = Number.parseInt(parts[0], 10)
|
||||||
|
const used = Number.parseInt(parts[1], 10)
|
||||||
|
const total = Number.parseInt(parts[2], 10)
|
||||||
|
if (!Number.isFinite(util) || !Number.isFinite(used) || !Number.isFinite(total)) return null
|
||||||
|
|
||||||
|
return { util, vram_used_mb: used, vram_total_mb: total }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run AI benchmark using Ollama
|
* Run AI benchmark using Ollama
|
||||||
*/
|
*/
|
||||||
private async _runAIBenchmark(): Promise<AIScores> {
|
private async _runAIBenchmark(): Promise<AIScores> {
|
||||||
|
// Live GPU-util overlay poller. Side-effect-only: it feeds telemetry frames
|
||||||
|
// and is cleared in the finally below so it never outlives the AI stage.
|
||||||
|
let gpuPollTimer: NodeJS.Timeout | null = null
|
||||||
try {
|
try {
|
||||||
|
|
||||||
this._updateStatus('running_ai', 'Running AI benchmark...')
|
this._updateStatus('running_ai', 'Running AI benchmark...')
|
||||||
|
|
@ -546,6 +611,29 @@ export class BenchmarkService {
|
||||||
throw new Error(`Ollama is not running or not accessible (${errorCode}). Ensure AI Assistant is installed and running.`)
|
throw new Error(`Ollama is not running or not accessible (${errorCode}). Ensure AI Assistant is installed and running.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GPU-util overlay (NVIDIA only): probe once; if a GPU answers, poll for the
|
||||||
|
// duration of the AI stage. If the probe returns null (no GPU / AMD), the
|
||||||
|
// overlay simply never appears. Best-effort: failures never affect the run.
|
||||||
|
try {
|
||||||
|
const initialGpu = await this._probeGpuStats()
|
||||||
|
if (initialGpu) {
|
||||||
|
this.telemetry?.setGpuStats(initialGpu)
|
||||||
|
let gpuProbeInFlight = false
|
||||||
|
gpuPollTimer = setInterval(() => {
|
||||||
|
if (gpuProbeInFlight) return
|
||||||
|
gpuProbeInFlight = true
|
||||||
|
this._probeGpuStats()
|
||||||
|
.then((stats) => this.telemetry?.setGpuStats(stats))
|
||||||
|
.catch(() => this.telemetry?.setGpuStats(null))
|
||||||
|
.finally(() => {
|
||||||
|
gpuProbeInFlight = false
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// GPU overlay is cosmetic; ignore probe failures entirely.
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the benchmark model is available, pull if not
|
// Check if the benchmark model is available, pull if not
|
||||||
const ollamaService = new (await import('./ollama_service.js')).OllamaService()
|
const ollamaService = new (await import('./ollama_service.js')).OllamaService()
|
||||||
const modelResponse = await ollamaService.downloadModel(AI_BENCHMARK_MODEL)
|
const modelResponse = await ollamaService.downloadModel(AI_BENCHMARK_MODEL)
|
||||||
|
|
@ -651,6 +739,10 @@ export class BenchmarkService {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`AI benchmark failed: ${error.message}`)
|
throw new Error(`AI benchmark failed: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
// Stop the GPU-util poller and clear the overlay from telemetry frames.
|
||||||
|
if (gpuPollTimer) clearInterval(gpuPollTimer)
|
||||||
|
this.telemetry?.setGpuStats(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export class BenchmarkTelemetrySampler {
|
||||||
private status: BenchmarkStatus = 'starting'
|
private status: BenchmarkStatus = 'starting'
|
||||||
private startedAt = Date.now()
|
private startedAt = Date.now()
|
||||||
private stageMetric: BenchmarkTelemetry['stage_metric'] | undefined
|
private stageMetric: BenchmarkTelemetry['stage_metric'] | undefined
|
||||||
|
private gpu: BenchmarkTelemetry['gpu'] | undefined
|
||||||
private sampling = false
|
private sampling = false
|
||||||
|
|
||||||
constructor(benchmarkId: string | null) {
|
constructor(benchmarkId: string | null) {
|
||||||
|
|
@ -38,10 +39,11 @@ export class BenchmarkTelemetrySampler {
|
||||||
this.timer = setInterval(() => void this._sample(), SAMPLE_INTERVAL_MS)
|
this.timer = setInterval(() => void this._sample(), SAMPLE_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tag subsequent frames with the current stage; clears any prior in-test metric. */
|
/** Tag subsequent frames with the current stage; clears any prior in-test state. */
|
||||||
setStage(status: BenchmarkStatus) {
|
setStage(status: BenchmarkStatus) {
|
||||||
this.status = status
|
this.status = status
|
||||||
this.stageMetric = undefined
|
this.stageMetric = undefined
|
||||||
|
this.gpu = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inject an in-test metric (e.g. live AI tokens/sec) into subsequent frames. */
|
/** Inject an in-test metric (e.g. live AI tokens/sec) into subsequent frames. */
|
||||||
|
|
@ -49,6 +51,11 @@ export class BenchmarkTelemetrySampler {
|
||||||
this.stageMetric = { kind, value, ...(ttftMs !== undefined ? { ttft_ms: ttftMs } : {}) }
|
this.stageMetric = { kind, value, ...(ttftMs !== undefined ? { ttft_ms: ttftMs } : {}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inject NVIDIA GPU stats into subsequent frames (null clears them). */
|
||||||
|
setGpuStats(gpu: NonNullable<BenchmarkTelemetry['gpu']> | null) {
|
||||||
|
this.gpu = gpu ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearInterval(this.timer)
|
clearInterval(this.timer)
|
||||||
|
|
@ -82,6 +89,7 @@ export class BenchmarkTelemetrySampler {
|
||||||
temp_c: tempC,
|
temp_c: tempC,
|
||||||
disk: { read_mb_s: readMb, write_mb_s: writeMb },
|
disk: { read_mb_s: readMb, write_mb_s: writeMb },
|
||||||
...(this.stageMetric ? { stage_metric: this.stageMetric } : {}),
|
...(this.stageMetric ? { stage_metric: this.stageMetric } : {}),
|
||||||
|
...(this.gpu ? { gpu: this.gpu } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
transmit.broadcast(BROADCAST_CHANNELS.BENCHMARK_TELEMETRY, payload)
|
transmit.broadcast(BROADCAST_CHANNELS.BENCHMARK_TELEMETRY, payload)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,25 @@ function StageHero({ run }: { run: BenchmarkRunHook }) {
|
||||||
<div className="text-desert-green">
|
<div className="text-desert-green">
|
||||||
<Sparkline data={run.aiTokHistory} height={64} />
|
<Sparkline data={run.aiTokHistory} height={64} />
|
||||||
</div>
|
</div>
|
||||||
|
{run.gpuUtil !== null && (
|
||||||
|
<div className="pt-3 border-t border-desert-stone-light">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-desert-stone-dark uppercase tracking-wide mb-2">
|
||||||
|
<IconChartBar className="w-4 h-4" /> GPU
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<LiveReadout value={run.gpuUtil} unit="%" label="Utilization" />
|
||||||
|
<LiveReadout
|
||||||
|
value={run.gpuVramUsedMb !== null ? run.gpuVramUsedMb / 1024 : null}
|
||||||
|
unit="GB"
|
||||||
|
label={
|
||||||
|
run.gpuVramTotalMb !== null
|
||||||
|
? `VRAM (of ${(run.gpuVramTotalMb / 1024).toFixed(1)} GB)`
|
||||||
|
: 'VRAM'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { IconChartBar, IconCpu, IconDatabase, IconRobot, IconServer } from '@tabler/icons-react'
|
||||||
|
import CircularGauge from '~/components/systeminfo/CircularGauge'
|
||||||
|
import StyledButton from '~/components/StyledButton'
|
||||||
|
import BenchmarkResult from '#models/benchmark_result'
|
||||||
|
|
||||||
|
interface ScoreRevealProps {
|
||||||
|
result: BenchmarkResult
|
||||||
|
scoreScale?: { max: number; caption: string }
|
||||||
|
onDone: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same thresholds as the NOMAD Score section on the benchmark page.
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 70) return 'text-green-600'
|
||||||
|
if (score >= 40) return 'text-yellow-600'
|
||||||
|
return 'text-red-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI tokens/sec normalized to 0-100 (30 tok/s = 50, 60 tok/s = 100),
|
||||||
|
// mirroring the benchmark page's getAIScore.
|
||||||
|
const getAIScore = (tokensPerSecond: number): number =>
|
||||||
|
Math.min(100, Math.max(0, (tokensPerSecond / 60) * 100))
|
||||||
|
|
||||||
|
/** rAF count-up from 0 to `target` with ease-out, for the big score number. */
|
||||||
|
function useCountUp(target: number, durationMs = 1200): number {
|
||||||
|
const [value, setValue] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0
|
||||||
|
const start = performance.now()
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - start) / durationMs)
|
||||||
|
const eased = 1 - Math.pow(1 - t, 3)
|
||||||
|
setValue(target * eased)
|
||||||
|
if (t < 1) raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [target, durationMs])
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-of-run score reveal. Replaces the abrupt unmount of the live run view
|
||||||
|
* with a deliberate report: big NOMAD score gauge + count-up number, then the
|
||||||
|
* sub-score gauges cascading in. Dismisses via the Continue button or an
|
||||||
|
* auto-dismiss timer.
|
||||||
|
*/
|
||||||
|
export default function ScoreReveal({
|
||||||
|
result,
|
||||||
|
scoreScale = { max: 100, caption: 'out of 100' },
|
||||||
|
onDone,
|
||||||
|
}: ScoreRevealProps) {
|
||||||
|
const displayScore = useCountUp(result.nomad_score)
|
||||||
|
|
||||||
|
// Sub-score gauges, mirroring the System Performance / AI Performance grids.
|
||||||
|
const gauges: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
variant: 'cpu' | 'memory' | 'disk'
|
||||||
|
icon: React.ReactNode
|
||||||
|
}[] = [
|
||||||
|
{ label: 'CPU', value: result.cpu_score * 100, variant: 'cpu', icon: <IconCpu className="w-6 h-6" /> },
|
||||||
|
{ label: 'Memory', value: result.memory_score * 100, variant: 'memory', icon: <IconDatabase className="w-6 h-6" /> },
|
||||||
|
{ label: 'Disk Read', value: result.disk_read_score * 100, variant: 'disk', icon: <IconServer className="w-6 h-6" /> },
|
||||||
|
{ label: 'Disk Write', value: result.disk_write_score * 100, variant: 'disk', icon: <IconServer className="w-6 h-6" /> },
|
||||||
|
]
|
||||||
|
if (result.ai_tokens_per_second) {
|
||||||
|
gauges.push({
|
||||||
|
label: 'AI Score',
|
||||||
|
value: getAIScore(result.ai_tokens_per_second),
|
||||||
|
variant: 'cpu',
|
||||||
|
icon: <IconRobot className="w-6 h-6" />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade: mount one gauge every 150ms; each CircularGauge animates 0→value
|
||||||
|
// on mount, so staggered mounts produce the cascade.
|
||||||
|
const [visibleCount, setVisibleCount] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (visibleCount >= gauges.length) return
|
||||||
|
const id = setTimeout(() => setVisibleCount((n) => n + 1), 150)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}, [visibleCount, gauges.length])
|
||||||
|
|
||||||
|
// Dismiss exactly once, whether via the button or the auto-dismiss timer.
|
||||||
|
const doneRef = useRef(false)
|
||||||
|
const onDoneRef = useRef(onDone)
|
||||||
|
onDoneRef.current = onDone
|
||||||
|
const fireDone = () => {
|
||||||
|
if (doneRef.current) return
|
||||||
|
doneRef.current = true
|
||||||
|
onDoneRef.current()
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(fireDone, 5000)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-desert-white rounded-lg border border-desert-stone-light overflow-hidden">
|
||||||
|
<div className="bg-desert-olive px-6 py-2 flex items-center gap-2">
|
||||||
|
<div className="w-1 h-4 bg-desert-green" />
|
||||||
|
<span className="text-xs font-semibold text-white uppercase tracking-wide">Report</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-8">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<CircularGauge
|
||||||
|
value={Math.min(100, (result.nomad_score / scoreScale.max) * 100)}
|
||||||
|
label="NOMAD Score"
|
||||||
|
size="lg"
|
||||||
|
variant="cpu"
|
||||||
|
subtext={scoreScale.caption}
|
||||||
|
icon={<IconChartBar className="w-8 h-8" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div
|
||||||
|
className={`text-5xl font-bold font-mono tabular-nums ${getScoreColor(result.nomad_score)}`}
|
||||||
|
>
|
||||||
|
{displayScore.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<p className="text-desert-stone-dark">
|
||||||
|
Your NOMAD Score is a weighted composite of all benchmark results.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6 mt-8">
|
||||||
|
{gauges.slice(0, visibleCount).map((g) => (
|
||||||
|
<div
|
||||||
|
key={g.label}
|
||||||
|
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light"
|
||||||
|
>
|
||||||
|
<CircularGauge value={g.value} label={g.label} size="md" variant={g.variant} icon={g.icon} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<StyledButton onClick={fireDone} icon="IconArrowRight">
|
||||||
|
Continue
|
||||||
|
</StyledButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,10 @@ type LiveState = {
|
||||||
/** Authoritative in-test disk throughput (sysbench interim lines), MiB/s. */
|
/** Authoritative in-test disk throughput (sysbench interim lines), MiB/s. */
|
||||||
diskMibs: number | null
|
diskMibs: number | null
|
||||||
diskMibsHistory: number[]
|
diskMibsHistory: number[]
|
||||||
|
/** NVIDIA GPU stats during the AI stage (null when no GPU / not sampling). */
|
||||||
|
gpuUtil: number | null
|
||||||
|
gpuVramUsedMb: number | null
|
||||||
|
gpuVramTotalMb: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_LIVE: LiveState = {
|
const EMPTY_LIVE: LiveState = {
|
||||||
|
|
@ -49,6 +53,9 @@ const EMPTY_LIVE: LiveState = {
|
||||||
cpuEventsHistory: [],
|
cpuEventsHistory: [],
|
||||||
diskMibs: null,
|
diskMibs: null,
|
||||||
diskMibsHistory: [],
|
diskMibsHistory: [],
|
||||||
|
gpuUtil: null,
|
||||||
|
gpuVramUsedMb: null,
|
||||||
|
gpuVramTotalMb: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushRing = (arr: number[], v: number) => {
|
const pushRing = (arr: number[], v: number) => {
|
||||||
|
|
@ -70,11 +77,14 @@ export function useBenchmarkRun(opts?: { onFinished?: (status: 'completed' | 'er
|
||||||
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
|
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
|
||||||
const [live, setLive] = useState<LiveState>(EMPTY_LIVE)
|
const [live, setLive] = useState<LiveState>(EMPTY_LIVE)
|
||||||
const [partials, setPartials] = useState<BenchmarkPartialResult[]>([])
|
const [partials, setPartials] = useState<BenchmarkPartialResult[]>([])
|
||||||
|
// Last-seen progress status, so stage transitions can reset in-test buffers.
|
||||||
|
const lastStatusRef = useRef<BenchmarkStatus | null>(null)
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
setLive(EMPTY_LIVE)
|
setLive(EMPTY_LIVE)
|
||||||
setPartials([])
|
setPartials([])
|
||||||
|
lastStatusRef.current = null
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -82,6 +92,20 @@ export function useBenchmarkRun(opts?: { onFinished?: (status: 'completed' | 'er
|
||||||
BROADCAST_CHANNELS.BENCHMARK_PROGRESS,
|
BROADCAST_CHANNELS.BENCHMARK_PROGRESS,
|
||||||
(data: BenchmarkProgressWithID) => {
|
(data: BenchmarkProgressWithID) => {
|
||||||
setProgress(data)
|
setProgress(data)
|
||||||
|
// On stage transition, clear the in-test buffers so each stage starts
|
||||||
|
// clean (e.g. the disk-read throughput doesn't linger into the write
|
||||||
|
// stage). Always-on host telemetry (perCore, cpuOverall, disk proxy,
|
||||||
|
// temp) is intentionally left untouched.
|
||||||
|
if (lastStatusRef.current !== data.status) {
|
||||||
|
lastStatusRef.current = data.status
|
||||||
|
setLive((prev) => ({
|
||||||
|
...prev,
|
||||||
|
cpuEventsPerSec: null,
|
||||||
|
cpuEventsHistory: [],
|
||||||
|
diskMibs: null,
|
||||||
|
diskMibsHistory: [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
if (data.partial_result) {
|
if (data.partial_result) {
|
||||||
const incoming = data.partial_result
|
const incoming = data.partial_result
|
||||||
setPartials((prev) => {
|
setPartials((prev) => {
|
||||||
|
|
@ -127,6 +151,9 @@ export function useBenchmarkRun(opts?: { onFinished?: (status: 'completed' | 'er
|
||||||
diskMibsHistory: isMib
|
diskMibsHistory: isMib
|
||||||
? pushRing(prev.diskMibsHistory, data.stage_metric!.value)
|
? pushRing(prev.diskMibsHistory, data.stage_metric!.value)
|
||||||
: prev.diskMibsHistory,
|
: prev.diskMibsHistory,
|
||||||
|
gpuUtil: data.gpu ? data.gpu.util : prev.gpuUtil,
|
||||||
|
gpuVramUsedMb: data.gpu ? data.gpu.vram_used_mb : prev.gpuVramUsedMb,
|
||||||
|
gpuVramTotalMb: data.gpu ? data.gpu.vram_total_mb : prev.gpuVramTotalMb,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
||||||
import { SERVICE_NAMES } from '../../../constants/service_names'
|
import { SERVICE_NAMES } from '../../../constants/service_names'
|
||||||
import { useBenchmarkRun } from '~/hooks/useBenchmarkRun'
|
import { useBenchmarkRun } from '~/hooks/useBenchmarkRun'
|
||||||
import BenchmarkRunView from '~/components/benchmark/BenchmarkRunView'
|
import BenchmarkRunView from '~/components/benchmark/BenchmarkRunView'
|
||||||
|
import ScoreReveal from '~/components/benchmark/ScoreReveal'
|
||||||
|
|
||||||
export default function BenchmarkPage(props: {
|
export default function BenchmarkPage(props: {
|
||||||
benchmark: {
|
benchmark: {
|
||||||
|
|
@ -36,6 +37,7 @@ export default function BenchmarkPage(props: {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
|
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
|
||||||
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
|
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
|
||||||
|
const [revealing, setRevealing] = useState(false)
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
|
|
@ -64,6 +66,7 @@ export default function BenchmarkPage(props: {
|
||||||
setIsRunning(false)
|
setIsRunning(false)
|
||||||
if (status === 'completed') {
|
if (status === 'completed') {
|
||||||
refetchLatest()
|
refetchLatest()
|
||||||
|
setRevealing(true)
|
||||||
} else {
|
} else {
|
||||||
setErrorMsg(message || 'Benchmark failed')
|
setErrorMsg(message || 'Benchmark failed')
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +225,23 @@ export default function BenchmarkPage(props: {
|
||||||
|
|
||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<BenchmarkRunView run={run} />
|
<BenchmarkRunView run={run} />
|
||||||
|
) : revealing ? (
|
||||||
|
// Reveal slot: wait for the refetched latest result to be the one
|
||||||
|
// from this run, then hand it to the score reveal.
|
||||||
|
(() => {
|
||||||
|
const ready =
|
||||||
|
latestResult && latestResult.benchmark_id === run.progress?.benchmark_id
|
||||||
|
return ready ? (
|
||||||
|
<ScoreReveal result={latestResult} onDone={() => setRevealing(false)} />
|
||||||
|
) : (
|
||||||
|
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm">
|
||||||
|
<div className="flex items-center justify-center gap-3 text-desert-green animate-pulse">
|
||||||
|
<div className="animate-spin h-6 w-6 border-2 border-desert-green border-t-transparent rounded-full" />
|
||||||
|
<span className="text-lg font-medium">Compiling report...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm">
|
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,8 @@ export type BenchmarkTelemetry = {
|
||||||
value: number
|
value: number
|
||||||
ttft_ms?: number
|
ttft_ms?: number
|
||||||
}
|
}
|
||||||
|
// NVIDIA GPU stats sampled during the AI stage (absent when no NVIDIA GPU)
|
||||||
|
gpu?: { util: number; vram_used_mb: number; vram_total_mb: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
// API request types
|
// API request types
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue