implemented auto updates

This commit is contained in:
2026-06-03 16:54:12 -04:00
parent bc4f38726a
commit 24cf346a62
5 changed files with 189 additions and 24 deletions
+2
View File
@@ -6,6 +6,8 @@ export function useStats(enabled = true) {
queryKey: ['stats'], queryKey: ['stats'],
queryFn: getStats, queryFn: getStats,
enabled, enabled,
refetchInterval: enabled ? 5000 : false,
refetchIntervalInBackground: true,
}) })
return { return {
+2
View File
@@ -7,5 +7,7 @@ export function useTests(filters = {}, enabled = true) {
queryFn: () => getTests(filters), queryFn: () => getTests(filters),
keepPreviousData: true, keepPreviousData: true,
enabled, enabled,
refetchInterval: enabled ? 3000 : false,
refetchIntervalInBackground: true,
}) })
} }
+36 -3
View File
@@ -1,5 +1,6 @@
import os import os
import threading import threading
import atexit
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
@@ -24,6 +25,7 @@ from db_py import (
clear_users, clear_users,
) )
from scanner import full_scan, is_scan_in_progress, resolve_runtime_path, scan_results_only 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 BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env") load_dotenv(BASE_DIR / ".env")
@@ -421,6 +423,8 @@ def set_config_route():
return jsonify({"error": "Request body must be a JSON object"}), 400 return jsonify({"error": "Request body must be a JSON object"}), 400
rescan_required = False rescan_required = False
watcher_restart_required = False
warnings = []
for key, value in updates.items(): for key, value in updates.items():
if key not in ALLOWED_KEYS: 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"}: if key in {"target_dir", "results_dir", "results_dir_ref", "scan_exclusions"}:
rescan_required = True 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: if rescan_required:
_apply_smb_env_from_config() _apply_smb_env_from_config()
@@ -443,16 +460,28 @@ def set_config_route():
return jsonify({"error": "Directories not configured"}), 400 return jsonify({"error": "Directories not configured"}), 400
if is_scan_in_progress(): 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( threading.Thread(
target=full_scan, target=full_scan,
args=(target_dir, results_dir, results_dir_ref), args=(target_dir, results_dir, results_dir_ref),
daemon=True, daemon=True,
).start() ).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") @app.post("/api/config/rescan")
@@ -526,11 +555,15 @@ def bootstrap():
tests = get_all_tests() tests = get_all_tests()
completed = len([t for t in tests if t.get("completed")]) completed = len([t for t in tests if t.get("completed")])
print(f"[server] Scan complete -> {len(tests)} tests found, {completed} completed") print(f"[server] Scan complete -> {len(tests)} tests found, {completed} completed")
start_results_watchers(results_dir, results_dir_ref)
else: else:
stop_results_watchers()
print("[server] No directories configured -> open the dashboard settings to get started.") print("[server] No directories configured -> open the dashboard settings to get started.")
if __name__ == "__main__": if __name__ == "__main__":
atexit.register(stop_results_watchers)
bootstrap() bootstrap()
print(f"[server] Listening on http://0.0.0.0:{PORT}") print(f"[server] Listening on http://0.0.0.0:{PORT}")
app.run(host="0.0.0.0", port=PORT, threaded=True) app.run(host="0.0.0.0", port=PORT, threaded=True)
+1
View File
@@ -3,3 +3,4 @@ Flask-Cors>=4.0.1,<5.0.0
smbprotocol>=1.13.0,<2.0.0 smbprotocol>=1.13.0,<2.0.0
PyJWT>=2.9.0,<3.0.0 PyJWT>=2.9.0,<3.0.0
python-dotenv>=1.0.1,<2.0.0 python-dotenv>=1.0.1,<2.0.0
watchdog>=4.0.2,<5.0.0
+146 -19
View File
@@ -1,29 +1,156 @@
import os import os
import sys import threading
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class TestResultsHandler(FileSystemEventHandler): from watchdog.events import FileSystemEventHandler
def __init__(self, callback): 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__() 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): def on_created(self, event):
if event.is_directory: if event.is_directory:
print(f"[watcher] Detected new test result directory: {event.src_path}") self._mark_completed(event.src_path)
self.callback(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): def stop_results_watchers():
event_handler = TestResultsHandler(callback) global _OBSERVER
observer = Observer()
observer.schedule(event_handler, results_dir, recursive=False) with _OBSERVER_LOCK:
observer.start() if _OBSERVER is None:
print(f"[watcher] Started watching for new test results in: {results_dir}") 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: try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop() observer.stop()
observer.join() observer.join(timeout=5)
except Exception:
pass
return
_OBSERVER = observer
print("[watcher] Results directory watcher started")