feat: refactor parsing and scanning logic for test files

- Updated `parseFilename` to `parseTargetFilename` and modified its return structure to include `test_id` instead of `file_id`.
- Introduced `parseResultFilename` to extract `test_id` and `device` from result file names.
- Enhanced `fullScan` to separately handle target and results directories, improving clarity and functionality.
- Updated database interactions to use `test_id` instead of `file_id` across various modules.
- Added a new `/rescan` endpoint to trigger a full scan of target and results directories.
- Improved logging and error handling throughout the scanning process.
- Introduced `parseTputRssi` to extract throughput and RSSI data from log files.
This commit is contained in:
2026-05-21 14:53:08 -04:00
parent ed0f93036d
commit a67815c61a
22 changed files with 9791 additions and 228 deletions
+1 -6
View File
@@ -36,11 +36,6 @@ export default function App() {
})
}, [allTests, filters])
function refresh() {
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ['tests'] })
}
const noConfig = !configLoading && !config?.target_dir && !config?.results_dir
const configuredButEmpty = !configLoading && config?.target_dir && config?.results_dir &&
!statsLoading && stats?.overall.total === 0
@@ -49,7 +44,7 @@ export default function App() {
<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">Test Dashboard</h1>
<h1 className="text-lg font-bold tracking-tight">CGW453 Test Dashboard</h1>
<div className="flex items-center gap-2">
<button
onClick={() => setShowConfig(true)}
+33 -14
View File
@@ -17,6 +17,9 @@ export default function ConfigModal({ onClose }) {
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
const [saveError, setSaveError] = useState(null)
// Directories are locked once both are saved — restart server to change them
const dirsLocked = !!(config?.target_dir && config?.results_dir)
useEffect(() => {
if (config) {
setForm({
@@ -99,7 +102,12 @@ export default function ConfigModal({ onClose }) {
<>
{/* Directories */}
<section>
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-3">Directories</h3>
<div className="flex items-center justify-between mb-3">
<h3 className="text-slate-300 text-xs uppercase tracking-widest">Directories</h3>
{dirsLocked && (
<span className="text-xs text-slate-500">🔒 Restart server to change directories</span>
)}
</div>
<div className="space-y-3">
{[
{ key: 'target_dir', label: 'Target Tests Directory' },
@@ -108,19 +116,30 @@ export default function ConfigModal({ onClose }) {
<div key={key}>
<label className="text-slate-400 text-xs block mb-1">{label}</label>
<div className="flex gap-2">
<input
type="text"
value={form[key] ?? ''}
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
placeholder="C:\path\to\folder"
className="flex-1 bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
/>
<button
onClick={() => setBrowser(key)}
className="px-3 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors whitespace-nowrap"
>
Browse
</button>
{dirsLocked ? (
<div
title={form[key] ?? ''}
className="flex-1 border border-slate-700/50 bg-slate-800/40 text-slate-500 text-sm rounded-lg px-3 py-2 truncate cursor-default select-all font-mono"
>
{(form[key] ?? '').replace(/^(.+[/\\])([^/\\]+[/\\][^/\\]*)$/, '…$2')}
</div>
) : (
<>
<input
type="text"
value={form[key] ?? ''}
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
placeholder="C:\path\to\folder"
className="flex-1 bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
/>
<button
onClick={() => setBrowser(key)}
className="px-3 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors whitespace-nowrap"
>
Browse
</button>
</>
)}
</div>
</div>
))}
+118 -63
View File
@@ -1,24 +1,14 @@
import { useState } from 'react'
import React, { useState } from 'react'
import StatusBadge from './StatusBadge'
const COLS = [
{ key: 'file_id', label: 'File ID' },
{ key: 'interference', label: 'Type' },
{ key: 'device', label: 'Device' },
{ key: 'rotation', label: 'Rotation' },
{ key: 'test_point', label: 'Test Point' },
{ key: 'rssi', label: 'RSSI' },
{ key: 'station', label: 'Station' },
{ key: 'band', label: 'Band' },
{ key: 'channel', label: 'Channel' },
{ key: 'bandwidth', label: 'BW' },
{ key: 'direction', label: 'Dir' },
{ key: 'completed', label: 'Status' },
{ key: 'duration_seconds', label: 'Duration' },
]
const TYPE_COLORS = {
COE: 'bg-blue-900/50 text-blue-300',
P2P: 'bg-purple-900/50 text-purple-300',
P3P: 'bg-orange-900/50 text-orange-300',
}
function fmtDuration(s) {
if (s == null) return '—'
if (s == null) return null
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = Math.floor(s % 60)
@@ -27,13 +17,46 @@ function fmtDuration(s) {
return `${sec}s`
}
function TagPill({ label, value, colorClass }) {
if (value == null || value === '') return null
return (
<div className="flex flex-col gap-0.5 min-w-[60px]">
<span className="text-[10px] text-slate-500 uppercase tracking-wide leading-none">{label}</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded whitespace-nowrap ${colorClass ?? 'bg-slate-800 text-slate-300'}`}>
{value}
</span>
</div>
)
}
function SortTh({ colKey, label, sort, onSort }) {
return (
<th
onClick={() => onSort(colKey)}
className="px-3 py-3 cursor-pointer select-none whitespace-nowrap hover:text-slate-200 transition-colors"
>
{label}
{sort.key === colKey && <span className="ml-1">{sort.dir === 1 ? '↑' : '↓'}</span>}
</th>
)
}
export default function TestTable({ tests = [], isLoading }) {
const [sort, setSort] = useState({ key: 'file_id', dir: 1 })
const [sort, setSort] = useState({ key: 'filename', dir: 1 })
const [expanded, setExpanded] = useState(new Set())
function toggleSort(key) {
setSort(s => ({ key, dir: s.key === key ? -s.dir : 1 }))
}
function toggleExpand(id) {
setExpanded(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const sorted = [...tests].sort((a, b) => {
let av = a[sort.key] ?? ''
let bv = b[sort.key] ?? ''
@@ -58,57 +81,89 @@ export default function TestTable({ tests = [], isLoading }) {
}
return (
<div className="overflow-x-auto rounded-xl border border-slate-800">
<div className="rounded-xl border border-slate-800 overflow-hidden">
<table className="w-full text-sm text-left text-slate-300 border-collapse">
<thead className="bg-slate-800 text-slate-400 text-xs uppercase tracking-wider">
<tr>
{COLS.map(col => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className="px-3 py-3 cursor-pointer select-none whitespace-nowrap hover:text-slate-200 transition-colors"
>
{col.label}
{sort.key === col.key && (
<span className="ml-1">{sort.dir === 1 ? '↑' : '↓'}</span>
)}
</th>
))}
<th className="px-3 py-3 w-6" />
<SortTh colKey="filename" label="File" sort={sort} onSort={toggleSort} />
<SortTh colKey="completed" label="Status" sort={sort} onSort={toggleSort} />
</tr>
</thead>
<tbody>
{sorted.map((test, i) => (
<tr
key={test.id}
className={`border-t border-slate-800 transition-colors ${
test.completed
? 'bg-emerald-950/20 hover:bg-emerald-950/40'
: i % 2 === 0 ? 'bg-slate-900 hover:bg-slate-800' : 'bg-slate-900/60 hover:bg-slate-800'
}`}
>
<td className="px-3 py-2 font-mono text-xs text-slate-200 whitespace-nowrap">{test.file_id ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${
test.interference === 'COE' ? 'bg-blue-900/50 text-blue-300' :
test.interference === 'P2P' ? 'bg-purple-900/50 text-purple-300' :
test.interference === 'P3P' ? 'bg-orange-900/50 text-orange-300' : ''
}`}>
{test.interference ?? '—'}
</span>
</td>
<td className="px-3 py-2 whitespace-nowrap">{test.device ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.rotation ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.test_point ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.rssi ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.station ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.band ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.channel ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.bandwidth ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.direction ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap"><StatusBadge completed={test.completed} /></td>
<td className="px-3 py-2 whitespace-nowrap text-slate-400">{fmtDuration(test.duration_seconds)}</td>
</tr>
))}
{sorted.map((test, i) => {
const isOpen = expanded.has(test.id)
const rowBase = test.completed
? 'bg-emerald-950/20 hover:bg-emerald-950/40'
: i % 2 === 0 ? 'bg-slate-900 hover:bg-slate-800' : 'bg-slate-900/60 hover:bg-slate-800'
return (
<React.Fragment key={test.id}>
<tr
onClick={() => toggleExpand(test.id)}
className={`border-t border-slate-800 cursor-pointer transition-colors ${rowBase}`}
>
<td className="px-3 py-2 text-slate-500 text-xs select-none">
{isOpen ? '' : '▸'}
</td>
<td className="px-3 py-2 text-xs text-slate-200 whitespace-nowrap">
{test.filename ?? '—'}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<StatusBadge completed={test.completed} />
</td>
</tr>
{isOpen && (
<tr className={`border-t border-slate-700/60 ${test.completed ? 'bg-emerald-950/10' : 'bg-slate-900/80'}`}>
<td colSpan={3} className="px-6 py-4">
<div className="flex flex-wrap gap-3">
<TagPill label="File ID" value={test.test_id} />
<TagPill label="Type" value={test.interference} colorClass={TYPE_COLORS[test.interference]} />
<TagPill label="Device" value={test.device} />
<TagPill label="Rotation" value={test.rotation} />
<TagPill label="Test Point" value={test.test_point} />
<TagPill label="RSSI" value={test.rssi} />
<TagPill label="Station" value={test.station} />
<TagPill label="Band" value={test.band} />
<TagPill label="Channel" value={test.channel} />
<TagPill label="Bandwidth" value={test.bandwidth} />
<TagPill label="Direction" value={test.direction} />
<TagPill label="Elapsed Time" value={fmtDuration(test.duration_seconds)} />
</div>
{(() => {
const rows = test.tput_results ? JSON.parse(test.tput_results) : []
if (!test.completed || rows.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-slate-700/50">
<table className="text-xs w-auto border-collapse">
<thead>
<tr className="text-slate-500 uppercase tracking-wide">
<th className="pr-6 pb-1 text-left font-medium">Station</th>
<th className="pr-6 pb-1 text-right font-medium">Throughput</th>
<th className="pr-6 pb-1 text-right font-medium">DL RSSI</th>
<th className="pb-1 text-right font-medium">UL RSSI</th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.station} className="text-slate-300">
<td className="pr-6 py-0.5 text-cyan-400 font-medium">STA{r.station}</td>
<td className="pr-6 py-0.5 text-right">{r.tput} Mbps</td>
<td className="pr-6 py-0.5 text-right">{r.dlRssi} dBm</td>
<td className="py-0.5 text-right">{r.ulRssi} dBm</td>
</tr>
))}
</tbody>
</table>
</div>
)
})()}
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
+27 -2
View File
@@ -6,6 +6,19 @@ function fmt(seconds) {
return `${m}m`
}
function fmtDays(seconds) {
if (seconds == null) return null
const days = seconds / 57600 // 16 hours per day
return days < 1 ? `${(days * 24).toFixed(1)}h` : `${days.toFixed(1)}d (16h/day)`
}
function fmtCompletionDate(seconds) {
if (seconds == null) return null
const days = seconds / 57600 // 16 hours per day
const date = new Date(Date.now() + days * 24 * 3600 * 1000)
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
}
export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds, byType }) {
const missingTypes = byType
? Object.entries(byType)
@@ -13,9 +26,12 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,
.map(([t]) => t)
: []
const days = fmtDays(estimatedRemainingSeconds)
const completionDate = fmtCompletionDate(estimatedRemainingSeconds)
return (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-3">
<div className="flex gap-6">
<div className="flex flex-wrap gap-6">
<div>
<p className="text-slate-400 text-xs uppercase tracking-widest">Time Elapsed</p>
<p className="text-2xl font-bold text-slate-100 mt-0.5">
@@ -23,11 +39,20 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,
</p>
</div>
<div>
<p className="text-slate-400 text-xs uppercase tracking-widest">Est. Remaining</p>
<p className="text-slate-400 text-xs uppercase tracking-widest">Est. Time Remaining</p>
<p className={`text-2xl font-bold mt-0.5 ${estimatedRemainingSeconds != null ? 'text-slate-100' : 'text-amber-400'}`}>
{estimatedRemainingSeconds != null ? fmt(estimatedRemainingSeconds) : '—'}
</p>
{days && (
<p className="text-slate-400 text-xs mt-0.5">{days}</p>
)}
</div>
{completionDate && (
<div>
<p className="text-slate-400 text-xs uppercase tracking-widest">Est. Completion</p>
<p className="text-lg font-semibold text-slate-100 mt-0.5">{completionDate}</p>
</div>
)}
</div>
{missingTypes.length > 0 && (