Merge pull request #2 from Mia-Wu_wnc/python-migration

Fixed Network path, added COE pairings and P3P pairings
This commit is contained in:
2026-05-27 16:10:21 -04:00
committed by GitHub
16 changed files with 851 additions and 90 deletions
+24 -4
View File
@@ -1,5 +1,4 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings } from 'lucide-react' import { Settings } from 'lucide-react'
import { useStats } from './hooks/useStats' import { useStats } from './hooks/useStats'
import { useTests } from './hooks/useTests' import { useTests } from './hooks/useTests'
@@ -11,8 +10,19 @@ import FilterPanel from './components/FilterPanel'
import TestTable from './components/TestTable' import TestTable from './components/TestTable'
import ConfigModal from './components/ConfigModal' import ConfigModal from './components/ConfigModal'
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
}
}
export default function App() { export default function App() {
const queryClient = useQueryClient()
const [showConfig, setShowConfig] = useState(false) const [showConfig, setShowConfig] = useState(false)
const [filters, setFilters] = useState({}) const [filters, setFilters] = useState({})
@@ -28,10 +38,15 @@ export default function App() {
if (t.completed !== want) return false if (t.completed !== want) return false
} }
const strFields = ['interference', 'device', 'rotation', 'test_point', const strFields = ['interference', 'device', 'rotation', 'test_point',
'rssi', 'station', 'band', 'channel', 'bandwidth', 'direction'] 'rssi', 'station', 'band', 'channel', 'bandwidth', 'direction', 'throttled']
for (const f of strFields) { for (const f of strFields) {
if (filters[f] && t[f] !== filters[f]) return false if (filters[f] && t[f] !== filters[f]) return false
} }
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
}
return true return true
}) })
}, [allTests, filters]) }, [allTests, filters])
@@ -99,6 +114,11 @@ export default function App() {
label={d.name} label={d.name}
value={`${(d.completionRate * 100).toFixed(1)}%`} value={`${(d.completionRate * 100).toFixed(1)}%`}
sub={`${d.completed} / ${d.total}`} 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} /> <CompletionBar value={d.completionRate} />
</div> </div>
@@ -120,7 +140,7 @@ export default function App() {
<p className="text-slate-500 text-xs"> <p className="text-slate-500 text-xs">
Showing {filteredTests.length} of {allTests.length} tests Showing {filteredTests.length} of {allTests.length} tests
</p> </p>
<TestTable tests={filteredTests} isLoading={testsLoading} /> <TestTable tests={filteredTests} allTests={allTests} isLoading={testsLoading} />
</div> </div>
</main> </main>
+113 -17
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import DirectoryBrowser from './DirectoryBrowser' import DirectoryBrowser from './DirectoryBrowser'
import { useConfig, useSaveConfig } from '../hooks/useConfig' import { useConfig, useSaveConfig } from '../hooks/useConfig'
import { apiFetch } from '../lib/api'
function fmtSeconds(s) { function fmtSeconds(s) {
if (s == null) return '' if (s == null) return ''
@@ -16,15 +17,21 @@ export default function ConfigModal({ onClose }) {
const [browser, setBrowser] = useState(null) // 'target_dir' | 'results_dir' | null const [browser, setBrowser] = useState(null) // 'target_dir' | 'results_dir' | null
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
const [saveError, setSaveError] = useState(null) const [saveError, setSaveError] = useState(null)
const [isRescanning, setIsRescanning] = useState(false)
const [isSavingTimes, setIsSavingTimes] = useState(false)
useEffect(() => { useEffect(() => {
if (config) { if (config) {
setForm({ setForm({
target_dir: config.target_dir ?? '', target_dir: config.target_dir ?? '',
results_dir: config.results_dir ?? '', results_dir: config.results_dir ?? '',
results_dir_ref: config.results_dir_ref ?? '',
avg_time_coe: config.avg_time_coe ? fmtSeconds(config.avg_time_coe) : '', avg_time_coe: config.avg_time_coe ? fmtSeconds(config.avg_time_coe) : '',
avg_time_p2p: config.avg_time_p2p ? fmtSeconds(config.avg_time_p2p) : '', avg_time_p2p: config.avg_time_p2p ? fmtSeconds(config.avg_time_p2p) : '',
avg_time_p3p: config.avg_time_p3p ? fmtSeconds(config.avg_time_p3p) : '', avg_time_p3p: config.avg_time_p3p ? fmtSeconds(config.avg_time_p3p) : '',
smb_username: config.smb_username ?? '',
smb_password: config.smb_password ?? '',
smb_domain: config.smb_domain ?? '',
}) })
} }
}, [config]) }, [config])
@@ -33,12 +40,16 @@ export default function ConfigModal({ onClose }) {
setSaveError(null) setSaveError(null)
setScanResult(null) setScanResult(null)
const payload = { const payload = {
target_dir: form.target_dir || null, target_dir: form.target_dir || null,
results_dir: form.results_dir || null, results_dir: form.results_dir || null,
results_dir_ref: form.results_dir_ref || null,
// Convert minutes → seconds; empty/null clears the override // Convert minutes → seconds; empty/null clears the override
avg_time_coe: form.avg_time_coe ? String(parseFloat(form.avg_time_coe) * 60) : null, avg_time_coe: form.avg_time_coe ? String(parseFloat(form.avg_time_coe) * 60) : null,
avg_time_p2p: form.avg_time_p2p ? String(parseFloat(form.avg_time_p2p) * 60) : null, avg_time_p2p: form.avg_time_p2p ? String(parseFloat(form.avg_time_p2p) * 60) : null,
avg_time_p3p: form.avg_time_p3p ? String(parseFloat(form.avg_time_p3p) * 60) : null, avg_time_p3p: form.avg_time_p3p ? String(parseFloat(form.avg_time_p3p) * 60) : null,
smb_username: form.smb_username || null,
smb_password: form.smb_password || null,
smb_domain: form.smb_domain || null,
} }
save(payload, { save(payload, {
onSuccess: (data) => { onSuccess: (data) => {
@@ -56,6 +67,43 @@ export default function ConfigModal({ onClose }) {
}) })
} }
async function handleSaveTimes() {
setSaveError(null)
setScanResult(null)
setIsSavingTimes(true)
try {
await apiFetch('/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
avg_time_coe: form.avg_time_coe ? String(parseFloat(form.avg_time_coe) * 60) : null,
avg_time_p2p: form.avg_time_p2p ? String(parseFloat(form.avg_time_p2p) * 60) : null,
avg_time_p3p: form.avg_time_p3p ? String(parseFloat(form.avg_time_p3p) * 60) : null,
}),
})
} catch (err) {
setSaveError(err?.message ?? 'Failed to save')
} finally {
setIsSavingTimes(false)
}
}
async function handleRescanResults() {
setSaveError(null)
setScanResult(null)
setIsRescanning(true)
try {
const data = await apiFetch('/config/rescan-results', { method: 'POST' })
if (data?.testCount !== null && data?.testCount !== undefined) {
setScanResult({ testCount: data.testCount, completedCount: data.completedCount })
}
} catch (err) {
setSaveError(err?.message ?? 'Rescan failed')
} finally {
setIsRescanning(false)
}
}
const AVG_FIELDS = [ const AVG_FIELDS = [
{ key: 'avg_time_coe', label: 'COE avg time (min)' }, { key: 'avg_time_coe', label: 'COE avg time (min)' },
{ key: 'avg_time_p2p', label: 'P2P avg time (min)' }, { key: 'avg_time_p2p', label: 'P2P avg time (min)' },
@@ -104,8 +152,9 @@ export default function ConfigModal({ onClose }) {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{[ {[
{ key: 'target_dir', label: 'Target Tests Directory' }, { key: 'target_dir', label: 'Target Tests Directory' },
{ key: 'results_dir', label: 'Results Directory' }, { key: 'results_dir', label: 'Results Directory (DUT)' },
{ key: 'results_dir_ref', label: 'Results Directory (Reference)' },
].map(({ key, label }) => ( ].map(({ key, label }) => (
<div key={key}> <div key={key}>
<label className="text-slate-400 text-xs block mb-1">{label}</label> <label className="text-slate-400 text-xs block mb-1">{label}</label>
@@ -152,25 +201,72 @@ export default function ConfigModal({ onClose }) {
))} ))}
</div> </div>
</section> </section>
{/* SMB Credentials */}
<section>
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-1">SMB Credentials</h3>
<p className="text-slate-500 text-xs mb-3">
Optional credentials for network shares used by target/results directories.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input
type="text"
value={form.smb_domain ?? ''}
onChange={e => setForm(f => ({ ...f, smb_domain: e.target.value }))}
placeholder="Domain"
className="w-full 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"
/>
<input
type="text"
value={form.smb_username ?? ''}
onChange={e => setForm(f => ({ ...f, smb_username: e.target.value }))}
placeholder="Username"
className="w-full 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"
/>
<input
type="password"
value={form.smb_password ?? ''}
onChange={e => setForm(f => ({ ...f, smb_password: e.target.value }))}
placeholder="Password"
className="w-full 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"
/>
</div>
</section>
</> </>
)} )}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex justify-end gap-2 px-5 py-4 border-t border-slate-800"> <div className="flex justify-between gap-2 px-5 py-4 border-t border-slate-800">
<button <button
onClick={onClose} onClick={handleRescanResults}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors" disabled={isRescanning || isPending || isSavingTimes}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
> >
Cancel {isRescanning ? 'Rescanning…' : 'Save Results'}
</button>
<button
onClick={handleSave}
disabled={isPending}
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isPending ? 'Saving…' : 'Save & Rescan'}
</button> </button>
<div className="flex gap-2">
<button
onClick={handleSaveTimes}
disabled={isSavingTimes || isPending || isRescanning}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
>
{isSavingTimes ? 'Saving…' : 'Save Times'}
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isPending || isRescanning || isSavingTimes}
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isPending ? 'Saving…' : 'Save All'}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+16 -3
View File
@@ -7,6 +7,7 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
const [dirs, setDirs] = useState(null) // null = not loaded yet const [dirs, setDirs] = useState(null) // null = not loaded yet
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [manualPath, setManualPath] = useState('')
async function navigate(path) { async function navigate(path) {
setLoading(true) setLoading(true)
@@ -16,6 +17,7 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
setCurrent(data.path) setCurrent(data.path)
setParent(data.parent) setParent(data.parent)
setDirs(data.dirs) setDirs(data.dirs)
setManualPath(data.path ?? '')
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
} finally { } finally {
@@ -23,13 +25,24 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
} }
} }
function goToManualPath() {
const path = manualPath.trim()
if (!path) {
navigate(null)
return
}
navigate(path)
}
// Load roots on first render // Load roots on first render
if (dirs === null && !loading && !error) { if (dirs === null && !loading && !error) {
navigate(null) navigate(null)
} }
const breadcrumbs = current ? current.replace(/\\/g, '/').split('/').filter(Boolean) : [] const normalizedCurrent = current ?? ''
const isUnixPath = !!current && current.startsWith('/') const normalizedPath = normalizedCurrent.replace(/\\/g, '/')
const isUnixPath = normalizedCurrent.startsWith('/')
const breadcrumbs = normalizedPath ? normalizedPath.split('/').filter(Boolean) : []
function breadcrumbPathAt(index) { function breadcrumbPathAt(index) {
const parts = breadcrumbs.slice(0, index + 1) const parts = breadcrumbs.slice(0, index + 1)
@@ -50,7 +63,7 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="px-4 py-2 border-b border-slate-800 flex items-center gap-1 text-xs text-slate-400 flex-wrap min-h-[36px]"> <div className="px-4 py-2 border-b border-slate-800 flex items-center gap-1 text-xs text-slate-400 flex-wrap min-h-[36px]">
<button onClick={() => navigate(null)} className="hover:text-slate-200">Drives</button> <button onClick={() => navigate(null)} className="hover:text-slate-200">Roots</button>
{breadcrumbs.map((part, i) => { {breadcrumbs.map((part, i) => {
const path = breadcrumbPathAt(i) const path = breadcrumbPathAt(i)
return ( return (
+2
View File
@@ -1,6 +1,7 @@
const FILTER_FIELDS = [ const FILTER_FIELDS = [
{ key: 'completed', label: 'Status', options: [{ value: '', label: 'All' }, { value: 'true', label: 'Completed' }, { value: 'false', label: 'Pending' }] }, { key: 'completed', label: 'Status', options: [{ value: '', label: 'All' }, { value: 'true', label: 'Completed' }, { value: 'false', label: 'Pending' }] },
{ key: 'interference', label: 'Type', options: [{ value: '', label: 'All' }, { value: 'COE', label: 'COE' }, { value: 'P2P', label: 'P2P' }, { value: 'P3P', label: 'P3P' }] }, { key: 'interference', label: 'Type', options: [{ value: '', label: 'All' }, { value: 'COE', label: 'COE' }, { value: 'P2P', label: 'P2P' }, { value: 'P3P', label: 'P3P' }] },
{ key: 'coePair', label: 'COE Pairs', options: [{ value: '', label: 'All' }, { value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }] },
] ]
const DERIVED_FIELDS = [ const DERIVED_FIELDS = [
@@ -13,6 +14,7 @@ const DERIVED_FIELDS = [
{ key: 'channel', label: 'Channel' }, { key: 'channel', label: 'Channel' },
{ key: 'bandwidth', label: 'Bandwidth' }, { key: 'bandwidth', label: 'Bandwidth' },
{ key: 'direction', label: 'Direction' }, { key: 'direction', label: 'Direction' },
{ key: 'throttled', label: 'Throttled' },
] ]
function unique(tests, key) { function unique(tests, key) {
+32 -5
View File
@@ -1,9 +1,36 @@
export default function StatCard({ label, value, sub, accent }) { export default function StatCard({ label, value, sub, accent, details, detailsPosition = 'below' }) {
const showDetails = details?.length > 0
const detailsOnRight = showDetails && detailsPosition === 'right'
return ( return (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-1 min-w-0"> <div className="bg-slate-900 border border-slate-800 rounded-xl p-4 min-w-0">
<p className="text-slate-400 text-xs uppercase tracking-widest truncate">{label}</p> <div className={`flex ${detailsOnRight ? 'items-start justify-between gap-4' : 'flex-col gap-1'}`}>
<p className={`text-3xl font-bold ${accent ?? 'text-slate-100'}`}>{value}</p> <div className="min-w-0">
{sub && <p className="text-slate-400 text-sm">{sub}</p>} <p className="text-slate-400 text-xs uppercase tracking-widest truncate">{label}</p>
<p className={`text-3xl font-bold ${accent ?? 'text-slate-100'}`}>{value}</p>
{sub && <p className="text-slate-400 text-sm">{sub}</p>}
</div>
{detailsOnRight && (
<div className="space-y-0.5 text-right shrink-0">
{details.map((line) => (
<p key={line} className="text-slate-500 text-xs font-medium">
{line}
</p>
))}
</div>
)}
</div>
{showDetails && !detailsOnRight && (
<div className="mt-1 space-y-0.5">
{details.map((line) => (
<p key={line} className="text-slate-500 text-xs">
{line}
</p>
))}
</div>
)}
</div> </div>
) )
} }
+1 -1
View File
@@ -1,5 +1,5 @@
export default function StatusBadge({ completed }) { export default function StatusBadge({ completed }) {
return completed return completed
? <span className="inline-flex items-center gap-1 text-emerald-400 text-sm font-medium"> Done</span> ? <span className="inline-flex items-center gap-1 text-emerald-400 text-sm font-medium"> Completed</span>
: <span className="inline-flex items-center gap-1 text-slate-500 text-sm"> Pending</span> : <span className="inline-flex items-center gap-1 text-slate-500 text-sm"> Pending</span>
} }
+264 -4
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react' import React, { useMemo, useState } from 'react'
import StatusBadge from './StatusBadge' import StatusBadge from './StatusBadge'
const TYPE_COLORS = { const TYPE_COLORS = {
@@ -7,6 +7,35 @@ const TYPE_COLORS = {
P3P: 'bg-orange-900/50 text-orange-300', P3P: 'bg-orange-900/50 text-orange-300',
} }
function parsePairArray(raw) {
if (!raw) return []
if (Array.isArray(raw)) return raw
if (typeof raw !== 'string') return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function parseTputRows(raw) {
if (!raw) return []
if (Array.isArray(raw)) return raw
if (typeof raw !== 'string') return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function testLookupKey(test) {
if (!test?.device || !test?.test_id) return null
return `${test.device}_${test.test_id}`
}
function fmtDuration(s) { function fmtDuration(s) {
if (s == null) return null if (s == null) return null
const h = Math.floor(s / 3600) const h = Math.floor(s / 3600)
@@ -41,9 +70,21 @@ function SortTh({ colKey, label, sort, onSort }) {
) )
} }
export default function TestTable({ tests = [], isLoading }) { export default function TestTable({ tests = [], allTests = [], isLoading }) {
const [sort, setSort] = useState({ key: 'filename', dir: 1 }) const [sort, setSort] = useState({ key: 'filename', dir: 1 })
const [expanded, setExpanded] = useState(new Set()) const [expanded, setExpanded] = useState(new Set())
const [pairedExpanded, setPairedExpanded] = useState(new Set())
const sourceTests = allTests.length > 0 ? allTests : tests
const testsByLookupKey = useMemo(() => {
const map = new Map()
for (const t of sourceTests) {
const key = testLookupKey(t)
if (key) map.set(key, t)
}
return map
}, [sourceTests])
function toggleSort(key) { function toggleSort(key) {
setSort(s => ({ key, dir: s.key === key ? -s.dir : 1 })) setSort(s => ({ key, dir: s.key === key ? -s.dir : 1 }))
@@ -57,12 +98,30 @@ export default function TestTable({ tests = [], isLoading }) {
}) })
} }
function togglePairedExpand(parentId, pairKey) {
const key = `${parentId}::${pairKey}`
setPairedExpanded(prev => {
const next = new Set(prev)
next.has(key) ? next.delete(key) : next.add(key)
return next
})
}
const sorted = [...tests].sort((a, b) => { const sorted = [...tests].sort((a, b) => {
let av = a[sort.key] ?? '' let av = a[sort.key] ?? ''
let bv = b[sort.key] ?? '' let bv = b[sort.key] ?? ''
if (typeof av === 'number' || typeof bv === 'number') return (av - bv) * sort.dir if (typeof av === 'number' || typeof bv === 'number') return (av - bv) * sort.dir
return String(av).localeCompare(String(bv)) * sort.dir return String(av).localeCompare(String(bv)) * sort.dir
}) })
const allExpanded = sorted.length > 0 && sorted.every(test => expanded.has(test.id))
function expandAll() {
setExpanded(new Set(sorted.map(test => test.id)))
}
function collapseAll() {
setExpanded(new Set())
}
if (isLoading) { if (isLoading) {
return ( return (
@@ -82,6 +141,22 @@ export default function TestTable({ tests = [], isLoading }) {
return ( return (
<div className="rounded-xl border border-slate-800 overflow-hidden"> <div className="rounded-xl border border-slate-800 overflow-hidden">
<div className="px-3 py-2 border-b border-slate-800 bg-slate-900/80 flex items-center justify-end gap-2">
<button
onClick={expandAll}
disabled={allExpanded}
className="px-2.5 py-1 text-xs rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Expand All
</button>
<button
onClick={collapseAll}
disabled={expanded.size === 0}
className="px-2.5 py-1 text-xs rounded-md border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Collapse All
</button>
</div>
<table className="w-full text-sm text-left text-slate-300 border-collapse"> <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"> <thead className="bg-slate-800 text-slate-400 text-xs uppercase tracking-wider">
<tr> <tr>
@@ -117,7 +192,15 @@ export default function TestTable({ tests = [], isLoading }) {
<tr className={`border-t border-slate-700/60 ${test.completed ? 'bg-emerald-950/10' : 'bg-slate-900/80'}`}> <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"> <td colSpan={3} className="px-6 py-4">
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<TagPill label="File ID" value={test.test_id} /> <TagPill label="COE Pairs" value={(() => {
const pairs = parsePairArray(test.coe_pair)
return pairs.length > 0 ? String(pairs.length) : null
})()} colorClass="bg-cyan-900/50 text-cyan-300" />
<TagPill label="P3P Pairs" value={(() => {
const pairs = parsePairArray(test.p3p_pair)
return pairs.length > 0 ? String(pairs.length) : null
})()} colorClass="bg-orange-900/50 text-orange-300" />
<TagPill label="Test ID" value={test.test_id} />
<TagPill label="Type" value={test.interference} colorClass={TYPE_COLORS[test.interference]} /> <TagPill label="Type" value={test.interference} colorClass={TYPE_COLORS[test.interference]} />
<TagPill label="Device" value={test.device} /> <TagPill label="Device" value={test.device} />
<TagPill label="Rotation" value={test.rotation} /> <TagPill label="Rotation" value={test.rotation} />
@@ -127,11 +210,12 @@ export default function TestTable({ tests = [], isLoading }) {
<TagPill label="Band" value={test.band} /> <TagPill label="Band" value={test.band} />
<TagPill label="Channel" value={test.channel} /> <TagPill label="Channel" value={test.channel} />
<TagPill label="Bandwidth" value={test.bandwidth} /> <TagPill label="Bandwidth" value={test.bandwidth} />
<TagPill label="Throttle" value={test.throttled} />
<TagPill label="Direction" value={test.direction} /> <TagPill label="Direction" value={test.direction} />
<TagPill label="Elapsed Time" value={fmtDuration(test.duration_seconds)} /> <TagPill label="Elapsed Time" value={fmtDuration(test.duration_seconds)} />
</div> </div>
{(() => { {(() => {
const rows = test.tput_results ? JSON.parse(test.tput_results) : [] const rows = parseTputRows(test.tput_results)
if (!test.completed || rows.length === 0) return null if (!test.completed || rows.length === 0) return null
return ( return (
<div className="mt-3 pt-3 border-t border-slate-700/50"> <div className="mt-3 pt-3 border-t border-slate-700/50">
@@ -158,6 +242,182 @@ export default function TestTable({ tests = [], isLoading }) {
</div> </div>
) )
})()} })()}
{(() => {
const pairs = parsePairArray(test.coe_pair)
if (pairs.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-slate-700/50">
<p className="text-[10px] uppercase tracking-wide text-slate-500 mb-2">Paired COE Tests</p>
<div className="flex flex-col gap-2">
{pairs.map(pairKey => {
const pairTest = testsByLookupKey.get(pairKey)
const itemKey = `${test.id}-${pairKey}`
const open = pairedExpanded.has(`${test.id}::${pairKey}`)
if (!pairTest) {
return (
<div key={itemKey} className="text-xs font-medium px-2.5 py-2 rounded bg-slate-800 text-slate-400">
{pairKey}
</div>
)
}
return (
<div key={itemKey} className="border border-slate-700/70 rounded-md overflow-hidden">
<button
type="button"
onClick={() => togglePairedExpand(test.id, pairKey)}
className="w-full px-2.5 py-2 bg-slate-900/70 hover:bg-slate-800 transition-colors flex items-center justify-between gap-2 text-left"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-slate-500 select-none">{open ? '▾' : '▸'}</span>
<span className="text-xs text-slate-200 truncate">{pairTest.filename ?? pairKey}</span>
</div>
<StatusBadge completed={pairTest.completed} />
</button>
{open && (
<div className="px-3 py-3 border-t border-slate-700/60 bg-slate-900/40">
<div className="flex flex-wrap gap-3">
<TagPill label="File ID" value={pairTest.test_id} />
<TagPill label="Type" value={pairTest.interference} colorClass={TYPE_COLORS[pairTest.interference]} />
<TagPill label="Device" value={pairTest.device} />
<TagPill label="Rotation" value={pairTest.rotation} />
<TagPill label="Test Point" value={pairTest.test_point} />
<TagPill label="RSSI" value={pairTest.rssi} />
<TagPill label="Station" value={pairTest.station} />
<TagPill label="Band" value={pairTest.band} />
<TagPill label="Channel" value={pairTest.channel} />
<TagPill label="Bandwidth" value={pairTest.bandwidth} />
<TagPill label="Throttle" value={pairTest.throttled} />
<TagPill label="Direction" value={pairTest.direction} />
<TagPill label="Elapsed Time" value={fmtDuration(pairTest.duration_seconds)} />
</div>
{(() => {
const rows = parseTputRows(pairTest.tput_results)
if (!pairTest.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={`${pairTest.id}-${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>
)
})()}
</div>
)}
</div>
)
})}
</div>
</div>
)
})()}
{(() => {
const pairs = parsePairArray(test.p3p_pair)
if (pairs.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-slate-700/50">
<p className="text-[10px] uppercase tracking-wide text-slate-500 mb-2">Paired P3P Tests</p>
<div className="flex flex-col gap-2">
{pairs.map(pairKey => {
const pairTest = testsByLookupKey.get(pairKey)
const itemKey = `${test.id}-${pairKey}`
const open = pairedExpanded.has(`${test.id}::${pairKey}`)
if (!pairTest) {
return (
<div key={itemKey} className="text-xs font-medium px-2.5 py-2 rounded bg-slate-800 text-slate-400">
{pairKey}
</div>
)
}
return (
<div key={itemKey} className="border border-slate-700/70 rounded-md overflow-hidden">
<button
type="button"
onClick={() => togglePairedExpand(test.id, pairKey)}
className="w-full px-2.5 py-2 bg-slate-900/70 hover:bg-slate-800 transition-colors flex items-center justify-between gap-2 text-left"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-slate-500 select-none">{open ? '▾' : '▸'}</span>
<span className="text-xs text-slate-200 truncate">{pairTest.filename ?? pairKey}</span>
</div>
<StatusBadge completed={pairTest.completed} />
</button>
{open && (
<div className="px-3 py-3 border-t border-slate-700/60 bg-slate-900/40">
<div className="flex flex-wrap gap-3">
<TagPill label="File ID" value={pairTest.test_id} />
<TagPill label="Type" value={pairTest.interference} colorClass={TYPE_COLORS[pairTest.interference]} />
<TagPill label="Device" value={pairTest.device} />
<TagPill label="Rotation" value={pairTest.rotation} />
<TagPill label="Test Point" value={pairTest.test_point} />
<TagPill label="RSSI" value={pairTest.rssi} />
<TagPill label="Station" value={pairTest.station} />
<TagPill label="Band" value={pairTest.band} />
<TagPill label="Channel" value={pairTest.channel} />
<TagPill label="Bandwidth" value={pairTest.bandwidth} />
<TagPill label="Throttle" value={pairTest.throttled} />
<TagPill label="Direction" value={pairTest.direction} />
<TagPill label="Elapsed Time" value={fmtDuration(pairTest.duration_seconds)} />
</div>
{(() => {
const rows = parseTputRows(pairTest.tput_results)
if (!pairTest.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={`${pairTest.id}-${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>
)
})()}
</div>
)}
</div>
)
})}
</div>
</div>
)
})()}
</td> </td>
</tr> </tr>
)} )}
+30 -12
View File
@@ -6,16 +6,33 @@ function fmt(seconds) {
return `${m}m` return `${m}m`
} }
function fmtDays(seconds) { function estimateDays(seconds) {
if (seconds == null) return null const s = Number(seconds)
const days = seconds / 57600 // 16 hours per day if (!Number.isFinite(s) || s < 0) return null
return days < 1 ? `${(days * 24).toFixed(1)}h` : `${days.toFixed(1)}d (16h/day)`
let days = s / 57600 // 16 hours per day
const estDate = new Date(Date.now() + days * 24 * 3600 * 1000)
// Calculate how many weekends
let weekends = 0
for (let d = new Date(); d < estDate; d.setDate(d.getDate() + 1)) {
if (d.getDay() === 0 || d.getDay() === 6) {
weekends++
}
}
const weekdays = (s - weekends * 24 * 3600) / 57600
days = weekdays + weekends
return Number.isFinite(days) ? days : null
} }
function fmtCompletionDate(seconds) { function fmtDaysLabel(days) {
if (seconds == null) return null if (days == null || !Number.isFinite(days)) return null
const days = seconds / 57600 // 16 hours per day return days < 1 ? `${(days * 24).toFixed(1)}h` : `${days.toFixed(1)}d`
}
function fmtCompletionDate(days) {
if (days == null || !Number.isFinite(days)) return null
const date = new Date(Date.now() + days * 24 * 3600 * 1000) const date = new Date(Date.now() + days * 24 * 3600 * 1000)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
} }
@@ -26,15 +43,16 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,
.map(([t]) => t) .map(([t]) => t)
: [] : []
const days = fmtDays(estimatedRemainingSeconds) const estimatedDays = estimateDays(estimatedRemainingSeconds)
const completionDate = fmtCompletionDate(estimatedRemainingSeconds) const daysLabel = fmtDaysLabel(estimatedDays)
const completionDate = fmtCompletionDate(estimatedDays)
return ( return (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-3"> <div className="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-3">
<div className="flex flex-wrap gap-6"> <div className="flex flex-wrap gap-6">
<div> <div>
<p className="text-slate-400 text-xs uppercase tracking-widest">Time Elapsed</p> <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"> <p className="text-2xl font-bold text-emerald-400 mt-0.5">
{fmt(elapsedSeconds) ?? '—'} {fmt(elapsedSeconds) ?? '—'}
</p> </p>
</div> </div>
@@ -43,8 +61,8 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,
<p className={`text-2xl font-bold mt-0.5 ${estimatedRemainingSeconds != null ? 'text-slate-100' : 'text-amber-400'}`}> <p className={`text-2xl font-bold mt-0.5 ${estimatedRemainingSeconds != null ? 'text-slate-100' : 'text-amber-400'}`}>
{estimatedRemainingSeconds != null ? fmt(estimatedRemainingSeconds) : '—'} {estimatedRemainingSeconds != null ? fmt(estimatedRemainingSeconds) : '—'}
</p> </p>
{days && ( {daysLabel && (
<p className="text-slate-400 text-xs mt-0.5">{days}</p> <p className="text-slate-400 text-xs mt-0.5">{daysLabel}</p>
)} )}
</div> </div>
{completionDate && ( {completionDate && (
+77 -8
View File
@@ -5,7 +5,7 @@ from flask import Flask, Response, jsonify, request, send_from_directory
from flask_cors import CORS from flask_cors import CORS
from db_py import count_tests, del_config, get_all_tests, get_config, set_config from db_py import count_tests, del_config, get_all_tests, get_config, set_config
from scanner import full_scan from scanner import full_scan, scan_results_only
from sse_py import broadcast, stream_events from sse_py import broadcast, stream_events
from watcher import start_watching from watcher import start_watching
@@ -13,9 +13,13 @@ PORT = int(os.getenv("PORT", "3001"))
ALLOWED_KEYS = { ALLOWED_KEYS = {
"target_dir", "target_dir",
"results_dir", "results_dir",
"results_dir_ref",
"avg_time_coe", "avg_time_coe",
"avg_time_p2p", "avg_time_p2p",
"avg_time_p3p", "avg_time_p3p",
"smb_username",
"smb_password",
"smb_domain",
} }
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
@@ -25,10 +29,25 @@ app = Flask(__name__, static_folder=str(DIST_DIR), static_url_path="")
CORS(app) CORS(app)
def _apply_smb_env_from_config():
mapping = {
"SMB_USERNAME": get_config("smb_username"),
"SMB_PASSWORD": get_config("smb_password"),
"SMB_DOMAIN": get_config("smb_domain"),
}
for env_key, value in mapping.items():
if value in (None, ""):
os.environ.pop(env_key, None)
else:
os.environ[env_key] = str(value)
@app.get("/api/tests") @app.get("/api/tests")
def get_tests_route(): def get_tests_route():
completed = request.args.get("completed") completed = request.args.get("completed")
interference = request.args.get("interference") interference = request.args.get("interference")
throttled = request.args.get("throttled")
device = request.args.get("device") device = request.args.get("device")
rotation = request.args.get("rotation") rotation = request.args.get("rotation")
test_point = request.args.get("testPoint") test_point = request.args.get("testPoint")
@@ -46,6 +65,8 @@ def get_tests_route():
tests = [t for t in tests if t.get("completed") == completed_value] tests = [t for t in tests if t.get("completed") == completed_value]
if interference: if interference:
tests = [t for t in tests if t.get("interference") == interference] tests = [t for t in tests if t.get("interference") == interference]
if throttled:
tests = [t for t in tests if t.get("throttled") == throttled]
if device: if device:
tests = [t for t in tests if t.get("device") == device] tests = [t for t in tests if t.get("device") == device]
if rotation: if rotation:
@@ -72,6 +93,7 @@ def get_tests_route():
@app.get("/api/stats") @app.get("/api/stats")
def get_stats_route(): def get_stats_route():
tests = get_all_tests() tests = get_all_tests()
types = ["COE", "P2P", "P3P"]
total_completed = sum(1 for t in tests if t.get("completed")) total_completed = sum(1 for t in tests if t.get("completed"))
overall = { overall = {
@@ -87,10 +109,21 @@ def get_stats_route():
continue continue
if name not in device_map: if name not in device_map:
device_map[name] = {"total": 0, "completed": 0} device_map[name] = {
"total": 0,
"completed": 0,
"byType": {t: {"total": 0, "completed": 0} for t in types},
}
device_map[name]["total"] += 1 device_map[name]["total"] += 1
interference = test.get("interference")
if interference in device_map[name]["byType"]:
device_map[name]["byType"][interference]["total"] += 1
if test.get("completed"): if test.get("completed"):
device_map[name]["completed"] += 1 device_map[name]["completed"] += 1
if interference in device_map[name]["byType"]:
device_map[name]["byType"][interference]["completed"] += 1
devices = [] devices = []
for name in sorted(device_map.keys()): for name in sorted(device_map.keys()):
@@ -101,6 +134,7 @@ def get_stats_route():
"total": stats["total"], "total": stats["total"],
"completed": stats["completed"], "completed": stats["completed"],
"completionRate": (stats["completed"] / stats["total"]) if stats["total"] else 0, "completionRate": (stats["completed"] / stats["total"]) if stats["total"] else 0,
"byType": stats["byType"],
} }
) )
@@ -110,7 +144,6 @@ def get_stats_route():
if test.get("completed") and duration is not None: if test.get("completed") and duration is not None:
elapsed_seconds += duration elapsed_seconds += duration
types = ["COE", "P2P", "P3P"]
by_type = {} by_type = {}
estimate_possible = True estimate_possible = True
estimated_remaining_seconds = 0 estimated_remaining_seconds = 0
@@ -189,14 +222,16 @@ def set_config_route():
else: else:
set_config(key, str(value)) set_config(key, str(value))
if key in {"target_dir", "results_dir"}: if key in {"target_dir", "results_dir", "results_dir_ref"}:
dirs_changed = True dirs_changed = True
if dirs_changed: if dirs_changed:
_apply_smb_env_from_config()
target_dir = get_config("target_dir") target_dir = get_config("target_dir")
results_dir = get_config("results_dir") results_dir = get_config("results_dir")
results_dir_ref = get_config("results_dir_ref")
try: try:
full_scan(target_dir, results_dir) full_scan(target_dir, results_dir, results_dir_ref)
start_watching(target_dir, results_dir) start_watching(target_dir, results_dir)
except Exception as exc: except Exception as exc:
print(f"[config] fullScan error: {exc}") print(f"[config] fullScan error: {exc}")
@@ -208,18 +243,21 @@ def set_config_route():
broadcast({"type": "update"}) broadcast({"type": "update"})
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed}) return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
broadcast({"type": "update"})
return jsonify({"ok": True, "testCount": None, "completedCount": None}) return jsonify({"ok": True, "testCount": None, "completedCount": None})
@app.post("/api/config/rescan") @app.post("/api/config/rescan")
def rescan_route(): def rescan_route():
_apply_smb_env_from_config()
target_dir = get_config("target_dir") target_dir = get_config("target_dir")
results_dir = get_config("results_dir") results_dir = get_config("results_dir")
results_dir_ref = get_config("results_dir_ref")
if not target_dir or not results_dir: if not target_dir or not results_dir:
return jsonify({"error": "Directories not configured"}), 400 return jsonify({"error": "Directories not configured"}), 400
try: try:
full_scan(target_dir, results_dir) full_scan(target_dir, results_dir, results_dir_ref)
start_watching(target_dir, results_dir) start_watching(target_dir, results_dir)
except Exception as exc: except Exception as exc:
print(f"[config] rescan error: {exc}") print(f"[config] rescan error: {exc}")
@@ -232,8 +270,37 @@ def rescan_route():
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed}) return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
@app.post("/api/config/rescan-results")
def rescan_results_route():
_apply_smb_env_from_config()
results_dir = get_config("results_dir")
results_dir_ref = get_config("results_dir_ref")
if not results_dir:
return jsonify({"error": "Results directory not configured"}), 400
try:
scan_results_only(results_dir, results_dir_ref)
except Exception as exc:
print(f"[config] rescan-results error: {exc}")
return jsonify({"error": f"Scan failed: {exc}"}), 500
tests = get_all_tests()
completed = len([t for t in tests if t.get("completed")])
print(f"[config] Results rescan complete -> {len(tests)} tests, {completed} completed")
broadcast({"type": "update"})
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
@app.get("/api/browse") @app.get("/api/browse")
def browse_route(): def browse_route():
def _normalize_request_path(path):
if not path:
return path
p = path.strip()
if os.name == "nt":
p = p.replace("/", "\\")
return p
def _configured_roots(): def _configured_roots():
raw = os.getenv("BROWSE_ROOTS", "").strip() raw = os.getenv("BROWSE_ROOTS", "").strip()
if not raw: if not raw:
@@ -261,7 +328,7 @@ def browse_route():
return False return False
roots = _configured_roots() roots = _configured_roots()
req_path = request.args.get("path") req_path = _normalize_request_path(request.args.get("path"))
if not req_path: if not req_path:
if roots: if roots:
@@ -329,8 +396,10 @@ def static_or_spa(path=""):
def bootstrap(): def bootstrap():
_apply_smb_env_from_config()
target_dir = get_config("target_dir") target_dir = get_config("target_dir")
results_dir = get_config("results_dir") results_dir = get_config("results_dir")
results_dir_ref = get_config("results_dir_ref")
if target_dir and results_dir: if target_dir and results_dir:
existing = count_tests() existing = count_tests()
@@ -338,7 +407,7 @@ def bootstrap():
print(f"[server] Resuming from DB -> {existing} tests already loaded.") print(f"[server] Resuming from DB -> {existing} tests already loaded.")
else: else:
print("[server] No cached data, scanning directories...") print("[server] No cached data, scanning directories...")
full_scan(target_dir, results_dir) full_scan(target_dir, results_dir, results_dir_ref)
tests = get_all_tests() tests = get_all_tests()
completed = len([t for t in tests if t.get("completed")]) completed = len([t for t in tests if t.get("completed")])
print(f"[server] Scan complete -> {len(tests)} tests found, {completed} completed") print(f"[server] Scan complete -> {len(tests)} tests found, {completed} completed")
Binary file not shown.
Binary file not shown.
Binary file not shown.
+33 -3
View File
@@ -64,6 +64,9 @@ def _init_db():
"dl_rssi_dbm REAL", "dl_rssi_dbm REAL",
"ul_rssi_dbm REAL", "ul_rssi_dbm REAL",
"tput_results TEXT", "tput_results TEXT",
"throttled TEXT",
"coe_pair TEXT",
"p3p_pair TEXT",
]: ]:
try: try:
_conn.execute(f"ALTER TABLE tests ADD COLUMN {col_def}") _conn.execute(f"ALTER TABLE tests ADD COLUMN {col_def}")
@@ -96,10 +99,10 @@ def upsert_test(test):
""" """
INSERT INTO tests INSERT INTO tests
(id, test_id, parent_dir, filename, interference, device, rotation, (id, test_id, parent_dir, filename, interference, device, rotation,
test_point, station, band, channel, bandwidth, rssi, direction) test_point, station, band, channel, bandwidth, rssi, direction, throttled)
VALUES VALUES
(:id, :test_id, :parent_dir, :filename, :interference, :device, :rotation, (:id, :test_id, :parent_dir, :filename, :interference, :device, :rotation,
:test_point, :station, :band, :channel, :bandwidth, :rssi, :direction) :test_point, :station, :band, :channel, :bandwidth, :rssi, :direction, :throttled)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
test_id = excluded.test_id, test_id = excluded.test_id,
parent_dir = excluded.parent_dir, parent_dir = excluded.parent_dir,
@@ -113,7 +116,8 @@ def upsert_test(test):
channel = excluded.channel, channel = excluded.channel,
bandwidth = excluded.bandwidth, bandwidth = excluded.bandwidth,
rssi = excluded.rssi, rssi = excluded.rssi,
direction = excluded.direction direction = excluded.direction,
throttled = excluded.throttled
""", """,
test, test,
) )
@@ -136,6 +140,32 @@ def mark_completed(test_id, device, completed_at, duration_seconds, tput_results
) )
def set_coe_pair(test_row_id, coe_pair):
json_value = json.dumps(coe_pair or [])
with _tx():
_conn.execute(
"""
UPDATE tests
SET coe_pair = ?
WHERE id = ?
""",
(json_value, test_row_id),
)
def set_p3p_pair(test_row_id, p3p_pair):
json_value = json.dumps(p3p_pair or [])
with _tx():
_conn.execute(
"""
UPDATE tests
SET p3p_pair = ?
WHERE id = ?
""",
(json_value, test_row_id),
)
def get_station_for_test(test_id, device): def get_station_for_test(test_id, device):
with _lock: with _lock:
row = _conn.execute( row = _conn.execute(
+6
View File
@@ -11,6 +11,11 @@ def parse_target_filename(filename, parent_dir):
test_identifier = f"{parent_dir}/{base_name}" test_identifier = f"{parent_dir}/{base_name}"
interference = segments[0] if segments and segments[0] in INTERFERENCE_TYPES else None interference = segments[0] if segments and segments[0] in INTERFERENCE_TYPES else None
test_id = next((s for s in segments if re.match(r"^R\d+[A-Z0-9]+$", s, re.IGNORECASE)), None) test_id = next((s for s in segments if re.match(r"^R\d+[A-Z0-9]+$", s, re.IGNORECASE)), None)
throttled = None
if interference == "P3P" and test_id:
# P3P test_id carries throttle marker: TH = throttled, otherwise UT.
throttled = "TH" if "TH" in test_id.upper() else "UT"
device = segments[1] if len(segments) > 1 else None device = segments[1] if len(segments) > 1 else None
test_point = next((s for s in segments if re.match(r"^TPT\w+$", s)), None) test_point = next((s for s in segments if re.match(r"^TPT\w+$", s)), None)
rssi = next((s for s in segments if re.match(r"^RSSI\d+$", s)), None) rssi = next((s for s in segments if re.match(r"^RSSI\d+$", s)), None)
@@ -39,6 +44,7 @@ def parse_target_filename(filename, parent_dir):
"bandwidth": bandwidth, "bandwidth": bandwidth,
"direction": direction, "direction": direction,
"rotation": rotation, "rotation": rotation,
"throttled": throttled,
} }
+1
View File
@@ -1,3 +1,4 @@
Flask>=3.0.0,<4.0.0 Flask>=3.0.0,<4.0.0
Flask-Cors>=4.0.1,<5.0.0 Flask-Cors>=4.0.1,<5.0.0
watchdog>=4.0.1,<5.0.0 watchdog>=4.0.1,<5.0.0
smbprotocol>=1.13.0,<2.0.0
+252 -33
View File
@@ -1,7 +1,15 @@
import os import os
import re import re
import smbclient
from db_py import clear_tests, mark_completed, upsert_test from db_py import (
clear_tests,
get_all_tests,
mark_completed,
set_coe_pair,
set_p3p_pair,
upsert_test,
)
from parser import ( from parser import (
parse_elapsed_time, parse_elapsed_time,
parse_result_filename, parse_result_filename,
@@ -11,24 +19,231 @@ from parser import (
) )
def full_scan(target_dir, results_dir): _SMB_SESSIONS = set()
def _normalize_input_path(path_value):
if not path_value:
return path_value
path = str(path_value).strip()
# Accept //server/share style and normalize to UNC for smbclient.
if path.startswith("//"):
return "\\\\" + path.lstrip("/").replace("/", "\\")
# Accept /<ipv4>/<share>/... and normalize to UNC for Linux-hosted inputs.
if re.match(r"^/\d{1,3}(?:\.\d{1,3}){3}/[^/]+", path):
return "\\\\" + path.lstrip("/").replace("/", "\\")
return path
def _is_unc_path(path):
return isinstance(path, str) and path.startswith("\\\\")
def _extract_unc_server(path):
if not _is_unc_path(path):
return None
rest = path[2:]
return rest.split("\\", 1)[0] if rest else None
def _register_smb_session_if_needed(path):
if not _is_unc_path(path):
return
server = _extract_unc_server(path)
if not server or server in _SMB_SESSIONS:
return
username = os.getenv("SMB_USERNAME", "").strip()
password = os.getenv("SMB_PASSWORD", "")
domain = os.getenv("SMB_DOMAIN", "").strip()
if username and domain and "\\" not in username and "@" not in username:
username = f"{domain}\\{username}"
if username:
smbclient.register_session(server, username=username, password=password)
else:
smbclient.register_session(server)
_SMB_SESSIONS.add(server)
def _iter_dir_entries(path):
path = _normalize_input_path(path)
if _is_unc_path(path):
_register_smb_session_if_needed(path)
return list(smbclient.scandir(path))
return list(os.scandir(path))
def _join_path(path, name):
if _is_unc_path(path):
base = path.rstrip("\\")
return f"{base}\\{name}"
return os.path.join(path, name)
def _read_text_lines(path):
path = _normalize_input_path(path)
if _is_unc_path(path):
_register_smb_session_if_needed(path)
with smbclient.open_file(path, mode="r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
yield line
return
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
yield line
def scan_results_only(results_dir, results_dir_ref=None):
"""Re-scan only the results directories without clearing or re-scanning targets."""
results_dir = _normalize_input_path(results_dir)
results_dir_ref = _normalize_input_path(results_dir_ref)
if not results_dir:
return
print(f"[scanner] results-only scan dir : {results_dir}")
if results_dir_ref:
print(f"[scanner] results-only scan dir ref : {results_dir_ref}")
scan_results(results_dir)
if results_dir_ref:
scan_results(results_dir_ref)
update_p2p_coe_pairs()
update_p3p_throttle_pairs()
def full_scan(target_dir, results_dir, results_dir_ref=None):
target_dir = _normalize_input_path(target_dir)
results_dir = _normalize_input_path(results_dir)
results_dir_ref = _normalize_input_path(results_dir_ref)
clear_tests() clear_tests()
if not target_dir or not results_dir: if not target_dir or not results_dir:
return return
print(f"[scanner] target dir : {target_dir}") print(f"[scanner] target dir : {target_dir}")
print(f"[scanner] results dir: {results_dir}") print(f"[scanner] results dir : {results_dir}")
if results_dir_ref:
print(f"[scanner] results dir ref : {results_dir_ref}")
scan_targets(target_dir) scan_targets(target_dir)
scan_results(results_dir) scan_results(results_dir)
if results_dir_ref:
scan_results(results_dir_ref)
update_p2p_coe_pairs()
update_p3p_throttle_pairs()
def update_p2p_coe_pairs():
pair_fields = [
"device",
"rotation",
"test_point",
"rssi",
"station",
"band",
"channel",
"bandwidth",
"direction",
]
tests = get_all_tests()
coe_by_key = {}
for test in tests:
if test.get("interference") != "COE":
continue
key = tuple(test.get(field) for field in pair_fields)
device = test.get("device")
test_id = test.get("test_id")
if not device or not test_id:
continue
coe_by_key.setdefault(key, []).append(f"{device}_{test_id}")
updated = 0
for test in tests:
if test.get("interference") != "P2P":
continue
key = tuple(test.get(field) for field in pair_fields)
pairs = sorted(set(coe_by_key.get(key, [])))
set_coe_pair(test.get("id"), pairs)
updated += 1
print(f"[scanner] coe_pair updated for {updated} P2P test(s)")
def update_p3p_throttle_pairs():
tests = get_all_tests()
p3p_lookup = {}
for test in tests:
if test.get("interference") != "P3P":
continue
device = test.get("device")
test_id = test.get("test_id")
if not device or not test_id:
continue
p3p_lookup.setdefault(f"{str(device).upper()}_{str(test_id).upper()}", []).append(test)
def _pair_test_id(test_id, throttled):
if not test_id or not throttled:
return None
upper_id = str(test_id).upper()
upper_throttled = str(throttled).upper()
if upper_throttled == "TH":
return re.sub("TH", "UT", upper_id, count=1)
if upper_throttled == "UT":
return re.sub("UT", "TH", upper_id, count=1)
return None
updated = 0
for test in tests:
if test.get("interference") != "P3P":
continue
device = test.get("device")
test_id = test.get("test_id")
pair_test_id = _pair_test_id(test_id, test.get("throttled"))
pairs = []
if device and pair_test_id:
lookup_key = f"{str(device).upper()}_{str(pair_test_id).upper()}"
matches = p3p_lookup.get(lookup_key, [])
for match in matches:
match_device = match.get("device")
match_test_id = match.get("test_id")
if match_device and match_test_id:
pairs.append(f"{match_device}_{match_test_id}")
set_p3p_pair(test.get("id"), sorted(set(pairs)))
updated += 1
print(f"[scanner] p3p_pair updated for {updated} P3P test(s)")
def scan_targets(target_dir): def scan_targets(target_dir):
target_dir = _normalize_input_path(target_dir)
try: try:
parent_entries = [ parent_entries = [
name entry.name
for name in os.listdir(target_dir) for entry in _iter_dir_entries(target_dir)
if os.path.isdir(os.path.join(target_dir, name)) if entry.is_dir()
] ]
except OSError as exc: except OSError as exc:
print(f"[scanner] Cannot read target dir: {exc}") print(f"[scanner] Cannot read target dir: {exc}")
@@ -37,14 +252,14 @@ def scan_targets(target_dir):
print(f"[scanner] subdirectories found: {len(parent_entries)}") print(f"[scanner] subdirectories found: {len(parent_entries)}")
for parent_name in parent_entries: for parent_name in parent_entries:
parent_path = os.path.join(target_dir, parent_name) parent_path = _join_path(target_dir, parent_name)
try: try:
files = [ files = [
name entry.name
for name in os.listdir(parent_path) for entry in _iter_dir_entries(parent_path)
if os.path.isfile(os.path.join(parent_path, name)) if entry.is_file()
and not name.startswith("GLOBAL") and not entry.name.startswith("GLOBAL")
and name.endswith(".ini") and entry.name.endswith(".ini")
] ]
except OSError as exc: except OSError as exc:
print(f"[scanner] Cannot read parent dir {parent_name}: {exc}") print(f"[scanner] Cannot read parent dir {parent_name}: {exc}")
@@ -77,16 +292,19 @@ def scan_targets(target_dir):
"bandwidth": parsed["bandwidth"], "bandwidth": parsed["bandwidth"],
"rssi": parsed["rssi"], "rssi": parsed["rssi"],
"direction": parsed["direction"], "direction": parsed["direction"],
"throttled": parsed["throttled"],
} }
) )
def scan_results(results_dir): def scan_results(results_dir):
results_dir = _normalize_input_path(results_dir)
try: try:
entries = [ entries = [
name entry.name
for name in os.listdir(results_dir) for entry in _iter_dir_entries(results_dir)
if os.path.isdir(os.path.join(results_dir, name)) if entry.is_dir()
] ]
except OSError as exc: except OSError as exc:
print(f"[scanner] Cannot read results dir: {exc}") print(f"[scanner] Cannot read results dir: {exc}")
@@ -98,21 +316,23 @@ def scan_results(results_dir):
def process_result_dir(results_dir, result_dir_name): def process_result_dir(results_dir, result_dir_name):
results_dir = _normalize_input_path(results_dir)
parsed = parse_result_filename(result_dir_name) parsed = parse_result_filename(result_dir_name)
test_id = parsed["test_id"] test_id = parsed["test_id"]
device = parsed["device"] device = parsed["device"]
if not test_id or not device: if not test_id or not device:
return return
result_dir_path = os.path.join(results_dir, result_dir_name) result_dir_path = _join_path(results_dir, result_dir_name)
try: try:
log_files = [ log_files = [
name entry.name
for name in os.listdir(result_dir_path) for entry in _iter_dir_entries(result_dir_path)
if os.path.isfile(os.path.join(result_dir_path, name)) if entry.is_file()
and name.endswith(".txt") and entry.name.endswith(".txt")
and test_id in name and test_id in entry.name
] ]
except OSError as exc: except OSError as exc:
print(f"[scanner] Cannot read result dir {result_dir_name}: {exc}") print(f"[scanner] Cannot read result dir {result_dir_name}: {exc}")
@@ -124,7 +344,7 @@ def process_result_dir(results_dir, result_dir_name):
return return
latest_log = sorted(log_files)[-1] latest_log = sorted(log_files)[-1]
log_path = os.path.join(result_dir_path, latest_log) log_path = _join_path(result_dir_path, latest_log)
completed_at = parse_timestamp(latest_log) completed_at = parse_timestamp(latest_log)
data = extract_log_data(log_path) data = extract_log_data(log_path)
@@ -156,17 +376,16 @@ def extract_log_data(log_file_path):
result = {"duration_seconds": None, "tputResults": []} result = {"duration_seconds": None, "tputResults": []}
try: try:
with open(log_file_path, "r", encoding="utf-8", errors="ignore") as fh: for line in _read_text_lines(log_file_path):
for line in fh: time_match = elapsed_regex.search(line)
time_match = elapsed_regex.search(line) if time_match:
if time_match: result["duration_seconds"] = parse_elapsed_time(time_match.group(1))
result["duration_seconds"] = parse_elapsed_time(time_match.group(1)) continue
continue
if tput_regex.search(line): if tput_regex.search(line):
parsed = parse_tput_rssi(line) parsed = parse_tput_rssi(line)
if parsed: if parsed:
result["tputResults"].append(parsed) result["tputResults"].append(parsed)
except OSError as exc: except OSError as exc:
print(f"[scanner] Cannot read log file {log_file_path}: {exc}") print(f"[scanner] Cannot read log file {log_file_path}: {exc}")