This commit is contained in:
2026-05-28 12:34:01 -04:00
parent d239de257d
commit 49310e6d59
9 changed files with 240 additions and 164 deletions
+20 -2
View File
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react' import { useEffect } from 'react'
import { getStats } from '../lib/api' import { getScanStatus, getStats } from '../lib/api'
export function useStats() { export function useStats() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -20,5 +20,23 @@ export function useStats() {
return () => es.close() return () => es.close()
}, [queryClient]) }, [queryClient])
return useQuery({ queryKey: ['stats'], queryFn: getStats, refetchInterval: 30_000 }) const scanStatusQuery = useQuery({
queryKey: ['scanStatus'],
queryFn: getScanStatus,
refetchInterval: (query) => (query.state.data?.scanning ? 1000 : 5000),
})
const isScanning = scanStatusQuery.data?.scanning ?? true
const statsQuery = useQuery({
queryKey: ['stats'],
queryFn: getStats,
enabled: !isScanning,
refetchInterval: isScanning ? false : 30_000,
})
return {
...statsQuery,
isScanning,
}
} }
+1
View File
@@ -13,6 +13,7 @@ export async function apiFetch(path, options = {}) {
} }
export const getStats = () => apiFetch('/stats') export const getStats = () => apiFetch('/stats')
export const getScanStatus = () => apiFetch('/scan-status')
export const getTests = (params = {}) => { export const getTests = (params = {}) => {
const qs = new URLSearchParams( const qs = new URLSearchParams(
Object.entries(params).filter(([, v]) => v !== '' && v !== undefined && v !== null) Object.entries(params).filter(([, v]) => v !== '' && v !== undefined && v !== null)
+6
View File
@@ -4,10 +4,16 @@ services:
container_name: dashboard-backend container_name: dashboard-backend
ports: ports:
- "3001:3001" - "3001:3001"
environment:
- HOST_BROWSE_ROOT=${HOST_BROWSE_ROOT:-C:/Users}
- HOST_MOUNT_ROOT=/host
volumes: volumes:
- type: bind - type: bind
source: ./server source: ./server
target: /app target: /app
- type: bind
source: ${HOST_BROWSE_ROOT:-C:/Users}
target: /host
restart: unless-stopped restart: unless-stopped
frontend: frontend:
+66 -44
View File
@@ -1,11 +1,12 @@
import os import os
import threading
from pathlib import Path from pathlib import Path
from flask import Flask, Response, jsonify, request, send_from_directory from flask import Flask, Response, jsonify, request, send_from_directory
from flask_cors import CORS from flask_cors import CORS
from db_py import count_tests, del_config, get_all_tests, get_config, set_config from db_py import count_tests, del_config, get_all_tests, get_config, set_config
from scanner import full_scan, scan_results_only from scanner import full_scan, is_scan_in_progress, resolve_runtime_path, scan_results_only
from sse_py import broadcast, stream_events from sse_py import broadcast, stream_events
from watcher import start_watching from watcher import start_watching
@@ -43,6 +44,39 @@ def _apply_smb_env_from_config():
os.environ[env_key] = str(value) os.environ[env_key] = str(value)
def _start_full_scan_background(target_dir, results_dir, results_dir_ref, source_label):
def _job():
try:
full_scan(target_dir, results_dir, results_dir_ref)
start_watching(target_dir, results_dir)
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")
except Exception as exc:
print(f"[{source_label}] background fullScan error: {exc}")
finally:
broadcast({"type": "update"})
worker = threading.Thread(target=_job, daemon=True)
worker.start()
def _start_results_scan_background(results_dir, results_dir_ref, source_label):
def _job():
try:
scan_results_only(results_dir, results_dir_ref)
tests = get_all_tests()
completed = len([t for t in tests if t.get("completed")])
print(f"[{source_label}] Results scan complete -> {len(tests)} tests, {completed} completed")
except Exception as exc:
print(f"[{source_label}] background rescan-results error: {exc}")
finally:
broadcast({"type": "update"})
worker = threading.Thread(target=_job, daemon=True)
worker.start()
@app.get("/api/tests") @app.get("/api/tests")
def get_tests_route(): def get_tests_route():
completed = request.args.get("completed") completed = request.args.get("completed")
@@ -197,6 +231,11 @@ def get_stats_route():
) )
@app.get("/api/scan-status")
def get_scan_status_route():
return jsonify({"scanning": is_scan_in_progress()})
@app.get("/api/config") @app.get("/api/config")
def get_config_route(): def get_config_route():
config = {} config = {}
@@ -227,21 +266,17 @@ def set_config_route():
if dirs_changed: if dirs_changed:
_apply_smb_env_from_config() _apply_smb_env_from_config()
target_dir = get_config("target_dir") target_dir = resolve_runtime_path(get_config("target_dir"))
results_dir = get_config("results_dir") results_dir = resolve_runtime_path(get_config("results_dir"))
results_dir_ref = get_config("results_dir_ref") results_dir_ref = resolve_runtime_path(get_config("results_dir_ref"))
try: if not target_dir or not results_dir:
full_scan(target_dir, results_dir, results_dir_ref) return jsonify({"error": "Directories not configured"}), 400
start_watching(target_dir, results_dir)
except Exception as exc:
print(f"[config] fullScan error: {exc}")
return jsonify({"error": f"Scan failed: {exc}"}), 500
tests = get_all_tests() if is_scan_in_progress():
completed = len([t for t in tests if t.get("completed")]) return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
print(f"[config] Scan complete -> {len(tests)} tests found, {completed} completed")
broadcast({"type": "update"}) _start_full_scan_background(target_dir, results_dir, results_dir_ref, "config")
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed}) return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
broadcast({"type": "update"}) broadcast({"type": "update"})
return jsonify({"ok": True, "testCount": None, "completedCount": None}) return jsonify({"ok": True, "testCount": None, "completedCount": None})
@@ -250,45 +285,32 @@ def set_config_route():
@app.post("/api/config/rescan") @app.post("/api/config/rescan")
def rescan_route(): def rescan_route():
_apply_smb_env_from_config() _apply_smb_env_from_config()
target_dir = get_config("target_dir") target_dir = resolve_runtime_path(get_config("target_dir"))
results_dir = get_config("results_dir") results_dir = resolve_runtime_path(get_config("results_dir"))
results_dir_ref = get_config("results_dir_ref") results_dir_ref = resolve_runtime_path(get_config("results_dir_ref"))
if not target_dir or not results_dir: if not target_dir or not results_dir:
return jsonify({"error": "Directories not configured"}), 400 return jsonify({"error": "Directories not configured"}), 400
try: if is_scan_in_progress():
full_scan(target_dir, results_dir, results_dir_ref) return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
start_watching(target_dir, results_dir)
except Exception as exc:
print(f"[config] rescan error: {exc}")
return jsonify({"error": f"Scan failed: {exc}"}), 500
tests = get_all_tests() _start_full_scan_background(target_dir, results_dir, results_dir_ref, "config")
completed = len([t for t in tests if t.get("completed")]) return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
print(f"[config] Rescan complete -> {len(tests)} tests, {completed} completed")
broadcast({"type": "update"})
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
@app.post("/api/config/rescan-results") @app.post("/api/config/rescan-results")
def rescan_results_route(): def rescan_results_route():
_apply_smb_env_from_config() _apply_smb_env_from_config()
results_dir = get_config("results_dir") results_dir = resolve_runtime_path(get_config("results_dir"))
results_dir_ref = get_config("results_dir_ref") results_dir_ref = resolve_runtime_path(get_config("results_dir_ref"))
if not results_dir: if not results_dir:
return jsonify({"error": "Results directory not configured"}), 400 return jsonify({"error": "Results directory not configured"}), 400
try: if is_scan_in_progress():
scan_results_only(results_dir, results_dir_ref) return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
except Exception as exc:
print(f"[config] rescan-results error: {exc}")
return jsonify({"error": f"Scan failed: {exc}"}), 500
tests = get_all_tests() _start_results_scan_background(results_dir, results_dir_ref, "config")
completed = len([t for t in tests if t.get("completed")]) return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
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/events") @app.get("/api/events")
@@ -314,9 +336,9 @@ def static_or_spa(path=""):
def bootstrap(): def bootstrap():
_apply_smb_env_from_config() _apply_smb_env_from_config()
target_dir = get_config("target_dir") target_dir = resolve_runtime_path(get_config("target_dir"))
results_dir = get_config("results_dir") results_dir = resolve_runtime_path(get_config("results_dir"))
results_dir_ref = get_config("results_dir_ref") results_dir_ref = resolve_runtime_path(get_config("results_dir_ref"))
if target_dir and results_dir: if target_dir and results_dir:
existing = count_tests() existing = count_tests()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+57
View File
@@ -166,6 +166,63 @@ def set_p3p_pair(test_row_id, p3p_pair):
) )
def update_all_p3p_pairs_sql():
with _tx():
cursor = _conn.execute(
"""
UPDATE tests AS t
SET p3p_pair = (
SELECT CASE
WHEN COUNT(*) = 0 THEN '[]'
ELSE '[' || GROUP_CONCAT('"' || m.device || '_' || m.test_id || '"') || ']'
END
FROM tests AS m
WHERE m.interference = 'P3P'
AND UPPER(m.device) = UPPER(t.device)
AND UPPER(m.test_id) = CASE
WHEN INSTR(UPPER(t.test_id), 'TH') > 0 THEN REPLACE(UPPER(t.test_id), 'TH', 'UT')
WHEN INSTR(UPPER(t.test_id), 'UT') > 0 THEN REPLACE(UPPER(t.test_id), 'UT', 'TH')
ELSE '__NO_MATCH__'
END
)
WHERE t.interference = 'P3P'
"""
)
return cursor.rowcount
def update_all_p2p_coe_pairs_sql():
with _tx():
cursor = _conn.execute(
"""
UPDATE tests AS t
SET coe_pair = (
SELECT CASE
WHEN COUNT(*) = 0 THEN '[]'
ELSE '[' || GROUP_CONCAT('"' || pair_value || '"') || ']'
END
FROM (
SELECT DISTINCT m.device || '_' || m.test_id AS pair_value
FROM tests AS m
WHERE m.interference = 'COE'
AND IFNULL(m.device, '') = IFNULL(t.device, '')
AND IFNULL(m.rotation, '') = IFNULL(t.rotation, '')
AND IFNULL(m.test_point, '') = IFNULL(t.test_point, '')
AND IFNULL(m.rssi, '') = IFNULL(t.rssi, '')
AND IFNULL(m.station, '') = IFNULL(t.station, '')
AND IFNULL(m.band, '') = IFNULL(t.band, '')
AND IFNULL(m.channel, '') = IFNULL(t.channel, '')
AND IFNULL(m.bandwidth, '') = IFNULL(t.bandwidth, '')
AND IFNULL(m.direction, '') = IFNULL(t.direction, '')
ORDER BY pair_value
)
)
WHERE t.interference = 'P2P'
"""
)
return cursor.rowcount
def get_station_for_test(test_id, device): def get_station_for_test(test_id, device):
with _lock: with _lock:
row = _conn.execute( row = _conn.execute(
+90 -118
View File
@@ -1,13 +1,13 @@
import os import os
import re import re
import threading
import smbclient import smbclient
from db_py import ( from db_py import (
clear_tests, clear_tests,
get_all_tests,
mark_completed, mark_completed,
set_coe_pair, update_all_p2p_coe_pairs_sql,
set_p3p_pair, update_all_p3p_pairs_sql,
upsert_test, upsert_test,
) )
from parser import ( from parser import (
@@ -20,13 +20,67 @@ from parser import (
_SMB_SESSIONS = set() _SMB_SESSIONS = set()
_WIN_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]")
_SCAN_STATE_LOCK = threading.Lock()
_ACTIVE_SCAN_COUNT = 0
def _scan_started():
global _ACTIVE_SCAN_COUNT
with _SCAN_STATE_LOCK:
_ACTIVE_SCAN_COUNT += 1
def _scan_finished():
global _ACTIVE_SCAN_COUNT
with _SCAN_STATE_LOCK:
_ACTIVE_SCAN_COUNT = max(0, _ACTIVE_SCAN_COUNT - 1)
def is_scan_in_progress():
with _SCAN_STATE_LOCK:
return _ACTIVE_SCAN_COUNT > 0
def resolve_runtime_path(path_value):
if not path_value:
return path_value
raw_path = str(path_value).strip()
if not raw_path:
return raw_path
# If the path is already valid in the current runtime, keep it.
if os.path.exists(raw_path):
return raw_path
# UNC/network paths are handled separately via smbclient.
if raw_path.startswith("\\\\") or raw_path.startswith("//"):
return raw_path
# Map Windows host paths when backend runs in Linux container.
if os.name != "nt" and _WIN_DRIVE_PATH_RE.match(raw_path):
mount_root = os.getenv("HOST_MOUNT_ROOT", "/host").strip() or "/host"
host_root = os.getenv("HOST_BROWSE_ROOT", "").strip()
raw_norm = raw_path.replace("\\", "/")
if host_root:
host_norm = host_root.replace("\\", "/").rstrip("/")
if raw_norm.lower() == host_norm.lower() or raw_norm.lower().startswith(host_norm.lower() + "/"):
relative = raw_norm[len(host_norm):].lstrip("/")
if relative:
return os.path.join(mount_root, *relative.split("/"))
return mount_root
return raw_path
def _normalize_input_path(path_value): def _normalize_input_path(path_value):
if not path_value: if not path_value:
return path_value return path_value
path = str(path_value).strip() path = resolve_runtime_path(path_value)
path = str(path).strip()
# Accept //server/share style and normalize to UNC for smbclient. # Accept //server/share style and normalize to UNC for smbclient.
if path.startswith("//"): if path.startswith("//"):
@@ -102,138 +156,54 @@ def _read_text_lines(path):
yield line yield line
def scan_results_only(results_dir, results_dir_ref=None): def scan_results_only(results_dir, results_dir_ref):
"""Re-scan only the results directories without clearing or re-scanning targets.""" """Re-scan only the results directories without clearing or re-scanning targets."""
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)
if not results_dir: if not results_dir or not results_dir_ref:
return return
print(f"[scanner] results-only scan dir : {results_dir}") _scan_started()
if results_dir_ref: try:
print(f"[scanner] results-only scan dir : {results_dir}")
print(f"[scanner] results-only scan dir ref : {results_dir_ref}") print(f"[scanner] results-only scan dir ref : {results_dir_ref}")
scan_results(results_dir) scan_results(results_dir)
if results_dir_ref:
scan_results(results_dir_ref) scan_results(results_dir_ref)
update_p2p_coe_pairs() update_p2p_coe_pairs()
update_p3p_throttle_pairs() update_p3p_throttle_pairs()
finally:
_scan_finished()
def full_scan(target_dir, results_dir, results_dir_ref=None): def full_scan(target_dir, results_dir, results_dir_ref):
target_dir = _normalize_input_path(target_dir) target_dir = _normalize_input_path(target_dir)
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_tests() _scan_started()
if not target_dir or not results_dir: try:
return clear_tests()
if not target_dir or not results_dir or not results_dir_ref:
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 : {results_dir}")
if results_dir_ref:
print(f"[scanner] results dir ref : {results_dir_ref}") print(f"[scanner] results dir ref : {results_dir_ref}")
scan_targets(target_dir) if not scan_targets(target_dir):
scan_results(results_dir) print("[scanner] Skipping results scan because target scan failed")
if results_dir_ref: return
scan_results(results_dir)
scan_results(results_dir_ref) scan_results(results_dir_ref)
update_p2p_coe_pairs() coe_pairs = update_all_p2p_coe_pairs_sql()
update_p3p_throttle_pairs() print(f"[scanner] coe_pair updated for {coe_pairs} P2P test(s)")
p3p_pairs = update_all_p3p_pairs_sql()
print(f"[scanner] p3p_pair updated for {p3p_pairs} P3P test(s)")
def update_p2p_coe_pairs(): finally:
pair_fields = [ _scan_finished()
"device",
"rotation",
"test_point",
"rssi",
"station",
"band",
"channel",
"bandwidth",
"direction",
]
tests = get_all_tests()
coe_by_key = {}
for test in tests:
if test.get("interference") != "COE":
continue
key = tuple(test.get(field) for field in pair_fields)
device = test.get("device")
test_id = test.get("test_id")
if not device or not test_id:
continue
coe_by_key.setdefault(key, []).append(f"{device}_{test_id}")
updated = 0
for test in tests:
if test.get("interference") != "P2P":
continue
key = tuple(test.get(field) for field in pair_fields)
pairs = sorted(set(coe_by_key.get(key, [])))
set_coe_pair(test.get("id"), pairs)
updated += 1
print(f"[scanner] coe_pair updated for {updated} P2P test(s)")
def update_p3p_throttle_pairs():
tests = get_all_tests()
p3p_lookup = {}
for test in tests:
if test.get("interference") != "P3P":
continue
device = test.get("device")
test_id = test.get("test_id")
if not device or not test_id:
continue
p3p_lookup.setdefault(f"{str(device).upper()}_{str(test_id).upper()}", []).append(test)
def _pair_test_id(test_id, throttled):
if not test_id or not throttled:
return None
upper_id = str(test_id).upper()
upper_throttled = str(throttled).upper()
if upper_throttled == "TH":
return re.sub("TH", "UT", upper_id, count=1)
if upper_throttled == "UT":
return re.sub("UT", "TH", upper_id, count=1)
return None
updated = 0
for test in tests:
if test.get("interference") != "P3P":
continue
device = test.get("device")
test_id = test.get("test_id")
pair_test_id = _pair_test_id(test_id, test.get("throttled"))
pairs = []
if device and pair_test_id:
lookup_key = f"{str(device).upper()}_{str(pair_test_id).upper()}"
matches = p3p_lookup.get(lookup_key, [])
for match in matches:
match_device = match.get("device")
match_test_id = match.get("test_id")
if match_device and match_test_id:
pairs.append(f"{match_device}_{match_test_id}")
set_p3p_pair(test.get("id"), sorted(set(pairs)))
updated += 1
print(f"[scanner] p3p_pair updated for {updated} P3P test(s)")
def scan_targets(target_dir): def scan_targets(target_dir):
@@ -247,7 +217,7 @@ def scan_targets(target_dir):
] ]
except OSError as exc: except OSError as exc:
print(f"[scanner] Cannot read target dir: {exc}") print(f"[scanner] Cannot read target dir: {exc}")
return return False
print(f"[scanner] subdirectories found: {len(parent_entries)}") print(f"[scanner] subdirectories found: {len(parent_entries)}")
@@ -296,6 +266,8 @@ def scan_targets(target_dir):
} }
) )
return True
def scan_results(results_dir): def scan_results(results_dir):
results_dir = _normalize_input_path(results_dir) results_dir = _normalize_input_path(results_dir)