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 { 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -35,10 +42,14 @@ export default function ConfigModal({ onClose }) {
|
|||||||
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)' },
|
||||||
@@ -105,7 +153,8 @@ export default function ConfigModal({ onClose }) {
|
|||||||
<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,12 +201,58 @@ 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
|
||||||
|
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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{browser && (
|
{browser && (
|
||||||
<DirectoryBrowser
|
<DirectoryBrowser
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<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-slate-400 text-xs uppercase tracking-widest truncate">{label}</p>
|
||||||
<p className={`text-3xl font-bold ${accent ?? 'text-slate-100'}`}>{value}</p>
|
<p className={`text-3xl font-bold ${accent ?? 'text-slate-100'}`}>{value}</p>
|
||||||
{sub && <p className="text-slate-400 text-sm">{sub}</p>}
|
{sub && <p className="text-slate-400 text-sm">{sub}</p>}
|
||||||
</div>
|
</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 }) {
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
+243
-24
@@ -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,8 +376,7 @@ 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))
|
||||||
|
|||||||
Reference in New Issue
Block a user