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 c2b7da4..24afa24 100644 --- a/dashboard/src/components/DirectoryBrowser.jsx +++ b/dashboard/src/components/DirectoryBrowser.jsx @@ -8,7 +8,6 @@ export default function DirectoryBrowser({ onSelect, onClose }) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [manualPath, setManualPath] = useState('') - const [networkHost, setNetworkHost] = useState('') async function navigate(path) { setLoading(true) @@ -35,31 +34,18 @@ export default function DirectoryBrowser({ onSelect, onClose }) { navigate(path) } - function goToHost() { - const host = networkHost.trim() - if (!host) return - navigate(`\\\\${host}`) - } - // Load roots on first render if (dirs === null && !loading && !error) { navigate(null) } const normalizedCurrent = current ?? '' + const normalizedPath = normalizedCurrent.replace(/\\/g, '/') const isUnixPath = normalizedCurrent.startsWith('/') - const isUncPath = normalizedCurrent.startsWith('\\\\') - const breadcrumbs = normalizedCurrent - ? (isUncPath - ? normalizedCurrent.slice(2).split(/\\+/).filter(Boolean) - : normalizedCurrent.replace(/\\/g, '/').split('/').filter(Boolean)) - : [] + const breadcrumbs = normalizedPath ? normalizedPath.split('/').filter(Boolean) : [] function breadcrumbPathAt(index) { const parts = breadcrumbs.slice(0, index + 1) - if (isUncPath) { - return `\\\\${parts.join('\\')}` - } if (isUnixPath) { return `/${parts.join('/')}` } @@ -95,40 +81,6 @@ export default function DirectoryBrowser({ onSelect, onClose }) { })} - {/* Jump controls */} -
-
- setManualPath(e.target.value)} - placeholder="Path (e.g. C:\\data or \\\\192.168.1.10\\share)" - className="flex-1 bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500" - /> - -
-
- setNetworkHost(e.target.value)} - placeholder="Network host/IP (e.g. 192.168.1.10)" - className="flex-1 bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500" - /> - -
-
- {/* Directory list */}
{loading && ( diff --git a/dashboard/src/components/TestTable.jsx b/dashboard/src/components/TestTable.jsx index 561e4c1..ccfd054 100644 --- a/dashboard/src/components/TestTable.jsx +++ b/dashboard/src/components/TestTable.jsx @@ -210,6 +210,34 @@ export default function TestTable({ tests = [], allTests = [], isLoading }) {
+ {(() => { + const rows = parseTputRows(test.tput_results) + if (!test.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.coe_pair) if (pairs.length === 0) return null @@ -298,34 +326,6 @@ export default function TestTable({ tests = [], allTests = [], isLoading }) { ) })()} - {(() => { - const rows = parseTputRows(test.tput_results) - if (!test.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
-
- ) - })()} )} diff --git a/server/app.py b/server/app.py index 5ad4b50..a360072 100644 --- a/server/app.py +++ b/server/app.py @@ -1,12 +1,11 @@ import os -import subprocess from pathlib import Path 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 @@ -14,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 @@ -26,6 +29,20 @@ 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") @@ -205,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}") @@ -224,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}") @@ -248,6 +270,27 @@ 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): @@ -258,48 +301,6 @@ def browse_route(): p = p.replace("/", "\\") return p - def _is_unc_host_only(path): - if os.name != "nt" or not path: - return False - p = path.rstrip("\\") - if not p.startswith("\\\\"): - return False - remainder = p[2:] - return bool(remainder) and "\\" not in remainder - - def _shares_for_unc_host(host): - # Enumerate SMB shares with native Windows tooling for host-only UNC paths. - proc = subprocess.run( - ["net", "view", f"\\\\{host}"], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - if proc.returncode != 0: - raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "Cannot query network host") - - shares = [] - in_table = False - for raw_line in proc.stdout.splitlines(): - line = raw_line.strip() - if not line: - continue - if line.startswith("---"): - in_table = True - continue - if not in_table: - continue - if line.lower().startswith("the command completed successfully"): - break - - first = line.split()[0] - if first and first.lower() not in {"share", "name"}: - shares.append(first) - - unique = sorted(set(shares), key=str.lower) - return [{"name": share, "path": f"\\\\{host}\\{share}"} for share in unique] - def _configured_roots(): raw = os.getenv("BROWSE_ROOTS", "").strip() if not raw: @@ -326,17 +327,6 @@ def browse_route(): continue return False - def _is_unc_host_allowed(host, roots): - if not roots: - return True - host_prefix = os.path.normcase(f"\\\\{host}\\") - host_exact = host_prefix.rstrip("\\") - for root in roots: - normalized_root = os.path.normcase(root) - if normalized_root == host_exact or normalized_root.startswith(host_prefix): - return True - return False - roots = _configured_roots() req_path = _normalize_request_path(request.args.get("path")) @@ -359,16 +349,6 @@ def browse_route(): return jsonify({"path": None, "parent": None, "dirs": dirs}) - if _is_unc_host_only(req_path): - host = req_path.rstrip("\\")[2:] - if not _is_unc_host_allowed(host, roots): - return jsonify({"error": "Path is outside allowed browse roots"}), 403 - try: - dirs = _shares_for_unc_host(host) - except Exception as exc: - return jsonify({"error": f"Cannot list shares for host: {exc}"}), 403 - return jsonify({"path": f"\\\\{host}", "parent": None, "dirs": dirs}) - if not os.path.exists(req_path): return jsonify({"error": "Path does not exist"}), 400 if not os.path.isdir(req_path): @@ -416,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() @@ -425,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 f95366c..ff2d7df 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 59c5e99..a47482d 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 c442712..5ed745f 100644 Binary files a/server/dashboard.db-wal and b/server/dashboard.db-wal differ 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 76304ba..5b6d61d 100644 --- a/server/scanner.py +++ b/server/scanner.py @@ -1,5 +1,6 @@ import os import re +import smbclient from db_py import clear_tests, get_all_tests, mark_completed, set_coe_pair, upsert_test from parser import ( @@ -11,16 +12,125 @@ 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() + + +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() @@ -66,11 +176,13 @@ def update_p2p_coe_pairs(): 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}") @@ -79,14 +191,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}") @@ -125,11 +237,13 @@ def scan_targets(target_dir): 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}") @@ -141,21 +255,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}") @@ -167,7 +283,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) @@ -199,17 +315,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}")