project-nomad/admin/inertia/components/systeminfo/CircularGauge.tsx
chriscrosstalk 7a5a254dd5
feat(benchmark): Require full benchmark with AI for community sharing (#99)
* feat(benchmark): Require full benchmark with AI for community sharing

Only allow users to share benchmark results with the community leaderboard
when they have completed a full benchmark that includes AI performance data.

Frontend changes:
- Add AI Assistant installation check via service API query
- Show pre-flight warning when clicking Full Benchmark without AI installed
- Disable AI Only button when AI Assistant not installed
- Show "Partial Benchmark" info alert for non-shareable results
- Only display "Share with Community" for full benchmarks with AI data
- Add note about AI installation requirement with link to Apps page

Backend changes:
- Validate benchmark_type is 'full' before allowing submission
- Require ai_tokens_per_second > 0 for community submission
- Return clear error messages explaining requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(benchmark): UI improvements and GPU detection fix

- Fix GPU detection to properly identify AMD discrete GPUs
- Fix gauge colors (high scores now green, low scores red)
- Fix gauge centering (SVG size matches container)
- Add info tooltips for Tokens/sec and Time to First Token

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(benchmark): Extract iGPU from AMD APU CPU name as fallback

When systeminformation doesn't detect graphics controllers (common on
headless Linux), extract the integrated GPU name from AMD APU CPU model
strings like "AMD Ryzen AI 9 HX 370 w/ Radeon 890M".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(benchmark): Add Builder Tag system for community leaderboard

- Add builder_tag column to benchmark_results table
- Create BuilderTagSelector component with word dropdowns + randomize
- Add 50 adjectives and 50 nouns for NOMAD-themed tags (e.g., Tactical-Llama-1234)
- Add anonymous sharing option checkbox
- Add builder tag display in Benchmark Details section
- Add Benchmark History section showing all past benchmarks
- Update submission API to accept anonymous flag
- Add /api/benchmark/builder-tag endpoint to update tags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(benchmark): Add HMAC signing for leaderboard submissions

Sign benchmark submissions with HMAC-SHA256 to prevent casual API abuse.
Includes X-NOMAD-Timestamp and X-NOMAD-Signature headers.

Note: Since NOMAD is open source, a determined attacker could extract
the secret. This provides protection against casual abuse only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:24:31 -08:00

160 lines
4.7 KiB
TypeScript

import { useEffect, useState } from 'react'
import classNames from '~/lib/classNames'
interface CircularGaugeProps {
value: number // percentage
label: string
icon?: React.ReactNode
size?: 'sm' | 'md' | 'lg'
variant?: 'cpu' | 'memory' | 'disk' | 'default'
subtext?: string
animated?: boolean
}
export default function CircularGauge({
value,
label,
icon,
size = 'md',
variant = 'default',
subtext,
animated = true,
}: CircularGaugeProps) {
const [animatedValue, setAnimatedValue] = useState(animated ? 0 : value)
useEffect(() => {
if (animated) {
const timeout = setTimeout(() => setAnimatedValue(value), 100)
return () => clearTimeout(timeout)
}
}, [value, animated])
const displayValue = animated ? animatedValue : value
// Size configs: container size must match SVG size (2 * (radius + strokeWidth))
const sizes = {
sm: {
container: 'w-28 h-28', // 112px = 2 * (48 + 8)
strokeWidth: 8,
radius: 48,
fontSize: 'text-xl',
labelSize: 'text-xs',
},
md: {
container: 'w-[140px] h-[140px]', // 140px = 2 * (60 + 10)
strokeWidth: 10,
radius: 60,
fontSize: 'text-2xl',
labelSize: 'text-sm',
},
lg: {
container: 'w-[244px] h-[244px]', // 244px = 2 * (110 + 12)
strokeWidth: 12,
radius: 110,
fontSize: 'text-4xl',
labelSize: 'text-base',
},
}
const config = sizes[size]
const circumference = 2 * Math.PI * config.radius
const offset = circumference - (displayValue / 100) * circumference
const getColor = () => {
// For benchmarks: higher scores = better = green
if (value >= 75) return 'desert-green'
if (value >= 50) return 'desert-olive'
if (value >= 25) return 'desert-orange'
return 'desert-red'
}
const color = getColor()
const center = config.radius + config.strokeWidth
return (
<div className="flex flex-col items-center gap-3">
<div className={classNames('relative', config.container)}>
<svg
className="transform -rotate-90"
width={center * 2}
height={center * 2}
viewBox={`0 0 ${center * 2} ${center * 2}`}
>
{/* Background circle */}
<circle
cx={center}
cy={center}
r={config.radius}
fill="none"
stroke="currentColor"
strokeWidth={config.strokeWidth}
className="text-desert-green-lighter opacity-30"
/>
{/* Progress circle */}
<circle
cx={center}
cy={center}
r={config.radius}
fill="none"
stroke="currentColor"
strokeWidth={config.strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className={classNames(
`text-${color}`,
'transition-all duration-1000 ease-out',
'drop-shadow-[0_0_1px_currentColor]'
)}
style={{
filter: 'drop-shadow(0 0 1px currentColor)',
}}
/>
{/* Tick marks */}
{Array.from({ length: 12 }).map((_, i) => {
const angle = (i * 30 * Math.PI) / 180
const ringGap = 8
const tickLength = 6
const innerRadius = config.radius - config.strokeWidth - ringGap
const outerRadius = config.radius - config.strokeWidth - ringGap - tickLength
const x1 = center + innerRadius * Math.cos(angle)
const y1 = center + innerRadius * Math.sin(angle)
const x2 = center + outerRadius * Math.cos(angle)
const y2 = center + outerRadius * Math.sin(angle)
return (
<line
key={i}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="currentColor"
strokeWidth="2"
className="text-desert-stone opacity-30"
/>
)
})}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
{icon && <div className="text-desert-green opacity-60 mb-1">{icon}</div>}
<div className={classNames('font-bold text-desert-green', config.fontSize)}>
{Math.round(displayValue)}%
</div>
{subtext && (
<div className="text-xs text-desert-stone-dark opacity-70 font-mono mt-0.5">
{subtext}
</div>
)}
</div>
</div>
<div className={classNames('font-semibold text-desert-green text-center', config.labelSize)}>
{label}
</div>
</div>
)
}