mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-03 07:19:27 +02:00
- Add i18next, react-i18next, i18next-browser-languagedetector packages - Configure i18n initialization with language detector in lib/i18n.ts - Created en/de translation files and moved most hard-coded strings into the files and translated them - Uses locale-aware date formatting where applicable - Added language-specific Wikipedia content files (wikipedia.en.json, wikipedia.de.json) and updated download URLs - Added NOMAD_REPO_URL env variable for fork-friendly URL resolution (easier testing and rollout independent of Crosstalk repo)
990 lines
42 KiB
TypeScript
990 lines
42 KiB
TypeScript
import { Head, Link, usePage } from '@inertiajs/react'
|
|
import { useState, useEffect, useRef } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import SettingsLayout from '~/layouts/SettingsLayout'
|
|
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 {
|
|
IconRobot,
|
|
IconChartBar,
|
|
IconCpu,
|
|
IconDatabase,
|
|
IconServer,
|
|
IconChevronDown,
|
|
IconClock,
|
|
} from '@tabler/icons-react'
|
|
import { useTransmit } from 'react-adonis-transmit'
|
|
import { BenchmarkProgress, BenchmarkStatus } from '../../../types/benchmark'
|
|
import BenchmarkResult from '#models/benchmark_result'
|
|
import api from '~/lib/api'
|
|
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
|
import { SERVICE_NAMES } from '../../../constants/service_names'
|
|
import { BROADCAST_CHANNELS } from '../../../constants/broadcast'
|
|
|
|
type BenchmarkProgressWithID = BenchmarkProgress & { benchmark_id: string }
|
|
|
|
export default function BenchmarkPage(props: {
|
|
benchmark: {
|
|
latestResult: BenchmarkResult | null
|
|
status: BenchmarkStatus
|
|
currentBenchmarkId: string | null
|
|
}
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
|
|
const { subscribe } = useTransmit()
|
|
const queryClient = useQueryClient()
|
|
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
|
|
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
|
|
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
|
|
const refetchLatestRef = useRef<(() => void) | null>(null)
|
|
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
|
|
)
|
|
|
|
// Fetch latest result
|
|
const { data: latestResult, refetch: refetchLatest } = useQuery({
|
|
queryKey: ['benchmark', 'latest'],
|
|
queryFn: async () => {
|
|
const res = await api.getLatestBenchmarkResult()
|
|
if (res && res.result) {
|
|
return res.result
|
|
}
|
|
return null
|
|
},
|
|
initialData: props.benchmark.latestResult,
|
|
})
|
|
refetchLatestRef.current = refetchLatest
|
|
|
|
// Fetch all benchmark results for history
|
|
const { data: benchmarkHistory } = useQuery({
|
|
queryKey: ['benchmark', 'history'],
|
|
queryFn: async () => {
|
|
const res = await api.getBenchmarkResults()
|
|
if (res && res.results && Array.isArray(res.results)) {
|
|
return res.results
|
|
}
|
|
return []
|
|
},
|
|
})
|
|
|
|
// Run benchmark mutation (uses sync mode by default for simpler local dev)
|
|
const runBenchmark = useMutation({
|
|
mutationFn: async (type: 'full' | 'system' | 'ai') => {
|
|
setIsRunning(true)
|
|
setProgress({
|
|
status: 'starting',
|
|
progress: 5,
|
|
message: t('benchmark.progress.starting'),
|
|
current_stage: t('benchmark.stages.starting'),
|
|
benchmark_id: '',
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
|
|
// Use sync mode - runs inline without needing Redis/queue worker
|
|
return await api.runBenchmark(type, true)
|
|
},
|
|
onSuccess: (data) => {
|
|
if (data?.success) {
|
|
setProgress({
|
|
status: 'completed',
|
|
progress: 100,
|
|
message: t('benchmark.progress.completed'),
|
|
current_stage: t('benchmark.stages.complete'),
|
|
benchmark_id: data.benchmark_id,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
refetchLatest()
|
|
} else {
|
|
setProgress({
|
|
status: 'error',
|
|
progress: 0,
|
|
message: t('benchmark.progress.failed'),
|
|
current_stage: t('benchmark.stages.error'),
|
|
benchmark_id: '',
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
}
|
|
setIsRunning(false)
|
|
},
|
|
onError: (error) => {
|
|
setProgress({
|
|
status: 'error',
|
|
progress: 0,
|
|
message: error.message || t('benchmark.progress.failed'),
|
|
current_stage: t('benchmark.stages.error'),
|
|
benchmark_id: '',
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
setIsRunning(false)
|
|
},
|
|
})
|
|
|
|
// Update builder tag mutation
|
|
const updateBuilderTag = useMutation({
|
|
mutationFn: async ({
|
|
benchmarkId,
|
|
builderTag,
|
|
}: {
|
|
benchmarkId: string
|
|
builderTag: string
|
|
invalidate?: boolean
|
|
}) => {
|
|
const res = await api.updateBuilderTag(benchmarkId, builderTag)
|
|
if (!res || !res.success) {
|
|
throw new Error(res?.error || 'Failed to update builder tag')
|
|
}
|
|
return res
|
|
},
|
|
onSuccess: (_, variables) => {
|
|
if (variables.invalidate) {
|
|
refetchLatest()
|
|
queryClient.invalidateQueries({ queryKey: ['benchmark', 'history'] })
|
|
}
|
|
},
|
|
})
|
|
|
|
// Submit to repository mutation
|
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
|
const submitResult = useMutation({
|
|
mutationFn: async ({ benchmarkId, anonymous }: { benchmarkId: string; anonymous: boolean }) => {
|
|
setSubmitError(null)
|
|
|
|
// First, save the current builder tag to the benchmark (don't refetch yet)
|
|
if (currentBuilderTag && !anonymous) {
|
|
await updateBuilderTag.mutateAsync({
|
|
benchmarkId,
|
|
builderTag: currentBuilderTag,
|
|
invalidate: false,
|
|
})
|
|
}
|
|
|
|
const res = await api.submitBenchmark(benchmarkId, anonymous)
|
|
if (!res || !res.success) {
|
|
throw new Error(res?.error || 'Failed to submit benchmark')
|
|
}
|
|
return res
|
|
},
|
|
onSuccess: () => {
|
|
refetchLatest()
|
|
queryClient.invalidateQueries({ queryKey: ['benchmark', 'history'] })
|
|
},
|
|
onError: (error: any) => {
|
|
// Check if this is a 409 Conflict error (already submitted)
|
|
if (error.status === 409) {
|
|
setSubmitError(t('benchmark.alreadySubmitted'))
|
|
} else {
|
|
setSubmitError(error.message)
|
|
}
|
|
},
|
|
})
|
|
|
|
// Check if the latest result is a full benchmark with AI data (eligible for sharing)
|
|
const canShareBenchmark =
|
|
latestResult &&
|
|
latestResult.benchmark_type === 'full' &&
|
|
latestResult.ai_tokens_per_second !== null &&
|
|
latestResult.ai_tokens_per_second > 0 &&
|
|
!latestResult.submitted_to_repository
|
|
|
|
// Handle Full Benchmark click with pre-flight check
|
|
const handleFullBenchmarkClick = () => {
|
|
if (!aiInstalled) {
|
|
setShowAIRequiredAlert(true)
|
|
return
|
|
}
|
|
setShowAIRequiredAlert(false)
|
|
runBenchmark.mutate('full')
|
|
}
|
|
|
|
// Simulate progress during sync benchmark (since we don't get SSE updates)
|
|
useEffect(() => {
|
|
if (!isRunning || progress?.status === 'completed' || progress?.status === 'error') return
|
|
|
|
const stages: {
|
|
status: BenchmarkStatus
|
|
progress: number
|
|
message: string
|
|
label: string
|
|
duration: number
|
|
}[] = [
|
|
{
|
|
status: 'detecting_hardware',
|
|
progress: 10,
|
|
message: t('benchmark.progress.detectingHardware'),
|
|
label: t('benchmark.stages.detectingHardware'),
|
|
duration: 2000,
|
|
},
|
|
{
|
|
status: 'running_cpu',
|
|
progress: 25,
|
|
message: t('benchmark.progress.runningCpu'),
|
|
label: t('benchmark.stages.cpuBenchmark'),
|
|
duration: 32000,
|
|
},
|
|
{
|
|
status: 'running_memory',
|
|
progress: 40,
|
|
message: t('benchmark.progress.runningMemory'),
|
|
label: t('benchmark.stages.memoryBenchmark'),
|
|
duration: 8000,
|
|
},
|
|
{
|
|
status: 'running_disk_read',
|
|
progress: 55,
|
|
message: t('benchmark.progress.runningDiskRead'),
|
|
label: t('benchmark.stages.diskReadTest'),
|
|
duration: 35000,
|
|
},
|
|
{
|
|
status: 'running_disk_write',
|
|
progress: 70,
|
|
message: t('benchmark.progress.runningDiskWrite'),
|
|
label: t('benchmark.stages.diskWriteTest'),
|
|
duration: 35000,
|
|
},
|
|
{
|
|
status: 'downloading_ai_model',
|
|
progress: 80,
|
|
message: t('benchmark.progress.downloadingAiModel'),
|
|
label: t('benchmark.stages.downloadingAiModel'),
|
|
duration: 5000,
|
|
},
|
|
{
|
|
status: 'running_ai',
|
|
progress: 85,
|
|
message: t('benchmark.progress.runningAi'),
|
|
label: t('benchmark.stages.aiInferenceTest'),
|
|
duration: 15000,
|
|
},
|
|
{
|
|
status: 'calculating_score',
|
|
progress: 95,
|
|
message: t('benchmark.progress.calculatingScore'),
|
|
label: t('benchmark.stages.calculatingScore'),
|
|
duration: 2000,
|
|
},
|
|
]
|
|
|
|
let currentStage = 0
|
|
const advanceStage = () => {
|
|
if (currentStage < stages.length && isRunning) {
|
|
const stage = stages[currentStage]
|
|
setProgress({
|
|
status: stage.status,
|
|
progress: stage.progress,
|
|
message: stage.message,
|
|
current_stage: stage.label,
|
|
benchmark_id: '',
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
currentStage++
|
|
}
|
|
}
|
|
|
|
// Start the first stage after a short delay
|
|
const timers: NodeJS.Timeout[] = []
|
|
let elapsed = 1000
|
|
stages.forEach((stage) => {
|
|
timers.push(setTimeout(() => advanceStage(), elapsed))
|
|
elapsed += stage.duration
|
|
})
|
|
|
|
return () => {
|
|
timers.forEach((t) => clearTimeout(t))
|
|
}
|
|
}, [isRunning])
|
|
|
|
// Listen for benchmark progress via SSE (backup for async mode)
|
|
useEffect(() => {
|
|
const unsubscribe = subscribe(BROADCAST_CHANNELS.BENCHMARK_PROGRESS, (data: BenchmarkProgressWithID) => {
|
|
setProgress(data)
|
|
if (data.status === 'completed' || data.status === 'error') {
|
|
setIsRunning(false)
|
|
refetchLatestRef.current?.()
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
unsubscribe()
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [subscribe])
|
|
|
|
const formatBytes = (bytes: number) => {
|
|
const gb = bytes / (1024 * 1024 * 1024)
|
|
return `${gb.toFixed(1)} GB`
|
|
}
|
|
|
|
const getScoreColor = (score: number) => {
|
|
if (score >= 70) return 'text-green-600'
|
|
if (score >= 40) return 'text-yellow-600'
|
|
return 'text-red-600'
|
|
}
|
|
|
|
const getProgressPercent = () => {
|
|
if (!progress) return 0
|
|
const stages: Record<BenchmarkStatus, number> = {
|
|
idle: 0,
|
|
starting: 5,
|
|
detecting_hardware: 10,
|
|
running_cpu: 25,
|
|
running_memory: 40,
|
|
running_disk_read: 55,
|
|
running_disk_write: 70,
|
|
downloading_ai_model: 80,
|
|
running_ai: 85,
|
|
calculating_score: 95,
|
|
completed: 100,
|
|
error: 0,
|
|
}
|
|
return stages[progress.status] || 0
|
|
}
|
|
|
|
// Calculate AI score from tokens per second (normalized to 0-100)
|
|
// Reference: 30 tok/s = 50 score, 60 tok/s = 100 score
|
|
const getAIScore = (tokensPerSecond: number | null): number => {
|
|
if (!tokensPerSecond) return 0
|
|
const score = (tokensPerSecond / 60) * 100
|
|
return Math.min(100, Math.max(0, score))
|
|
}
|
|
|
|
return (
|
|
<SettingsLayout>
|
|
<Head title={t('benchmark.title')} />
|
|
<div className="xl:pl-72 w-full">
|
|
<main className="px-6 lg:px-12 py-6 lg:py-8">
|
|
<div className="mb-8">
|
|
<h1 className="text-4xl font-bold text-desert-green mb-2">{t('benchmark.heading')}</h1>
|
|
<p className="text-desert-stone-dark">
|
|
{t('benchmark.description')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Run Benchmark Section */}
|
|
<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" />
|
|
{t('benchmark.runBenchmark')}
|
|
</h2>
|
|
|
|
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm">
|
|
{isRunning ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="animate-spin h-6 w-6 border-2 border-desert-green border-t-transparent rounded-full" />
|
|
<span className="text-lg font-medium">
|
|
{progress?.current_stage || t('benchmark.runningBenchmark')}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-desert-stone-lighter rounded-full h-4 overflow-hidden">
|
|
<div
|
|
className="bg-desert-green h-full transition-all duration-500"
|
|
style={{ width: `${getProgressPercent()}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-desert-stone-dark">{progress?.message}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{progress?.status === 'error' && (
|
|
<Alert
|
|
type="error"
|
|
title={t('benchmark.benchmarkFailed')}
|
|
message={progress.message}
|
|
variant="bordered"
|
|
dismissible
|
|
onDismiss={() => setProgress(null)}
|
|
/>
|
|
)}
|
|
{showAIRequiredAlert && (
|
|
<Alert
|
|
type="warning"
|
|
title={t('benchmark.aiRequired', { name: aiAssistantName })}
|
|
message={t('benchmark.aiRequiredMessage', { name: aiAssistantName })}
|
|
variant="bordered"
|
|
dismissible
|
|
onDismiss={() => setShowAIRequiredAlert(false)}
|
|
>
|
|
<Link
|
|
href="/settings/apps"
|
|
className="text-sm text-desert-green hover:underline mt-2 inline-block font-medium"
|
|
>
|
|
{t('benchmark.goToApps', { name: aiAssistantName })}
|
|
</Link>
|
|
</Alert>
|
|
)}
|
|
<p className="text-desert-stone-dark">
|
|
{t('benchmark.benchmarkDescription')}
|
|
</p>
|
|
<div className="flex flex-wrap gap-4">
|
|
<StyledButton
|
|
onClick={handleFullBenchmarkClick}
|
|
disabled={runBenchmark.isPending}
|
|
icon="IconPlayerPlay"
|
|
>
|
|
{t('benchmark.runFullBenchmark')}
|
|
</StyledButton>
|
|
<StyledButton
|
|
variant="secondary"
|
|
onClick={() => runBenchmark.mutate('system')}
|
|
disabled={runBenchmark.isPending}
|
|
icon="IconCpu"
|
|
>
|
|
{t('benchmark.systemOnly')}
|
|
</StyledButton>
|
|
<StyledButton
|
|
variant="secondary"
|
|
onClick={() => runBenchmark.mutate('ai')}
|
|
disabled={runBenchmark.isPending || !aiInstalled}
|
|
icon="IconWand"
|
|
title={
|
|
!aiInstalled
|
|
? t('benchmark.aiOnlyTooltip', { name: aiAssistantName })
|
|
: undefined
|
|
}
|
|
>
|
|
{t('benchmark.aiOnly')}
|
|
</StyledButton>
|
|
</div>
|
|
{!aiInstalled && (
|
|
<p className="text-sm text-desert-stone-dark">
|
|
<span className="text-amber-600">Note:</span> {t('benchmark.aiNotInstalledNote', { name: aiAssistantName })}
|
|
<Link
|
|
href="/settings/apps"
|
|
className="text-desert-green hover:underline ml-1"
|
|
>
|
|
{t('benchmark.installIt')}
|
|
</Link>{' '}
|
|
{t('benchmark.aiNotInstalledSuffix')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Results Section */}
|
|
{latestResult && (
|
|
<>
|
|
<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" />
|
|
{t('benchmark.nomadScore')}
|
|
</h2>
|
|
|
|
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm">
|
|
<div className="flex flex-col md:flex-row items-center gap-8">
|
|
<div className="shrink-0">
|
|
<CircularGauge
|
|
value={latestResult.nomad_score}
|
|
label="NOMAD Score"
|
|
size="lg"
|
|
variant="cpu"
|
|
subtext={t('benchmark.outOf100')}
|
|
icon={<IconChartBar className="w-8 h-8" />}
|
|
/>
|
|
</div>
|
|
<div className="flex-1 space-y-4">
|
|
<div
|
|
className={`text-5xl font-bold ${getScoreColor(latestResult.nomad_score)}`}
|
|
>
|
|
{latestResult.nomad_score.toFixed(1)}
|
|
</div>
|
|
<p className="text-desert-stone-dark">
|
|
{t('benchmark.nomadScoreDescription')}
|
|
</p>
|
|
|
|
{/* Share with Community - Only for full benchmarks with AI data */}
|
|
{canShareBenchmark && (
|
|
<div className="space-y-4 mt-6 pt-6 border-t border-desert-stone-light">
|
|
<h3 className="font-semibold text-desert-green">{t('benchmark.shareWithCommunity')}</h3>
|
|
<p className="text-sm text-desert-stone-dark">
|
|
{t('benchmark.shareDescription')}
|
|
</p>
|
|
|
|
{/* Builder Tag Selector */}
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-desert-stone-dark">
|
|
{t('benchmark.yourBuilderTag')}
|
|
</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">
|
|
{t('benchmark.shareAnonymously')}
|
|
</span>
|
|
</label>
|
|
|
|
<StyledButton
|
|
onClick={() =>
|
|
submitResult.mutate({
|
|
benchmarkId: latestResult.benchmark_id,
|
|
anonymous: shareAnonymously,
|
|
})
|
|
}
|
|
disabled={submitResult.isPending}
|
|
icon="IconCloudUpload"
|
|
>
|
|
{submitResult.isPending ? t('benchmark.submitting') : t('benchmark.shareButton')}
|
|
</StyledButton>
|
|
{submitError && (
|
|
<Alert
|
|
type="error"
|
|
title={t('benchmark.submissionFailed')}
|
|
message={submitError}
|
|
variant="bordered"
|
|
dismissible
|
|
onDismiss={() => setSubmitError(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Show message for partial benchmarks */}
|
|
{latestResult &&
|
|
!latestResult.submitted_to_repository &&
|
|
!canShareBenchmark && (
|
|
<Alert
|
|
type="info"
|
|
title={t('benchmark.partialBenchmark')}
|
|
message={t('benchmark.partialBenchmarkMessage', { type: latestResult.benchmark_type, name: aiAssistantName })}
|
|
variant="bordered"
|
|
/>
|
|
)}
|
|
|
|
{latestResult.submitted_to_repository && (
|
|
<Alert
|
|
type="success"
|
|
title={t('benchmark.sharedWithCommunity')}
|
|
message={t('benchmark.sharedMessage')}
|
|
variant="bordered"
|
|
>
|
|
<a
|
|
href="https://benchmark.projectnomad.us"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-desert-green hover:underline mt-2 inline-block"
|
|
>
|
|
{t('benchmark.viewLeaderboard')}
|
|
</a>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<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" />
|
|
{t('benchmark.systemPerformance')}
|
|
</h2>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
|
|
<CircularGauge
|
|
value={latestResult.cpu_score * 100}
|
|
label={t('benchmark.cpu')}
|
|
size="md"
|
|
variant="cpu"
|
|
icon={<IconCpu className="w-6 h-6" />}
|
|
/>
|
|
</div>
|
|
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
|
|
<CircularGauge
|
|
value={latestResult.memory_score * 100}
|
|
label={t('benchmark.memory')}
|
|
size="md"
|
|
variant="memory"
|
|
icon={<IconDatabase className="w-6 h-6" />}
|
|
/>
|
|
</div>
|
|
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
|
|
<CircularGauge
|
|
value={latestResult.disk_read_score * 100}
|
|
label={t('benchmark.diskRead')}
|
|
size="md"
|
|
variant="disk"
|
|
icon={<IconServer className="w-6 h-6" />}
|
|
/>
|
|
</div>
|
|
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
|
|
<CircularGauge
|
|
value={latestResult.disk_write_score * 100}
|
|
label={t('benchmark.diskWrite')}
|
|
size="md"
|
|
variant="disk"
|
|
icon={<IconServer className="w-6 h-6" />}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* AI Performance Section */}
|
|
<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" />
|
|
{t('benchmark.aiPerformance')}
|
|
</h2>
|
|
|
|
{latestResult.ai_tokens_per_second ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
|
|
<CircularGauge
|
|
value={getAIScore(latestResult.ai_tokens_per_second)}
|
|
label={t('benchmark.aiScore')}
|
|
size="md"
|
|
variant="cpu"
|
|
icon={<IconRobot className="w-6 h-6" />}
|
|
/>
|
|
</div>
|
|
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm flex items-center justify-center">
|
|
<div className="flex items-center gap-4">
|
|
<IconRobot className="w-10 h-10 text-desert-green" />
|
|
<div>
|
|
<div className="text-3xl font-bold text-desert-green">
|
|
{latestResult.ai_tokens_per_second.toFixed(1)}
|
|
</div>
|
|
<div className="text-sm text-desert-stone-dark flex items-center gap-1">
|
|
{t('benchmark.tokensPerSecond')}
|
|
<InfoTooltip text={t('benchmark.tokensPerSecondTooltip')} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm flex items-center justify-center">
|
|
<div className="flex items-center gap-4">
|
|
<IconRobot className="w-10 h-10 text-desert-green" />
|
|
<div>
|
|
<div className="text-3xl font-bold text-desert-green">
|
|
{latestResult.ai_time_to_first_token?.toFixed(0) || 'N/A'} ms
|
|
</div>
|
|
<div className="text-sm text-desert-stone-dark flex items-center gap-1">
|
|
{t('benchmark.timeToFirstToken')}
|
|
<InfoTooltip text={t('benchmark.timeToFirstTokenTooltip')} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
|
|
<div className="text-center text-desert-stone-dark">
|
|
<IconRobot className="w-12 h-12 mx-auto mb-3 opacity-40" />
|
|
<p className="font-medium">{t('benchmark.noAIData')}</p>
|
|
<p className="text-sm mt-1">
|
|
{t('benchmark.noAIDataMessage')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<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" />
|
|
{t('benchmark.hardwareInformation')}
|
|
</h2>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<InfoCard
|
|
title={t('benchmark.processor')}
|
|
icon={<IconCpu className="w-6 h-6" />}
|
|
variant="elevated"
|
|
data={[
|
|
{ label: t('benchmark.modelLabel'), value: latestResult.cpu_model },
|
|
{ label: t('benchmark.cores'), value: latestResult.cpu_cores },
|
|
{ label: t('benchmark.threads'), value: latestResult.cpu_threads },
|
|
]}
|
|
/>
|
|
<InfoCard
|
|
title={t('benchmark.systemLabel')}
|
|
icon={<IconServer className="w-6 h-6" />}
|
|
variant="elevated"
|
|
data={[
|
|
{ label: t('benchmark.ram'), value: formatBytes(latestResult.ram_bytes) },
|
|
{ label: t('benchmark.diskType'), value: latestResult.disk_type.toUpperCase() },
|
|
{ label: t('benchmark.gpu'), value: latestResult.gpu_model || t('benchmark.notDetected') },
|
|
]}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<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" />
|
|
{t('benchmark.benchmarkDetails')}
|
|
</h2>
|
|
|
|
<div className="bg-desert-white rounded-lg border border-desert-stone-light shadow-sm overflow-hidden">
|
|
{/* Summary row - always visible */}
|
|
<button
|
|
onClick={() => setShowDetails(!showDetails)}
|
|
className="w-full p-6 flex items-center justify-between hover:bg-desert-stone-lighter/30 transition-colors"
|
|
>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-left flex-1">
|
|
<div>
|
|
<div className="text-desert-stone-dark">{t('benchmark.benchmarkId')}</div>
|
|
<div className="font-mono text-xs">
|
|
{latestResult.benchmark_id.slice(0, 8)}...
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-desert-stone-dark">{t('benchmark.type')}</div>
|
|
<div className="capitalize">{latestResult.benchmark_type}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-desert-stone-dark">{t('benchmark.date')}</div>
|
|
<div>
|
|
{new Date(
|
|
latestResult.created_at as unknown as string
|
|
).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-desert-stone-dark">{t('benchmark.nomadScore')}</div>
|
|
<div className="font-bold text-desert-green">
|
|
{latestResult.nomad_score.toFixed(1)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<IconChevronDown
|
|
className={`w-5 h-5 text-desert-stone-dark transition-transform ${showDetails ? 'rotate-180' : ''}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Expanded details */}
|
|
{showDetails && (
|
|
<div className="border-t border-desert-stone-light p-6 bg-desert-stone-lighter/20">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Raw Scores */}
|
|
<div>
|
|
<h4 className="font-semibold text-desert-green mb-3">{t('benchmark.rawScores')}</h4>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.cpuScore')}</span>
|
|
<span className="font-mono">
|
|
{(latestResult.cpu_score * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.memoryScore')}</span>
|
|
<span className="font-mono">
|
|
{(latestResult.memory_score * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.diskReadScore')}</span>
|
|
<span className="font-mono">
|
|
{(latestResult.disk_read_score * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.diskWriteScore')}</span>
|
|
<span className="font-mono">
|
|
{(latestResult.disk_write_score * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
{latestResult.ai_tokens_per_second && (
|
|
<>
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.aiTokensSec')}</span>
|
|
<span className="font-mono">
|
|
{latestResult.ai_tokens_per_second.toFixed(1)}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">
|
|
{t('benchmark.aiTimeToFirstToken')}
|
|
</span>
|
|
<span className="font-mono">
|
|
{latestResult.ai_time_to_first_token?.toFixed(0) || 'N/A'} ms
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Benchmark Info */}
|
|
<div>
|
|
<h4 className="font-semibold text-desert-green mb-3">{t('benchmark.benchmarkInfo')}</h4>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.fullBenchmarkId')}</span>
|
|
<span className="font-mono text-xs">{latestResult.benchmark_id}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.benchmarkType')}</span>
|
|
<span className="capitalize">{latestResult.benchmark_type}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.runDate')}</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">{t('benchmark.builderTag')}</span>
|
|
<span className="font-mono">
|
|
{latestResult.builder_tag || t('benchmark.notSet')}
|
|
</span>
|
|
</div>
|
|
{latestResult.ai_model_used && (
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.aiModelUsed')}</span>
|
|
<span>{latestResult.ai_model_used}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">
|
|
{t('benchmark.submittedToRepository')}
|
|
</span>
|
|
<span>{latestResult.submitted_to_repository ? t('benchmark.yes') : t('benchmark.no')}</span>
|
|
</div>
|
|
{latestResult.repository_id && (
|
|
<div className="flex justify-between">
|
|
<span className="text-desert-stone-dark">{t('benchmark.repositoryId')}</span>
|
|
<span className="font-mono text-xs">
|
|
{latestResult.repository_id}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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" />
|
|
{t('benchmark.benchmarkHistory')}
|
|
</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">
|
|
<IconClock className="w-5 h-5 text-desert-stone-dark" />
|
|
<span className="font-medium text-desert-green">
|
|
{t('benchmark.benchmarksRecorded', { count: benchmarkHistory.length, plural: benchmarkHistory.length !== 1 ? 's' : '' })}
|
|
</span>
|
|
</div>
|
|
<IconChevronDown
|
|
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">
|
|
{t('benchmark.date')}
|
|
</th>
|
|
<th className="text-left p-3 font-medium text-desert-stone-dark">
|
|
{t('benchmark.type')}
|
|
</th>
|
|
<th className="text-left p-3 font-medium text-desert-stone-dark">
|
|
{t('benchmark.score')}
|
|
</th>
|
|
<th className="text-left p-3 font-medium text-desert-stone-dark">
|
|
{t('benchmark.builderTag')}
|
|
</th>
|
|
<th className="text-left p-3 font-medium text-desert-stone-dark">
|
|
{t('benchmark.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>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!latestResult && !isRunning && (
|
|
<Alert
|
|
type="info"
|
|
title={t('benchmark.noBenchmarkResults')}
|
|
message={t('benchmark.noBenchmarkResultsMessage')}
|
|
variant="bordered"
|
|
/>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</SettingsLayout>
|
|
)
|
|
}
|