2026-05-20 11:52:18 -04:00
|
|
|
import { useState, useMemo } from 'react'
|
|
|
|
|
import { Settings } from 'lucide-react'
|
|
|
|
|
import { useStats } from './hooks/useStats'
|
|
|
|
|
import { useTests } from './hooks/useTests'
|
|
|
|
|
import { useConfig } from './hooks/useConfig'
|
|
|
|
|
import StatCard from './components/StatCard'
|
|
|
|
|
import CompletionBar from './components/CompletionBar'
|
|
|
|
|
import TimeDisplay from './components/TimeDisplay'
|
|
|
|
|
import FilterPanel from './components/FilterPanel'
|
|
|
|
|
import TestTable from './components/TestTable'
|
|
|
|
|
import ConfigModal from './components/ConfigModal'
|
|
|
|
|
|
2026-05-27 11:29:02 -04:00
|
|
|
function hasCoePairs(value) {
|
|
|
|
|
if (!value) return false
|
|
|
|
|
if (Array.isArray(value)) return value.length > 0
|
|
|
|
|
if (typeof value !== 'string') return false
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(value)
|
|
|
|
|
return Array.isArray(parsed) && parsed.length > 0
|
|
|
|
|
} catch {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 11:52:18 -04:00
|
|
|
export default function App() {
|
|
|
|
|
const [showConfig, setShowConfig] = useState(false)
|
|
|
|
|
const [filters, setFilters] = useState({})
|
|
|
|
|
|
|
|
|
|
const { data: stats, isLoading: statsLoading } = useStats()
|
|
|
|
|
const { data: allTests = [], isLoading: testsLoading } = useTests()
|
|
|
|
|
const { data: config, isLoading: configLoading } = useConfig()
|
|
|
|
|
|
|
|
|
|
// Apply filters client-side
|
|
|
|
|
const filteredTests = useMemo(() => {
|
|
|
|
|
return allTests.filter(t => {
|
|
|
|
|
if (filters.completed !== undefined && filters.completed !== '') {
|
|
|
|
|
const want = filters.completed === 'true' ? 1 : 0
|
|
|
|
|
if (t.completed !== want) return false
|
|
|
|
|
}
|
|
|
|
|
const strFields = ['interference', 'device', 'rotation', 'test_point',
|
2026-05-27 10:29:26 -04:00
|
|
|
'rssi', 'station', 'band', 'channel', 'bandwidth', 'direction', 'throttled']
|
2026-05-20 11:52:18 -04:00
|
|
|
for (const f of strFields) {
|
|
|
|
|
if (filters[f] && t[f] !== filters[f]) return false
|
|
|
|
|
}
|
2026-05-27 11:29:02 -04:00
|
|
|
if (filters.coePair === 'yes' || filters.coePair === 'no') {
|
|
|
|
|
const paired = hasCoePairs(t.coe_pair)
|
|
|
|
|
if (filters.coePair === 'yes' && !paired) return false
|
|
|
|
|
if (filters.coePair === 'no' && paired) return false
|
|
|
|
|
}
|
2026-05-20 11:52:18 -04:00
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
}, [allTests, filters])
|
|
|
|
|
|
|
|
|
|
const noConfig = !configLoading && !config?.target_dir && !config?.results_dir
|
|
|
|
|
const configuredButEmpty = !configLoading && config?.target_dir && config?.results_dir &&
|
|
|
|
|
!statsLoading && stats?.overall.total === 0
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col">
|
|
|
|
|
{/* Top bar */}
|
|
|
|
|
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
|
2026-05-21 14:53:08 -04:00
|
|
|
<h1 className="text-lg font-bold tracking-tight">CGW453 Test Dashboard</h1>
|
2026-05-20 11:52:18 -04:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowConfig(true)}
|
|
|
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Settings size={14} />
|
|
|
|
|
Settings
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<main className="flex-1 px-6 py-6 flex flex-col gap-6 max-w-screen-2xl mx-auto w-full">
|
|
|
|
|
|
|
|
|
|
{/* No-config prompt */}
|
|
|
|
|
{noConfig && (
|
|
|
|
|
<div className="bg-blue-950/40 border border-blue-800 rounded-xl px-5 py-4 text-blue-300 text-sm">
|
|
|
|
|
No directories configured.{' '}
|
|
|
|
|
<button onClick={() => setShowConfig(true)} className="underline hover:text-blue-100">
|
|
|
|
|
Open Settings
|
|
|
|
|
</button>{' '}
|
|
|
|
|
to point the dashboard at your target and results directories.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Configured but no tests found */}
|
|
|
|
|
{configuredButEmpty && (
|
|
|
|
|
<div className="bg-amber-950/40 border border-amber-700 rounded-xl px-5 py-4 text-amber-300 text-sm">
|
|
|
|
|
Directories are configured but no tests were found.{' '}
|
|
|
|
|
Ensure the target directory contains subdirectories with <code className="font-mono bg-amber-900/40 px-1 rounded">TC_WIFI_*.ini</code> files.{' '}
|
|
|
|
|
<button onClick={() => setShowConfig(true)} className="underline hover:text-amber-100">
|
|
|
|
|
Check Settings
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Stats row */}
|
|
|
|
|
{!statsLoading && stats && (
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<StatCard
|
|
|
|
|
label="Overall"
|
|
|
|
|
value={`${((stats.overall.completionRate ?? 0) * 100).toFixed(1)}%`}
|
|
|
|
|
sub={`${stats.overall.completed} / ${stats.overall.total} tests`}
|
|
|
|
|
accent="text-emerald-400"
|
|
|
|
|
/>
|
|
|
|
|
<CompletionBar value={stats.overall.completionRate ?? 0} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{stats.devices.map(d => (
|
|
|
|
|
<div key={d.name} className="flex flex-col gap-2">
|
|
|
|
|
<StatCard
|
|
|
|
|
label={d.name}
|
|
|
|
|
value={`${(d.completionRate * 100).toFixed(1)}%`}
|
|
|
|
|
sub={`${d.completed} / ${d.total}`}
|
2026-05-27 10:29:26 -04:00
|
|
|
detailsPosition="right"
|
|
|
|
|
details={['COE', 'P2P', 'P3P'].map((type) => {
|
|
|
|
|
const typeStats = d.byType?.[type] ?? { completed: 0, total: 0 }
|
|
|
|
|
return `${type}: ${typeStats.completed}/${typeStats.total}`
|
|
|
|
|
})}
|
2026-05-20 11:52:18 -04:00
|
|
|
/>
|
|
|
|
|
<CompletionBar value={d.completionRate} />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
<div className="col-span-2 sm:col-span-1 lg:col-span-2">
|
|
|
|
|
<TimeDisplay
|
|
|
|
|
elapsedSeconds={stats.timing.elapsedSeconds}
|
|
|
|
|
estimatedRemainingSeconds={stats.timing.estimatedRemainingSeconds}
|
|
|
|
|
byType={stats.timing.byType}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Filters + Table */}
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<FilterPanel filters={filters} onChange={setFilters} allTests={allTests} />
|
|
|
|
|
<p className="text-slate-500 text-xs">
|
|
|
|
|
Showing {filteredTests.length} of {allTests.length} tests
|
|
|
|
|
</p>
|
2026-05-27 11:29:02 -04:00
|
|
|
<TestTable tests={filteredTests} allTests={allTests} isLoading={testsLoading} />
|
2026-05-20 11:52:18 -04:00
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
{showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|