597 lines
18 KiB
Python
597 lines
18 KiB
Python
import os
|
|
import threading
|
|
import atexit
|
|
from datetime import datetime, timedelta, timezone
|
|
from functools import wraps
|
|
from pathlib import Path
|
|
|
|
import jwt
|
|
from dotenv import load_dotenv
|
|
from flask import Flask, g, jsonify, request, send_from_directory
|
|
from flask_cors import CORS
|
|
from werkzeug.security import check_password_hash, generate_password_hash
|
|
|
|
from db_py import (
|
|
count_admin_users,
|
|
count_tests,
|
|
count_users,
|
|
create_user,
|
|
delete_user,
|
|
del_config,
|
|
get_all_tests,
|
|
get_all_users,
|
|
get_config,
|
|
get_user_by_id,
|
|
get_user_by_username,
|
|
set_config,
|
|
clear_users,
|
|
update_user_password,
|
|
)
|
|
from scanner import full_scan, get_scan_errors, is_scan_in_progress, resolve_runtime_path
|
|
from watcher import start_results_watchers, stop_results_watchers
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
load_dotenv(BASE_DIR / ".env")
|
|
|
|
PORT = int(os.getenv("PORT", "3001"))
|
|
JWT_SECRET = os.getenv("JWT_SECRET")
|
|
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
|
JWT_EXPIRES_HOURS = int(os.getenv("JWT_EXPIRES_HOURS", "8"))
|
|
DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME")
|
|
DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD")
|
|
DEFAULT_VIEWER_USERNAME = os.getenv("DEFAULT_VIEWER_USERNAME")
|
|
DEFAULT_VIEWER_PASSWORD = os.getenv("DEFAULT_VIEWER_PASSWORD")
|
|
|
|
ALLOWED_KEYS = {
|
|
"target_dir",
|
|
"results_dir",
|
|
"results_dir_ref",
|
|
"avg_time_coe",
|
|
"avg_time_p2p",
|
|
"avg_time_p3p",
|
|
"scan_exclusions",
|
|
"smb_username",
|
|
"smb_password",
|
|
"smb_domain",
|
|
}
|
|
|
|
DIST_DIR = BASE_DIR.parent / "dashboard" / "dist"
|
|
|
|
app = Flask(__name__, static_folder=str(DIST_DIR), static_url_path="")
|
|
CORS(app)
|
|
|
|
|
|
def _make_token(user):
|
|
now = datetime.now(timezone.utc)
|
|
payload = {
|
|
"sub": str(user["id"]),
|
|
"username": user["username"],
|
|
"role": user["role"],
|
|
"iat": int(now.timestamp()),
|
|
"exp": int((now + timedelta(hours=JWT_EXPIRES_HOURS)).timestamp()),
|
|
}
|
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
|
|
|
|
def _decode_token(token):
|
|
try:
|
|
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
|
|
|
|
def _extract_bearer_token():
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if not auth_header.lower().startswith("bearer "):
|
|
return None
|
|
return auth_header[7:].strip() or None
|
|
|
|
|
|
def require_auth(fn):
|
|
@wraps(fn)
|
|
def wrapper(*args, **kwargs):
|
|
token = _extract_bearer_token()
|
|
if not token:
|
|
return jsonify({"error": "Authentication required"}), 401
|
|
|
|
payload = _decode_token(token)
|
|
if payload is None:
|
|
return jsonify({"error": "Invalid or expired token"}), 401
|
|
|
|
try:
|
|
user_id = int(payload.get("sub"))
|
|
except (TypeError, ValueError):
|
|
return jsonify({"error": "Invalid token subject"}), 401
|
|
|
|
user = get_user_by_id(user_id)
|
|
if not user or not user.get("is_active"):
|
|
return jsonify({"error": "User is not authorized"}), 401
|
|
|
|
g.current_user = {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
"role": user["role"],
|
|
}
|
|
return fn(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
def require_role(required_role):
|
|
def decorator(fn):
|
|
@wraps(fn)
|
|
def wrapper(*args, **kwargs):
|
|
current_user = getattr(g, "current_user", None)
|
|
if not current_user:
|
|
return jsonify({"error": "Authentication required"}), 401
|
|
if current_user.get("role") != required_role:
|
|
return jsonify({"error": "Forbidden"}), 403
|
|
return fn(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def _create_default_user_if_missing(username, password, role):
|
|
if not username or not password:
|
|
print(f"[server] Skipping default {role} seed: username/password not configured")
|
|
return
|
|
|
|
existing = get_user_by_username(username)
|
|
if existing:
|
|
return
|
|
|
|
create_user(
|
|
username,
|
|
generate_password_hash(password),
|
|
role=role,
|
|
is_active=1,
|
|
)
|
|
print(f"[server] Created default {role} user: {username}")
|
|
|
|
|
|
def _ensure_default_users():
|
|
if count_users() > 0:
|
|
print("[server] Users already exist, skipping default account seeding.")
|
|
if count_users() == 0:
|
|
print("[server] No users found. Seeding default accounts...")
|
|
|
|
_create_default_user_if_missing(DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD, "admin")
|
|
_create_default_user_if_missing(DEFAULT_VIEWER_USERNAME, DEFAULT_VIEWER_PASSWORD, "viewer")
|
|
|
|
|
|
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")
|
|
@require_auth
|
|
def get_tests_route():
|
|
completed = request.args.get("completed")
|
|
interference = request.args.get("interference")
|
|
throttled = request.args.get("throttled")
|
|
device = request.args.get("device")
|
|
rotation = request.args.get("rotation")
|
|
test_point = request.args.get("testPoint")
|
|
station = request.args.get("station")
|
|
band = request.args.get("band")
|
|
channel = request.args.get("channel")
|
|
bandwidth = request.args.get("bandwidth")
|
|
rssi = request.args.get("rssi")
|
|
direction = request.args.get("direction")
|
|
|
|
tests = get_all_tests()
|
|
|
|
if completed is not None:
|
|
completed_value = 1 if completed == "true" else 0
|
|
tests = [t for t in tests if t.get("completed") == completed_value]
|
|
if interference:
|
|
tests = [t for t in tests if t.get("interference") == interference]
|
|
if throttled:
|
|
tests = [t for t in tests if t.get("throttled") == throttled]
|
|
if device:
|
|
tests = [t for t in tests if t.get("device") == device]
|
|
if rotation:
|
|
tests = [t for t in tests if t.get("rotation") == rotation]
|
|
if test_point:
|
|
tests = [t for t in tests if t.get("test_point") == test_point]
|
|
if station:
|
|
tests = [t for t in tests if t.get("station") == station]
|
|
if band:
|
|
tests = [t for t in tests if t.get("band") == band]
|
|
if channel:
|
|
tests = [t for t in tests if t.get("channel") == channel]
|
|
if bandwidth:
|
|
tests = [t for t in tests if t.get("bandwidth") == bandwidth]
|
|
if rssi:
|
|
tests = [t for t in tests if t.get("rssi") == rssi]
|
|
if direction:
|
|
tests = [t for t in tests if t.get("direction") == direction]
|
|
|
|
tests.sort(key=lambda item: ((item.get("interference") or ""), (item.get("test_id") or "")))
|
|
return jsonify(tests)
|
|
|
|
|
|
@app.get("/api/stats")
|
|
@require_auth
|
|
def get_stats_route():
|
|
tests = get_all_tests()
|
|
types = ["COE", "P2P", "P3P"]
|
|
|
|
total_completed = sum(1 for t in tests if t.get("completed"))
|
|
overall = {
|
|
"total": len(tests),
|
|
"completed": total_completed,
|
|
"completionRate": (total_completed / len(tests)) if tests else 0,
|
|
}
|
|
|
|
device_map = {}
|
|
for test in tests:
|
|
name = test.get("device")
|
|
if not name:
|
|
continue
|
|
|
|
if name not in device_map:
|
|
device_map[name] = {
|
|
"total": 0,
|
|
"completed": 0,
|
|
"byType": {t: {"total": 0, "completed": 0} for t in types},
|
|
}
|
|
device_map[name]["total"] += 1
|
|
|
|
interference = test.get("interference")
|
|
if interference in device_map[name]["byType"]:
|
|
device_map[name]["byType"][interference]["total"] += 1
|
|
|
|
if test.get("completed"):
|
|
device_map[name]["completed"] += 1
|
|
if interference in device_map[name]["byType"]:
|
|
device_map[name]["byType"][interference]["completed"] += 1
|
|
|
|
devices = []
|
|
for name in sorted(device_map.keys()):
|
|
stats = device_map[name]
|
|
devices.append(
|
|
{
|
|
"name": name,
|
|
"total": stats["total"],
|
|
"completed": stats["completed"],
|
|
"completionRate": (stats["completed"] / stats["total"]) if stats["total"] else 0,
|
|
"byType": stats["byType"],
|
|
}
|
|
)
|
|
|
|
elapsed_seconds = 0
|
|
for test in tests:
|
|
duration = test.get("duration_seconds")
|
|
if test.get("completed") and duration is not None:
|
|
elapsed_seconds += duration
|
|
|
|
by_type = {}
|
|
estimate_possible = True
|
|
estimated_remaining_seconds = 0
|
|
|
|
for test_type in types:
|
|
type_tests = [t for t in tests if t.get("interference") == test_type]
|
|
completed_tests = [t for t in type_tests if t.get("completed")]
|
|
with_duration = [t for t in completed_tests if t.get("duration_seconds") is not None]
|
|
remaining = len(type_tests) - len(completed_tests)
|
|
|
|
calc_avg = None
|
|
if with_duration:
|
|
calc_avg = sum(t.get("duration_seconds") for t in with_duration) / len(with_duration)
|
|
|
|
manual_value = get_config(f"avg_time_{test_type.lower()}")
|
|
|
|
avg_seconds = None
|
|
avg_source = None
|
|
|
|
if manual_value is not None:
|
|
avg_seconds = float(manual_value)
|
|
avg_source = "manual_override" if calc_avg is not None else "manual"
|
|
elif calc_avg is not None:
|
|
avg_seconds = calc_avg
|
|
avg_source = "calculated"
|
|
|
|
by_type[test_type] = {
|
|
"total": len(type_tests),
|
|
"completed": len(completed_tests),
|
|
"remaining": remaining,
|
|
"avgSeconds": avg_seconds,
|
|
"avgSource": avg_source,
|
|
}
|
|
|
|
if remaining > 0:
|
|
if avg_seconds is not None:
|
|
estimated_remaining_seconds += avg_seconds * remaining
|
|
else:
|
|
estimate_possible = False
|
|
|
|
return jsonify(
|
|
{
|
|
"overall": overall,
|
|
"devices": devices,
|
|
"timing": {
|
|
"elapsedSeconds": elapsed_seconds,
|
|
"estimatedRemainingSeconds": estimated_remaining_seconds if estimate_possible else None,
|
|
"byType": by_type,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@app.get("/api/scan-status")
|
|
@require_auth
|
|
def get_scan_status_route():
|
|
return jsonify({"scanning": is_scan_in_progress(), "errors": get_scan_errors()})
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
def auth_login_route():
|
|
body = request.get_json(silent=True)
|
|
if not isinstance(body, dict):
|
|
return jsonify({"error": "Request body must be a JSON object"}), 400
|
|
|
|
username = (body.get("username") or "").strip()
|
|
password = body.get("password") or ""
|
|
|
|
if not username or not password:
|
|
return jsonify({"error": "Username and password are required"}), 400
|
|
|
|
user = get_user_by_username(username)
|
|
if not user or not user.get("is_active"):
|
|
return jsonify({"error": "Invalid username or password"}), 401
|
|
|
|
if not check_password_hash(user["password_hash"], password):
|
|
return jsonify({"error": "Invalid username or password"}), 401
|
|
|
|
token = _make_token(user)
|
|
return jsonify(
|
|
{
|
|
"token": token,
|
|
"user": {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
"role": user["role"],
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@app.get("/api/auth/me")
|
|
@require_auth
|
|
def auth_me_route():
|
|
return jsonify({"user": g.current_user})
|
|
|
|
|
|
@app.get("/api/users")
|
|
@require_auth
|
|
@require_role("admin")
|
|
def list_users_route():
|
|
return jsonify(get_all_users())
|
|
|
|
|
|
@app.post("/api/users")
|
|
@require_auth
|
|
@require_role("admin")
|
|
def create_user_route():
|
|
body = request.get_json(silent=True)
|
|
if not isinstance(body, dict):
|
|
return jsonify({"error": "Request body must be a JSON object"}), 400
|
|
|
|
username = (body.get("username") or "").strip()
|
|
password = body.get("password") or ""
|
|
role = (body.get("role") or "viewer").strip().lower()
|
|
|
|
if not username or not password:
|
|
return jsonify({"error": "Username and password are required"}), 400
|
|
|
|
if role not in {"admin", "viewer"}:
|
|
return jsonify({"error": "Role must be admin or viewer"}), 400
|
|
|
|
if get_user_by_username(username):
|
|
return jsonify({"error": "User already exists"}), 409
|
|
|
|
user_id = create_user(username, generate_password_hash(password), role=role, is_active=1)
|
|
return jsonify({"id": user_id, "username": username, "role": role, "is_active": 1}), 201
|
|
|
|
|
|
@app.post("/api/users/<int:user_id>/password")
|
|
@require_auth
|
|
@require_role("admin")
|
|
def set_user_password_route(user_id):
|
|
body = request.get_json(silent=True)
|
|
if not isinstance(body, dict):
|
|
return jsonify({"error": "Request body must be a JSON object"}), 400
|
|
|
|
password = body.get("password") or ""
|
|
if not password:
|
|
return jsonify({"error": "Password is required"}), 400
|
|
|
|
user = get_user_by_id(user_id)
|
|
if not user:
|
|
return jsonify({"error": "User not found"}), 404
|
|
|
|
updated = update_user_password(user_id, generate_password_hash(password))
|
|
if updated == 0:
|
|
return jsonify({"error": "User not found"}), 404
|
|
|
|
return jsonify({"ok": True, "id": user_id})
|
|
|
|
|
|
@app.delete("/api/users/<int:user_id>")
|
|
@require_auth
|
|
@require_role("admin")
|
|
def delete_user_route(user_id):
|
|
user = get_user_by_id(user_id)
|
|
if not user:
|
|
return jsonify({"error": "User not found"}), 404
|
|
|
|
current_user = getattr(g, "current_user", None) or {}
|
|
if current_user.get("id") == user_id:
|
|
return jsonify({"error": "You cannot delete your own account"}), 400
|
|
|
|
if user.get("role") == "admin" and count_admin_users() <= 1:
|
|
return jsonify({"error": "Cannot delete the last active admin"}), 400
|
|
|
|
deleted = delete_user(user_id)
|
|
if deleted == 0:
|
|
return jsonify({"error": "User not found"}), 404
|
|
|
|
return jsonify({"ok": True, "id": user_id})
|
|
|
|
|
|
@app.get("/api/config")
|
|
@require_auth
|
|
@require_role("admin")
|
|
def get_config_route():
|
|
config = {}
|
|
for key in ALLOWED_KEYS:
|
|
config[key] = get_config(key)
|
|
return jsonify(config)
|
|
|
|
|
|
@app.post("/api/config")
|
|
@require_auth
|
|
@require_role("admin")
|
|
def set_config_route():
|
|
updates = request.get_json(silent=True)
|
|
if not isinstance(updates, dict):
|
|
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:
|
|
continue
|
|
|
|
if value in (None, ""):
|
|
del_config(key)
|
|
else:
|
|
set_config(key, str(value))
|
|
|
|
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()
|
|
target_dir = resolve_runtime_path(get_config("target_dir"))
|
|
results_dir = resolve_runtime_path(get_config("results_dir"))
|
|
results_dir_ref = resolve_runtime_path(get_config("results_dir_ref"))
|
|
if not target_dir or not results_dir:
|
|
return jsonify({"error": "Directories not configured"}), 400
|
|
|
|
if is_scan_in_progress():
|
|
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,
|
|
"warnings": warnings,
|
|
})
|
|
|
|
return jsonify({"ok": True, "testCount": None, "completedCount": None, "warnings": warnings})
|
|
|
|
|
|
@app.post("/api/config/rescan")
|
|
@require_auth
|
|
@require_role("admin")
|
|
def rescan_route():
|
|
_apply_smb_env_from_config()
|
|
target_dir = resolve_runtime_path(get_config("target_dir"))
|
|
results_dir = resolve_runtime_path(get_config("results_dir"))
|
|
results_dir_ref = resolve_runtime_path(get_config("results_dir_ref"))
|
|
if not target_dir or not results_dir:
|
|
return jsonify({"error": "Directories not configured"}), 400
|
|
|
|
if is_scan_in_progress():
|
|
return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None})
|
|
|
|
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})
|
|
|
|
|
|
@app.get("/")
|
|
@app.get("/<path:path>")
|
|
def static_or_spa(path=""):
|
|
if not DIST_DIR.exists():
|
|
return jsonify({"error": "Frontend dist not found"}), 404
|
|
|
|
if path and (DIST_DIR / path).is_file():
|
|
return send_from_directory(DIST_DIR, path)
|
|
return send_from_directory(DIST_DIR, "index.html")
|
|
|
|
|
|
def bootstrap():
|
|
_ensure_default_users()
|
|
_apply_smb_env_from_config()
|
|
target_dir = resolve_runtime_path(get_config("target_dir"))
|
|
results_dir = resolve_runtime_path(get_config("results_dir"))
|
|
results_dir_ref = resolve_runtime_path(get_config("results_dir_ref"))
|
|
|
|
if target_dir and results_dir:
|
|
existing = count_tests()
|
|
if existing > 0:
|
|
print(f"[server] Resuming from DB -> {existing} tests already loaded.")
|
|
else:
|
|
print("[server] No cached data, scanning directories...")
|
|
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")
|
|
|
|
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)
|