diff --git a/dashboard/src/components/ConfigModal.jsx b/dashboard/src/components/ConfigModal.jsx index 3f54fed..9a3b41e 100644 --- a/dashboard/src/components/ConfigModal.jsx +++ b/dashboard/src/components/ConfigModal.jsx @@ -24,6 +24,7 @@ export default function ConfigModal({ onClose }) { target_dir: config.target_dir ?? '', results_dir: config.results_dir ?? '', results_dir_ref: config.results_dir_ref ?? '', + scan_exclusions: config.scan_exclusions ?? '', 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) : '', @@ -41,6 +42,7 @@ export default function ConfigModal({ onClose }) { target_dir: form.target_dir || null, results_dir: form.results_dir || null, results_dir_ref: form.results_dir_ref || null, + scan_exclusions: form.scan_exclusions || 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, @@ -165,6 +167,19 @@ export default function ConfigModal({ onClose }) { /> ))} +
+ + setForm(f => ({ ...f, scan_exclusions: e.target.value }))} + placeholder="SP, BW320, CGW452_P3P, CGW452_COE, P3P_ROT2, P3P_ROT3" + 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" + /> +

+ Case-insensitive. Rules are split by commas. +

+
diff --git a/dashboard/src/components/TestTable.jsx b/dashboard/src/components/TestTable.jsx index 9b86ba4..f9df034 100644 --- a/dashboard/src/components/TestTable.jsx +++ b/dashboard/src/components/TestTable.jsx @@ -182,7 +182,7 @@ export default function TestTable({ tests = [], allTests = [], isLoading }) { {isOpen ? '▾' : '▸'} - {test.filename ?? '—'} + {test.filename.replace(".ini", "") ?? '—'} diff --git a/dashboard/src/components/TimeDisplay.jsx b/dashboard/src/components/TimeDisplay.jsx index 9b1c3d5..fa1f584 100644 --- a/dashboard/src/components/TimeDisplay.jsx +++ b/dashboard/src/components/TimeDisplay.jsx @@ -6,7 +6,7 @@ function fmt(seconds) { return `${m}m` } -function estimateDays(seconds) { +function estimateTime(seconds) { const s = Number(seconds) if (!Number.isFinite(s) || s < 0) return null @@ -26,7 +26,8 @@ function estimateDays(seconds) { function fmtDaysLabel(days) { if (days == null || !Number.isFinite(days)) return null - return days < 1 ? `${(days * 24).toFixed(1)}h` : `${days.toFixed(1)}d` + const h = parseInt(days % 1 * 24) + return days < 1 ? `${(days * 24).toFixed(1)}h` : `${parseInt(days)}d ${h}h` } function fmtCompletionDate(days) { @@ -43,7 +44,7 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds, .map(([t]) => t) : [] - const estimatedDays = estimateDays(estimatedRemainingSeconds) + const estimatedDays = estimateTime(estimatedRemainingSeconds) const daysLabel = fmtDaysLabel(estimatedDays) const completionDate = fmtCompletionDate(estimatedDays) @@ -59,11 +60,8 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,

Est. Time Remaining

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

- {daysLabel && ( -

{daysLabel}

- )}
{completionDate && (
diff --git a/docker-compose.yml b/docker-compose.yml index 0d549d2..f576d7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,14 +9,14 @@ services: # Linux VM: HOST_BROWSE_ROOT=/home/wnc # Windows Docker Desktop: HOST_BROWSE_ROOT=C:/Users # Then enter paths under /host/... in the dashboard settings. - - HOST_BROWSE_ROOT=${HOST_BROWSE_ROOT:-C:/Users} + - HOST_BROWSE_ROOT=${HOST_BROWSE_ROOT} - HOST_MOUNT_ROOT=/host volumes: - type: bind source: ./server target: /app - type: bind - source: ${HOST_BROWSE_ROOT:-C:/Users} + source: ${HOST_BROWSE_ROOT} target: /host restart: unless-stopped diff --git a/s -ExecutionPolicy RemoteSigned) ; (& cUsers26005101DesktopProjectstest_house_dashboardserver.venvScriptsActivate.ps1) b/s -ExecutionPolicy RemoteSigned) ; (& cUsers26005101DesktopProjectstest_house_dashboardserver.venvScriptsActivate.ps1) new file mode 100644 index 0000000..c4fccb1 --- /dev/null +++ b/s -ExecutionPolicy RemoteSigned) ; (& cUsers26005101DesktopProjectstest_house_dashboardserver.venvScriptsActivate.ps1) @@ -0,0 +1,97 @@ +commit 74d8a8565ed2963c458201f1fd68ee1e609e2799 (HEAD -> main, tag: v1.0.0, secondary/main, origin/main, origin/HEAD) +Author: Mia Wu +Date: Thu May 28 16:13:54 2026 -0400 + + remove all sp and bw320 test cases, remove p3p and coe for 452 + +commit c1aaf69fd039462f8db2f52d260e15f654881536 +Author: Mia Wu +Date: Thu May 28 15:14:21 2026 -0400 + + remove SP, P2P BW320, 452 P3P testcases + +commit 49310e6d591ca1f368cde8c197b158f956ed05d0 +Author: Mia Wu +Date: Thu May 28 12:34:01 2026 -0400 + + fix scan + +commit d239de257d82c5bd21bbc94cd9d8161717ac9c33 +Author: Mia Wu +Date: Thu May 28 11:01:47 2026 -0400 + + remove browse, add image + +commit 1cbb887e1df3634acdc4acd0503d95db4de88b37 +Merge: 72a6f84 7f253c4 +Author: Mia Wu +Date: Wed May 27 16:10:21 2026 -0400 + + Merge pull request #2 from Mia-Wu_wnc/python-migration + + Fixed Network path, added COE pairings and P3P pairings + +commit 7f253c4f741fd49bf29a2a6bd6b4e0176fa35698 (origin/python-migration) +Author: Mia Wu +Date: Wed May 27 16:07:46 2026 -0400 + + p3p pairs + +commit ba86f5830f16aa1265d71c879536bd5618d899ca +Author: Mia Wu +Date: Wed May 27 15:02:32 2026 -0400 + + fix: allow network paths + +commit 5996443f38f7a4afdceb81e8b9d8d72538ffdd46 +Author: Mia Wu +Date: Wed May 27 11:29:02 2026 -0400 + + coe pair filter + +commit 8da59570248e13ec251a11298fc43f64b5d7802c +Author: Mia Wu +Date: Wed May 27 10:34:52 2026 -0400 + + expand, collapse all + +commit d560b2e0b4f9fa253cf4ff7ddf436dd8e2f59b73 +Author: Mia Wu +Date: Wed May 27 10:29:26 2026 -0400 + + UI changes + +commit 72a6f8425bfa397cd9073cf8d61e6ab69f820c19 +Merge: a67815c 1ba2aa3 +Author: Mia Wu +Date: Tue May 26 14:56:58 2026 -0400 + + Merge pull request #1 from Mia-Wu_wnc/python-migration + + python migration + +commit 1ba2aa39d80af9367c839c7b5ea40b61d6d78507 +Author: Mia Wu +Date: Tue May 26 14:36:34 2026 -0400 + + python migration + +commit a67815c61a567e69bcbac61d5ce56bf491cda3a6 +Author: Mia Wu +Date: Thu May 21 14:53:08 2026 -0400 + + feat: refactor parsing and scanning logic for test files + + - Updated `parseFilename` to `parseTargetFilename` and modified its return structure to include `test_id` instead of `file_id`. + - Introduced `parseResultFilename` to extract `test_id` and `device` from result file names. + - Enhanced `fullScan` to separately handle target and results directories, improving clarity and functionality. + - Updated database interactions to use `test_id` instead of `file_id` across various modules. + - Added a new `/rescan` endpoint to trigger a full scan of target and results directories. + - Improved logging and error handling throughout the scanning process. + - Introduced `parseTputRssi` to extract throughput and RSSI data from log files. + +commit ed0f93036dbd0625771571c35cd3602bd36b9b6c +Author: Mia Wu +Date: Wed May 20 11:52:18 2026 -0400 + + Initial Commit diff --git a/server/app.py b/server/app.py index e2d953b..b4a0049 100644 --- a/server/app.py +++ b/server/app.py @@ -18,6 +18,7 @@ ALLOWED_KEYS = { "avg_time_coe", "avg_time_p2p", "avg_time_p3p", + "scan_exclusions", "smb_username", "smb_password", "smb_domain", @@ -48,7 +49,7 @@ def _start_full_scan_background(target_dir, results_dir, results_dir_ref, source def _job(): try: full_scan(target_dir, results_dir, results_dir_ref) - start_watching(target_dir, results_dir) + start_watching(target_dir, results_dir, results_dir_ref) tests = get_all_tests() completed = len([t for t in tests if t.get("completed")]) print(f"[{source_label}] Scan complete -> {len(tests)} tests, {completed} completed") @@ -250,7 +251,7 @@ def set_config_route(): if not isinstance(updates, dict): return jsonify({"error": "Request body must be a JSON object"}), 400 - dirs_changed = False + rescan_required = False for key, value in updates.items(): if key not in ALLOWED_KEYS: @@ -261,10 +262,10 @@ def set_config_route(): else: set_config(key, str(value)) - if key in {"target_dir", "results_dir", "results_dir_ref"}: - dirs_changed = True + if key in {"target_dir", "results_dir", "results_dir_ref", "scan_exclusions"}: + rescan_required = True - if dirs_changed: + if rescan_required: _apply_smb_env_from_config() target_dir = resolve_runtime_path(get_config("target_dir")) results_dir = resolve_runtime_path(get_config("results_dir")) diff --git a/server/dashboard.db b/server/dashboard.db index 5265cb9..86b8ee1 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 deleted file mode 100644 index a4a780d..0000000 Binary files a/server/dashboard.db-shm and /dev/null differ diff --git a/server/dashboard.db-wal b/server/dashboard.db-wal deleted file mode 100644 index f9ef1d0..0000000 Binary files a/server/dashboard.db-wal and /dev/null differ diff --git a/server/scanner.py b/server/scanner.py index af3fb91..f71639d 100644 --- a/server/scanner.py +++ b/server/scanner.py @@ -5,6 +5,7 @@ import smbclient from db_py import ( clear_tests, + get_config, mark_completed, update_all_p2p_coe_pairs_sql, update_all_p3p_pairs_sql, @@ -23,6 +24,125 @@ _SMB_SESSIONS = set() _WIN_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]") _SCAN_STATE_LOCK = threading.Lock() _ACTIVE_SCAN_COUNT = 0 +_DEFAULT_SCAN_EXCLUSIONS = "" + +def _parse_scan_exclusions(value): + raw = value if value not in (None, "") else _DEFAULT_SCAN_EXCLUSIONS + if raw == "" or raw is None: + return None + parts = re.split(r"[,\n;]", str(raw)) + return {token.strip().upper() for token in parts if token.strip()} + + +def _build_match_tokens(parsed): + tokens = set() + + def _add(value): + if value in (None, ""): + return + tokens.add(str(value).upper()) + + _add(parsed.get("interference")) + _add(parsed.get("device")) + _add(parsed.get("rotation")) + _add(parsed.get("test_point")) + _add(parsed.get("rssi")) + _add(parsed.get("station")) + _add(parsed.get("band")) + _add(parsed.get("channel")) + _add(parsed.get("bandwidth")) + _add(parsed.get("direction")) + _add(parsed.get("throttled")) + _add(parsed.get("test_id")) + + for bw in parsed.get("extra_bandwidths") or []: + _add(bw) + + # Add numeric alias for devices, e.g. CGW453 -> 453. + device = (parsed.get("device") or "").upper() + device_digits = re.sub(r"\D", "", device) + if device_digits: + tokens.add(device_digits) + + # SP can appear as flag or specific token variant (e.g., SP40). + if parsed.get("sp"): + tokens.add("SP") + tokens.add(str(parsed.get("sp")).upper()) + + return tokens + + +def _normalize_bandwidth_value(value): + if value in (None, ""): + return set() + + raw = str(value).strip().upper() + if not raw: + return set() + + normalized = {raw} + digits = re.sub(r"\D", "", raw) + if digits: + normalized.add(digits) + normalized.add(f"BW{digits}") + + return normalized + + +def _extract_coe_bandwidth_tokens(ini_path): + keys = {"Bandwidth_fh2", "Bandwidth_fh5", "Bandwidth_fh6"} + line_re = re.compile(r"^\s*([A-Za-z0-9_]+)\s*=\s*([^\r\n#;]+)") + tokens = set() + + try: + for line in _read_text_lines(ini_path): + match = line_re.match(line) + if not match: + continue + + key = match.group(1).strip() + if key not in keys: + continue + + raw_value = match.group(2).strip() + for part in re.split(r"[,/\s]+", raw_value): + part = part.strip() + if not part: + continue + tokens.update(_normalize_bandwidth_value(part)) + except OSError as exc: + print(f"[scanner] Cannot read ini file {ini_path}: {exc}") + + return tokens + + +def _rule_matches_target(rule, parsed_tokens): + # Rule parts are AND-ed: CGW453_P3P_ROT2 => device AND interference AND rotation. + # Support _, -, or spaces as condition separators. + parts = [p for p in re.split(r"[_\-\s]+", rule) if p] + if not parts: + return False + + def _part_matches(part): + if part in parsed_tokens: + return True + + # Support tag variants (e.g., BW80 should match BW80M/BW80+80 in source names). + # Keep this conservative for very short parts to avoid overmatching. + if len(part) >= 3: + for token in parsed_tokens: + if token.startswith(part) or part in token: + return True + + return False + + return all(_part_matches(part) for part in parts) + + +def _should_exclude_target(parsed, exclusions): + parsed_tokens = _build_match_tokens(parsed) + # Any matching rule excludes the testcase. + return any(_rule_matches_target(rule, parsed_tokens) for rule in exclusions) def _scan_started(): @@ -210,6 +330,7 @@ def full_scan(target_dir, results_dir, results_dir_ref): def scan_targets(target_dir): target_dir = _normalize_input_path(target_dir) + exclusions = _parse_scan_exclusions(get_config("scan_exclusions")) try: parent_entries = [ @@ -245,14 +366,11 @@ def scan_targets(target_dir): #print(f"[scanner] skip (no test_id): {parent_name}/{filename}") continue - # Remove all SP testcases and BW320 testcases - if parsed["sp"] or parsed["bandwidth"] == "BW320": - #print(f"[scanner] skip (SP test or BW320): {parent_name}/{filename}") - continue - - #Remove P3P and COE testcases for CGW452 - if parsed["interference"] in ["P3P", "COE"] and parsed["device"] == "CGW452": - #print(f"[scanner] skip ({parsed['interference']} CGW452): {parent_name}/{filename}") + if (parsed.get("interference") or "").upper() == "COE": + ini_path = _join_path(parent_path, filename) + parsed["extra_bandwidths"] = sorted(_extract_coe_bandwidth_tokens(ini_path)) + + if exclusions and _should_exclude_target(parsed, exclusions): continue #print( f"[scanner] found: {parent_name}/{filename} -> test_id={parsed['test_id']}") diff --git a/server/watcher.py b/server/watcher.py index b38bdb0..839d06a 100644 --- a/server/watcher.py +++ b/server/watcher.py @@ -5,15 +5,40 @@ from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from db_py import reset_by_file_id_and_device -from scanner import full_scan, parse_deleted_result_dir_name, process_result_dir +from scanner import ( + full_scan, + parse_deleted_result_dir_name, + process_result_dir, + _is_unc_path, + _normalize_input_path, +) from sse_py import broadcast _target_observer = None -_results_observer = None +_results_observers = [] _scan_timer = None _scan_lock = threading.Lock() +def _path_exists(path): + """Check if path exists, handling UNC paths.""" + if _is_unc_path(path): + try: + import smbclient + return smbclient.path.isdir(path) + except Exception: + return False + return os.path.isdir(path) + + +def _normalize_watcher_path(path): + """Normalize path for watcher comparison, handling UNC paths.""" + normalized = _normalize_input_path(path) + if not _is_unc_path(normalized): + normalized = os.path.normcase(os.path.abspath(normalized)) + return normalized + + def _schedule_full_scan(target_dir, results_dir, delay_seconds=1.0): global _scan_timer with _scan_lock: @@ -66,16 +91,29 @@ class _TargetHandler(FileSystemEventHandler): class _ResultsHandler(FileSystemEventHandler): def __init__(self, results_dir): - self.results_dir = os.path.normcase(os.path.abspath(results_dir)) + self.results_dir = _normalize_watcher_path(results_dir) def _is_direct_child_dir(self, path): - parent = os.path.normcase(os.path.abspath(os.path.dirname(path))) - return parent == self.results_dir + try: + if _is_unc_path(path): + parent = _normalize_watcher_path(os.path.dirname(path)) + else: + parent = os.path.normcase(os.path.abspath(os.path.dirname(path))) + return parent == self.results_dir + except Exception: + return False def _is_file_under_result_child(self, path): - parent_dir = os.path.normcase(os.path.abspath(os.path.dirname(path))) - grandparent = os.path.normcase(os.path.abspath(os.path.dirname(parent_dir))) - return grandparent == self.results_dir + try: + if _is_unc_path(path): + parent_dir = _normalize_watcher_path(os.path.dirname(path)) + grandparent = _normalize_watcher_path(os.path.dirname(parent_dir)) + else: + parent_dir = os.path.normcase(os.path.abspath(os.path.dirname(path))) + grandparent = os.path.normcase(os.path.abspath(os.path.dirname(parent_dir))) + return grandparent == self.results_dir + except Exception: + return False def _result_child_name_for_file(self, path): return os.path.basename(os.path.dirname(path)) @@ -164,7 +202,7 @@ class _ResultsHandler(FileSystemEventHandler): def stop_watching(): - global _target_observer, _results_observer, _scan_timer + global _target_observer, _results_observers, _scan_timer if _scan_timer: _scan_timer.cancel() @@ -175,27 +213,30 @@ def stop_watching(): _target_observer.join(timeout=2) _target_observer = None - if _results_observer: - _results_observer.stop() - _results_observer.join(timeout=2) - _results_observer = None + for obs in _results_observers: + try: + obs.stop() + obs.join(timeout=2) + except Exception as e: + print(f"[watcher] error stopping results observer: {e}") + _results_observers.clear() -def start_watching(target_dir, results_dir): - global _target_observer, _results_observer +def start_watching(target_dir, results_dir, results_dir_ref=None): + global _target_observer, _results_observers stop_watching() if not target_dir or not results_dir: print("[watcher] target_dir/results_dir not configured; watcher disabled.") return - if not os.path.isdir(target_dir): - print(f"[watcher] target_dir does not exist inside runtime: {target_dir}") + if not _path_exists(target_dir): + print(f"[watcher] target_dir does not exist or is not accessible: {target_dir}") print("[watcher] watcher disabled until valid paths are configured.") return - if not os.path.isdir(results_dir): - print(f"[watcher] results_dir does not exist inside runtime: {results_dir}") + if not _path_exists(results_dir): + print(f"[watcher] results_dir does not exist or is not accessible: {results_dir}") print("[watcher] watcher disabled until valid paths are configured.") return @@ -204,11 +245,26 @@ def start_watching(target_dir, results_dir): _target_observer.schedule(_TargetHandler(target_dir, results_dir), target_dir, recursive=True) _target_observer.daemon = True _target_observer.start() + print(f"[watcher] target observer started for: {target_dir}") - _results_observer = Observer() - _results_observer.schedule(_ResultsHandler(results_dir), results_dir, recursive=True) - _results_observer.daemon = True - _results_observer.start() + # Watch primary results directory + results_obs = Observer() + results_obs.schedule(_ResultsHandler(results_dir), results_dir, recursive=True) + results_obs.daemon = True + results_obs.start() + _results_observers.append(results_obs) + print(f"[watcher] results observer started for: {results_dir}") + + # Watch reference results directory if provided and different + if results_dir_ref and results_dir_ref != results_dir and _path_exists(results_dir_ref): + ref_obs = Observer() + ref_obs.schedule(_ResultsHandler(results_dir_ref), results_dir_ref, recursive=True) + ref_obs.daemon = True + ref_obs.start() + _results_observers.append(ref_obs) + print(f"[watcher] results observer started for reference: {results_dir_ref}") + elif results_dir_ref and results_dir_ref != results_dir: + print(f"[watcher] reference results_dir does not exist or is not accessible: {results_dir_ref}") except Exception as exc: print(f"[watcher] failed to start watchers: {exc}") stop_watching()