diff --git a/.gitignore b/.gitignore index fc8a770..e1cb9c1 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ server/__pycache__/ server/dashboard.db* server/*.pyc *.pyc +server/.env # Test output test/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a5bdc4b..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,144 +0,0 @@ -# CLAUDE.md — Project Context for AI Assistants - -## What this project is - -A real-time web dashboard that tracks WiFi test execution progress. It compares a **target tests directory** (`.ini` files defining every test that must run) against a **results directory** (folders created when each test completes). The dashboard shows completion rates, elapsed time, estimated time remaining, and a filterable table of every test. - ---- - -## How to run - -### Backend (Python) -```powershell -cd server -.\.venv\Scripts\Activate.ps1 # activate venv -python app.py # starts Flask on port 3001 -``` - -### Frontend (dev) -```powershell -cd dashboard -npm run dev # Vite dev server; proxies /api → localhost:3001 -``` - -### Frontend (production build) -```powershell -cd dashboard -npm run build # outputs to dashboard/dist/ -# Flask serves dist/ automatically when app.py is running -``` - ---- - -## Tech stack - -| Layer | Technology | -|---|---| -| Frontend | React 18 + Vite, TailwindCSS v4 (`@tailwindcss/vite`), TanStack React Query | -| Backend | Python 3, Flask, flask-cors | -| Database | SQLite via Python stdlib `sqlite3` (WAL mode, single persistent connection + RLock) | -| File watching | `watchdog` Python library | -| Real-time | Server-Sent Events (SSE) — one-way push from backend to browser | - ---- - -## Key files - -### Backend (`server/`) - -| File | Purpose | -|---|---| -| `app.py` | Flask app, all route handlers, `bootstrap()`, `__main__` | -| `db_py.py` | SQLite schema init, all query helpers (`upsert_test`, `mark_completed`, `reset_by_file_id_and_device`, `get_config`, `set_config`, etc.) | -| `scanner.py` | `full_scan()`, `scan_targets()`, `scan_results()`, `process_result_dir()`, `parse_deleted_result_dir_name()` | -| `parser.py` | `parse_target_filename()`, `parse_result_filename()`, `parse_timestamp()`, `parse_elapsed_time()`, `parse_tput_rssi()` | -| `watcher.py` | Two watchdog `Observer` instances — `_TargetHandler` and `_ResultsHandler`. `start_watching()` / `stop_watching()` | -| `sse_py.py` | Per-client `Queue` registry, `stream_events()` generator, `broadcast(data)` | - -### Frontend (`dashboard/src/`) - -| File | Purpose | -|---|---| -| `lib/api.js` | Thin `fetch` wrapper; all API calls go through `apiFetch('/path')` | -| `hooks/useStats.js` | Fetches `/api/stats`; **owns the SSE `EventSource`**; invalidates `['stats']` and `['tests']` on `update` events | -| `hooks/useTests.js` | Fetches `/api/tests` | -| `hooks/useConfig.js` | Fetches/saves `/api/config` | -| `components/ConfigModal.jsx` | Settings modal: directory paths (always editable) + avg time overrides | -| `components/DirectoryBrowser.jsx` | Server-side folder picker using `/api/browse` | - ---- - -## Data model - -### How tests are identified - -- **Target files** live in subdirectories of the target directory. Format: `TC_WIFI_.ini`. Files named `GLOBAL.ini` are skipped. -- **`test_id`**: segment matching `R\d+[A-Z0-9]+` (e.g., `R2COERXAX014`) -- **Result directories**: top-level subdirectories of the results directory. A test is **completed** when a result directory name contains the same `test_id`. -- **Result folder name example**: `COE_CGW453_R2COERXAX014_TPT3E_RSSI70_STA56_2GHZ_CH1_BW20_TCP_MIMOFD_SONFD_MESHFD_LPI_UL` - -### Tags parsed from target filename - -`interference`, `device`, `test_point`, `rssi`, `station`, `band`, `channel`, `bandwidth`, `direction` - -`rotation` is parsed from the **parent folder name** (segment matching `ROT\d+`). - -### Database tables - -**`tests`** — one row per target `.ini` file: -- `id` (TEXT PK) — `parent_dir/base_filename` -- `test_id`, `parent_dir`, `filename`, `interference`, `device`, `rotation`, `test_point`, `station`, `band`, `channel`, `bandwidth`, `rssi`, `direction` -- `completed` (0/1), `completed_at` (ISO timestamp), `duration_seconds` (REAL) -- `tput_results` (JSON array of `{station, tput, dlRssi, ulRssi}`) - -**`config`** — key/value store: -- `target_dir`, `results_dir` -- `avg_time_coe`, `avg_time_p2p`, `avg_time_p3p` (seconds as string; NULL = use calculated average) - ---- - -## API routes - -| Method | Path | Description | -|---|---|---| -| GET | `/api/tests` | All tests; supports query filters (`completed`, `interference`, `device`, `rotation`, `testPoint`, `station`, `band`, `channel`, `bandwidth`, `rssi`, `direction`) | -| GET | `/api/stats` | Aggregated stats: overall, per-device, timing with estimates | -| GET | `/api/events` | SSE stream; sends `data: {"type":"update"}\n\n` on any directory change | -| GET | `/api/config` | Current config values | -| POST | `/api/config` | Update config; triggers full rescan + rewatcher if dirs changed | -| POST | `/api/config/rescan` | Force full rescan without changing config | -| GET | `/api/browse?path=...` | List subdirectories at a server path (omit `path` for drive roots) | - ---- - -## Watcher architecture - -Two `watchdog.Observer` instances run in daemon threads: - -1. **`_TargetHandler`** — watches `target_dir` recursively. Any add/delete/move of a `TC_WIFI_*.ini` file schedules a **debounced full scan** (1 second timer, cancels and restarts on rapid changes). - -2. **`_ResultsHandler`** — watches `results_dir` recursively. Handles events surgically: - - Directory created → `process_result_dir()` + broadcast - - Directory deleted → `reset_by_file_id_and_device()` + broadcast - - Directory moved in/out/renamed → appropriate reset/process + broadcast - - File created/deleted inside a result dir → `process_result_dir()` + broadcast - -### Critical Windows quirk -When a directory is deleted (including Recycle Bin), `watchdog` calls `os.path.isdir()` at event-processing time. The directory is already gone, so it returns `False`, making `event.is_directory = False` even for directory events. **All handlers check `_is_direct_child_dir(path)` directly** — never gate on `event.is_directory` for delete/move cases. - ---- - -## SSE implementation notes - -- `sse_py.py`: each connected client gets a `queue.Queue`. `broadcast()` calls `put_nowait(payload)` on all queues. `stream_events()` is a generator yielding `f"data: {payload}\n\n"` (real newlines — `\n` not `\\n`). -- `useStats.js`: creates `new EventSource('/api/events')` once on mount. On `{"type":"update"}` message, invalidates React Query keys `['stats']` and `['tests']`. -- Vite dev proxy forwards `/api/events` to Flask, keeping the SSE connection alive. - ---- - -## Known issues / gotchas - -- `config.json` in `server/` is a legacy file used for a one-time migration to SQLite on first run. It is no longer needed once `dashboard.db` exists. -- The backend entrypoint is `app.py`, not `main.py`. -- Flask dev server (`app.run`) is used directly — no gunicorn/waitress configured yet. -- Port is `3001` (configurable via `PORT` env var). diff --git a/DESIGNPLAN.md b/DESIGNPLAN.md index 63c5bd2..996b4c9 100644 --- a/DESIGNPLAN.md +++ b/DESIGNPLAN.md @@ -1,28 +1,38 @@ ## Design Plan -**Inputs**: target tests directory, result tests directory +**Inputs**: target tests directory, reference result tests directory, DUT result tests directory **Output**: a dashboard that displays completion rate, elapsed time, estimated time remaining, and a list of all tests that can be filtered by tags **Requirements**: - A test is marked completed when there is a filename match in result tests directory -- When there is a change in the input directories, data should update automatically or when refreshed - Dashboard can be accessed by different devices, but input directories are stored locally on one machine - - Add SSO later +- Login required in order to view dashboard + - Viewer + - Only able to view + - Admin + - Able to access and modify settings +- Can enter exclusions for tests that are not ran in settings Front end UI requirements: -- Completion rate +- Completion rate and number of tests completed - Overall completion rate - CGW 452 completion rate + - number of tests completed for each type - CGW453 completion rate + - number of tests completed for each type - Time elapsed - Estimated Time remaining +- Estimated date to complete + - 16 hrs on weekdays + - 24 hrs on weekends - List of all tests - Can filter by Tags: - Completed: Boolean + - Type - Device: String - - Rotation: String + - Rotation: String - Test Point: String - Station: String - Band: String @@ -30,3 +40,7 @@ Front end UI requirements: - Bandwidth: String - RSSI: String - Direction: String + - A P2P test with COE pairings should be displayed together + - P3P tests should be paired together by throttled/unthrottled + - Completed tests + - Display the avg throughput for each station diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index d8e7c94..f13f07e 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,6 +1,6 @@ # Implementation Plan — Test Dashboard -> **Status as of May 2026**: fully implemented and running. Backend migrated from the original Node.js plan to Python (Flask). See current tech stack and structure below. +> **Status as of June 2026**: fully implemented and running. Backend is Python (Flask) with JWT authentication and role-based access control. ## 1. Overview @@ -14,10 +14,9 @@ A web dashboard that monitors test execution progress by comparing a **target te |---|---|---| | Frontend | React + Vite | Dev server proxies `/api` → `localhost:3001` | | Backend | Python 3 + Flask | Replaced original Node.js/Express plan | -| Real-time | Server-Sent Events (SSE) | One-way push from backend to browser | -| Directory watching | watchdog | Python filesystem watcher (Windows-compatible) | +| Auth | JWT (PyJWT) | Bearer token auth with `admin` and `viewer` roles | | Database | SQLite (via `sqlite3` stdlib) | WAL mode; single persistent connection with RLock | -| Frontend state | TanStack React Query | Cache + SSE-driven invalidation | +| Frontend state | TanStack React Query | Query cache + manual invalidation after mutations | | UI | TailwindCSS v4 | Via `@tailwindcss/vite` plugin | --- @@ -35,7 +34,7 @@ test_house_dashboard/ │ ├── index.html │ ├── package.json │ └── src/ -│ ├── App.jsx – root layout, SSE wiring via useStats +│ ├── App.jsx – root layout, auth gate, modal settings entry │ ├── main.jsx │ ├── components/ │ │ ├── StatCard.jsx – metric card (label / value / sub) @@ -44,22 +43,20 @@ test_house_dashboard/ │ │ ├── FilterPanel.jsx – dropdown tag filters │ │ ├── TimeDisplay.jsx – elapsed / estimated time display │ │ ├── StatusBadge.jsx – completed / pending indicator -│ │ ├── ConfigModal.jsx – settings modal (dirs + avg time overrides) -│ │ └── DirectoryBrowser.jsx – server-side folder picker (uses /api/browse) +│ │ └── ConfigModal.jsx – settings modal (dirs + avg time overrides) │ ├── hooks/ -│ │ ├── useStats.js – fetches stats; owns the SSE EventSource +│ │ ├── useAuth.js – login session state + role checks +│ │ ├── useStats.js – fetches stats │ │ ├── useTests.js – fetches test list │ │ └── useConfig.js – fetches/saves config │ └── lib/ -│ └── api.js – thin fetch wrapper; BASE = '/api' +│ └── api.js – fetch wrapper with JWT header injection │ └── server/ ← Python backend - ├── app.py – Flask app, all routes, bootstrap(), __main__ + ├── app.py – Flask app, JWT auth, role guards, bootstrap(), __main__ ├── db_py.py – SQLite schema, query helpers, config CRUD ├── scanner.py – full_scan(), scan_targets(), scan_results(), process_result_dir() ├── parser.py – parse_target_filename(), parse_result_filename(), etc. - ├── watcher.py – watchdog observers for target + results dirs - ├── sse_py.py – SSE queue registry + broadcast() ├── requirements.txt ├── dashboard.db – SQLite database (auto-created) └── .venv/ – Python virtual environment @@ -185,9 +182,20 @@ CREATE TABLE config ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); + +-- Stores dashboard users +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, -- admin | viewer + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); -- Config keys: -- target_dir – path to target tests directory -- results_dir – path to results directory +-- results_dir_ref – optional reference results directory -- avg_time_coe – manual avg seconds per COE test (NULL = use calculated) -- avg_time_p2p – manual avg seconds per P2P test (NULL = use calculated) -- avg_time_p3p – manual avg seconds per P3P test (NULL = use calculated) @@ -201,12 +209,23 @@ CREATE TABLE config ( | Method | Path | Description | |---|---|---| +| `POST` | `/api/auth/login` | Login with username/password; returns JWT + user role | +| `GET` | `/api/auth/me` | Resolve current authenticated user from JWT | +| `GET` | `/api/users` | Admin-only list of users | +| `POST` | `/api/users` | Admin-only create user (`admin` / `viewer`) | | `GET` | `/api/tests` | All tests; supports query filters (see below) | | `GET` | `/api/stats` | Aggregated stats object | -| `GET` | `/api/events` | SSE stream — pushes `update` events on directory change | +| `GET` | `/api/scan-status` | Returns active scan state | | `GET` | `/api/config` | Current directory paths and avg time overrides | -| `POST` | `/api/config` | Set `targetDir`, `resultsDir`, avg time overrides; triggers re-scan | -| `GET` | `/api/browse` | List subdirectories at a given server path (directory picker) | +| `POST` | `/api/config` | Save config and optionally trigger full re-scan | +| `POST` | `/api/config/rescan` | Force full re-scan using saved config | +| `POST` | `/api/config/rescan-results` | Re-process only results directories | + +Auth/role rules: + +- All dashboard data routes require a valid JWT. +- `/api/config*` and `/api/users*` are admin-only. +- Frontend stores JWT in localStorage key `dashboard_jwt` and sends `Authorization: Bearer `. ### `GET /api/tests` Query Parameters @@ -277,44 +296,13 @@ CREATE TABLE config ( - `estimatedRemainingSeconds` = `(avgCOE × COE_remaining) + (avgP2P × P2P_remaining) + (avgP3P × P3P_remaining)` - If any type has remaining tests but no avg time (calculated or manual), that type contributes `null` to the sum and the UI flags it as **input required** -### `GET /api/browse` — Directory Browser - -Allows the frontend to navigate the server's local filesystem so users can pick directories without typing paths manually. - -**Query parameters**: -- `path` (optional) — absolute path to list. If omitted or empty, returns the filesystem roots (e.g., `C:\`, `D:\` on Windows). - -**Response**: -```json -{ - "path": "C:\\Tests", - "parent": "C:\\", - "dirs": [ - { "name": "Target", "path": "C:\\Tests\\Target" }, - { "name": "Results", "path": "C:\\Tests\\Results" } - ] -} -``` - -- Only **directories** are returned (no files). -- Hidden directories (names starting with `.`) are excluded. -- If `path` does not exist or is not a directory, returns `400`. -- `parent` is `null` when already at a filesystem root. - ---- - ## 7. Real-time Updates -- Backend registers an SSE endpoint at `GET /api/events` (`sse_py.py`) -- `watchdog` watches both target and results directories via two separate `Observer` instances (`watcher.py`) -- On any change, the backend updates SQLite and calls `broadcast({"type": "update"})`, which pushes `data: {"type":"update"}\n\n` to all connected SSE clients -- `useStats.js` owns the `EventSource('/api/events')` connection; on `update` it calls `queryClient.invalidateQueries` for both `['stats']` and `['tests']` +Current implementation does not use SSE/watchdog. -**Windows watchdog quirk — `is_directory` unreliable on delete**: when a directory is deleted, watchdog calls `os.path.isdir()` to set `event.is_directory`, but the directory is already gone by then, so it returns `False`. All delete/move handlers therefore check `_is_direct_child_dir(path)` directly instead of relying on `event.is_directory`. - -**Result directory deleted**: `reset_by_file_id_and_device(test_id, device)` resets `completed = 0`, `completed_at = NULL`, `duration_seconds = NULL`. broadcast fires unconditionally regardless of whether the folder name was parseable. - -**Recycle Bin delete on Windows**: fires a `MovedEvent` (`src` = result dir, `dest` = `$RECYCLE.BIN\...`). Handled by `on_moved` via `_is_direct_child_dir` on the source path. +- Stats/tests/config are fetched via React Query. +- Mutations (`saveConfig`, `Save Times`, `Save Results`) manually invalidate affected query keys. +- Full scans/rescans run only when triggered by settings actions. --- @@ -324,7 +312,7 @@ Allows the frontend to navigate the server's local filesystem so users can pick ``` ┌──────────────────────────────────────────────────────────────┐ -│ Test Dashboard [⟳ Refresh] [⚙ Settings] │ +│ Test Dashboard [⚙ Settings] │ ├──────────┬──────────┬──────────┬────────────┬───────────────┤ │ Overall │ CGW452 │ CGW453 │ Time │ Est. Remaining │ │ 60% │ 70% │ 50% │ Elapsed │ │ @@ -341,7 +329,7 @@ Allows the frontend to navigate the server's local filesystem so users can pick └──────────────────────────────────────────────────────────────┘ ``` -**Inline avg-time input**: When a type has remaining tests but zero completed, a warning banner appears with inline input fields for the missing avg time(s). Submitting saves to `POST /api/config` and immediately recalculates the estimate. +**Auth gate**: App shows a sign-in screen first. Dashboard renders only when authenticated. Settings button appears only for admin users. ### 8.2 Component Breakdown @@ -350,29 +338,24 @@ Allows the frontend to navigate the server's local filesystem so users can pick | `StatCard` | Displays a metric (label, value, sub-value); used for completion rate + counts | | `CompletionBar` | Progress bar with percentage label | | `TimeDisplay` | Formats seconds into `Xh Ym`; shows elapsed and estimated remaining | -| `AvgTimeWarning` | Banner shown when a type has remaining tests but no avg time; contains inline inputs | | `FilterPanel` | Dropdown filters for each tag including Interference; maintains filter state | -| `TestTable` | Virtualized (react-window) table of tests; columns sortable; File ID as primary ID column | -| `ConfigModal` | Form for directory paths (with picker button) + manual avg time overrides per type | -| `DirectoryBrowser` | Inline folder picker inside `ConfigModal`; navigates the server filesystem via `/api/browse` | +| `TestTable` | Filtered table of tests with status and parsed tags | +| `ConfigModal` | Admin settings modal for directories, avg overrides, and SMB credentials | | `StatusBadge` | Green checkmark or grey circle for completed/pending | ### 8.3 Settings / Config A gear icon opens `ConfigModal` with two sections: -1. **Directories** — target and results directory paths. Each path field has a **Browse** button that opens the `DirectoryBrowser`: - - Starts at the filesystem root (lists available drives on Windows) - - Displays current path as a clickable breadcrumb - - Lists subdirectories; clicking one navigates into it - - **Select This Folder** button confirms the selection and populates the path field - - **Cancel** closes the browser without changing the field - - On save, triggers a full re-scan and re-watch +1. **Directories** — target/results paths plus optional reference results path and exclusion rules. + - On Save All, backend updates config and triggers full re-scan when directory/exclusion fields changed. 2. **Avg Time Overrides** — three number inputs (COE, P2P, P3P) in minutes. Each shows: - - The calculated average from completed tests (if any), labelled `Auto: 7m 30s` - - A manual override field; when filled, it takes precedence over the calculated value - - Clear button to remove the override and revert to calculated - - If no completed tests exist for a type, the field is highlighted with a required indicator + - Manual override values converted to seconds and saved in config. +3. **SMB Credentials** — domain, username, password for network share access. +4. **Action buttons**: + - Save All + - Save Times + - Save Results (`/api/config/rescan-results`) All config values are POSTed to `POST /api/config` as key/value pairs and persisted in SQLite. @@ -398,26 +381,23 @@ In production, Flask serves the built React `dist/` as static files via `send_fr ## 10. Implementation Status ### Completed -- [x] Python Flask backend (`app.py`) with all API routes +- [x] Python Flask backend (`app.py`) with JWT auth and role guards - [x] SQLite schema, WAL mode, thread-safe helpers (`db_py.py`) -- [x] Tag parsing from target filenames and parent folder names (`parser.py`) -- [x] Full directory scan on startup / config change (`scanner.py`) -- [x] Watchdog filesystem watchers for both target and results dirs (`watcher.py`) - - [x] Windows `is_directory` timing bug fixed (check path directly) - - [x] Recycle Bin delete handled via `on_moved` -- [x] SSE broadcast with correct `\n\n` terminators (`sse_py.py`) -- [x] All frontend components and hooks -- [x] Config modal: directories always editable (lock removed) +- [x] Users table + user management APIs (admin-only) +- [x] Default user seeding from environment (`DEFAULT_ADMIN_*`, `DEFAULT_VIEWER_*`) +- [x] Tag parsing and scan logic (`parser.py`, `scanner.py`) +- [x] Full scan / rescan / results-only rescan flows +- [x] Frontend auth flow (login screen, JWT persistence, role-based UI) +- [x] Config modal (directories, avg overrides, SMB credentials) - [x] Vite dev proxy + Flask static serving for production - [x] Config migrated from `config.json` → SQLite on first run ### Remaining / Future -- [ ] SSO / authentication (noted in design plan) +- [ ] Admin UI for user create/list (currently API-only) +- [ ] Invalid test results +- [ ] Watchdog automatic updates - [ ] Test detail view (click a row to see tput/RSSI breakdown) -- [ ] Production deployment docs (systemd / Task Scheduler service) -2. Install `@tanstack/react-query`, `axios`, `tailwindcss`, `lucide-react` -3. Build `api.js` — base fetch helpers + SSE subscription hook -4. Build `useTests` and `useStats` hooks +- [ ] Production deployment docs (service startup, backup, secrets handling) 5. Build `StatCard`, `CompletionBar`, `TimeDisplay`, `StatusBadge` 6. Build `App.jsx` layout with stats row diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 4b33272..939c6b0 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -13,6 +13,7 @@ "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-router-dom": "^7.16.0", "tailwindcss": "^4.3.0" }, "devDependencies": { @@ -1340,6 +1341,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2437,6 +2451,44 @@ "react": "^19.2.6" } }, + "node_modules/react-router": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", + "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", + "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", + "license": "MIT", + "dependencies": { + "react-router": "7.16.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -2486,6 +2538,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index bd24a44..2ebb0d0 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -15,6 +15,7 @@ "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-router-dom": "^7.16.0", "tailwindcss": "^4.3.0" }, "devDependencies": { diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index fa49f9f..beb0e9f 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -1,8 +1,10 @@ -import { useState, useMemo } from 'react' +import { useMemo, useState } from 'react' import { Settings } from 'lucide-react' +import { useQueryClient } from '@tanstack/react-query' import { useStats } from './hooks/useStats' import { useTests } from './hooks/useTests' import { useConfig } from './hooks/useConfig' +import { useAuth } from './hooks/useAuth' import cgw453Image from './assets/CGW453.PNG' import StatCard from './components/StatCard' import CompletionBar from './components/CompletionBar' @@ -24,12 +26,19 @@ function hasCoePairs(value) { } export default function App() { + const queryClient = useQueryClient() + const { user, isReady, isAuthenticated, isAdmin, login, logout } = useAuth() + const [showConfig, setShowConfig] = useState(false) const [filters, setFilters] = useState({}) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [loginError, setLoginError] = useState('') + const [isLoggingIn, setIsLoggingIn] = useState(false) - const { data: stats, isLoading: statsLoading } = useStats() - const { data: allTests = [], isLoading: testsLoading } = useTests() - const { data: config, isLoading: configLoading } = useConfig() + const { data: stats, isLoading: statsLoading } = useStats(isAuthenticated) + const { data: allTests = [], isLoading: testsLoading } = useTests({}, isAuthenticated) + const { data: config, isLoading: configLoading } = useConfig(isAuthenticated && isAdmin) // Apply filters client-side const filteredTests = useMemo(() => { @@ -52,22 +61,114 @@ export default function App() { }) }, [allTests, filters]) - const noConfig = !configLoading && !config?.target_dir && !config?.results_dir - const configuredButEmpty = !configLoading && config?.target_dir && config?.results_dir && + const noConfig = isAdmin && !configLoading && !config?.target_dir && !config?.results_dir + const configuredButEmpty = isAdmin && !configLoading && config?.target_dir && config?.results_dir && !statsLoading && stats?.overall.total === 0 + async function handleLogin(e) { + e.preventDefault() + setLoginError('') + setIsLoggingIn(true) + try { + await login(username.trim(), password) + setPassword('') + } catch (err) { + setLoginError(err?.message ?? 'Login failed') + } finally { + setIsLoggingIn(false) + } + } + + function handleLogout() { + logout() + queryClient.clear() + setShowConfig(false) + setFilters({}) + } + + if (!isReady) { + return ( +
+

Checking session...

+
+ ) + } + + if (!isAuthenticated) { + return ( +
+
+
+

Sign in

+
+ + {loginError && ( +
+ {loginError} +
+ )} + + + + + + +
+
+ ) + } + return (
{/* Top bar */}

CGW453 Test Dashboard

+ + {user?.username} ({user?.role}) + + {isAdmin && ( + + )}
@@ -155,7 +256,7 @@ export default function App() {
- {showConfig && setShowConfig(false)} />} + {isAdmin && showConfig && setShowConfig(false)} />} ) } diff --git a/dashboard/src/components/ConfigModal.jsx b/dashboard/src/components/ConfigModal.jsx index 53ffb50..3c9419f 100644 --- a/dashboard/src/components/ConfigModal.jsx +++ b/dashboard/src/components/ConfigModal.jsx @@ -160,7 +160,7 @@ export default function ConfigModal({ onClose }) { {[ { key: 'target_dir', label: 'Target Tests Directory' }, { key: 'results_dir', label: 'Results Directory (DUT)' }, - { key: 'results_dir_ref', label: 'Results Directory (Reference)' }, + { key: 'results_dir_ref', label: 'Results Directory (REF)' }, ].map(({ key, label }) => (
diff --git a/dashboard/src/hooks/useAuth.js b/dashboard/src/hooks/useAuth.js new file mode 100644 index 0000000..44d02c7 --- /dev/null +++ b/dashboard/src/hooks/useAuth.js @@ -0,0 +1,74 @@ +import { createContext, createElement, useContext, useEffect, useMemo, useState } from 'react' +import { getMe, getStoredToken, login as loginApi, setStoredToken } from '../lib/api' + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null) + const [isReady, setIsReady] = useState(false) + + useEffect(() => { + async function initializeAuth() { + const token = getStoredToken() + if (!token) { + setIsReady(true) + return + } + + try { + const data = await getMe() + setUser(data?.user ?? null) + } catch { + setStoredToken(null) + setUser(null) + } finally { + setIsReady(true) + } + } + + initializeAuth() + }, []) + + useEffect(() => { + function handleUnauthorized() { + setUser(null) + } + + window.addEventListener('auth:unauthorized', handleUnauthorized) + return () => window.removeEventListener('auth:unauthorized', handleUnauthorized) + }, []) + + async function login(username, password) { + const data = await loginApi({ username, password }) + setStoredToken(data?.token) + setUser(data?.user ?? null) + return data?.user ?? null + } + + function logout() { + setStoredToken(null) + setUser(null) + } + + const value = useMemo( + () => ({ + user, + isReady, + isAuthenticated: Boolean(user), + isAdmin: user?.role === 'admin', + login, + logout, + }), + [user, isReady] + ) + + return createElement(AuthContext.Provider, { value }, children) +} + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used inside AuthProvider') + } + return context +} diff --git a/dashboard/src/hooks/useConfig.js b/dashboard/src/hooks/useConfig.js index edf6549..6a945a1 100644 --- a/dashboard/src/hooks/useConfig.js +++ b/dashboard/src/hooks/useConfig.js @@ -1,8 +1,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { getConfig, saveConfig } from '../lib/api' -export function useConfig() { - return useQuery({ queryKey: ['config'], queryFn: getConfig }) +export function useConfig(enabled = true) { + return useQuery({ queryKey: ['config'], queryFn: getConfig, enabled }) } export function useSaveConfig() { diff --git a/dashboard/src/hooks/useStats.js b/dashboard/src/hooks/useStats.js index 5837378..cfb2b7a 100644 --- a/dashboard/src/hooks/useStats.js +++ b/dashboard/src/hooks/useStats.js @@ -1,10 +1,11 @@ import { useQuery } from '@tanstack/react-query' import { getStats } from '../lib/api' -export function useStats() { +export function useStats(enabled = true) { const statsQuery = useQuery({ queryKey: ['stats'], queryFn: getStats, + enabled, }) return { diff --git a/dashboard/src/hooks/useTests.js b/dashboard/src/hooks/useTests.js index 9adfb4c..528b248 100644 --- a/dashboard/src/hooks/useTests.js +++ b/dashboard/src/hooks/useTests.js @@ -1,10 +1,11 @@ import { useQuery } from '@tanstack/react-query' import { getTests } from '../lib/api' -export function useTests(filters = {}) { +export function useTests(filters = {}, enabled = true) { return useQuery({ queryKey: ['tests', filters], queryFn: () => getTests(filters), keepPreviousData: true, + enabled, }) } diff --git a/dashboard/src/lib/api.js b/dashboard/src/lib/api.js index e69b048..6f53148 100644 --- a/dashboard/src/lib/api.js +++ b/dashboard/src/lib/api.js @@ -1,10 +1,33 @@ const BASE = '/api' +const TOKEN_KEY = 'dashboard_jwt' + +export function getStoredToken() { + return localStorage.getItem(TOKEN_KEY) +} + +export function setStoredToken(token) { + if (!token) { + localStorage.removeItem(TOKEN_KEY) + return + } + localStorage.setItem(TOKEN_KEY, token) +} + export async function apiFetch(path, options = {}) { + const token = getStoredToken() + const authHeader = token ? { Authorization: `Bearer ${token}` } : {} + const { headers: customHeaders = {}, ...restOptions } = options const res = await fetch(`${BASE}${path}`, { - headers: { 'Content-Type': 'application/json', ...options.headers }, - ...options, + ...restOptions, + headers: { 'Content-Type': 'application/json', ...authHeader, ...customHeaders }, }) + + if (res.status === 401) { + setStoredToken(null) + window.dispatchEvent(new Event('auth:unauthorized')) + } + if (!res.ok) { const text = await res.text().catch(() => res.statusText) throw new Error(text || res.statusText) @@ -12,6 +35,9 @@ export async function apiFetch(path, options = {}) { return res.json() } +export const login = (body) => apiFetch('/auth/login', { method: 'POST', body: JSON.stringify(body) }) +export const getMe = () => apiFetch('/auth/me') + export const getStats = () => apiFetch('/stats') export const getTests = (params = {}) => { const qs = new URLSearchParams( diff --git a/dashboard/src/main.jsx b/dashboard/src/main.jsx index 1e93110..1e2e46c 100644 --- a/dashboard/src/main.jsx +++ b/dashboard/src/main.jsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import App from './App.jsx' +import { AuthProvider } from './hooks/useAuth' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000, retry: 1 } }, @@ -11,7 +12,9 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')).render( - + + + , ) diff --git a/server/.dockerignore b/server/.dockerignore index 418543d..60e602f 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -1,4 +1,5 @@ .venv __pycache__ *.pyc -dashboard.db* \ No newline at end of file +dashboard.db* +.env \ No newline at end of file diff --git a/server/.env b/server/.env deleted file mode 100644 index dd00d95..0000000 --- a/server/.env +++ /dev/null @@ -1,5 +0,0 @@ -# Flask backend port (default: 3001) -PORT=3001 - -# Note: Target and results directories are configured via the dashboard Settings UI -# and stored in SQLite (dashboard.db), not in environment variables. diff --git a/server/.env.template b/server/.env.template new file mode 100644 index 0000000..8dff798 --- /dev/null +++ b/server/.env.template @@ -0,0 +1,11 @@ +# Flask backend port (default: 3001) +PORT=3001 + +JWT_SECRET= +JWT_EXPIRES_HOURS=8 +DEFAULT_ADMIN_USERNAME=wnc +DEFAULT_ADMIN_PASSWORD=@wnc111111 +JWT_ALGORITHM=HS256 + +DEFAULT_VIEWER_USERNAME=viewer +DEFAULT_VIEWER_PASSWORD=viewer3040 \ No newline at end of file diff --git a/server/app.py b/server/app.py index e257163..8a4bff6 100644 --- a/server/app.py +++ b/server/app.py @@ -1,13 +1,41 @@ import os +from datetime import datetime, timedelta, timezone +from functools import wraps from pathlib import Path -from flask import Flask, jsonify, request, send_from_directory +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_tests, del_config, get_all_tests, get_config, set_config +from db_py import ( + count_tests, + count_users, + create_user, + del_config, + get_all_tests, + get_all_users, + get_config, + get_user_by_id, + get_user_by_username, + set_config, + clear_users, +) from scanner import full_scan, is_scan_in_progress, resolve_runtime_path, scan_results_only +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", @@ -21,13 +49,110 @@ ALLOWED_KEYS = { "smb_domain", } -BASE_DIR = Path(__file__).resolve().parent 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] 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"), @@ -43,6 +168,7 @@ def _apply_smb_env_from_config(): @app.get("/api/tests") +@require_auth def get_tests_route(): completed = request.args.get("completed") interference = request.args.get("interference") @@ -90,6 +216,7 @@ def get_tests_route(): @app.get("/api/stats") +@require_auth def get_stats_route(): tests = get_all_tests() types = ["COE", "P2P", "P3P"] @@ -197,11 +324,84 @@ def get_stats_route(): @app.get("/api/scan-status") +@require_auth def get_scan_status_route(): return jsonify({"scanning": is_scan_in_progress()}) +@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.get("/api/config") +@require_auth +@require_role("admin") def get_config_route(): config = {} for key in ALLOWED_KEYS: @@ -210,6 +410,8 @@ def get_config_route(): @app.post("/api/config") +@require_auth +@require_role("admin") def set_config_route(): updates = request.get_json(silent=True) if not isinstance(updates, dict): @@ -249,6 +451,8 @@ def set_config_route(): @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")) @@ -267,6 +471,8 @@ def rescan_route(): @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")) @@ -295,6 +501,7 @@ def static_or_spa(path=""): 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")) diff --git a/server/dashboard.db b/server/dashboard.db deleted file mode 100644 index bff3e1c..0000000 Binary files a/server/dashboard.db and /dev/null differ diff --git a/server/db_py.py b/server/db_py.py index d956262..24ca79e 100644 --- a/server/db_py.py +++ b/server/db_py.py @@ -54,8 +54,18 @@ def _init_db(): duration_seconds REAL ); + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_tests_test_id ON tests (test_id); CREATE INDEX IF NOT EXISTS idx_tests_device ON tests (device); + CREATE INDEX IF NOT EXISTS idx_users_username ON users (username); """ ) @@ -278,4 +288,64 @@ def count_tests(): return row["n"] +def get_user_by_username(username): + with _lock: + row = _conn.execute( + """ + SELECT id, username, password_hash, role, is_active, created_at + FROM users + WHERE username = ? + """, + (username,), + ).fetchone() + return dict(row) if row else None + + +def create_user(username, password_hash, role="viewer", is_active=1): + with _tx(): + cursor = _conn.execute( + """ + INSERT INTO users (username, password_hash, role, is_active) + VALUES (?, ?, ?, ?) + """, + (username, password_hash, role, is_active), + ) + return cursor.lastrowid + + +def count_users(): + with _lock: + row = _conn.execute("SELECT COUNT(*) AS n FROM users").fetchone() + return row["n"] + + +def get_user_by_id(user_id): + with _lock: + row = _conn.execute( + """ + SELECT id, username, password_hash, role, is_active, created_at + FROM users + WHERE id = ? + """, + (user_id,), + ).fetchone() + return dict(row) if row else None + + +def get_all_users(): + with _lock: + rows = _conn.execute( + """ + SELECT id, username, role, is_active, created_at + FROM users + ORDER BY username ASC + """ + ).fetchall() + return [dict(row) for row in rows] + +def clear_users(): + with _tx(): + _conn.execute("DELETE FROM users") + + _init_db() diff --git a/server/requirements.txt b/server/requirements.txt index bad148b..80ff2f4 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,3 +1,5 @@ Flask>=3.0.0,<4.0.0 Flask-Cors>=4.0.1,<5.0.0 smbprotocol>=1.13.0,<2.0.0 +PyJWT>=2.9.0,<3.0.0 +python-dotenv>=1.0.1,<2.0.0