From 0796982fd779a0a60c6804d060b3996258eddc1e Mon Sep 17 00:00:00 2001 From: Mia Wu Date: Thu, 4 Jun 2026 13:21:56 -0400 Subject: [PATCH] read log files from end, added user management --- dashboard/src/App.jsx | 7 +- dashboard/src/components/ConfigModal.jsx | 357 ++++++++++++++++++++--- dashboard/src/lib/api.js | 4 + server/app.py | 48 +++ server/db_py.py | 37 +++ server/parser.py | 14 + server/scanner.py | 66 +++-- server/watcher.py | 4 +- 8 files changed, 471 insertions(+), 66 deletions(-) diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index beb0e9f..9c0fe7b 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -256,7 +256,12 @@ export default function App() { - {isAdmin && showConfig && setShowConfig(false)} />} + {isAdmin && showConfig && ( + setShowConfig(false)} + currentUserId={user?.id ?? null} + /> + )} ) } diff --git a/dashboard/src/components/ConfigModal.jsx b/dashboard/src/components/ConfigModal.jsx index 76b4dc6..57a102a 100644 --- a/dashboard/src/components/ConfigModal.jsx +++ b/dashboard/src/components/ConfigModal.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' import { useConfig, useSaveConfig } from '../hooks/useConfig' -import { apiFetch } from '../lib/api' +import { apiFetch, createUser, deleteUser, getUsers, setUserPassword } from '../lib/api' function useScanPoller(onDone) { const timerRef = useRef(null) @@ -42,17 +42,37 @@ function fmtSeconds(s) { return String(m) } -export default function ConfigModal({ onClose }) { +function getErrorMessage(err, fallback) { + if (!err?.message) return fallback + try { + const parsed = JSON.parse(err.message) + if (parsed?.error) return parsed.error + } catch { + // Keep original error text when not JSON. + } + return err.message || fallback +} + +export default function ConfigModal({ onClose, currentUserId = null }) { const queryClient = useQueryClient() const { data: config, isLoading } = useConfig() const { mutate: save, isPending } = useSaveConfig() + const [activeTab, setActiveTab] = useState('general') const [form, setForm] = useState({}) - const [scanResult, setScanResult] = useState(null) const [saveError, setSaveError] = useState(null) const [isRescanning, setIsRescanning] = useState(false) const [isScanning, setIsScanning] = useState(false) const [isSavingTimes, setIsSavingTimes] = useState(false) + const [users, setUsers] = useState([]) + const [usersLoading, setUsersLoading] = useState(false) + const [usersError, setUsersError] = useState('') + const [usersNotice, setUsersNotice] = useState('') + const [isCreatingUser, setIsCreatingUser] = useState(false) + const [isSettingPassword, setIsSettingPassword] = useState(false) + const [deletingUserId, setDeletingUserId] = useState(null) + const [createForm, setCreateForm] = useState({ username: '', password: '', role: 'viewer' }) + const [passwordForm, setPasswordForm] = useState({ userId: '', password: '' }) const scanPoller = useScanPoller(() => { queryClient.invalidateQueries({ queryKey: ['stats'] }) @@ -80,7 +100,6 @@ export default function ConfigModal({ onClose }) { function handleSave() { setSaveError(null) - setScanResult(null) const payload = { target_dir: form.target_dir || null, results_dir: form.results_dir || null, @@ -111,7 +130,6 @@ export default function ConfigModal({ onClose }) { async function handleSaveTimes() { setSaveError(null) - setScanResult(null) setIsSavingTimes(true) try { await apiFetch('/config', { @@ -134,7 +152,6 @@ export default function ConfigModal({ onClose }) { async function handleRescanResults() { setSaveError(null) - setScanResult(null) setIsRescanning(true) try { const data = await apiFetch('/config/rescan-results', { method: 'POST' }) @@ -157,16 +174,133 @@ export default function ConfigModal({ onClose }) { { key: 'avg_time_p3p', label: 'P3P avg time (min)' }, ] + const loadUsers = useCallback(async () => { + setUsersLoading(true) + setUsersError('') + try { + const data = await getUsers() + setUsers(Array.isArray(data) ? data : []) + setPasswordForm((prev) => ({ + ...prev, + userId: prev.userId || (data?.[0]?.id ? String(data[0].id) : ''), + })) + } catch (err) { + setUsersError(getErrorMessage(err, 'Failed to load users')) + } finally { + setUsersLoading(false) + } + }, []) + + useEffect(() => { + if (activeTab === 'users') { + loadUsers() + } + }, [activeTab, loadUsers]) + + async function handleCreateUser() { + setUsersError('') + setUsersNotice('') + + const username = createForm.username.trim() + const password = createForm.password + const role = createForm.role + + if (!username || !password) { + setUsersError('Username and password are required') + return + } + + setIsCreatingUser(true) + try { + await createUser({ username, password, role }) + setUsersNotice(`User ${username} created`) + setCreateForm({ username: '', password: '', role: 'viewer' }) + await loadUsers() + } catch (err) { + setUsersError(getErrorMessage(err, 'Failed to create user')) + } finally { + setIsCreatingUser(false) + } + } + + async function handleSetPassword() { + setUsersError('') + setUsersNotice('') + + const userId = parseInt(passwordForm.userId, 10) + const password = passwordForm.password + + if (!userId || !password) { + setUsersError('Select a user and provide a new password') + return + } + + setIsSettingPassword(true) + try { + await setUserPassword(userId, { password }) + const target = users.find((u) => u.id === userId) + setUsersNotice(`Password updated for ${target?.username || `user #${userId}`}`) + setPasswordForm((prev) => ({ ...prev, password: '' })) + } catch (err) { + setUsersError(getErrorMessage(err, 'Failed to set password')) + } finally { + setIsSettingPassword(false) + } + } + + async function handleDeleteUser(userId, username) { + setUsersError('') + setUsersNotice('') + + const confirmed = window.confirm(`Delete user ${username}? This cannot be undone.`) + if (!confirmed) return + + setDeletingUserId(userId) + try { + await deleteUser(userId) + setUsersNotice(`User ${username} deleted`) + setPasswordForm((prev) => ({ + ...prev, + userId: prev.userId === String(userId) ? '' : prev.userId, + })) + await loadUsers() + } catch (err) { + setUsersError(getErrorMessage(err, 'Failed to delete user')) + } finally { + setDeletingUserId(null) + } + } + return ( <>
-
+
{/* Header */}
-

Settings

+
+

Settings

+ Admin +
+
+
+ + +
+
+
{/* Error banner */} {saveError && ( @@ -175,6 +309,18 @@ export default function ConfigModal({ onClose }) {
)} + {usersError && activeTab === 'users' && ( +
+ {usersError} +
+ )} + + {usersNotice && activeTab === 'users' && ( +
+ {usersNotice} +
+ )} + {/* Scanning indicator (save triggered a scan) */} {isScanning && (
@@ -191,9 +337,9 @@ export default function ConfigModal({ onClose }) {
)} - {isLoading ? ( + {activeTab === 'general' && isLoading ? (

Loading…

- ) : ( + ) : activeTab === 'general' ? ( <> {/* Directories */}
@@ -285,40 +431,173 @@ export default function ConfigModal({ onClose }) {
+ ) : ( + <> +
+

Current Users

+ {usersLoading ? ( +

Loading users…

+ ) : ( +
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + {!users.length && ( + + + + )} + +
UsernameRoleStatusCreatedAction
{u.username}{u.role}{u.is_active ? 'Active' : 'Inactive'}{u.created_at ?? '-'} + {u.id === currentUserId ? ( + + Current User + + ) : ( + + )} +
No users found.
+
+ )} +
+ +
+

Create User

+
+ setCreateForm((prev) => ({ ...prev, username: e.target.value }))} + placeholder="Username" + className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500" + /> + setCreateForm((prev) => ({ ...prev, password: e.target.value }))} + placeholder="Password" + className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500" + /> + +
+
+ +
+
+ +
+

Set User Password

+
+ + setPasswordForm((prev) => ({ ...prev, password: e.target.value }))} + placeholder="New password" + className="w-full bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500" + /> + +
+
+ )}
{/* Footer */}
- -
- - - -
+ {activeTab === 'general' ? ( + <> + +
+ + + +
+ + ) : ( +
+ +
+ )}
diff --git a/dashboard/src/lib/api.js b/dashboard/src/lib/api.js index 6f53148..5865aa8 100644 --- a/dashboard/src/lib/api.js +++ b/dashboard/src/lib/api.js @@ -37,6 +37,10 @@ export async function apiFetch(path, options = {}) { export const login = (body) => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(body) }) export const getMe = () => apiFetch('/auth/me') +export const getUsers = () => apiFetch('/users') +export const createUser = (body) => apiFetch('/users', { method: 'POST', body: JSON.stringify(body) }) +export const setUserPassword = (userId, body) => apiFetch(`/users/${userId}/password`, { method: 'POST', body: JSON.stringify(body) }) +export const deleteUser = (userId) => apiFetch(`/users/${userId}`, { method: 'DELETE' }) export const getStats = () => apiFetch('/stats') export const getTests = (params = {}) => { diff --git a/server/app.py b/server/app.py index 06a044c..03fe5aa 100644 --- a/server/app.py +++ b/server/app.py @@ -12,9 +12,11 @@ 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, @@ -23,6 +25,7 @@ from db_py import ( get_user_by_username, set_config, clear_users, + update_user_password, ) from scanner import full_scan, is_scan_in_progress, resolve_runtime_path, scan_results_only from watcher import start_results_watchers, stop_results_watchers @@ -404,6 +407,51 @@ def create_user_route(): return jsonify({"id": user_id, "username": username, "role": role, "is_active": 1}), 201 +@app.post("/api/users//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/") +@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") diff --git a/server/db_py.py b/server/db_py.py index 0799304..d00d111 100644 --- a/server/db_py.py +++ b/server/db_py.py @@ -452,6 +452,43 @@ def create_user(username, password_hash, role="viewer", is_active=1): return cursor.lastrowid +def update_user_password(user_id, password_hash): + with _tx(): + cursor = _conn.execute( + """ + UPDATE users + SET password_hash = ? + WHERE id = ? + """, + (password_hash, user_id), + ) + return cursor.rowcount + + +def delete_user(user_id): + with _tx(): + cursor = _conn.execute( + """ + DELETE FROM users + WHERE id = ? + """, + (user_id,), + ) + return cursor.rowcount + + +def count_admin_users(): + with _lock: + row = _conn.execute( + """ + SELECT COUNT(*) AS n + FROM users + WHERE role = 'admin' AND is_active = 1 + """ + ).fetchone() + return row["n"] + + def count_users(): with _lock: row = _conn.execute("SELECT COUNT(*) AS n FROM users").fetchone() diff --git a/server/parser.py b/server/parser.py index 8302dee..6ae7b00 100644 --- a/server/parser.py +++ b/server/parser.py @@ -1,5 +1,6 @@ import re +_DEFAULT_SCAN_EXCLUSIONS = "" INTERFERENCE_TYPES = {"COE", "P2P", "P3P"} DIRECTION_TYPES = {"UL", "DL"} @@ -67,4 +68,17 @@ def parse_timestamp(filename): second = parts[5] if len(parts) > 5 else "00" return f"{year}-{month}-{day}T{hour}:{minute}:{second}" +def parse_deleted_result_dir_name(dir_name): + segments = re.split(r"[_\-]", dir_name) + test_id = next((s for s in segments if re.match(r"^R\d+[A-Z0-9]+$", s, re.IGNORECASE)), None) + device = next((s for s in segments if re.match(r"^CGW\d+$", s, re.IGNORECASE)), None) + return test_id, device + +def parse_scan_exclusions(value): + raw = value if value not in (None, "") else _DEFAULT_SCAN_EXCLUSIONS + if raw == "" or raw is None: + return None + parts = re.split(r"[,\n;]", str(raw)) + return {token.strip().upper() for token in parts if token.strip()} + diff --git a/server/scanner.py b/server/scanner.py index 1fb8241..bb26e39 100644 --- a/server/scanner.py +++ b/server/scanner.py @@ -18,22 +18,16 @@ from parser import ( parse_result_filename, parse_target_filename, parse_timestamp, + parse_scan_exclusions, ) _SMB_SESSIONS = set() -_WIN_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]") _SCAN_STATE_LOCK = threading.Lock() _ACTIVE_SCAN_COUNT = 0 -_DEFAULT_SCAN_EXCLUSIONS = "" _ELAPSED_TIME_LINE_RE = re.compile(r"Elapsed\s+time\s*:\s*(\d+):(\d{1,2}):(\d{1,2}(?:\.\d+)?)", re.IGNORECASE) -def _parse_scan_exclusions(value): - raw = value if value not in (None, "") else _DEFAULT_SCAN_EXCLUSIONS - if raw == "" or raw is None: - return None - parts = re.split(r"[,\n;]", str(raw)) - return {token.strip().upper() for token in parts if token.strip()} + def _build_match_tokens(parsed): @@ -299,22 +293,51 @@ def _parse_elapsed_time_to_seconds(line): seconds = float(match.group(3)) return int(hours * 3600 + minutes * 60 + seconds) - -def extract_elapsed_seconds_from_logs(result_dir_path, files): - elapsed_seconds = None +def extract_elapsed_seconds(result_dir_path, files): log_files = [name for name in files if _is_candidate_log_file(name)] for log_name in log_files: log_path = _join_path(result_dir_path, log_name) try: - for line in _read_text_lines(log_path): - parsed = _parse_elapsed_time_to_seconds(line) - if parsed is not None: - elapsed_seconds = parsed + # Read the log file in reverse to find the most recent "Elapsed time" line + path = _normalize_input_path(log_path) + chunk_size = 8192 + + if _is_unc_path(path): + _register_smb_session_if_needed(path) + file_obj = smbclient.open_file(path, mode="rb") + else: + file_obj = open(path, "rb") + + with file_obj as fh: + fh.seek(0, os.SEEK_END) + file_size = fh.tell() + position = file_size + carry = b"" + + while position > 0: + read_size = min(chunk_size, position) + position -= read_size + fh.seek(position) + chunk = fh.read(read_size) + + data = chunk + carry + lines = data.split(b"\n") + carry = lines[0] + + for raw_line in reversed(lines[1:]): + parsed = _parse_elapsed_time_to_seconds(raw_line.decode("utf-8", errors="ignore")) + if parsed is not None: + return parsed + + if carry: + parsed = _parse_elapsed_time_to_seconds(carry.decode("utf-8", errors="ignore")) + if parsed is not None: + return parsed except OSError as exc: print(f"[scanner] Cannot read log file {log_path}: {exc}") - return elapsed_seconds + return None def scan_results_only(results_dir, results_dir_ref): @@ -374,7 +397,7 @@ def full_scan(target_dir, results_dir, results_dir_ref): def scan_targets(target_dir): target_dir = _normalize_input_path(target_dir) - exclusions = _parse_scan_exclusions(get_config("scan_exclusions")) + exclusions = parse_scan_exclusions(get_config("scan_exclusions")) batch_tests = [] try: @@ -484,7 +507,7 @@ def process_result_dir(results_dir, result_dir_name): print(f"[scanner] Cannot read result dir {result_dir_name}: {exc}") return None - elapsed_seconds = extract_elapsed_seconds_from_logs(result_dir_path, files) + elapsed_seconds = extract_elapsed_seconds(result_dir_path, files) measurement_db = next((name for name in files if name.lower() == "measurement.db"), None) if not measurement_db: @@ -514,13 +537,6 @@ def process_result_dir(results_dir, result_dir_name): } -def parse_deleted_result_dir_name(dir_name): - segments = re.split(r"[_\-]", dir_name) - test_id = next((s for s in segments if re.match(r"^R\d+[A-Z0-9]+$", s, re.IGNORECASE)), None) - device = next((s for s in segments if re.match(r"^CGW\d+$", s, re.IGNORECASE)), None) - return test_id, device - - def extract_measurement_data(db_file_path): result = {"duration_seconds": None, "tputResults": []} local_db_path = None diff --git a/server/watcher.py b/server/watcher.py index 2f2d0a8..dadf793 100644 --- a/server/watcher.py +++ b/server/watcher.py @@ -10,7 +10,9 @@ from db_py import ( 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 +from scanner import process_result_dir, resolve_runtime_path + +from parser import parse_deleted_result_dir_name _OBSERVER = None