mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-05 16:26:15 +02:00
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>
This commit is contained in:
parent
82719188cd
commit
4d34b9a2f3
|
|
@ -169,9 +169,10 @@ export default class BenchmarkController {
|
|||
*/
|
||||
async submit({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(submitBenchmarkValidator)
|
||||
const anonymous = request.input('anonymous') === true || request.input('anonymous') === 'true'
|
||||
|
||||
try {
|
||||
const submitResult = await this.benchmarkService.submitToRepository(payload.benchmark_id)
|
||||
const submitResult = await this.benchmarkService.submitToRepository(payload.benchmark_id, anonymous)
|
||||
return response.send({
|
||||
success: true,
|
||||
repository_id: submitResult.repository_id,
|
||||
|
|
@ -185,6 +186,48 @@ export default class BenchmarkController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update builder tag for a benchmark result
|
||||
*/
|
||||
async updateBuilderTag({ request, response }: HttpContext) {
|
||||
const benchmarkId = request.input('benchmark_id')
|
||||
const builderTag = request.input('builder_tag')
|
||||
|
||||
if (!benchmarkId) {
|
||||
return response.status(400).send({
|
||||
success: false,
|
||||
error: 'benchmark_id is required',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await this.benchmarkService.getResultById(benchmarkId)
|
||||
if (!result) {
|
||||
return response.status(404).send({
|
||||
success: false,
|
||||
error: 'Benchmark result not found',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate builder tag format if provided
|
||||
if (builderTag) {
|
||||
const tagPattern = /^[A-Za-z]+-[A-Za-z]+-\d{4}$/
|
||||
if (!tagPattern.test(builderTag)) {
|
||||
return response.status(400).send({
|
||||
success: false,
|
||||
error: 'Invalid builder tag format. Expected: Word-Word-0000',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
result.builder_tag = builderTag || null
|
||||
await result.save()
|
||||
|
||||
return response.send({
|
||||
success: true,
|
||||
builder_tag: result.builder_tag,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comparison stats from central repository
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ export default class BenchmarkResult extends BaseModel {
|
|||
@column()
|
||||
declare repository_id: string | null
|
||||
|
||||
@column()
|
||||
declare builder_tag: string | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export class BenchmarkService {
|
|||
/**
|
||||
* Submit benchmark results to central repository
|
||||
*/
|
||||
async submitToRepository(benchmarkId?: string): Promise<RepositorySubmitResponse> {
|
||||
async submitToRepository(benchmarkId?: string, anonymous?: boolean): Promise<RepositorySubmitResponse> {
|
||||
const result = benchmarkId
|
||||
? await this.getResultById(benchmarkId)
|
||||
: await this.getLatestResult()
|
||||
|
|
@ -145,6 +145,7 @@ export class BenchmarkService {
|
|||
nomad_score: result.nomad_score,
|
||||
nomad_version: SystemService.getAppVersion(),
|
||||
benchmark_version: '1.0.0',
|
||||
builder_tag: anonymous ? null : result.builder_tag,
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'benchmark_results'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('builder_tag', 64).nullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('builder_tag')
|
||||
})
|
||||
}
|
||||
}
|
||||
131
admin/inertia/components/BuilderTagSelector.tsx
Normal file
131
admin/inertia/components/BuilderTagSelector.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/outline'
|
||||
import {
|
||||
ADJECTIVES,
|
||||
NOUNS,
|
||||
generateRandomNumber,
|
||||
generateRandomBuilderTag,
|
||||
parseBuilderTag,
|
||||
buildBuilderTag,
|
||||
} from '~/lib/builderTagWords'
|
||||
|
||||
interface BuilderTagSelectorProps {
|
||||
value: string | null
|
||||
onChange: (tag: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function BuilderTagSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: BuilderTagSelectorProps) {
|
||||
const [adjective, setAdjective] = useState<string>(ADJECTIVES[0])
|
||||
const [noun, setNoun] = useState<string>(NOUNS[0])
|
||||
const [number, setNumber] = useState<string>(generateRandomNumber())
|
||||
|
||||
// Parse existing value on mount
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const parsed = parseBuilderTag(value)
|
||||
if (parsed) {
|
||||
setAdjective(parsed.adjective)
|
||||
setNoun(parsed.noun)
|
||||
setNumber(parsed.number)
|
||||
}
|
||||
} else {
|
||||
// Generate a random tag for new users
|
||||
const randomTag = generateRandomBuilderTag()
|
||||
const parsed = parseBuilderTag(randomTag)
|
||||
if (parsed) {
|
||||
setAdjective(parsed.adjective)
|
||||
setNoun(parsed.noun)
|
||||
setNumber(parsed.number)
|
||||
onChange(randomTag)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update parent when selections change
|
||||
const updateTag = (newAdjective: string, newNoun: string, newNumber: string) => {
|
||||
const tag = buildBuilderTag(newAdjective, newNoun, newNumber)
|
||||
onChange(tag)
|
||||
}
|
||||
|
||||
const handleAdjectiveChange = (newAdjective: string) => {
|
||||
setAdjective(newAdjective)
|
||||
updateTag(newAdjective, noun, number)
|
||||
}
|
||||
|
||||
const handleNounChange = (newNoun: string) => {
|
||||
setNoun(newNoun)
|
||||
updateTag(adjective, newNoun, number)
|
||||
}
|
||||
|
||||
const handleRandomize = () => {
|
||||
const newAdjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const newNoun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
const newNumber = generateRandomNumber()
|
||||
setAdjective(newAdjective)
|
||||
setNoun(newNoun)
|
||||
setNumber(newNumber)
|
||||
updateTag(newAdjective, newNoun, newNumber)
|
||||
}
|
||||
|
||||
const currentTag = buildBuilderTag(adjective, noun, number)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={adjective}
|
||||
onChange={(e) => handleAdjectiveChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="px-3 py-2 bg-desert-stone-lighter border border-desert-stone-light rounded-lg text-desert-green font-medium focus:outline-none focus:ring-2 focus:ring-desert-green disabled:opacity-50"
|
||||
>
|
||||
{ADJECTIVES.map((adj) => (
|
||||
<option key={adj} value={adj}>
|
||||
{adj}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="text-desert-stone-dark font-bold">-</span>
|
||||
|
||||
<select
|
||||
value={noun}
|
||||
onChange={(e) => handleNounChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="px-3 py-2 bg-desert-stone-lighter border border-desert-stone-light rounded-lg text-desert-green font-medium focus:outline-none focus:ring-2 focus:ring-desert-green disabled:opacity-50"
|
||||
>
|
||||
{NOUNS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="text-desert-stone-dark font-bold">-</span>
|
||||
|
||||
<span className="px-3 py-2 bg-desert-stone-lighter border border-desert-stone-light rounded-lg text-desert-green font-mono font-bold">
|
||||
{number}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRandomize}
|
||||
disabled={disabled}
|
||||
className="p-2 text-desert-stone-dark hover:text-desert-green hover:bg-desert-stone-lighter rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Randomize"
|
||||
>
|
||||
<ArrowPathIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-desert-stone-dark">Your Builder Tag:</span>
|
||||
<span className="font-mono font-bold text-desert-green">{currentTag}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
admin/inertia/lib/builderTagWords.ts
Normal file
145
admin/inertia/lib/builderTagWords.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// Builder Tag word lists for generating unique, NOMAD-themed identifiers
|
||||
// Format: [Adjective]-[Noun]-[4-digit number]
|
||||
|
||||
export const ADJECTIVES = [
|
||||
'Tactical',
|
||||
'Stealth',
|
||||
'Rogue',
|
||||
'Shadow',
|
||||
'Ghost',
|
||||
'Silent',
|
||||
'Covert',
|
||||
'Lone',
|
||||
'Nomad',
|
||||
'Digital',
|
||||
'Cyber',
|
||||
'Off-Grid',
|
||||
'Remote',
|
||||
'Arctic',
|
||||
'Desert',
|
||||
'Mountain',
|
||||
'Urban',
|
||||
'Bunker',
|
||||
'Hidden',
|
||||
'Secure',
|
||||
'Armored',
|
||||
'Fortified',
|
||||
'Mobile',
|
||||
'Solar',
|
||||
'Nuclear',
|
||||
'Storm',
|
||||
'Thunder',
|
||||
'Iron',
|
||||
'Steel',
|
||||
'Titanium',
|
||||
'Carbon',
|
||||
'Quantum',
|
||||
'Neural',
|
||||
'Alpha',
|
||||
'Omega',
|
||||
'Delta',
|
||||
'Sigma',
|
||||
'Apex',
|
||||
'Prime',
|
||||
'Elite',
|
||||
'Midnight',
|
||||
'Dawn',
|
||||
'Dusk',
|
||||
'Feral',
|
||||
'Relic',
|
||||
'Analog',
|
||||
'Hardened',
|
||||
'Vigilant',
|
||||
'Outland',
|
||||
'Frontier',
|
||||
] as const
|
||||
|
||||
export const NOUNS = [
|
||||
'Llama',
|
||||
'Wolf',
|
||||
'Bear',
|
||||
'Eagle',
|
||||
'Falcon',
|
||||
'Hawk',
|
||||
'Raven',
|
||||
'Fox',
|
||||
'Coyote',
|
||||
'Panther',
|
||||
'Cobra',
|
||||
'Viper',
|
||||
'Phoenix',
|
||||
'Dragon',
|
||||
'Sentinel',
|
||||
'Guardian',
|
||||
'Ranger',
|
||||
'Scout',
|
||||
'Survivor',
|
||||
'Prepper',
|
||||
'Nomad',
|
||||
'Wanderer',
|
||||
'Drifter',
|
||||
'Outpost',
|
||||
'Shelter',
|
||||
'Bunker',
|
||||
'Vault',
|
||||
'Cache',
|
||||
'Haven',
|
||||
'Fortress',
|
||||
'Citadel',
|
||||
'Node',
|
||||
'Hub',
|
||||
'Grid',
|
||||
'Network',
|
||||
'Signal',
|
||||
'Beacon',
|
||||
'Tower',
|
||||
'Server',
|
||||
'Cluster',
|
||||
'Array',
|
||||
'Matrix',
|
||||
'Core',
|
||||
'Nexus',
|
||||
'Archive',
|
||||
'Relay',
|
||||
'Silo',
|
||||
'Depot',
|
||||
'Bastion',
|
||||
'Homestead',
|
||||
] as const
|
||||
|
||||
export type Adjective = (typeof ADJECTIVES)[number]
|
||||
export type Noun = (typeof NOUNS)[number]
|
||||
|
||||
export function generateRandomNumber(): string {
|
||||
return String(Math.floor(Math.random() * 10000)).padStart(4, '0')
|
||||
}
|
||||
|
||||
export function generateRandomBuilderTag(): string {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
const number = generateRandomNumber()
|
||||
return `${adjective}-${noun}-${number}`
|
||||
}
|
||||
|
||||
export function parseBuilderTag(tag: string): {
|
||||
adjective: Adjective
|
||||
noun: Noun
|
||||
number: string
|
||||
} | null {
|
||||
const match = tag.match(/^(.+)-(.+)-(\d{4})$/)
|
||||
if (!match) return null
|
||||
|
||||
const [, adjective, noun, number] = match
|
||||
if (!ADJECTIVES.includes(adjective as Adjective)) return null
|
||||
if (!NOUNS.includes(noun as Noun)) return null
|
||||
|
||||
return {
|
||||
adjective: adjective as Adjective,
|
||||
noun: noun as Noun,
|
||||
number,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBuilderTag(adjective: string, noun: string, number: string): string {
|
||||
return `${adjective}-${noun}-${number}`
|
||||
}
|
||||
|
|
@ -1,18 +1,20 @@
|
|||
import { Head, Link } from '@inertiajs/react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import CircularGauge from '~/components/systeminfo/CircularGauge'
|
||||
import InfoCard from '~/components/systeminfo/InfoCard'
|
||||
import Alert from '~/components/Alert'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import InfoTooltip from '~/components/InfoTooltip'
|
||||
import BuilderTagSelector from '~/components/BuilderTagSelector'
|
||||
import {
|
||||
ChartBarIcon,
|
||||
CpuChipIcon,
|
||||
CircleStackIcon,
|
||||
ServerIcon,
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { IconRobot } from '@tabler/icons-react'
|
||||
import { useTransmit } from 'react-adonis-transmit'
|
||||
|
|
@ -29,10 +31,16 @@ export default function BenchmarkPage(props: {
|
|||
}
|
||||
}) {
|
||||
const { subscribe } = useTransmit()
|
||||
const queryClient = useQueryClient()
|
||||
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
|
||||
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showAIRequiredAlert, setShowAIRequiredAlert] = useState(false)
|
||||
const [shareAnonymously, setShareAnonymously] = useState(false)
|
||||
const [currentBuilderTag, setCurrentBuilderTag] = useState<string | null>(
|
||||
props.benchmark.latestResult?.builder_tag || null
|
||||
)
|
||||
|
||||
// Check if AI Assistant is installed
|
||||
const { data: aiInstalled } = useQuery({
|
||||
|
|
@ -61,6 +69,16 @@ export default function BenchmarkPage(props: {
|
|||
initialData: props.benchmark.latestResult,
|
||||
})
|
||||
|
||||
// Fetch all benchmark results for history
|
||||
const { data: benchmarkHistory } = useQuery({
|
||||
queryKey: ['benchmark', 'history'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/benchmark/results')
|
||||
const data = await res.json()
|
||||
return data.results as BenchmarkResult[]
|
||||
},
|
||||
})
|
||||
|
||||
// Run benchmark mutation (uses sync mode by default for simpler local dev)
|
||||
const runBenchmark = useMutation({
|
||||
mutationFn: async (type: 'full' | 'system' | 'ai') => {
|
||||
|
|
@ -118,15 +136,45 @@ export default function BenchmarkPage(props: {
|
|||
},
|
||||
})
|
||||
|
||||
// Update builder tag mutation
|
||||
const updateBuilderTag = useMutation({
|
||||
mutationFn: async ({ benchmarkId, builderTag }: { benchmarkId: string; builderTag: string }) => {
|
||||
const res = await fetch('/api/benchmark/builder-tag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ benchmark_id: benchmarkId, builder_tag: builderTag }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to update builder tag')
|
||||
}
|
||||
return data
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchLatest()
|
||||
queryClient.invalidateQueries({ queryKey: ['benchmark', 'history'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Submit to repository mutation
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const submitResult = useMutation({
|
||||
mutationFn: async (benchmarkId?: string) => {
|
||||
mutationFn: async ({ benchmarkId, anonymous }: { benchmarkId: string; anonymous: boolean }) => {
|
||||
setSubmitError(null)
|
||||
|
||||
// First, save the current builder tag to the benchmark
|
||||
if (currentBuilderTag && !anonymous) {
|
||||
await fetch('/api/benchmark/builder-tag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ benchmark_id: benchmarkId, builder_tag: currentBuilderTag }),
|
||||
})
|
||||
}
|
||||
|
||||
const res = await fetch('/api/benchmark/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ benchmark_id: benchmarkId }),
|
||||
body: JSON.stringify({ benchmark_id: benchmarkId, anonymous }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) {
|
||||
|
|
@ -136,6 +184,7 @@ export default function BenchmarkPage(props: {
|
|||
},
|
||||
onSuccess: () => {
|
||||
refetchLatest()
|
||||
queryClient.invalidateQueries({ queryKey: ['benchmark', 'history'] })
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setSubmitError(error.message)
|
||||
|
|
@ -395,12 +444,43 @@ export default function BenchmarkPage(props: {
|
|||
|
||||
{/* Share with Community - Only for full benchmarks with AI data */}
|
||||
{canShareBenchmark && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4 mt-6 pt-6 border-t border-desert-stone-light">
|
||||
<h3 className="font-semibold text-desert-green">Share with Community</h3>
|
||||
<p className="text-sm text-desert-stone-dark">
|
||||
Share your benchmark score anonymously with the NOMAD community. Only your hardware specs and scores are sent — no identifying information.
|
||||
Share your benchmark on the community leaderboard. Choose a Builder Tag to claim your spot, or share anonymously.
|
||||
</p>
|
||||
|
||||
{/* Builder Tag Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-desert-stone-dark">
|
||||
Your Builder Tag
|
||||
</label>
|
||||
<BuilderTagSelector
|
||||
value={currentBuilderTag}
|
||||
onChange={setCurrentBuilderTag}
|
||||
disabled={shareAnonymously || submitResult.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Anonymous checkbox */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareAnonymously}
|
||||
onChange={(e) => setShareAnonymously(e.target.checked)}
|
||||
disabled={submitResult.isPending}
|
||||
className="w-4 h-4 rounded border-desert-stone-light text-desert-green focus:ring-desert-green"
|
||||
/>
|
||||
<span className="text-sm text-desert-stone-dark">
|
||||
Share anonymously (no Builder Tag shown on leaderboard)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<StyledButton
|
||||
onClick={() => submitResult.mutate(latestResult.benchmark_id)}
|
||||
onClick={() => submitResult.mutate({
|
||||
benchmarkId: latestResult.benchmark_id,
|
||||
anonymous: shareAnonymously
|
||||
})}
|
||||
disabled={submitResult.isPending}
|
||||
icon='CloudArrowUpIcon'
|
||||
>
|
||||
|
|
@ -677,6 +757,10 @@ export default function BenchmarkPage(props: {
|
|||
<span className="text-desert-stone-dark">Run Date</span>
|
||||
<span>{new Date(latestResult.created_at as unknown as string).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-desert-stone-dark">Builder Tag</span>
|
||||
<span className="font-mono">{latestResult.builder_tag || 'Not set'}</span>
|
||||
</div>
|
||||
{latestResult.ai_model_used && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-desert-stone-dark">AI Model Used</span>
|
||||
|
|
@ -700,6 +784,83 @@ export default function BenchmarkPage(props: {
|
|||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benchmark History */}
|
||||
{benchmarkHistory && benchmarkHistory.length > 1 && (
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
|
||||
<div className="w-1 h-6 bg-desert-green" />
|
||||
Benchmark History
|
||||
</h2>
|
||||
|
||||
<div className="bg-desert-white rounded-lg border border-desert-stone-light shadow-sm overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-desert-stone-lighter/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="w-5 h-5 text-desert-stone-dark" />
|
||||
<span className="font-medium text-desert-green">
|
||||
{benchmarkHistory.length} benchmark{benchmarkHistory.length !== 1 ? 's' : ''} recorded
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={`w-5 h-5 text-desert-stone-dark transition-transform ${showHistory ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showHistory && (
|
||||
<div className="border-t border-desert-stone-light">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-desert-stone-lighter/50">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium text-desert-stone-dark">Date</th>
|
||||
<th className="text-left p-3 font-medium text-desert-stone-dark">Type</th>
|
||||
<th className="text-left p-3 font-medium text-desert-stone-dark">Score</th>
|
||||
<th className="text-left p-3 font-medium text-desert-stone-dark">Builder Tag</th>
|
||||
<th className="text-left p-3 font-medium text-desert-stone-dark">Shared</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-desert-stone-lighter">
|
||||
{benchmarkHistory.map((result) => (
|
||||
<tr
|
||||
key={result.benchmark_id}
|
||||
className={`hover:bg-desert-stone-lighter/30 ${
|
||||
result.benchmark_id === latestResult?.benchmark_id
|
||||
? 'bg-desert-green/5'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="p-3">
|
||||
{new Date(result.created_at as unknown as string).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="p-3 capitalize">{result.benchmark_type}</td>
|
||||
<td className="p-3">
|
||||
<span className="font-bold text-desert-green">
|
||||
{result.nomad_score.toFixed(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{result.builder_tag || '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{result.submitted_to_repository ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="text-desert-stone-dark">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ router
|
|||
router.get('/results/latest', [BenchmarkController, 'latest'])
|
||||
router.get('/results/:id', [BenchmarkController, 'show'])
|
||||
router.post('/submit', [BenchmarkController, 'submit'])
|
||||
router.post('/builder-tag', [BenchmarkController, 'updateBuilderTag'])
|
||||
router.get('/comparison', [BenchmarkController, 'comparison'])
|
||||
router.get('/status', [BenchmarkController, 'status'])
|
||||
router.get('/settings', [BenchmarkController, 'settings'])
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export type BenchmarkResultSlim = Pick<
|
|||
| 'nomad_score'
|
||||
| 'submitted_to_repository'
|
||||
| 'created_at'
|
||||
| 'builder_tag'
|
||||
> & {
|
||||
cpu_model: string
|
||||
gpu_model: string | null
|
||||
|
|
@ -113,6 +114,7 @@ export type RepositorySubmission = Pick<
|
|||
nomad_version: string
|
||||
benchmark_version: string
|
||||
ram_gb: number
|
||||
builder_tag: string | null // null = anonymous submission
|
||||
}
|
||||
|
||||
// Central repository response types
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user