mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-29 21:19:25 +02:00
* 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>
160 lines
4.7 KiB
TypeScript
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>
|
|
)
|
|
}
|