From 24cf346a62ccf97b50cd64fd821a93acbf6ca37c Mon Sep 17 00:00:00 2001 From: Mia Wu Date: Wed, 3 Jun 2026 16:54:12 -0400 Subject: [PATCH] implemented auto updates --- dashboard/src/hooks/useStats.js | 2 + dashboard/src/hooks/useTests.js | 2 + server/app.py | 39 +++++++- server/requirements.txt | 1 + server/watcher.py | 169 ++++++++++++++++++++++++++++---- 5 files changed, 189 insertions(+), 24 deletions(-) diff --git a/dashboard/src/hooks/useStats.js b/dashboard/src/hooks/useStats.js index cfb2b7a..30f56fd 100644 --- a/dashboard/src/hooks/useStats.js +++ b/dashboard/src/hooks/useStats.js @@ -6,6 +6,8 @@ export function useStats(enabled = true) { queryKey: ['stats'], queryFn: getStats, enabled, + refetchInterval: enabled ? 5000 : false, + refetchIntervalInBackground: true, }) return { diff --git a/dashboard/src/hooks/useTests.js b/dashboard/src/hooks/useTests.js index 528b248..7b8f63b 100644 --- a/dashboard/src/hooks/useTests.js +++ b/dashboard/src/hooks/useTests.js @@ -7,5 +7,7 @@ export function useTests(filters = {}, enabled = true) { queryFn: () => getTests(filters), keepPreviousData: true, enabled, + refetchInterval: enabled ? 3000 : false, + refetchIntervalInBackground: true, }) } diff --git a/server/app.py b/server/app.py index 14cd59e..06a044c 100644 --- a/server/app.py +++ b/server/app.py @@ -1,5 +1,6 @@ import os import threading +import atexit from datetime import datetime, timedelta, timezone from functools import wraps from pathlib import Path @@ -24,6 +25,7 @@ from db_py import ( clear_users, ) from scanner import full_scan, is_scan_in_progress, resolve_runtime_path, scan_results_only +from watcher import start_results_watchers, stop_results_watchers BASE_DIR = Path(__file__).resolve().parent load_dotenv(BASE_DIR / ".env") @@ -421,6 +423,8 @@ def set_config_route(): return jsonify({"error": "Request body must be a JSON object"}), 400 rescan_required = False + watcher_restart_required = False + warnings = [] for key, value in updates.items(): if key not in ALLOWED_KEYS: @@ -433,6 +437,19 @@ def set_config_route(): if key in {"target_dir", "results_dir", "results_dir_ref", "scan_exclusions"}: rescan_required = True + if key in {"results_dir", "results_dir_ref", "smb_username", "smb_password", "smb_domain"}: + watcher_restart_required = True + + if watcher_restart_required: + _apply_smb_env_from_config() + results_dir = resolve_runtime_path(get_config("results_dir")) + results_dir_ref = resolve_runtime_path(get_config("results_dir_ref")) + try: + start_results_watchers(results_dir, results_dir_ref) + except Exception as exc: + warning = f"Watcher restart failed: {exc}" + warnings.append(warning) + print(f"[server] {warning}") if rescan_required: _apply_smb_env_from_config() @@ -443,16 +460,28 @@ def set_config_route(): return jsonify({"error": "Directories not configured"}), 400 if is_scan_in_progress(): - return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None}) + return jsonify({ + "ok": True, + "scanning": True, + "testCount": None, + "completedCount": None, + "warnings": warnings, + }) threading.Thread( target=full_scan, args=(target_dir, results_dir, results_dir_ref), daemon=True, ).start() - return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None}) + return jsonify({ + "ok": True, + "scanning": True, + "testCount": None, + "completedCount": None, + "warnings": warnings, + }) - return jsonify({"ok": True, "testCount": None, "completedCount": None}) + return jsonify({"ok": True, "testCount": None, "completedCount": None, "warnings": warnings}) @app.post("/api/config/rescan") @@ -526,11 +555,15 @@ def bootstrap(): 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") + + start_results_watchers(results_dir, results_dir_ref) else: + stop_results_watchers() print("[server] No directories configured -> open the dashboard settings to get started.") if __name__ == "__main__": + atexit.register(stop_results_watchers) bootstrap() print(f"[server] Listening on http://0.0.0.0:{PORT}") app.run(host="0.0.0.0", port=PORT, threaded=True) diff --git a/server/requirements.txt b/server/requirements.txt index 80ff2f4..791d08f 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -3,3 +3,4 @@ Flask-Cors>=4.0.1,<5.0.0 smbprotocol>=1.13.0,<2.0.0 PyJWT>=2.9.0,<3.0.0 python-dotenv>=1.0.1,<2.0.0 +watchdog>=4.0.2,<5.0.0 diff --git a/server/watcher.py b/server/watcher.py index 5c7137d..ce9cbd7 100644 --- a/server/watcher.py +++ b/server/watcher.py @@ -1,29 +1,156 @@ import os -import sys -import time -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler +import threading -class TestResultsHandler(FileSystemEventHandler): - def __init__(self, callback): +from watchdog.events import FileSystemEventHandler +from watchdog.observers.polling import PollingObserver + +from db_py import ( + mark_tests_completed, + reset_by_file_id_and_device, + update_all_p2p_coe_pairs_sql, + update_all_p3p_pairs_sql, +) +from scanner import parse_deleted_result_dir_name, process_result_dir, resolve_runtime_path + + +_OBSERVER = None +_OBSERVER_LOCK = threading.RLock() + + +class ResultsDirEventHandler(FileSystemEventHandler): + def __init__(self, results_root): super().__init__() - self.callback = callback + self.results_root = str(results_root) + + def _is_direct_child(self, path_value): + parent = os.path.dirname(path_value.rstrip("\\/")) + return os.path.normcase(parent) == os.path.normcase(self.results_root.rstrip("\\/")) + + def _mark_completed(self, dir_path): + if not self._is_direct_child(dir_path): + return + + dir_name = os.path.basename(dir_path.rstrip("\\/")) + completion = process_result_dir(self.results_root, dir_name) + if not completion: + return + + mark_tests_completed([completion]) + coe_pairs = update_all_p2p_coe_pairs_sql() + p3p_pairs = update_all_p3p_pairs_sql() + print( + "[watcher] Marked completed from new result dir " + f"{dir_name} (coe_pair={coe_pairs}, p3p_pair={p3p_pairs})" + ) + + def _mark_completed_from_file(self, file_path): + # measurement.db arriving after directory creation should update that single test. + if os.path.basename(file_path).lower() != "measurement.db": + return + + parent_dir = os.path.dirname(file_path.rstrip("\\/")) + self._mark_completed(parent_dir) + + def _clear_completed(self, dir_path): + if not self._is_direct_child(dir_path): + return + + dir_name = os.path.basename(dir_path.rstrip("\\/")) + test_id, device = parse_deleted_result_dir_name(dir_name) + if not test_id: + return + + reset_by_file_id_and_device(test_id, device) + coe_pairs = update_all_p2p_coe_pairs_sql() + p3p_pairs = update_all_p3p_pairs_sql() + print( + "[watcher] Cleared completed from removed result dir " + f"{dir_name} (coe_pair={coe_pairs}, p3p_pair={p3p_pairs})" + ) def on_created(self, event): if event.is_directory: - print(f"[watcher] Detected new test result directory: {event.src_path}") - self.callback(event.src_path) + self._mark_completed(event.src_path) + return + + self._mark_completed_from_file(event.src_path) + + def on_deleted(self, event): + if event.is_directory: + self._clear_completed(event.src_path) + + def on_moved(self, event): + if event.is_directory: + self._clear_completed(event.src_path) + self._mark_completed(event.dest_path) + return + + self._mark_completed_from_file(event.dest_path) -def start_watching_results_dir(results_dir, callback): - event_handler = TestResultsHandler(callback) - observer = Observer() - observer.schedule(event_handler, results_dir, recursive=False) - observer.start() - print(f"[watcher] Started watching for new test results in: {results_dir}") - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - observer.stop() - observer.join() \ No newline at end of file +def stop_results_watchers(): + global _OBSERVER + + with _OBSERVER_LOCK: + if _OBSERVER is None: + return + + try: + _OBSERVER.stop() + _OBSERVER.join(timeout=5) + finally: + _OBSERVER = None + print("[watcher] Stopped results directory watcher") + + +def start_results_watchers(results_dir, results_dir_ref): + global _OBSERVER + + with _OBSERVER_LOCK: + stop_results_watchers() + + resolved = [] + seen = set() + for raw_dir in (results_dir, results_dir_ref): + if not raw_dir: + continue + path = resolve_runtime_path(raw_dir) + if not path: + continue + normalized = os.path.normcase(str(path).rstrip("\\/")) + if normalized and normalized not in seen: + seen.add(normalized) + resolved.append(str(path)) + + if not resolved: + print("[watcher] No results directories configured, watcher not started") + return + + observer = PollingObserver(timeout=1.0) + + scheduled_count = 0 + for path in resolved: + try: + observer.schedule(ResultsDirEventHandler(path), path, recursive=False) + scheduled_count += 1 + print(f"[watcher] Watching results directory: {path}") + except Exception as exc: + print(f"[watcher] Skipping watch path {path}: {exc}") + + if scheduled_count == 0: + print("[watcher] No valid watch paths available, watcher not started") + return + + try: + observer.start() + except Exception as exc: + print(f"[watcher] Failed to start results watcher: {exc}") + try: + observer.stop() + observer.join(timeout=5) + except Exception: + pass + return + + _OBSERVER = observer + print("[watcher] Results directory watcher started") \ No newline at end of file