diff --git a/dashboard/src/components/ConfigModal.jsx b/dashboard/src/components/ConfigModal.jsx
index 942028e..72eaa4d 100644
--- a/dashboard/src/components/ConfigModal.jsx
+++ b/dashboard/src/components/ConfigModal.jsx
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import DirectoryBrowser from './DirectoryBrowser'
import { useConfig, useSaveConfig } from '../hooks/useConfig'
+import { apiFetch } from '../lib/api'
function fmtSeconds(s) {
if (s == null) return ''
@@ -16,15 +17,21 @@ export default function ConfigModal({ onClose }) {
const [browser, setBrowser] = useState(null) // 'target_dir' | 'results_dir' | null
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
const [saveError, setSaveError] = useState(null)
+ const [isRescanning, setIsRescanning] = useState(false)
+ const [isSavingTimes, setIsSavingTimes] = useState(false)
useEffect(() => {
if (config) {
setForm({
- target_dir: config.target_dir ?? '',
- results_dir: config.results_dir ?? '',
+ target_dir: config.target_dir ?? '',
+ results_dir: config.results_dir ?? '',
+ results_dir_ref: config.results_dir_ref ?? '',
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) : '',
+ smb_username: config.smb_username ?? '',
+ smb_password: config.smb_password ?? '',
+ smb_domain: config.smb_domain ?? '',
})
}
}, [config])
@@ -33,12 +40,16 @@ export default function ConfigModal({ onClose }) {
setSaveError(null)
setScanResult(null)
const payload = {
- target_dir: form.target_dir || null,
- results_dir: form.results_dir || null,
+ target_dir: form.target_dir || null,
+ results_dir: form.results_dir || null,
+ results_dir_ref: form.results_dir_ref || 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,
avg_time_p3p: form.avg_time_p3p ? String(parseFloat(form.avg_time_p3p) * 60) : null,
+ smb_username: form.smb_username || null,
+ smb_password: form.smb_password || null,
+ smb_domain: form.smb_domain || null,
}
save(payload, {
onSuccess: (data) => {
@@ -56,6 +67,43 @@ export default function ConfigModal({ onClose }) {
})
}
+ async function handleSaveTimes() {
+ setSaveError(null)
+ setScanResult(null)
+ setIsSavingTimes(true)
+ try {
+ await apiFetch('/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ 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,
+ avg_time_p3p: form.avg_time_p3p ? String(parseFloat(form.avg_time_p3p) * 60) : null,
+ }),
+ })
+ } catch (err) {
+ setSaveError(err?.message ?? 'Failed to save')
+ } finally {
+ setIsSavingTimes(false)
+ }
+ }
+
+ async function handleRescanResults() {
+ setSaveError(null)
+ setScanResult(null)
+ setIsRescanning(true)
+ try {
+ const data = await apiFetch('/config/rescan-results', { method: 'POST' })
+ if (data?.testCount !== null && data?.testCount !== undefined) {
+ setScanResult({ testCount: data.testCount, completedCount: data.completedCount })
+ }
+ } catch (err) {
+ setSaveError(err?.message ?? 'Rescan failed')
+ } finally {
+ setIsRescanning(false)
+ }
+ }
+
const AVG_FIELDS = [
{ key: 'avg_time_coe', label: 'COE avg time (min)' },
{ key: 'avg_time_p2p', label: 'P2P avg time (min)' },
@@ -104,8 +152,9 @@ export default function ConfigModal({ onClose }) {
{[
- { key: 'target_dir', label: 'Target Tests Directory' },
- { key: 'results_dir', label: 'Results Directory' },
+ { key: 'target_dir', label: 'Target Tests Directory' },
+ { key: 'results_dir', label: 'Results Directory (DUT)' },
+ { key: 'results_dir_ref', label: 'Results Directory (Reference)' },
].map(({ key, label }) => (
@@ -152,25 +201,72 @@ export default function ConfigModal({ onClose }) {
))}
+
+ {/* SMB Credentials */}
+
>
)}
{/* Footer */}
-
+
-
+
+
+
+
+
diff --git a/dashboard/src/components/DirectoryBrowser.jsx b/dashboard/src/components/DirectoryBrowser.jsx
index c2b7da4..24afa24 100644
--- a/dashboard/src/components/DirectoryBrowser.jsx
+++ b/dashboard/src/components/DirectoryBrowser.jsx
@@ -8,7 +8,6 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [manualPath, setManualPath] = useState('')
- const [networkHost, setNetworkHost] = useState('')
async function navigate(path) {
setLoading(true)
@@ -35,31 +34,18 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
navigate(path)
}
- function goToHost() {
- const host = networkHost.trim()
- if (!host) return
- navigate(`\\\\${host}`)
- }
-
// Load roots on first render
if (dirs === null && !loading && !error) {
navigate(null)
}
const normalizedCurrent = current ?? ''
+ const normalizedPath = normalizedCurrent.replace(/\\/g, '/')
const isUnixPath = normalizedCurrent.startsWith('/')
- const isUncPath = normalizedCurrent.startsWith('\\\\')
- const breadcrumbs = normalizedCurrent
- ? (isUncPath
- ? normalizedCurrent.slice(2).split(/\\+/).filter(Boolean)
- : normalizedCurrent.replace(/\\/g, '/').split('/').filter(Boolean))
- : []
+ const breadcrumbs = normalizedPath ? normalizedPath.split('/').filter(Boolean) : []
function breadcrumbPathAt(index) {
const parts = breadcrumbs.slice(0, index + 1)
- if (isUncPath) {
- return `\\\\${parts.join('\\')}`
- }
if (isUnixPath) {
return `/${parts.join('/')}`
}
@@ -95,40 +81,6 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
})}
- {/* Jump controls */}
-
-
{/* Directory list */}
{loading && (
diff --git a/dashboard/src/components/TestTable.jsx b/dashboard/src/components/TestTable.jsx
index 561e4c1..ccfd054 100644
--- a/dashboard/src/components/TestTable.jsx
+++ b/dashboard/src/components/TestTable.jsx
@@ -210,6 +210,34 @@ export default function TestTable({ tests = [], allTests = [], isLoading }) {
+ {(() => {
+ const rows = parseTputRows(test.tput_results)
+ if (!test.completed || rows.length === 0) return null
+ return (
+
+
+
+
+ | Station |
+ Throughput |
+ DL RSSI |
+ UL RSSI |
+
+
+
+ {rows.map(r => (
+
+ | STA{r.station} |
+ {r.tput} Mbps |
+ {r.dlRssi} dBm |
+ {r.ulRssi} dBm |
+
+ ))}
+
+
+
+ )
+ })()}
{(() => {
const pairs = parsePairArray(test.coe_pair)
if (pairs.length === 0) return null
@@ -298,34 +326,6 @@ export default function TestTable({ tests = [], allTests = [], isLoading }) {
)
})()}
- {(() => {
- const rows = parseTputRows(test.tput_results)
- if (!test.completed || rows.length === 0) return null
- return (
-
-
-
-
- | Station |
- Throughput |
- DL RSSI |
- UL RSSI |
-
-
-
- {rows.map(r => (
-
- | STA{r.station} |
- {r.tput} Mbps |
- {r.dlRssi} dBm |
- {r.ulRssi} dBm |
-
- ))}
-
-
-
- )
- })()}
)}
diff --git a/server/app.py b/server/app.py
index 5ad4b50..a360072 100644
--- a/server/app.py
+++ b/server/app.py
@@ -1,12 +1,11 @@
import os
-import subprocess
from pathlib import Path
from flask import Flask, Response, jsonify, request, send_from_directory
from flask_cors import CORS
from db_py import count_tests, del_config, get_all_tests, get_config, set_config
-from scanner import full_scan
+from scanner import full_scan, scan_results_only
from sse_py import broadcast, stream_events
from watcher import start_watching
@@ -14,9 +13,13 @@ PORT = int(os.getenv("PORT", "3001"))
ALLOWED_KEYS = {
"target_dir",
"results_dir",
+ "results_dir_ref",
"avg_time_coe",
"avg_time_p2p",
"avg_time_p3p",
+ "smb_username",
+ "smb_password",
+ "smb_domain",
}
BASE_DIR = Path(__file__).resolve().parent
@@ -26,6 +29,20 @@ app = Flask(__name__, static_folder=str(DIST_DIR), static_url_path="")
CORS(app)
+def _apply_smb_env_from_config():
+ mapping = {
+ "SMB_USERNAME": get_config("smb_username"),
+ "SMB_PASSWORD": get_config("smb_password"),
+ "SMB_DOMAIN": get_config("smb_domain"),
+ }
+
+ for env_key, value in mapping.items():
+ if value in (None, ""):
+ os.environ.pop(env_key, None)
+ else:
+ os.environ[env_key] = str(value)
+
+
@app.get("/api/tests")
def get_tests_route():
completed = request.args.get("completed")
@@ -205,14 +222,16 @@ def set_config_route():
else:
set_config(key, str(value))
- if key in {"target_dir", "results_dir"}:
+ if key in {"target_dir", "results_dir", "results_dir_ref"}:
dirs_changed = True
if dirs_changed:
+ _apply_smb_env_from_config()
target_dir = get_config("target_dir")
results_dir = get_config("results_dir")
+ results_dir_ref = get_config("results_dir_ref")
try:
- full_scan(target_dir, results_dir)
+ full_scan(target_dir, results_dir, results_dir_ref)
start_watching(target_dir, results_dir)
except Exception as exc:
print(f"[config] fullScan error: {exc}")
@@ -224,18 +243,21 @@ def set_config_route():
broadcast({"type": "update"})
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
+ broadcast({"type": "update"})
return jsonify({"ok": True, "testCount": None, "completedCount": None})
@app.post("/api/config/rescan")
def rescan_route():
+ _apply_smb_env_from_config()
target_dir = get_config("target_dir")
results_dir = get_config("results_dir")
+ results_dir_ref = get_config("results_dir_ref")
if not target_dir or not results_dir:
return jsonify({"error": "Directories not configured"}), 400
try:
- full_scan(target_dir, results_dir)
+ full_scan(target_dir, results_dir, results_dir_ref)
start_watching(target_dir, results_dir)
except Exception as exc:
print(f"[config] rescan error: {exc}")
@@ -248,6 +270,27 @@ def rescan_route():
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
+@app.post("/api/config/rescan-results")
+def rescan_results_route():
+ _apply_smb_env_from_config()
+ results_dir = get_config("results_dir")
+ results_dir_ref = get_config("results_dir_ref")
+ if not results_dir:
+ return jsonify({"error": "Results directory not configured"}), 400
+
+ try:
+ scan_results_only(results_dir, results_dir_ref)
+ except Exception as exc:
+ print(f"[config] rescan-results error: {exc}")
+ return jsonify({"error": f"Scan failed: {exc}"}), 500
+
+ tests = get_all_tests()
+ completed = len([t for t in tests if t.get("completed")])
+ print(f"[config] Results rescan complete -> {len(tests)} tests, {completed} completed")
+ broadcast({"type": "update"})
+ return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
+
+
@app.get("/api/browse")
def browse_route():
def _normalize_request_path(path):
@@ -258,48 +301,6 @@ def browse_route():
p = p.replace("/", "\\")
return p
- def _is_unc_host_only(path):
- if os.name != "nt" or not path:
- return False
- p = path.rstrip("\\")
- if not p.startswith("\\\\"):
- return False
- remainder = p[2:]
- return bool(remainder) and "\\" not in remainder
-
- def _shares_for_unc_host(host):
- # Enumerate SMB shares with native Windows tooling for host-only UNC paths.
- proc = subprocess.run(
- ["net", "view", f"\\\\{host}"],
- capture_output=True,
- text=True,
- timeout=10,
- check=False,
- )
- if proc.returncode != 0:
- raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "Cannot query network host")
-
- shares = []
- in_table = False
- for raw_line in proc.stdout.splitlines():
- line = raw_line.strip()
- if not line:
- continue
- if line.startswith("---"):
- in_table = True
- continue
- if not in_table:
- continue
- if line.lower().startswith("the command completed successfully"):
- break
-
- first = line.split()[0]
- if first and first.lower() not in {"share", "name"}:
- shares.append(first)
-
- unique = sorted(set(shares), key=str.lower)
- return [{"name": share, "path": f"\\\\{host}\\{share}"} for share in unique]
-
def _configured_roots():
raw = os.getenv("BROWSE_ROOTS", "").strip()
if not raw:
@@ -326,17 +327,6 @@ def browse_route():
continue
return False
- def _is_unc_host_allowed(host, roots):
- if not roots:
- return True
- host_prefix = os.path.normcase(f"\\\\{host}\\")
- host_exact = host_prefix.rstrip("\\")
- for root in roots:
- normalized_root = os.path.normcase(root)
- if normalized_root == host_exact or normalized_root.startswith(host_prefix):
- return True
- return False
-
roots = _configured_roots()
req_path = _normalize_request_path(request.args.get("path"))
@@ -359,16 +349,6 @@ def browse_route():
return jsonify({"path": None, "parent": None, "dirs": dirs})
- if _is_unc_host_only(req_path):
- host = req_path.rstrip("\\")[2:]
- if not _is_unc_host_allowed(host, roots):
- return jsonify({"error": "Path is outside allowed browse roots"}), 403
- try:
- dirs = _shares_for_unc_host(host)
- except Exception as exc:
- return jsonify({"error": f"Cannot list shares for host: {exc}"}), 403
- return jsonify({"path": f"\\\\{host}", "parent": None, "dirs": dirs})
-
if not os.path.exists(req_path):
return jsonify({"error": "Path does not exist"}), 400
if not os.path.isdir(req_path):
@@ -416,8 +396,10 @@ def static_or_spa(path=""):
def bootstrap():
+ _apply_smb_env_from_config()
target_dir = get_config("target_dir")
results_dir = get_config("results_dir")
+ results_dir_ref = get_config("results_dir_ref")
if target_dir and results_dir:
existing = count_tests()
@@ -425,7 +407,7 @@ def bootstrap():
print(f"[server] Resuming from DB -> {existing} tests already loaded.")
else:
print("[server] No cached data, scanning directories...")
- full_scan(target_dir, results_dir)
+ full_scan(target_dir, results_dir, results_dir_ref)
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")
diff --git a/server/dashboard.db b/server/dashboard.db
index f95366c..ff2d7df 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
index 59c5e99..a47482d 100644
Binary files a/server/dashboard.db-shm and b/server/dashboard.db-shm differ
diff --git a/server/dashboard.db-wal b/server/dashboard.db-wal
index c442712..5ed745f 100644
Binary files a/server/dashboard.db-wal and b/server/dashboard.db-wal differ
diff --git a/server/requirements.txt b/server/requirements.txt
index 5227761..3be0ebb 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -1,3 +1,4 @@
Flask>=3.0.0,<4.0.0
Flask-Cors>=4.0.1,<5.0.0
watchdog>=4.0.1,<5.0.0
+smbprotocol>=1.13.0,<2.0.0
diff --git a/server/scanner.py b/server/scanner.py
index 76304ba..5b6d61d 100644
--- a/server/scanner.py
+++ b/server/scanner.py
@@ -1,5 +1,6 @@
import os
import re
+import smbclient
from db_py import clear_tests, get_all_tests, mark_completed, set_coe_pair, upsert_test
from parser import (
@@ -11,16 +12,125 @@ from parser import (
)
-def full_scan(target_dir, results_dir):
+_SMB_SESSIONS = set()
+
+
+def _normalize_input_path(path_value):
+ if not path_value:
+ return path_value
+
+ path = str(path_value).strip()
+
+ # Accept //server/share style and normalize to UNC for smbclient.
+ if path.startswith("//"):
+ return "\\\\" + path.lstrip("/").replace("/", "\\")
+
+ # Accept ///... and normalize to UNC for Linux-hosted inputs.
+ if re.match(r"^/\d{1,3}(?:\.\d{1,3}){3}/[^/]+", path):
+ return "\\\\" + path.lstrip("/").replace("/", "\\")
+
+ return path
+
+
+def _is_unc_path(path):
+ return isinstance(path, str) and path.startswith("\\\\")
+
+
+def _extract_unc_server(path):
+ if not _is_unc_path(path):
+ return None
+ rest = path[2:]
+ return rest.split("\\", 1)[0] if rest else None
+
+
+def _register_smb_session_if_needed(path):
+ if not _is_unc_path(path):
+ return
+
+ server = _extract_unc_server(path)
+ if not server or server in _SMB_SESSIONS:
+ 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}"
+
+ if username:
+ smbclient.register_session(server, username=username, password=password)
+ else:
+ smbclient.register_session(server)
+
+ _SMB_SESSIONS.add(server)
+
+
+def _iter_dir_entries(path):
+ path = _normalize_input_path(path)
+ if _is_unc_path(path):
+ _register_smb_session_if_needed(path)
+ return list(smbclient.scandir(path))
+ return list(os.scandir(path))
+
+
+def _join_path(path, name):
+ if _is_unc_path(path):
+ base = path.rstrip("\\")
+ return f"{base}\\{name}"
+ return os.path.join(path, name)
+
+
+def _read_text_lines(path):
+ path = _normalize_input_path(path)
+ if _is_unc_path(path):
+ _register_smb_session_if_needed(path)
+ with smbclient.open_file(path, mode="r", encoding="utf-8", errors="ignore") as fh:
+ for line in fh:
+ yield line
+ return
+
+ with open(path, "r", encoding="utf-8", errors="ignore") as fh:
+ for line in fh:
+ yield line
+
+
+def scan_results_only(results_dir, results_dir_ref=None):
+ """Re-scan only the results directories without clearing or re-scanning targets."""
+ results_dir = _normalize_input_path(results_dir)
+ results_dir_ref = _normalize_input_path(results_dir_ref)
+
+ if not results_dir:
+ return
+
+ print(f"[scanner] results-only scan dir : {results_dir}")
+ if results_dir_ref:
+ print(f"[scanner] results-only scan dir ref : {results_dir_ref}")
+
+ scan_results(results_dir)
+ if results_dir_ref:
+ scan_results(results_dir_ref)
+ update_p2p_coe_pairs()
+
+
+def full_scan(target_dir, results_dir, results_dir_ref=None):
+ target_dir = _normalize_input_path(target_dir)
+ results_dir = _normalize_input_path(results_dir)
+ results_dir_ref = _normalize_input_path(results_dir_ref)
+
clear_tests()
if not target_dir or not results_dir:
return
- print(f"[scanner] target dir : {target_dir}")
- print(f"[scanner] results dir: {results_dir}")
+ print(f"[scanner] target dir : {target_dir}")
+ print(f"[scanner] results dir : {results_dir}")
+ if results_dir_ref:
+ print(f"[scanner] results dir ref : {results_dir_ref}")
scan_targets(target_dir)
scan_results(results_dir)
+ if results_dir_ref:
+ scan_results(results_dir_ref)
update_p2p_coe_pairs()
@@ -66,11 +176,13 @@ def update_p2p_coe_pairs():
def scan_targets(target_dir):
+ target_dir = _normalize_input_path(target_dir)
+
try:
parent_entries = [
- name
- for name in os.listdir(target_dir)
- if os.path.isdir(os.path.join(target_dir, name))
+ entry.name
+ for entry in _iter_dir_entries(target_dir)
+ if entry.is_dir()
]
except OSError as exc:
print(f"[scanner] Cannot read target dir: {exc}")
@@ -79,14 +191,14 @@ def scan_targets(target_dir):
print(f"[scanner] subdirectories found: {len(parent_entries)}")
for parent_name in parent_entries:
- parent_path = os.path.join(target_dir, parent_name)
+ parent_path = _join_path(target_dir, parent_name)
try:
files = [
- name
- for name in os.listdir(parent_path)
- if os.path.isfile(os.path.join(parent_path, name))
- and not name.startswith("GLOBAL")
- and name.endswith(".ini")
+ entry.name
+ for entry in _iter_dir_entries(parent_path)
+ if entry.is_file()
+ and not entry.name.startswith("GLOBAL")
+ and entry.name.endswith(".ini")
]
except OSError as exc:
print(f"[scanner] Cannot read parent dir {parent_name}: {exc}")
@@ -125,11 +237,13 @@ def scan_targets(target_dir):
def scan_results(results_dir):
+ results_dir = _normalize_input_path(results_dir)
+
try:
entries = [
- name
- for name in os.listdir(results_dir)
- if os.path.isdir(os.path.join(results_dir, name))
+ entry.name
+ for entry in _iter_dir_entries(results_dir)
+ if entry.is_dir()
]
except OSError as exc:
print(f"[scanner] Cannot read results dir: {exc}")
@@ -141,21 +255,23 @@ def scan_results(results_dir):
def process_result_dir(results_dir, result_dir_name):
+ results_dir = _normalize_input_path(results_dir)
+
parsed = parse_result_filename(result_dir_name)
test_id = parsed["test_id"]
device = parsed["device"]
if not test_id or not device:
return
- result_dir_path = os.path.join(results_dir, result_dir_name)
+ result_dir_path = _join_path(results_dir, result_dir_name)
try:
log_files = [
- name
- for name in os.listdir(result_dir_path)
- if os.path.isfile(os.path.join(result_dir_path, name))
- and name.endswith(".txt")
- and test_id in name
+ entry.name
+ for entry in _iter_dir_entries(result_dir_path)
+ if entry.is_file()
+ and entry.name.endswith(".txt")
+ and test_id in entry.name
]
except OSError as exc:
print(f"[scanner] Cannot read result dir {result_dir_name}: {exc}")
@@ -167,7 +283,7 @@ def process_result_dir(results_dir, result_dir_name):
return
latest_log = sorted(log_files)[-1]
- log_path = os.path.join(result_dir_path, latest_log)
+ log_path = _join_path(result_dir_path, latest_log)
completed_at = parse_timestamp(latest_log)
data = extract_log_data(log_path)
@@ -199,17 +315,16 @@ def extract_log_data(log_file_path):
result = {"duration_seconds": None, "tputResults": []}
try:
- with open(log_file_path, "r", encoding="utf-8", errors="ignore") as fh:
- for line in fh:
- time_match = elapsed_regex.search(line)
- if time_match:
- result["duration_seconds"] = parse_elapsed_time(time_match.group(1))
- continue
+ for line in _read_text_lines(log_file_path):
+ time_match = elapsed_regex.search(line)
+ if time_match:
+ result["duration_seconds"] = parse_elapsed_time(time_match.group(1))
+ continue
- if tput_regex.search(line):
- parsed = parse_tput_rssi(line)
- if parsed:
- result["tputResults"].append(parsed)
+ if tput_regex.search(line):
+ parsed = parse_tput_rssi(line)
+ if parsed:
+ result["tputResults"].append(parsed)
except OSError as exc:
print(f"[scanner] Cannot read log file {log_file_path}: {exc}")