Files
test_dashboard/dashboard/src/App.jsx
T

264 lines
9.9 KiB
React
Raw Normal View History

2026-06-02 11:31:11 -04:00
import { useMemo, useState } from 'react'
2026-05-20 11:52:18 -04:00
import { Settings } from 'lucide-react'
2026-06-02 11:31:11 -04:00
import { useQueryClient } from '@tanstack/react-query'
2026-05-20 11:52:18 -04:00
import { useStats } from './hooks/useStats'
import { useTests } from './hooks/useTests'
import { useConfig } from './hooks/useConfig'
2026-06-02 11:31:11 -04:00
import { useAuth } from './hooks/useAuth'
2026-05-28 11:01:47 -04:00
import cgw453Image from './assets/CGW453.PNG'
2026-05-20 11:52:18 -04:00
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() {
2026-06-02 11:31:11 -04:00
const queryClient = useQueryClient()
const { user, isReady, isAuthenticated, isAdmin, login, logout } = useAuth()
2026-05-20 11:52:18 -04:00
const [showConfig, setShowConfig] = useState(false)
const [filters, setFilters] = useState({})
2026-06-02 11:31:11 -04:00
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [isLoggingIn, setIsLoggingIn] = useState(false)
2026-05-20 11:52:18 -04:00
2026-06-02 11:31:11 -04:00
const { data: stats, isLoading: statsLoading } = useStats(isAuthenticated)
const { data: allTests = [], isLoading: testsLoading } = useTests({}, isAuthenticated)
const { data: config, isLoading: configLoading } = useConfig(isAuthenticated && isAdmin)
2026-05-20 11:52:18 -04:00
// 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])
2026-06-02 11:31:11 -04:00
const noConfig = isAdmin && !configLoading && !config?.target_dir && !config?.results_dir
const configuredButEmpty = isAdmin && !configLoading && config?.target_dir && config?.results_dir &&
2026-05-20 11:52:18 -04:00
!statsLoading && stats?.overall.total === 0
2026-06-02 11:31:11 -04:00
async function handleLogin(e) {
e.preventDefault()
setLoginError('')
setIsLoggingIn(true)
try {
await login(username.trim(), password)
setPassword('')
} catch (err) {
setLoginError(err?.message ?? 'Login failed')
} finally {
setIsLoggingIn(false)
}
}
function handleLogout() {
logout()
queryClient.clear()
setShowConfig(false)
setFilters({})
}
if (!isReady) {
return (
<div className="min-h-screen bg-slate-950 text-slate-200 flex items-center justify-center">
<p className="text-sm text-slate-400">Checking session...</p>
</div>
)
}
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-4">
<form
onSubmit={handleLogin}
className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-xl p-6 space-y-4"
>
<div>
<h1 className="text-xl font-semibold tracking-tight">Sign in</h1>
</div>
{loginError && (
<div className="bg-red-950/50 border border-red-700 rounded-lg px-3 py-2 text-red-300 text-sm">
{loginError}
</div>
)}
<label className="block text-sm text-slate-300">
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-600"
autoComplete="username"
required
/>
</label>
<label className="block text-sm text-slate-300">
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-600"
autoComplete="current-password"
required
/>
</label>
<button
type="submit"
disabled={isLoggingIn}
className="w-full rounded-lg bg-cyan-700 hover:bg-cyan-600 disabled:opacity-60 px-4 py-2 text-sm font-medium transition-colors"
>
{isLoggingIn ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
)
}
2026-05-20 11:52:18 -04:00
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">
<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">
2026-06-02 11:31:11 -04:00
<span className="text-xs text-slate-400 hidden md:inline">
{user?.username} ({user?.role})
</span>
{isAdmin && (
<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>
)}
2026-05-20 11:52:18 -04:00
<button
2026-06-02 11:31:11 -04:00
onClick={handleLogout}
className="px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
2026-05-20 11:52:18 -04:00
>
2026-06-02 11:31:11 -04:00
Logout
2026-05-20 11:52:18 -04:00
</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 && (
2026-05-28 11:01:47 -04:00
<div className="grid grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)] gap-4">
<div className="lg:row-span-2 bg-slate-900 border border-slate-800 rounded-xl p-4 flex items-center justify-center min-h-[220px]">
<img
src={cgw453Image}
alt="CGW453 test object"
className="max-h-48 w-auto object-contain"
2026-05-20 11:52:18 -04:00
/>
</div>
2026-05-28 11:01:47 -04:00
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="flex flex-col gap-2">
2026-05-20 11:52:18 -04:00
<StatCard
2026-05-28 11:01:47 -04:00
label="Overall"
value={`${((stats.overall.completionRate ?? 0) * 100).toFixed(1)}%`}
sub={`${stats.overall.completed} / ${stats.overall.total} tests`}
accent="text-emerald-400"
2026-05-20 11:52:18 -04:00
/>
2026-05-28 11:01:47 -04:00
<CompletionBar value={stats.overall.completionRate ?? 0} />
2026-05-20 11:52:18 -04:00
</div>
2026-05-28 11:01:47 -04:00
{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}`}
detailsPosition="right"
details={['COE', 'P2P', 'P3P'].map((type) => {
const typeStats = d.byType?.[type] ?? { completed: 0, total: 0 }
return `${type}: ${typeStats.completed}/${typeStats.total} tests`
})}
/>
<CompletionBar value={d.completionRate} />
</div>
))}
</div>
<div>
2026-05-20 11:52:18 -04:00
<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>
2026-06-02 11:31:11 -04:00
{isAdmin && showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
2026-05-20 11:52:18 -04:00
</div>
)
}