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,
|
||||
BenchmarkStatus,
|
||||
BenchmarkProgress,
|
||||
BenchmarkTelemetry,
|
||||
HardwareInfo,
|
||||
DiskType,
|
||||
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
|
||||
*/
|
||||
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 {
|
||||
|
||||
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.`)
|
||||
}
|
||||
|
||||
// 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
|
||||
const ollamaService = new (await import('./ollama_service.js')).OllamaService()
|
||||
const modelResponse = await ollamaService.downloadModel(AI_BENCHMARK_MODEL)
|
||||
|
|
@ -651,6 +739,10 @@ export class BenchmarkService {
|
|||
}
|
||||
} catch (error) {
|
||||
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 startedAt = Date.now()
|
||||
private stageMetric: BenchmarkTelemetry['stage_metric'] | undefined
|
||||
private gpu: BenchmarkTelemetry['gpu'] | undefined
|
||||
private sampling = false
|
||||
|
||||
constructor(benchmarkId: string | null) {
|
||||
|
|
@ -38,10 +39,11 @@ export class BenchmarkTelemetrySampler {
|
|||
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) {
|
||||
this.status = status
|
||||
this.stageMetric = undefined
|
||||
this.gpu = undefined
|
||||
}
|
||||
|
||||
/** 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 } : {}) }
|
||||
}
|
||||
|
||||
/** Inject NVIDIA GPU stats into subsequent frames (null clears them). */
|
||||
setGpuStats(gpu: NonNullable<BenchmarkTelemetry['gpu']> | null) {
|
||||
this.gpu = gpu ?? undefined
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
|
|
@ -82,6 +89,7 @@ export class BenchmarkTelemetrySampler {
|
|||
temp_c: tempC,
|
||||
disk: { read_mb_s: readMb, write_mb_s: writeMb },
|
||||
...(this.stageMetric ? { stage_metric: this.stageMetric } : {}),
|
||||
...(this.gpu ? { gpu: this.gpu } : {}),
|
||||
}
|
||||
|
||||
transmit.broadcast(BROADCAST_CHANNELS.BENCHMARK_TELEMETRY, payload)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,25 @@ function StageHero({ run }: { run: BenchmarkRunHook }) {
|
|||
<div className="text-desert-green">
|
||||
<Sparkline data={run.aiTokHistory} height={64} />
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
diskMibs: number | null
|
||||
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 = {
|
||||
|
|
@ -49,6 +53,9 @@ const EMPTY_LIVE: LiveState = {
|
|||
cpuEventsHistory: [],
|
||||
diskMibs: null,
|
||||
diskMibsHistory: [],
|
||||
gpuUtil: null,
|
||||
gpuVramUsedMb: null,
|
||||
gpuVramTotalMb: null,
|
||||
}
|
||||
|
||||
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 [live, setLive] = useState<LiveState>(EMPTY_LIVE)
|
||||
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(() => {
|
||||
setProgress(null)
|
||||
setLive(EMPTY_LIVE)
|
||||
setPartials([])
|
||||
lastStatusRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -82,6 +92,20 @@ export function useBenchmarkRun(opts?: { onFinished?: (status: 'completed' | 'er
|
|||
BROADCAST_CHANNELS.BENCHMARK_PROGRESS,
|
||||
(data: BenchmarkProgressWithID) => {
|
||||
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) {
|
||||
const incoming = data.partial_result
|
||||
setPartials((prev) => {
|
||||
|
|
@ -127,6 +151,9 @@ export function useBenchmarkRun(opts?: { onFinished?: (status: 'completed' | 'er
|
|||
diskMibsHistory: isMib
|
||||
? pushRing(prev.diskMibsHistory, data.stage_metric!.value)
|
||||
: 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 { useBenchmarkRun } from '~/hooks/useBenchmarkRun'
|
||||
import BenchmarkRunView from '~/components/benchmark/BenchmarkRunView'
|
||||
import ScoreReveal from '~/components/benchmark/ScoreReveal'
|
||||
|
||||
export default function BenchmarkPage(props: {
|
||||
benchmark: {
|
||||
|
|
@ -36,6 +37,7 @@ export default function BenchmarkPage(props: {
|
|||
const queryClient = useQueryClient()
|
||||
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
|
||||
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
|
||||
const [revealing, setRevealing] = useState(false)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
|
@ -64,6 +66,7 @@ export default function BenchmarkPage(props: {
|
|||
setIsRunning(false)
|
||||
if (status === 'completed') {
|
||||
refetchLatest()
|
||||
setRevealing(true)
|
||||
} else {
|
||||
setErrorMsg(message || 'Benchmark failed')
|
||||
}
|
||||
|
|
@ -222,6 +225,23 @@ export default function BenchmarkPage(props: {
|
|||
|
||||
{isRunning ? (
|
||||
<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="space-y-6">
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ export type BenchmarkTelemetry = {
|
|||
value: 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue