Merge pull request #2 from Mia-Wu_wnc/python-migration
Fixed Network path, added COE pairings and P3P pairings
This commit is contained in:
+24
-4
@@ -1,5 +1,4 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { useStats } from './hooks/useStats'
|
||||
import { useTests } from './hooks/useTests'
|
||||
@@ -11,8 +10,19 @@ import FilterPanel from './components/FilterPanel'
|
||||
import TestTable from './components/TestTable'
|
||||
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() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [filters, setFilters] = useState({})
|
||||
|
||||
@@ -28,10 +38,15 @@ export default function App() {
|
||||
if (t.completed !== want) return false
|
||||
}
|
||||
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) {
|
||||
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
|
||||
})
|
||||
}, [allTests, filters])
|
||||
@@ -99,6 +114,11 @@ export default function App() {
|
||||
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>
|
||||
@@ -120,7 +140,7 @@ export default function App() {
|
||||
<p className="text-slate-500 text-xs">
|
||||
Showing {filteredTests.length} of {allTests.length} tests
|
||||
</p>
|
||||
<TestTable tests={filteredTests} isLoading={testsLoading} />
|
||||
<TestTable tests={filteredTests} allTests={allTests} isLoading={testsLoading} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import DirectoryBrowser from './DirectoryBrowser'
|
||||
import { useConfig, useSaveConfig } from '../hooks/useConfig'
|
||||
import { apiFetch } from '../lib/api'
|
||||
|
||||
function fmtSeconds(s) {
|
||||
if (s == null) return ''
|
||||
@@ -16,15 +17,21 @@ export default function ConfigModal({ onClose }) {
|
||||
const [browser, setBrowser] = useState(null) // 'target_dir' | 'results_dir' | null
|
||||
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
|
||||
const [saveError, setSaveError] = useState(null)
|
||||
const [isRescanning, setIsRescanning] = useState(false)
|
||||
const [isSavingTimes, setIsSavingTimes] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setForm({
|
||||
target_dir: config.target_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_p2p: config.avg_time_p2p ? fmtSeconds(config.avg_time_p2p) : '',
|
||||
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])
|
||||
@@ -35,10 +42,14 @@ export default function ConfigModal({ onClose }) {
|
||||
const payload = {
|
||||
target_dir: form.target_dir || null,
|
||||
results_dir: form.results_dir || null,
|
||||
results_dir_ref: form.results_dir_ref || null,
|
||||
// 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_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,
|
||||
smb_username: form.smb_username || null,
|
||||
smb_password: form.smb_password || null,
|
||||
smb_domain: form.smb_domain || null,
|
||||
}
|
||||
save(payload, {
|
||||
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 = [
|
||||
{ key: 'avg_time_coe', label: 'COE avg time (min)' },
|
||||
{ key: 'avg_time_p2p', label: 'P2P avg time (min)' },
|
||||
@@ -105,7 +153,8 @@ export default function ConfigModal({ onClose }) {
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={key}>
|
||||
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
||||
@@ -152,12 +201,58 @@ export default function ConfigModal({ onClose }) {
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* 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
|
||||
onClick={handleRescanResults}
|
||||
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"
|
||||
>
|
||||
{isRescanning ? 'Rescanning…' : 'Save Results'}
|
||||
</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"
|
||||
@@ -166,14 +261,15 @@ export default function ConfigModal({ onClose }) {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
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 & Rescan'}
|
||||
{isPending ? 'Saving…' : 'Save All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{browser && (
|
||||
<DirectoryBrowser
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
|
||||
const [dirs, setDirs] = useState(null) // null = not loaded yet
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [manualPath, setManualPath] = useState('')
|
||||
|
||||
async function navigate(path) {
|
||||
setLoading(true)
|
||||
@@ -16,6 +17,7 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
|
||||
setCurrent(data.path)
|
||||
setParent(data.parent)
|
||||
setDirs(data.dirs)
|
||||
setManualPath(data.path ?? '')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} 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
|
||||
if (dirs === null && !loading && !error) {
|
||||
navigate(null)
|
||||
}
|
||||
|
||||
const breadcrumbs = current ? current.replace(/\\/g, '/').split('/').filter(Boolean) : []
|
||||
const isUnixPath = !!current && current.startsWith('/')
|
||||
const normalizedCurrent = current ?? ''
|
||||
const normalizedPath = normalizedCurrent.replace(/\\/g, '/')
|
||||
const isUnixPath = normalizedCurrent.startsWith('/')
|
||||
const breadcrumbs = normalizedPath ? normalizedPath.split('/').filter(Boolean) : []
|
||||
|
||||
function breadcrumbPathAt(index) {
|
||||
const parts = breadcrumbs.slice(0, index + 1)
|
||||
@@ -50,7 +63,7 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
|
||||
|
||||
{/* 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]">
|
||||
<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) => {
|
||||
const path = breadcrumbPathAt(i)
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const FILTER_FIELDS = [
|
||||
{ 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: 'coePair', label: 'COE Pairs', options: [{ value: '', label: 'All' }, { value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }] },
|
||||
]
|
||||
|
||||
const DERIVED_FIELDS = [
|
||||
@@ -13,6 +14,7 @@ const DERIVED_FIELDS = [
|
||||
{ key: 'channel', label: 'Channel' },
|
||||
{ key: 'bandwidth', label: 'Bandwidth' },
|
||||
{ key: 'direction', label: 'Direction' },
|
||||
{ key: 'throttled', label: 'Throttled' },
|
||||
]
|
||||
|
||||
function unique(tests, key) {
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<div className={`flex ${detailsOnRight ? 'items-start justify-between gap-4' : 'flex-col gap-1'}`}>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default function StatusBadge({ 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>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
const TYPE_COLORS = {
|
||||
@@ -7,6 +7,35 @@ const TYPE_COLORS = {
|
||||
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) {
|
||||
if (s == null) return null
|
||||
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 [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) {
|
||||
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) => {
|
||||
let av = a[sort.key] ?? ''
|
||||
let bv = b[sort.key] ?? ''
|
||||
if (typeof av === 'number' || typeof bv === 'number') return (av - 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) {
|
||||
return (
|
||||
@@ -82,6 +141,22 @@ export default function TestTable({ tests = [], isLoading }) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<thead className="bg-slate-800 text-slate-400 text-xs uppercase tracking-wider">
|
||||
<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'}`}>
|
||||
<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="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="Device" value={test.device} />
|
||||
<TagPill label="Rotation" value={test.rotation} />
|
||||
@@ -127,11 +210,12 @@ export default function TestTable({ tests = [], isLoading }) {
|
||||
<TagPill label="Band" value={test.band} />
|
||||
<TagPill label="Channel" value={test.channel} />
|
||||
<TagPill label="Bandwidth" value={test.bandwidth} />
|
||||
<TagPill label="Throttle" value={test.throttled} />
|
||||
<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) : []
|
||||
const rows = parseTputRows(test.tput_results)
|
||||
if (!test.completed || rows.length === 0) return null
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-slate-700/50">
|
||||
@@ -158,6 +242,182 @@ export default function TestTable({ tests = [], isLoading }) {
|
||||
</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>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -6,16 +6,33 @@ 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 estimateDays(seconds) {
|
||||
const s = Number(seconds)
|
||||
if (!Number.isFinite(s) || s < 0) return null
|
||||
|
||||
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) {
|
||||
if (seconds == null) return null
|
||||
const days = seconds / 57600 // 16 hours per day
|
||||
function fmtDaysLabel(days) {
|
||||
if (days == null || !Number.isFinite(days)) return null
|
||||
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)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
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)
|
||||
: []
|
||||
|
||||
const days = fmtDays(estimatedRemainingSeconds)
|
||||
const completionDate = fmtCompletionDate(estimatedRemainingSeconds)
|
||||
const estimatedDays = estimateDays(estimatedRemainingSeconds)
|
||||
const daysLabel = fmtDaysLabel(estimatedDays)
|
||||
const completionDate = fmtCompletionDate(estimatedDays)
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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) ?? '—'}
|
||||
</p>
|
||||
</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'}`}>
|
||||
{estimatedRemainingSeconds != null ? fmt(estimatedRemainingSeconds) : '—'}
|
||||
</p>
|
||||
{days && (
|
||||
<p className="text-slate-400 text-xs mt-0.5">{days}</p>
|
||||
{daysLabel && (
|
||||
<p className="text-slate-400 text-xs mt-0.5">{daysLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
{completionDate && (
|
||||
|
||||
+77
-8
@@ -5,7 +5,7 @@ from flask import Flask, Response, jsonify, request, send_from_directory
|
||||
from flask_cors import CORS
|
||||
|
||||
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 watcher import start_watching
|
||||
|
||||
@@ -13,9 +13,13 @@ PORT = int(os.getenv("PORT", "3001"))
|
||||
ALLOWED_KEYS = {
|
||||
"target_dir",
|
||||
"results_dir",
|
||||
"results_dir_ref",
|
||||
"avg_time_coe",
|
||||
"avg_time_p2p",
|
||||
"avg_time_p3p",
|
||||
"smb_username",
|
||||
"smb_password",
|
||||
"smb_domain",
|
||||
}
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
@@ -25,10 +29,25 @@ app = Flask(__name__, static_folder=str(DIST_DIR), static_url_path="")
|
||||
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")
|
||||
def get_tests_route():
|
||||
completed = request.args.get("completed")
|
||||
interference = request.args.get("interference")
|
||||
throttled = request.args.get("throttled")
|
||||
device = request.args.get("device")
|
||||
rotation = request.args.get("rotation")
|
||||
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]
|
||||
if 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:
|
||||
tests = [t for t in tests if t.get("device") == device]
|
||||
if rotation:
|
||||
@@ -72,6 +93,7 @@ def get_tests_route():
|
||||
@app.get("/api/stats")
|
||||
def get_stats_route():
|
||||
tests = get_all_tests()
|
||||
types = ["COE", "P2P", "P3P"]
|
||||
|
||||
total_completed = sum(1 for t in tests if t.get("completed"))
|
||||
overall = {
|
||||
@@ -87,10 +109,21 @@ def get_stats_route():
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
interference = test.get("interference")
|
||||
if interference in device_map[name]["byType"]:
|
||||
device_map[name]["byType"][interference]["total"] += 1
|
||||
|
||||
if test.get("completed"):
|
||||
device_map[name]["completed"] += 1
|
||||
if interference in device_map[name]["byType"]:
|
||||
device_map[name]["byType"][interference]["completed"] += 1
|
||||
|
||||
devices = []
|
||||
for name in sorted(device_map.keys()):
|
||||
@@ -101,6 +134,7 @@ def get_stats_route():
|
||||
"total": stats["total"],
|
||||
"completed": stats["completed"],
|
||||
"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:
|
||||
elapsed_seconds += duration
|
||||
|
||||
types = ["COE", "P2P", "P3P"]
|
||||
by_type = {}
|
||||
estimate_possible = True
|
||||
estimated_remaining_seconds = 0
|
||||
@@ -189,14 +222,16 @@ def set_config_route():
|
||||
else:
|
||||
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
|
||||
|
||||
if dirs_changed:
|
||||
_apply_smb_env_from_config()
|
||||
target_dir = get_config("target_dir")
|
||||
results_dir = get_config("results_dir")
|
||||
results_dir_ref = get_config("results_dir_ref")
|
||||
try:
|
||||
full_scan(target_dir, results_dir)
|
||||
full_scan(target_dir, results_dir, results_dir_ref)
|
||||
start_watching(target_dir, results_dir)
|
||||
except Exception as exc:
|
||||
print(f"[config] fullScan error: {exc}")
|
||||
@@ -208,18 +243,21 @@ def set_config_route():
|
||||
broadcast({"type": "update"})
|
||||
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
|
||||
|
||||
broadcast({"type": "update"})
|
||||
return jsonify({"ok": True, "testCount": None, "completedCount": None})
|
||||
|
||||
|
||||
@app.post("/api/config/rescan")
|
||||
def rescan_route():
|
||||
_apply_smb_env_from_config()
|
||||
target_dir = get_config("target_dir")
|
||||
results_dir = get_config("results_dir")
|
||||
results_dir_ref = get_config("results_dir_ref")
|
||||
if not target_dir or not results_dir:
|
||||
return jsonify({"error": "Directories not configured"}), 400
|
||||
|
||||
try:
|
||||
full_scan(target_dir, results_dir)
|
||||
full_scan(target_dir, results_dir, results_dir_ref)
|
||||
start_watching(target_dir, results_dir)
|
||||
except Exception as exc:
|
||||
print(f"[config] rescan error: {exc}")
|
||||
@@ -232,8 +270,37 @@ def rescan_route():
|
||||
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")
|
||||
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():
|
||||
raw = os.getenv("BROWSE_ROOTS", "").strip()
|
||||
if not raw:
|
||||
@@ -261,7 +328,7 @@ def browse_route():
|
||||
return False
|
||||
|
||||
roots = _configured_roots()
|
||||
req_path = request.args.get("path")
|
||||
req_path = _normalize_request_path(request.args.get("path"))
|
||||
|
||||
if not req_path:
|
||||
if roots:
|
||||
@@ -329,8 +396,10 @@ def static_or_spa(path=""):
|
||||
|
||||
|
||||
def bootstrap():
|
||||
_apply_smb_env_from_config()
|
||||
target_dir = get_config("target_dir")
|
||||
results_dir = get_config("results_dir")
|
||||
results_dir_ref = get_config("results_dir_ref")
|
||||
|
||||
if target_dir and results_dir:
|
||||
existing = count_tests()
|
||||
@@ -338,7 +407,7 @@ def bootstrap():
|
||||
print(f"[server] Resuming from DB -> {existing} tests already loaded.")
|
||||
else:
|
||||
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()
|
||||
completed = len([t for t in tests if t.get("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
@@ -64,6 +64,9 @@ def _init_db():
|
||||
"dl_rssi_dbm REAL",
|
||||
"ul_rssi_dbm REAL",
|
||||
"tput_results TEXT",
|
||||
"throttled TEXT",
|
||||
"coe_pair TEXT",
|
||||
"p3p_pair TEXT",
|
||||
]:
|
||||
try:
|
||||
_conn.execute(f"ALTER TABLE tests ADD COLUMN {col_def}")
|
||||
@@ -96,10 +99,10 @@ def upsert_test(test):
|
||||
"""
|
||||
INSERT INTO tests
|
||||
(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
|
||||
(: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
|
||||
test_id = excluded.test_id,
|
||||
parent_dir = excluded.parent_dir,
|
||||
@@ -113,7 +116,8 @@ def upsert_test(test):
|
||||
channel = excluded.channel,
|
||||
bandwidth = excluded.bandwidth,
|
||||
rssi = excluded.rssi,
|
||||
direction = excluded.direction
|
||||
direction = excluded.direction,
|
||||
throttled = excluded.throttled
|
||||
""",
|
||||
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):
|
||||
with _lock:
|
||||
row = _conn.execute(
|
||||
|
||||
@@ -11,6 +11,11 @@ def parse_target_filename(filename, parent_dir):
|
||||
test_identifier = f"{parent_dir}/{base_name}"
|
||||
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)
|
||||
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
|
||||
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)
|
||||
@@ -39,6 +44,7 @@ def parse_target_filename(filename, parent_dir):
|
||||
"bandwidth": bandwidth,
|
||||
"direction": direction,
|
||||
"rotation": rotation,
|
||||
"throttled": throttled,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
Flask>=3.0.0,<4.0.0
|
||||
Flask-Cors>=4.0.1,<5.0.0
|
||||
watchdog>=4.0.1,<5.0.0
|
||||
smbprotocol>=1.13.0,<2.0.0
|
||||
|
||||
+242
-23
@@ -1,7 +1,15 @@
|
||||
import os
|
||||
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 (
|
||||
parse_elapsed_time,
|
||||
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()
|
||||
if not target_dir or not results_dir:
|
||||
return
|
||||
|
||||
print(f"[scanner] target dir : {target_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_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):
|
||||
target_dir = _normalize_input_path(target_dir)
|
||||
|
||||
try:
|
||||
parent_entries = [
|
||||
name
|
||||
for name in os.listdir(target_dir)
|
||||
if os.path.isdir(os.path.join(target_dir, name))
|
||||
entry.name
|
||||
for entry in _iter_dir_entries(target_dir)
|
||||
if entry.is_dir()
|
||||
]
|
||||
except OSError as 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)}")
|
||||
|
||||
for parent_name in parent_entries:
|
||||
parent_path = os.path.join(target_dir, parent_name)
|
||||
parent_path = _join_path(target_dir, parent_name)
|
||||
try:
|
||||
files = [
|
||||
name
|
||||
for name in os.listdir(parent_path)
|
||||
if os.path.isfile(os.path.join(parent_path, name))
|
||||
and not name.startswith("GLOBAL")
|
||||
and name.endswith(".ini")
|
||||
entry.name
|
||||
for entry in _iter_dir_entries(parent_path)
|
||||
if entry.is_file()
|
||||
and not entry.name.startswith("GLOBAL")
|
||||
and entry.name.endswith(".ini")
|
||||
]
|
||||
except OSError as exc:
|
||||
print(f"[scanner] Cannot read parent dir {parent_name}: {exc}")
|
||||
@@ -77,16 +292,19 @@ def scan_targets(target_dir):
|
||||
"bandwidth": parsed["bandwidth"],
|
||||
"rssi": parsed["rssi"],
|
||||
"direction": parsed["direction"],
|
||||
"throttled": parsed["throttled"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def scan_results(results_dir):
|
||||
results_dir = _normalize_input_path(results_dir)
|
||||
|
||||
try:
|
||||
entries = [
|
||||
name
|
||||
for name in os.listdir(results_dir)
|
||||
if os.path.isdir(os.path.join(results_dir, name))
|
||||
entry.name
|
||||
for entry in _iter_dir_entries(results_dir)
|
||||
if entry.is_dir()
|
||||
]
|
||||
except OSError as 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):
|
||||
results_dir = _normalize_input_path(results_dir)
|
||||
|
||||
parsed = parse_result_filename(result_dir_name)
|
||||
test_id = parsed["test_id"]
|
||||
device = parsed["device"]
|
||||
if not test_id or not device:
|
||||
return
|
||||
|
||||
result_dir_path = os.path.join(results_dir, result_dir_name)
|
||||
result_dir_path = _join_path(results_dir, result_dir_name)
|
||||
|
||||
try:
|
||||
log_files = [
|
||||
name
|
||||
for name in os.listdir(result_dir_path)
|
||||
if os.path.isfile(os.path.join(result_dir_path, name))
|
||||
and name.endswith(".txt")
|
||||
and test_id in name
|
||||
entry.name
|
||||
for entry in _iter_dir_entries(result_dir_path)
|
||||
if entry.is_file()
|
||||
and entry.name.endswith(".txt")
|
||||
and test_id in entry.name
|
||||
]
|
||||
except OSError as 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
|
||||
|
||||
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)
|
||||
data = extract_log_data(log_path)
|
||||
|
||||
@@ -156,8 +376,7 @@ def extract_log_data(log_file_path):
|
||||
|
||||
result = {"duration_seconds": None, "tputResults": []}
|
||||
try:
|
||||
with open(log_file_path, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
for line in _read_text_lines(log_file_path):
|
||||
time_match = elapsed_regex.search(line)
|
||||
if time_match:
|
||||
result["duration_seconds"] = parse_elapsed_time(time_match.group(1))
|
||||
|
||||
Reference in New Issue
Block a user