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) ; (& cUsers26005101DesktopProjectstest_house_dashboardserver.venvScriptsActivate.ps1) b/s -ExecutionPolicy RemoteSigned) ; (& cUsers26005101DesktopProjectstest_house_dashboardserver.venvScriptsActivate.ps1)
new file mode 100644
index 0000000..c4fccb1
--- /dev/null
+++ b/s -ExecutionPolicy RemoteSigned) ; (& cUsers26005101DesktopProjectstest_house_dashboardserver.venvScriptsActivate.ps1)
@@ -0,0 +1,97 @@
+[33mcommit 74d8a8565ed2963c458201f1fd68ee1e609e2799[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mmain[m[33m, [m[1;33mtag: [m[1;33mv1.0.0[m[33m, [m[1;31msecondary/main[m[33m, [m[1;31morigin/main[m[33m, [m[1;31morigin/HEAD[m[33m)[m
+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
+
+[33mcommit c1aaf69fd039462f8db2f52d260e15f654881536[m
+Author: Mia Wu
+Date: Thu May 28 15:14:21 2026 -0400
+
+ remove SP, P2P BW320, 452 P3P testcases
+
+[33mcommit 49310e6d591ca1f368cde8c197b158f956ed05d0[m
+Author: Mia Wu
+Date: Thu May 28 12:34:01 2026 -0400
+
+ fix scan
+
+[33mcommit d239de257d82c5bd21bbc94cd9d8161717ac9c33[m
+Author: Mia Wu
+Date: Thu May 28 11:01:47 2026 -0400
+
+ remove browse, add image
+
+[33mcommit 1cbb887e1df3634acdc4acd0503d95db4de88b37[m
+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
+
+[33mcommit 7f253c4f741fd49bf29a2a6bd6b4e0176fa35698[m[33m ([m[1;31morigin/python-migration[m[33m)[m
+Author: Mia Wu
+Date: Wed May 27 16:07:46 2026 -0400
+
+ p3p pairs
+
+[33mcommit ba86f5830f16aa1265d71c879536bd5618d899ca[m
+Author: Mia Wu
+Date: Wed May 27 15:02:32 2026 -0400
+
+ fix: allow network paths
+
+[33mcommit 5996443f38f7a4afdceb81e8b9d8d72538ffdd46[m
+Author: Mia Wu
+Date: Wed May 27 11:29:02 2026 -0400
+
+ coe pair filter
+
+[33mcommit 8da59570248e13ec251a11298fc43f64b5d7802c[m
+Author: Mia Wu
+Date: Wed May 27 10:34:52 2026 -0400
+
+ expand, collapse all
+
+[33mcommit d560b2e0b4f9fa253cf4ff7ddf436dd8e2f59b73[m
+Author: Mia Wu
+Date: Wed May 27 10:29:26 2026 -0400
+
+ UI changes
+
+[33mcommit 72a6f8425bfa397cd9073cf8d61e6ab69f820c19[m
+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
+
+[33mcommit 1ba2aa39d80af9367c839c7b5ea40b61d6d78507[m
+Author: Mia Wu
+Date: Tue May 26 14:36:34 2026 -0400
+
+ python migration
+
+[33mcommit a67815c61a567e69bcbac61d5ce56bf491cda3a6[m
+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.
+
+[33mcommit ed0f93036dbd0625771571c35cd3602bd36b9b6c[m
+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()
|