fix watcher for smb

This commit is contained in:
2026-07-14 11:43:59 -04:00
parent 834ab81a94
commit b68b8f54bd
7 changed files with 190 additions and 133 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

+10 -42
View File
@@ -22,11 +22,11 @@ function useScanPoller(onDone) {
const data = await apiFetch('/scan-status') const data = await apiFetch('/scan-status')
if (!data?.scanning) { if (!data?.scanning) {
stop() stop()
onDoneRef.current() onDoneRef.current(data)
} }
} catch { } catch {
stop() stop()
onDoneRef.current() onDoneRef.current(null)
} }
}, 2000) }, 2000)
}, [stop]) }, [stop])
@@ -61,7 +61,6 @@ export default function ConfigModal({ onClose, currentUserId = null }) {
const [activeTab, setActiveTab] = useState('general') const [activeTab, setActiveTab] = useState('general')
const [form, setForm] = useState({}) const [form, setForm] = useState({})
const [saveError, setSaveError] = useState(null) const [saveError, setSaveError] = useState(null)
const [isRescanning, setIsRescanning] = useState(false)
const [isScanning, setIsScanning] = useState(false) const [isScanning, setIsScanning] = useState(false)
const [isSavingTimes, setIsSavingTimes] = useState(false) const [isSavingTimes, setIsSavingTimes] = useState(false)
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
@@ -74,11 +73,13 @@ export default function ConfigModal({ onClose, currentUserId = null }) {
const [createForm, setCreateForm] = useState({ username: '', password: '', role: 'viewer' }) const [createForm, setCreateForm] = useState({ username: '', password: '', role: 'viewer' })
const [passwordForm, setPasswordForm] = useState({ userId: '', password: '' }) const [passwordForm, setPasswordForm] = useState({ userId: '', password: '' })
const scanPoller = useScanPoller(() => { const scanPoller = useScanPoller((data) => {
queryClient.invalidateQueries({ queryKey: ['stats'] }) queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ['tests'] }) queryClient.invalidateQueries({ queryKey: ['tests'] })
setIsRescanning(false)
setIsScanning(false) setIsScanning(false)
if (data?.errors?.length) {
setSaveError(data.errors.join('\n'))
}
}) })
useEffect(() => { useEffect(() => {
@@ -150,24 +151,6 @@ export default function ConfigModal({ onClose, currentUserId = null }) {
} }
} }
async function handleRescanResults() {
setSaveError(null)
setIsRescanning(true)
try {
const data = await apiFetch('/config/rescan-results', { method: 'POST' })
if (data?.scanning) {
scanPoller.start()
} else {
setIsRescanning(false)
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ['tests'] })
}
} catch (err) {
setSaveError(err?.message ?? 'Rescan failed')
setIsRescanning(false)
}
}
const AVG_FIELDS = [ const AVG_FIELDS = [
{ key: 'avg_time_coe', label: 'COE avg time (min)' }, { key: 'avg_time_coe', label: 'COE avg time (min)' },
{ key: 'avg_time_p2p', label: 'P2P avg time (min)' }, { key: 'avg_time_p2p', label: 'P2P avg time (min)' },
@@ -306,8 +289,8 @@ export default function ConfigModal({ onClose, currentUserId = null }) {
<div className="space-y-6 px-4 pb-4 sm:px-5"> <div className="space-y-6 px-4 pb-4 sm:px-5">
{/* Error banner */} {/* Error banner */}
{saveError && ( {saveError && (
<div className="bg-red-950/50 border border-red-700 rounded-lg px-4 py-3 text-red-300 text-sm"> <div className="bg-red-950/50 border border-red-700 rounded-lg px-4 py-3 text-red-300 text-sm space-y-1">
{saveError} {saveError.split('\n').map((line, i) => <div key={i}>{line}</div>)}
</div> </div>
)} )}
@@ -331,14 +314,6 @@ export default function ConfigModal({ onClose, currentUserId = null }) {
</div> </div>
)} )}
{/* Rescan indicator (rescan button used) */}
{!isScanning && isRescanning && (
<div className="bg-blue-950/50 border border-blue-700 rounded-lg px-4 py-3 text-blue-300 text-sm flex items-center gap-2">
<span className="animate-spin inline-block"></span>
Results scan in progress
</div>
)}
{activeTab === 'general' && isLoading ? ( {activeTab === 'general' && isLoading ? (
<p className="text-slate-500 text-sm">Loading</p> <p className="text-slate-500 text-sm">Loading</p>
) : activeTab === 'general' ? ( ) : activeTab === 'general' ? (
@@ -561,23 +536,16 @@ export default function ConfigModal({ onClose, currentUserId = null }) {
{activeTab === 'general' ? ( {activeTab === 'general' ? (
<> <>
<div className="ml-auto flex flex-col items-end gap-2 sm:flex-row sm:flex-wrap sm:justify-end"> <div className="ml-auto flex flex-col items-end gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
<button
onClick={handleRescanResults}
disabled={isRescanning || isPending || isSavingTimes}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
>
{isRescanning ? 'Rescanning…' : 'Save Results'}
</button>
<button <button
onClick={handleSaveTimes} onClick={handleSaveTimes}
disabled={isSavingTimes || isPending || isRescanning} disabled={isSavingTimes || isPending}
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors" className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
> >
{isSavingTimes ? 'Saving…' : 'Save Times'} {isSavingTimes ? 'Saving…' : 'Save Times'}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isPending || isRescanning || isSavingTimes} disabled={isPending || isSavingTimes}
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors" className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
> >
{isPending ? 'Saving…' : 'Save All'} {isPending ? 'Saving…' : 'Save All'}
+2 -23
View File
@@ -27,7 +27,7 @@ from db_py import (
clear_users, clear_users,
update_user_password, update_user_password,
) )
from scanner import full_scan, is_scan_in_progress, resolve_runtime_path, scan_results_only from scanner import full_scan, get_scan_errors, is_scan_in_progress, resolve_runtime_path
from watcher import start_results_watchers, stop_results_watchers from watcher import start_results_watchers, stop_results_watchers
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
@@ -334,7 +334,7 @@ def get_stats_route():
@app.get("/api/scan-status") @app.get("/api/scan-status")
@require_auth @require_auth
def get_scan_status_route(): def get_scan_status_route():
return jsonify({"scanning": is_scan_in_progress()}) return jsonify({"scanning": is_scan_in_progress(), "errors": get_scan_errors()})
@app.post("/api/auth/login") @app.post("/api/auth/login")
@@ -554,27 +554,6 @@ def rescan_route():
return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None}) return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
@app.post("/api/config/rescan-results")
@require_auth
@require_role("admin")
def rescan_results_route():
_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"))
if not results_dir:
return jsonify({"error": "Results directory not configured"}), 400
if is_scan_in_progress():
return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
threading.Thread(
target=scan_results_only,
args=(results_dir, results_dir_ref),
daemon=True,
).start()
return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
@app.get("/") @app.get("/")
@app.get("/<path:path>") @app.get("/<path:path>")
def static_or_spa(path=""): def static_or_spa(path=""):
+38 -15
View File
@@ -3,6 +3,7 @@ import re
import threading import threading
import tempfile import tempfile
import smbclient import smbclient
from pathlib import Path
from db_py import ( from db_py import (
mark_tests_completed, mark_tests_completed,
@@ -25,6 +26,8 @@ from parser import (
_SMB_SESSIONS = set() _SMB_SESSIONS = set()
_SCAN_STATE_LOCK = threading.Lock() _SCAN_STATE_LOCK = threading.Lock()
_ACTIVE_SCAN_COUNT = 0 _ACTIVE_SCAN_COUNT = 0
_LAST_SCAN_ERRORS = []
_SCAN_ERRORS_LOCK = threading.Lock()
_ELAPSED_TIME_LINE_RE = re.compile(r"Elapsed\s+time\s*:\s*(\d+):(\d{1,2}):(\d{1,2}(?:\.\d+)?)", re.IGNORECASE) _ELAPSED_TIME_LINE_RE = re.compile(r"Elapsed\s+time\s*:\s*(\d+):(\d{1,2}):(\d{1,2}(?:\.\d+)?)", re.IGNORECASE)
@@ -158,6 +161,23 @@ def is_scan_in_progress():
return _ACTIVE_SCAN_COUNT > 0 return _ACTIVE_SCAN_COUNT > 0
def _clear_scan_errors():
global _LAST_SCAN_ERRORS
with _SCAN_ERRORS_LOCK:
_LAST_SCAN_ERRORS = []
def _record_scan_error(msg):
with _SCAN_ERRORS_LOCK:
if msg not in _LAST_SCAN_ERRORS:
_LAST_SCAN_ERRORS.append(msg)
def get_scan_errors():
with _SCAN_ERRORS_LOCK:
return list(_LAST_SCAN_ERRORS)
def resolve_runtime_path(path_value): def resolve_runtime_path(path_value):
if not path_value: if not path_value:
return path_value return path_value
@@ -166,12 +186,15 @@ def resolve_runtime_path(path_value):
if not raw_path: if not raw_path:
return raw_path return raw_path
# If the path is already valid in the current runtime, keep it. # UNC/network paths are handled separately via smbclient — check before
if os.path.exists(raw_path): # any local filesystem call because backslashes are regular characters on
# Linux, so Path(unc).exists() treats the whole string as one path
# component and raises ENAMETOOLONG for paths > 255 chars.
if raw_path.startswith("\\\\") or raw_path.startswith("//"):
return raw_path return raw_path
# UNC/network paths are handled separately via smbclient. # If the path is already valid in the current runtime, keep it.
if raw_path.startswith("\\\\") or raw_path.startswith("//"): if Path(raw_path).exists():
return raw_path return raw_path
# Map host paths (Windows or Linux) to the container mount point when running in a container. # Map host paths (Windows or Linux) to the container mount point when running in a container.
@@ -185,7 +208,7 @@ def resolve_runtime_path(path_value):
if raw_norm.lower() == host_norm.lower() or raw_norm.lower().startswith(host_norm.lower() + "/"): if raw_norm.lower() == host_norm.lower() or raw_norm.lower().startswith(host_norm.lower() + "/"):
relative = raw_norm[len(host_norm):].lstrip("/") relative = raw_norm[len(host_norm):].lstrip("/")
if relative: if relative:
return os.path.join(mount_root, *relative.split("/")) return str(Path(mount_root).joinpath(*relative.split("/")))
return mount_root return mount_root
return raw_path return raw_path
@@ -248,14 +271,14 @@ def _iter_dir_entries(path):
if _is_unc_path(path): if _is_unc_path(path):
_register_smb_session_if_needed(path) _register_smb_session_if_needed(path)
return list(smbclient.scandir(path)) return list(smbclient.scandir(path))
return list(os.scandir(path)) return list(Path(path).iterdir())
def _join_path(path, name): def _join_path(path, name):
if _is_unc_path(path): if _is_unc_path(path):
base = path.rstrip("\\") base = path.rstrip("\\")
return f"{base}\\{name}" return f"{base}\\{name}"
return os.path.join(path, name) return str(Path(path) / name)
def _read_text_lines(path): def _read_text_lines(path):
@@ -371,6 +394,7 @@ def full_scan(target_dir, results_dir, results_dir_ref):
results_dir = _normalize_input_path(results_dir) results_dir = _normalize_input_path(results_dir)
results_dir_ref = _normalize_input_path(results_dir_ref) results_dir_ref = _normalize_input_path(results_dir_ref)
_clear_scan_errors()
_scan_started() _scan_started()
try: try:
clear_tests() clear_tests()
@@ -378,7 +402,7 @@ def full_scan(target_dir, results_dir, results_dir_ref):
return return
print(f"[scanner] target dir : {target_dir}") print(f"[scanner] target dir : {target_dir}")
print(f"[scanner] results dir : {results_dir}") print(f"[scanner] results dir dut : {results_dir}")
print(f"[scanner] results dir ref : {results_dir_ref}") print(f"[scanner] results dir ref : {results_dir_ref}")
if not scan_targets(target_dir): if not scan_targets(target_dir):
@@ -406,8 +430,9 @@ def scan_targets(target_dir):
for entry in _iter_dir_entries(target_dir) for entry in _iter_dir_entries(target_dir)
if entry.is_dir() if entry.is_dir()
] ]
except OSError as exc: except (OSError, ValueError) as exc:
print(f"[scanner] Cannot read target dir: {exc}") print(f"[scanner] Cannot read target dir: {exc}")
_record_scan_error(f"Cannot read target directory: {exc}")
return False return False
print(f"[scanner] subdirectories found: {len(parent_entries)}") print(f"[scanner] subdirectories found: {len(parent_entries)}")
@@ -422,7 +447,7 @@ def scan_targets(target_dir):
and not entry.name.startswith("GLOBAL") and not entry.name.startswith("GLOBAL")
and entry.name.endswith(".ini") and entry.name.endswith(".ini")
] ]
except OSError as exc: except (OSError, ValueError) as exc:
print(f"[scanner] Cannot read parent dir {parent_name}: {exc}") print(f"[scanner] Cannot read parent dir {parent_name}: {exc}")
continue continue
@@ -476,8 +501,9 @@ def scan_results(results_dir):
for entry in _iter_dir_entries(results_dir) for entry in _iter_dir_entries(results_dir)
if entry.is_dir() if entry.is_dir()
] ]
except OSError as exc: except (OSError, ValueError) as exc:
print(f"[scanner] Cannot read results dir: {exc}") print(f"[scanner] Cannot read results dir: {exc}")
_record_scan_error(f"Cannot read results directory: {exc}")
return return
print(f"[scanner] results: {len(entries)} result dir(s) found") print(f"[scanner] results: {len(entries)} result dir(s) found")
@@ -557,9 +583,6 @@ def extract_measurement_data(db_file_path):
print(f"[scanner] Cannot read measurement db {db_file_path}: {exc}") print(f"[scanner] Cannot read measurement db {db_file_path}: {exc}")
finally: finally:
if local_db_path: if local_db_path:
try: Path(local_db_path).unlink(missing_ok=True)
os.remove(local_db_path)
except OSError:
pass
return result return result
+140 -51
View File
@@ -1,8 +1,10 @@
import os import os
import threading import threading
import smbclient
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from watchdog.observers.polling import PollingObserver from watchdog.observers.polling import PollingObserver
from pathlib import Path
from db_py import ( from db_py import (
mark_tests_completed, mark_tests_completed,
@@ -15,24 +17,23 @@ from scanner import process_result_dir, resolve_runtime_path
from parser import parse_deleted_result_dir_name from parser import parse_deleted_result_dir_name
_OBSERVER = None _WATCHERS: list = []
_OBSERVER_LOCK = threading.RLock() _WATCHERS_LOCK = threading.RLock()
class ResultsDirEventHandler(FileSystemEventHandler): class ResultsDirEventHandler(FileSystemEventHandler):
def __init__(self, results_root): def __init__(self, results_root):
super().__init__() super().__init__()
self.results_root = str(results_root) self.results_root = Path(results_root)
def _is_direct_child(self, path_value): def _is_direct_child(self, path_value):
parent = os.path.dirname(path_value.rstrip("\\/")) return Path(path_value).parent == self.results_root
return os.path.normcase(parent) == os.path.normcase(self.results_root.rstrip("\\/"))
def _mark_completed(self, dir_path): def _mark_completed(self, dir_path):
if not self._is_direct_child(dir_path): if not self._is_direct_child(dir_path):
return return
dir_name = os.path.basename(dir_path.rstrip("\\/")) dir_name = Path(dir_path).name
completion = process_result_dir(self.results_root, dir_name) completion = process_result_dir(self.results_root, dir_name)
if not completion: if not completion:
return return
@@ -47,19 +48,19 @@ class ResultsDirEventHandler(FileSystemEventHandler):
def _mark_completed_from_file(self, file_path): def _mark_completed_from_file(self, file_path):
# New measurement/log files arriving after directory creation should update that single test. # New measurement/log files arriving after directory creation should update that single test.
filename = os.path.basename(file_path).lower() p = Path(file_path)
filename = p.name.lower()
is_duration_log = filename.endswith(".log") or filename.endswith(".txt") or "log" in filename is_duration_log = filename.endswith(".log") or filename.endswith(".txt") or "log" in filename
if filename != "measurement.db" and not is_duration_log: if filename != "measurement.db" and not is_duration_log:
return return
parent_dir = os.path.dirname(file_path.rstrip("\\/")) self._mark_completed(p.parent)
self._mark_completed(parent_dir)
def _clear_completed(self, dir_path): def _clear_completed(self, dir_path):
if not self._is_direct_child(dir_path): if not self._is_direct_child(dir_path):
return return
dir_name = os.path.basename(dir_path.rstrip("\\/")) dir_name = Path(dir_path).name
test_id, device = parse_deleted_result_dir_name(dir_name) test_id, device = parse_deleted_result_dir_name(dir_name)
if not test_id: if not test_id:
return return
@@ -92,69 +93,157 @@ class ResultsDirEventHandler(FileSystemEventHandler):
self._mark_completed_from_file(event.dest_path) self._mark_completed_from_file(event.dest_path)
def stop_results_watchers(): class _SmbPollerThread(threading.Thread):
global _OBSERVER """Polls a UNC results directory via smbclient and updates the DB on directory changes."""
with _OBSERVER_LOCK: _INTERVAL = 5.0
if _OBSERVER is None:
return
def __init__(self, unc_path):
super().__init__(daemon=True, name=f"smb-poller-{unc_path}")
self._path = unc_path.rstrip("\\")
self._stop_evt = threading.Event()
def stop(self):
self._stop_evt.set()
def run(self):
prev = self._snapshot()
while not self._stop_evt.wait(self._INTERVAL):
try:
curr = self._snapshot()
for name in curr - prev:
completion = process_result_dir(self._path, name)
if completion:
mark_tests_completed([completion])
coe = update_all_p2p_coe_pairs_sql()
p3p = update_all_p3p_pairs_sql()
print(f"[watcher] SMB: marked completed {name} (coe={coe}, p3p={p3p})")
for name in prev - curr:
test_id, device = parse_deleted_result_dir_name(name)
if test_id:
reset_by_file_id_and_device(test_id, device)
coe = update_all_p2p_coe_pairs_sql()
p3p = update_all_p3p_pairs_sql()
print(f"[watcher] SMB: cleared completed {name} (coe={coe}, p3p={p3p})")
prev = curr
except Exception as exc:
print(f"[watcher] SMB poll error {self._path}: {exc}")
def _snapshot(self):
try: try:
_OBSERVER.stop() return {e.name for e in smbclient.scandir(self._path) if e.is_dir()}
_OBSERVER.join(timeout=5) except Exception as exc:
finally: print(f"[watcher] SMB scandir error {self._path}: {exc}")
_OBSERVER = None return set()
print("[watcher] Stopped results directory watcher")
def _register_smb_session(unc_path):
server = unc_path[2:].split("\\", 1)[0]
if not server:
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}"
try:
if username:
smbclient.register_session(server, username=username, password=password)
else:
smbclient.register_session(server)
except Exception as exc:
print(f"[watcher] SMB session registration failed for {server}: {exc}")
def stop_results_watchers():
global _WATCHERS
with _WATCHERS_LOCK:
if not _WATCHERS:
return
for w in _WATCHERS:
try:
w.stop()
except Exception:
pass
for w in _WATCHERS:
try:
w.join(timeout=5)
except Exception:
pass
_WATCHERS = []
print("[watcher] Stopped results directory watcher(s)")
def start_results_watchers(results_dir, results_dir_ref): def start_results_watchers(results_dir, results_dir_ref):
global _OBSERVER global _WATCHERS
with _OBSERVER_LOCK: with _WATCHERS_LOCK:
stop_results_watchers() stop_results_watchers()
resolved = [] local_paths = []
smb_paths = []
seen = set() seen = set()
for raw_dir in (results_dir, results_dir_ref): for raw_dir in (results_dir, results_dir_ref):
if not raw_dir: if not raw_dir:
continue continue
path = resolve_runtime_path(raw_dir) path = resolve_runtime_path(raw_dir)
if not path: if not path:
continue continue
normalized = os.path.normcase(str(path).rstrip("\\/")) path = str(path)
if normalized and normalized not in seen:
seen.add(normalized)
resolved.append(str(path))
if not resolved: if path.startswith("\\\\") or path.startswith("//"):
unc = "\\\\" + path.lstrip("/\\").replace("/", "\\")
key = unc.lower()
if key not in seen:
seen.add(key)
smb_paths.append(unc)
else:
p = Path(path)
key = os.path.normcase(str(p))
if key not in seen:
seen.add(key)
local_paths.append(p)
if not local_paths and not smb_paths:
print("[watcher] No results directories configured, watcher not started") print("[watcher] No results directories configured, watcher not started")
return return
observer = PollingObserver(timeout=1.0) watchers = []
scheduled_count = 0 if local_paths:
for path in resolved: observer = PollingObserver(timeout=1.0)
try: scheduled = 0
observer.schedule(ResultsDirEventHandler(path), path, recursive=False) for p in local_paths:
scheduled_count += 1 try:
print(f"[watcher] Watching results directory: {path}") observer.schedule(ResultsDirEventHandler(p), str(p), recursive=False)
except Exception as exc: scheduled += 1
print(f"[watcher] Skipping watch path {path}: {exc}") print(f"[watcher] Watching local directory: {p}")
except Exception as exc:
print(f"[watcher] Skipping local watch path {p}: {exc}")
if scheduled:
try:
observer.start()
watchers.append(observer)
except Exception as exc:
print(f"[watcher] Failed to start local watcher: {exc}")
try:
observer.stop()
observer.join(timeout=5)
except Exception:
pass
if scheduled_count == 0: for unc in smb_paths:
_register_smb_session(unc)
poller = _SmbPollerThread(unc)
poller.start()
watchers.append(poller)
print(f"[watcher] Watching SMB directory: {unc}")
if not watchers:
print("[watcher] No valid watch paths available, watcher not started") print("[watcher] No valid watch paths available, watcher not started")
return return
try: _WATCHERS = watchers
observer.start() print(f"[watcher] Results directory watcher(s) started ({len(watchers)})")
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")