diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index c13ad5c..e77876e 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -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` + })} /> @@ -120,7 +140,7 @@ export default function App() {

Showing {filteredTests.length} of {allTests.length} tests

- + diff --git a/dashboard/src/components/ConfigModal.jsx b/dashboard/src/components/ConfigModal.jsx index 942028e..72eaa4d 100644 --- a/dashboard/src/components/ConfigModal.jsx +++ b/dashboard/src/components/ConfigModal.jsx @@ -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 ?? '', + 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]) @@ -33,12 +40,16 @@ export default function ConfigModal({ onClose }) { setSaveError(null) setScanResult(null) const payload = { - target_dir: form.target_dir || null, - results_dir: form.results_dir || null, + 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)' }, @@ -104,8 +152,9 @@ export default function ConfigModal({ onClose }) {
{[ - { key: 'target_dir', label: 'Target Tests Directory' }, - { key: 'results_dir', label: 'Results Directory' }, + { key: 'target_dir', label: 'Target Tests Directory' }, + { key: 'results_dir', label: 'Results Directory (DUT)' }, + { key: 'results_dir_ref', label: 'Results Directory (Reference)' }, ].map(({ key, label }) => (
@@ -152,25 +201,72 @@ export default function ConfigModal({ onClose }) { ))}
+ + {/* SMB Credentials */} +
+

SMB Credentials

+

+ Optional credentials for network shares used by target/results directories. +

+
+ 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" + /> + 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" + /> + 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" + /> +
+
)}
{/* Footer */} -
+
- +
+ + + +
diff --git a/dashboard/src/components/DirectoryBrowser.jsx b/dashboard/src/components/DirectoryBrowser.jsx index c9a74a8..24afa24 100644 --- a/dashboard/src/components/DirectoryBrowser.jsx +++ b/dashboard/src/components/DirectoryBrowser.jsx @@ -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 */}
- + {breadcrumbs.map((part, i) => { const path = breadcrumbPathAt(i) return ( diff --git a/dashboard/src/components/FilterPanel.jsx b/dashboard/src/components/FilterPanel.jsx index 8b46c82..597f4f4 100644 --- a/dashboard/src/components/FilterPanel.jsx +++ b/dashboard/src/components/FilterPanel.jsx @@ -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) { diff --git a/dashboard/src/components/StatCard.jsx b/dashboard/src/components/StatCard.jsx index e1a4a04..2691fd7 100644 --- a/dashboard/src/components/StatCard.jsx +++ b/dashboard/src/components/StatCard.jsx @@ -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 ( -
-

{label}

-

{value}

- {sub &&

{sub}

} +
+
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ + {detailsOnRight && ( +
+ {details.map((line) => ( +

+ {line} +

+ ))} +
+ )} +
+ + {showDetails && !detailsOnRight && ( +
+ {details.map((line) => ( +

+ {line} +

+ ))} +
+ )}
) } diff --git a/dashboard/src/components/StatusBadge.jsx b/dashboard/src/components/StatusBadge.jsx index 4744623..3a8df79 100644 --- a/dashboard/src/components/StatusBadge.jsx +++ b/dashboard/src/components/StatusBadge.jsx @@ -1,5 +1,5 @@ export default function StatusBadge({ completed }) { return completed - ? ✓ Done + ? ✓ Completed : ○ Pending } diff --git a/dashboard/src/components/TestTable.jsx b/dashboard/src/components/TestTable.jsx index 84aa7de..9b86ba4 100644 --- a/dashboard/src/components/TestTable.jsx +++ b/dashboard/src/components/TestTable.jsx @@ -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 (
+
+ + +
@@ -117,7 +192,15 @@ export default function TestTable({ tests = [], isLoading }) { )} diff --git a/dashboard/src/components/TimeDisplay.jsx b/dashboard/src/components/TimeDisplay.jsx index e848cbc..d1397cf 100644 --- a/dashboard/src/components/TimeDisplay.jsx +++ b/dashboard/src/components/TimeDisplay.jsx @@ -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 (

Time Elapsed

-

+

{fmt(elapsedSeconds) ?? '—'}

@@ -43,8 +61,8 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,

{estimatedRemainingSeconds != null ? fmt(estimatedRemainingSeconds) : '—'}

- {days && ( -

{days}

+ {daysLabel && ( +

{daysLabel}

)}
{completionDate && ( diff --git a/server/app.py b/server/app.py index be1fd88..a360072 100644 --- a/server/app.py +++ b/server/app.py @@ -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") diff --git a/server/dashboard.db b/server/dashboard.db index f937ecf..5d734f6 100644 Binary files a/server/dashboard.db and b/server/dashboard.db differ diff --git a/server/dashboard.db-shm b/server/dashboard.db-shm index 46874e5..73f4f32 100644 Binary files a/server/dashboard.db-shm and b/server/dashboard.db-shm differ diff --git a/server/dashboard.db-wal b/server/dashboard.db-wal index b9000fd..fb2f5c5 100644 Binary files a/server/dashboard.db-wal and b/server/dashboard.db-wal differ diff --git a/server/db_py.py b/server/db_py.py index 9f6dbba..dd3abcb 100644 --- a/server/db_py.py +++ b/server/db_py.py @@ -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( diff --git a/server/parser.py b/server/parser.py index f243938..76a8b36 100644 --- a/server/parser.py +++ b/server/parser.py @@ -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, } diff --git a/server/requirements.txt b/server/requirements.txt index 5227761..3be0ebb 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -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 diff --git a/server/scanner.py b/server/scanner.py index 8cd16de..65051de 100644 --- a/server/scanner.py +++ b/server/scanner.py @@ -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 ///... 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}") + 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,17 +376,16 @@ 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: - time_match = elapsed_regex.search(line) - if time_match: - result["duration_seconds"] = parse_elapsed_time(time_match.group(1)) - continue + 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)) + continue - if tput_regex.search(line): - parsed = parse_tput_rssi(line) - if parsed: - result["tputResults"].append(parsed) + if tput_regex.search(line): + parsed = parse_tput_rssi(line) + if parsed: + result["tputResults"].append(parsed) except OSError as exc: print(f"[scanner] Cannot read log file {log_file_path}: {exc}")
- + { + const pairs = parsePairArray(test.coe_pair) + return pairs.length > 0 ? String(pairs.length) : null + })()} colorClass="bg-cyan-900/50 text-cyan-300" /> + { + const pairs = parsePairArray(test.p3p_pair) + return pairs.length > 0 ? String(pairs.length) : null + })()} colorClass="bg-orange-900/50 text-orange-300" /> + @@ -127,11 +210,12 @@ export default function TestTable({ tests = [], isLoading }) { +
{(() => { - 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 (
@@ -158,6 +242,182 @@ export default function TestTable({ tests = [], isLoading }) {
) })()} + {(() => { + const pairs = parsePairArray(test.coe_pair) + if (pairs.length === 0) return null + return ( +
+

Paired COE Tests

+
+ {pairs.map(pairKey => { + const pairTest = testsByLookupKey.get(pairKey) + const itemKey = `${test.id}-${pairKey}` + const open = pairedExpanded.has(`${test.id}::${pairKey}`) + + if (!pairTest) { + return ( +
+ {pairKey} +
+ ) + } + + return ( +
+ + + {open && ( +
+
+ + + + + + + + + + + + + +
+ {(() => { + const rows = parseTputRows(pairTest.tput_results) + if (!pairTest.completed || rows.length === 0) return null + return ( +
+ + + + + + + + + + + {rows.map(r => ( + + + + + + + ))} + +
StationThroughputDL RSSIUL RSSI
STA{r.station}{r.tput} Mbps{r.dlRssi} dBm{r.ulRssi} dBm
+
+ ) + })()} +
+ )} +
+ ) + })} +
+
+ ) + })()} + {(() => { + const pairs = parsePairArray(test.p3p_pair) + if (pairs.length === 0) return null + return ( +
+

Paired P3P Tests

+
+ {pairs.map(pairKey => { + const pairTest = testsByLookupKey.get(pairKey) + const itemKey = `${test.id}-${pairKey}` + const open = pairedExpanded.has(`${test.id}::${pairKey}`) + + if (!pairTest) { + return ( +
+ {pairKey} +
+ ) + } + + return ( +
+ + + {open && ( +
+
+ + + + + + + + + + + + + +
+ {(() => { + const rows = parseTputRows(pairTest.tput_results) + if (!pairTest.completed || rows.length === 0) return null + return ( +
+ + + + + + + + + + + {rows.map(r => ( + + + + + + + ))} + +
StationThroughputDL RSSIUL RSSI
STA{r.station}{r.tput} Mbps{r.dlRssi} dBm{r.ulRssi} dBm
+
+ ) + })()} +
+ )} +
+ ) + })} +
+
+ ) + })()}