@@ -42,6 +42,7 @@ server/__pycache__/
|
|||||||
server/dashboard.db*
|
server/dashboard.db*
|
||||||
server/*.pyc
|
server/*.pyc
|
||||||
*.pyc
|
*.pyc
|
||||||
|
server/.env
|
||||||
|
|
||||||
# Test output
|
# Test output
|
||||||
test/
|
test/
|
||||||
@@ -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_<tags>.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).
|
|
||||||
+18
-4
@@ -1,26 +1,36 @@
|
|||||||
## Design Plan
|
## 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
|
**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**:
|
**Requirements**:
|
||||||
- A test is marked completed when there is a filename match in result tests directory
|
- 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
|
- 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:
|
Front end UI requirements:
|
||||||
- Completion rate
|
- Completion rate and number of tests completed
|
||||||
- Overall completion rate
|
- Overall completion rate
|
||||||
- CGW 452 completion rate
|
- CGW 452 completion rate
|
||||||
|
- number of tests completed for each type
|
||||||
- CGW453 completion rate
|
- CGW453 completion rate
|
||||||
|
- number of tests completed for each type
|
||||||
|
|
||||||
- Time elapsed
|
- Time elapsed
|
||||||
- Estimated Time remaining
|
- Estimated Time remaining
|
||||||
|
- Estimated date to complete
|
||||||
|
- 16 hrs on weekdays
|
||||||
|
- 24 hrs on weekends
|
||||||
- List of all tests
|
- List of all tests
|
||||||
- Can filter by Tags:
|
- Can filter by Tags:
|
||||||
- Completed: Boolean
|
- Completed: Boolean
|
||||||
|
- Type
|
||||||
- Device: String
|
- Device: String
|
||||||
- Rotation: String
|
- Rotation: String
|
||||||
- Test Point: String
|
- Test Point: String
|
||||||
@@ -30,3 +40,7 @@ Front end UI requirements:
|
|||||||
- Bandwidth: String
|
- Bandwidth: String
|
||||||
- RSSI: String
|
- RSSI: String
|
||||||
- Direction: 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
|
||||||
|
|||||||
+61
-81
@@ -1,6 +1,6 @@
|
|||||||
# Implementation Plan — Test Dashboard
|
# 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
|
## 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` |
|
| Frontend | React + Vite | Dev server proxies `/api` → `localhost:3001` |
|
||||||
| Backend | Python 3 + Flask | Replaced original Node.js/Express plan |
|
| Backend | Python 3 + Flask | Replaced original Node.js/Express plan |
|
||||||
| Real-time | Server-Sent Events (SSE) | One-way push from backend to browser |
|
| Auth | JWT (PyJWT) | Bearer token auth with `admin` and `viewer` roles |
|
||||||
| Directory watching | watchdog | Python filesystem watcher (Windows-compatible) |
|
|
||||||
| Database | SQLite (via `sqlite3` stdlib) | WAL mode; single persistent connection with RLock |
|
| 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 |
|
| UI | TailwindCSS v4 | Via `@tailwindcss/vite` plugin |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -35,7 +34,7 @@ test_house_dashboard/
|
|||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── App.jsx – root layout, SSE wiring via useStats
|
│ ├── App.jsx – root layout, auth gate, modal settings entry
|
||||||
│ ├── main.jsx
|
│ ├── main.jsx
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── StatCard.jsx – metric card (label / value / sub)
|
│ │ ├── StatCard.jsx – metric card (label / value / sub)
|
||||||
@@ -44,22 +43,20 @@ test_house_dashboard/
|
|||||||
│ │ ├── FilterPanel.jsx – dropdown tag filters
|
│ │ ├── FilterPanel.jsx – dropdown tag filters
|
||||||
│ │ ├── TimeDisplay.jsx – elapsed / estimated time display
|
│ │ ├── TimeDisplay.jsx – elapsed / estimated time display
|
||||||
│ │ ├── StatusBadge.jsx – completed / pending indicator
|
│ │ ├── StatusBadge.jsx – completed / pending indicator
|
||||||
│ │ ├── ConfigModal.jsx – settings modal (dirs + avg time overrides)
|
│ │ └── ConfigModal.jsx – settings modal (dirs + avg time overrides)
|
||||||
│ │ └── DirectoryBrowser.jsx – server-side folder picker (uses /api/browse)
|
|
||||||
│ ├── hooks/
|
│ ├── 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
|
│ │ ├── useTests.js – fetches test list
|
||||||
│ │ └── useConfig.js – fetches/saves config
|
│ │ └── useConfig.js – fetches/saves config
|
||||||
│ └── lib/
|
│ └── lib/
|
||||||
│ └── api.js – thin fetch wrapper; BASE = '/api'
|
│ └── api.js – fetch wrapper with JWT header injection
|
||||||
│
|
│
|
||||||
└── server/ ← Python backend
|
└── 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
|
├── db_py.py – SQLite schema, query helpers, config CRUD
|
||||||
├── scanner.py – full_scan(), scan_targets(), scan_results(), process_result_dir()
|
├── scanner.py – full_scan(), scan_targets(), scan_results(), process_result_dir()
|
||||||
├── parser.py – parse_target_filename(), parse_result_filename(), etc.
|
├── 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
|
├── requirements.txt
|
||||||
├── dashboard.db – SQLite database (auto-created)
|
├── dashboard.db – SQLite database (auto-created)
|
||||||
└── .venv/ – Python virtual environment
|
└── .venv/ – Python virtual environment
|
||||||
@@ -185,9 +182,20 @@ CREATE TABLE config (
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
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:
|
-- Config keys:
|
||||||
-- target_dir – path to target tests directory
|
-- target_dir – path to target tests directory
|
||||||
-- results_dir – path to results 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_coe – manual avg seconds per COE test (NULL = use calculated)
|
||||||
-- avg_time_p2p – manual avg seconds per P2P 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)
|
-- avg_time_p3p – manual avg seconds per P3P test (NULL = use calculated)
|
||||||
@@ -201,12 +209,23 @@ CREATE TABLE config (
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| 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/tests` | All tests; supports query filters (see below) |
|
||||||
| `GET` | `/api/stats` | Aggregated stats object |
|
| `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 |
|
| `GET` | `/api/config` | Current directory paths and avg time overrides |
|
||||||
| `POST` | `/api/config` | Set `targetDir`, `resultsDir`, avg time overrides; triggers re-scan |
|
| `POST` | `/api/config` | Save config and optionally trigger full re-scan |
|
||||||
| `GET` | `/api/browse` | List subdirectories at a given server path (directory picker) |
|
| `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 <token>`.
|
||||||
|
|
||||||
### `GET /api/tests` Query Parameters
|
### `GET /api/tests` Query Parameters
|
||||||
|
|
||||||
@@ -277,44 +296,13 @@ CREATE TABLE config (
|
|||||||
- `estimatedRemainingSeconds` = `(avgCOE × COE_remaining) + (avgP2P × P2P_remaining) + (avgP3P × P3P_remaining)`
|
- `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**
|
- 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
|
## 7. Real-time Updates
|
||||||
|
|
||||||
- Backend registers an SSE endpoint at `GET /api/events` (`sse_py.py`)
|
Current implementation does not use SSE/watchdog.
|
||||||
- `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']`
|
|
||||||
|
|
||||||
**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`.
|
- Stats/tests/config are fetched via React Query.
|
||||||
|
- Mutations (`saveConfig`, `Save Times`, `Save Results`) manually invalidate affected query keys.
|
||||||
**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.
|
- Full scans/rescans run only when triggered by settings actions.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 │
|
│ Overall │ CGW452 │ CGW453 │ Time │ Est. Remaining │
|
||||||
│ 60% │ 70% │ 50% │ Elapsed │ │
|
│ 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
|
### 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 |
|
| `StatCard` | Displays a metric (label, value, sub-value); used for completion rate + counts |
|
||||||
| `CompletionBar` | Progress bar with percentage label |
|
| `CompletionBar` | Progress bar with percentage label |
|
||||||
| `TimeDisplay` | Formats seconds into `Xh Ym`; shows elapsed and estimated remaining |
|
| `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 |
|
| `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 |
|
| `TestTable` | Filtered table of tests with status and parsed tags |
|
||||||
| `ConfigModal` | Form for directory paths (with picker button) + manual avg time overrides per type |
|
| `ConfigModal` | Admin settings modal for directories, avg overrides, and SMB credentials |
|
||||||
| `DirectoryBrowser` | Inline folder picker inside `ConfigModal`; navigates the server filesystem via `/api/browse` |
|
|
||||||
| `StatusBadge` | Green checkmark or grey circle for completed/pending |
|
| `StatusBadge` | Green checkmark or grey circle for completed/pending |
|
||||||
|
|
||||||
### 8.3 Settings / Config
|
### 8.3 Settings / Config
|
||||||
|
|
||||||
A gear icon opens `ConfigModal` with two sections:
|
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`:
|
1. **Directories** — target/results paths plus optional reference results path and exclusion rules.
|
||||||
- Starts at the filesystem root (lists available drives on Windows)
|
- On Save All, backend updates config and triggers full re-scan when directory/exclusion fields changed.
|
||||||
- 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
|
|
||||||
2. **Avg Time Overrides** — three number inputs (COE, P2P, P3P) in minutes. Each shows:
|
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`
|
- Manual override values converted to seconds and saved in config.
|
||||||
- A manual override field; when filled, it takes precedence over the calculated value
|
3. **SMB Credentials** — domain, username, password for network share access.
|
||||||
- Clear button to remove the override and revert to calculated
|
4. **Action buttons**:
|
||||||
- If no completed tests exist for a type, the field is highlighted with a required indicator
|
- 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.
|
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
|
## 10. Implementation Status
|
||||||
|
|
||||||
### Completed
|
### 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] SQLite schema, WAL mode, thread-safe helpers (`db_py.py`)
|
||||||
- [x] Tag parsing from target filenames and parent folder names (`parser.py`)
|
- [x] Users table + user management APIs (admin-only)
|
||||||
- [x] Full directory scan on startup / config change (`scanner.py`)
|
- [x] Default user seeding from environment (`DEFAULT_ADMIN_*`, `DEFAULT_VIEWER_*`)
|
||||||
- [x] Watchdog filesystem watchers for both target and results dirs (`watcher.py`)
|
- [x] Tag parsing and scan logic (`parser.py`, `scanner.py`)
|
||||||
- [x] Windows `is_directory` timing bug fixed (check path directly)
|
- [x] Full scan / rescan / results-only rescan flows
|
||||||
- [x] Recycle Bin delete handled via `on_moved`
|
- [x] Frontend auth flow (login screen, JWT persistence, role-based UI)
|
||||||
- [x] SSE broadcast with correct `\n\n` terminators (`sse_py.py`)
|
- [x] Config modal (directories, avg overrides, SMB credentials)
|
||||||
- [x] All frontend components and hooks
|
|
||||||
- [x] Config modal: directories always editable (lock removed)
|
|
||||||
- [x] Vite dev proxy + Flask static serving for production
|
- [x] Vite dev proxy + Flask static serving for production
|
||||||
- [x] Config migrated from `config.json` → SQLite on first run
|
- [x] Config migrated from `config.json` → SQLite on first run
|
||||||
|
|
||||||
### Remaining / Future
|
### 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)
|
- [ ] Test detail view (click a row to see tput/RSSI breakdown)
|
||||||
- [ ] Production deployment docs (systemd / Task Scheduler service)
|
- [ ] Production deployment docs (service startup, backup, secrets handling)
|
||||||
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
|
|
||||||
5. Build `StatCard`, `CompletionBar`, `TimeDisplay`, `StatusBadge`
|
5. Build `StatCard`, `CompletionBar`, `TimeDisplay`, `StatusBadge`
|
||||||
6. Build `App.jsx` layout with stats row
|
6. Build `App.jsx` layout with stats row
|
||||||
|
|
||||||
|
|||||||
Generated
+58
@@ -13,6 +13,7 @@
|
|||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router-dom": "^7.16.0",
|
||||||
"tailwindcss": "^4.3.0"
|
"tailwindcss": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1340,6 +1341,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2437,6 +2451,44 @@
|
|||||||
"react": "^19.2.6"
|
"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": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||||
@@ -2486,6 +2538,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router-dom": "^7.16.0",
|
||||||
"tailwindcss": "^4.3.0"
|
"tailwindcss": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+108
-7
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Settings } from 'lucide-react'
|
import { Settings } from 'lucide-react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useStats } from './hooks/useStats'
|
import { useStats } from './hooks/useStats'
|
||||||
import { useTests } from './hooks/useTests'
|
import { useTests } from './hooks/useTests'
|
||||||
import { useConfig } from './hooks/useConfig'
|
import { useConfig } from './hooks/useConfig'
|
||||||
|
import { useAuth } from './hooks/useAuth'
|
||||||
import cgw453Image from './assets/CGW453.PNG'
|
import cgw453Image from './assets/CGW453.PNG'
|
||||||
import StatCard from './components/StatCard'
|
import StatCard from './components/StatCard'
|
||||||
import CompletionBar from './components/CompletionBar'
|
import CompletionBar from './components/CompletionBar'
|
||||||
@@ -24,12 +26,19 @@ function hasCoePairs(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { user, isReady, isAuthenticated, isAdmin, login, logout } = useAuth()
|
||||||
|
|
||||||
const [showConfig, setShowConfig] = useState(false)
|
const [showConfig, setShowConfig] = useState(false)
|
||||||
const [filters, setFilters] = useState({})
|
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: stats, isLoading: statsLoading } = useStats(isAuthenticated)
|
||||||
const { data: allTests = [], isLoading: testsLoading } = useTests()
|
const { data: allTests = [], isLoading: testsLoading } = useTests({}, isAuthenticated)
|
||||||
const { data: config, isLoading: configLoading } = useConfig()
|
const { data: config, isLoading: configLoading } = useConfig(isAuthenticated && isAdmin)
|
||||||
|
|
||||||
// Apply filters client-side
|
// Apply filters client-side
|
||||||
const filteredTests = useMemo(() => {
|
const filteredTests = useMemo(() => {
|
||||||
@@ -52,16 +61,101 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
}, [allTests, filters])
|
}, [allTests, filters])
|
||||||
|
|
||||||
const noConfig = !configLoading && !config?.target_dir && !config?.results_dir
|
const noConfig = isAdmin && !configLoading && !config?.target_dir && !config?.results_dir
|
||||||
const configuredButEmpty = !configLoading && config?.target_dir && config?.results_dir &&
|
const configuredButEmpty = isAdmin && !configLoading && config?.target_dir && config?.results_dir &&
|
||||||
!statsLoading && stats?.overall.total === 0
|
!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 (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-200 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-slate-400">Checking session...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-4">
|
||||||
|
<form
|
||||||
|
onSubmit={handleLogin}
|
||||||
|
className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-xl p-6 space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Sign in</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginError && (
|
||||||
|
<div className="bg-red-950/50 border border-red-700 rounded-lg px-3 py-2 text-red-300 text-sm">
|
||||||
|
{loginError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="block text-sm text-slate-300">
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-600"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-sm text-slate-300">
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:outline-none focus:ring-2 focus:ring-cyan-600"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
className="w-full rounded-lg bg-cyan-700 hover:bg-cyan-600 disabled:opacity-60 px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{isLoggingIn ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col">
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
|
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
|
||||||
<h1 className="text-lg font-bold tracking-tight">CGW453 Test Dashboard</h1>
|
<h1 className="text-lg font-bold tracking-tight">CGW453 Test Dashboard</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-400 hidden md:inline">
|
||||||
|
{user?.username} ({user?.role})
|
||||||
|
</span>
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowConfig(true)}
|
onClick={() => setShowConfig(true)}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||||
@@ -69,6 +163,13 @@ export default function App() {
|
|||||||
<Settings size={14} />
|
<Settings size={14} />
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -155,7 +256,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
|
{isAdmin && showConfig && <ConfigModal onClose={() => setShowConfig(false)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export default function ConfigModal({ onClose }) {
|
|||||||
{[
|
{[
|
||||||
{ key: 'target_dir', label: 'Target Tests Directory' },
|
{ key: 'target_dir', label: 'Target Tests Directory' },
|
||||||
{ key: 'results_dir', label: 'Results Directory (DUT)' },
|
{ 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 }) => (
|
].map(({ key, label }) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { getConfig, saveConfig } from '../lib/api'
|
import { getConfig, saveConfig } from '../lib/api'
|
||||||
|
|
||||||
export function useConfig() {
|
export function useConfig(enabled = true) {
|
||||||
return useQuery({ queryKey: ['config'], queryFn: getConfig })
|
return useQuery({ queryKey: ['config'], queryFn: getConfig, enabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSaveConfig() {
|
export function useSaveConfig() {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getStats } from '../lib/api'
|
import { getStats } from '../lib/api'
|
||||||
|
|
||||||
export function useStats() {
|
export function useStats(enabled = true) {
|
||||||
const statsQuery = useQuery({
|
const statsQuery = useQuery({
|
||||||
queryKey: ['stats'],
|
queryKey: ['stats'],
|
||||||
queryFn: getStats,
|
queryFn: getStats,
|
||||||
|
enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getTests } from '../lib/api'
|
import { getTests } from '../lib/api'
|
||||||
|
|
||||||
export function useTests(filters = {}) {
|
export function useTests(filters = {}, enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['tests', filters],
|
queryKey: ['tests', filters],
|
||||||
queryFn: () => getTests(filters),
|
queryFn: () => getTests(filters),
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
|
enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
const BASE = '/api'
|
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 = {}) {
|
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}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
...restOptions,
|
||||||
...options,
|
headers: { 'Content-Type': 'application/json', ...authHeader, ...customHeaders },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
setStoredToken(null)
|
||||||
|
window.dispatchEvent(new Event('auth:unauthorized'))
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => res.statusText)
|
const text = await res.text().catch(() => res.statusText)
|
||||||
throw new Error(text || res.statusText)
|
throw new Error(text || res.statusText)
|
||||||
@@ -12,6 +35,9 @@ export async function apiFetch(path, options = {}) {
|
|||||||
return res.json()
|
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 getStats = () => apiFetch('/stats')
|
||||||
export const getTests = (params = {}) => {
|
export const getTests = (params = {}) => {
|
||||||
const qs = new URLSearchParams(
|
const qs = new URLSearchParams(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
import { AuthProvider } from './hooks/useAuth'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { staleTime: 10_000, retry: 1 } },
|
defaultOptions: { queries: { staleTime: 10_000, retry: 1 } },
|
||||||
@@ -11,7 +12,9 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
dashboard.db*
|
dashboard.db*
|
||||||
|
.env
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
+210
-3
@@ -1,13 +1,41 @@
|
|||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from functools import wraps
|
||||||
from pathlib import Path
|
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 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
|
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"))
|
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 = {
|
ALLOWED_KEYS = {
|
||||||
"target_dir",
|
"target_dir",
|
||||||
"results_dir",
|
"results_dir",
|
||||||
@@ -21,13 +49,110 @@ ALLOWED_KEYS = {
|
|||||||
"smb_domain",
|
"smb_domain",
|
||||||
}
|
}
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
|
||||||
DIST_DIR = BASE_DIR.parent / "dashboard" / "dist"
|
DIST_DIR = BASE_DIR.parent / "dashboard" / "dist"
|
||||||
|
|
||||||
app = Flask(__name__, static_folder=str(DIST_DIR), static_url_path="")
|
app = Flask(__name__, static_folder=str(DIST_DIR), static_url_path="")
|
||||||
CORS(app)
|
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():
|
def _apply_smb_env_from_config():
|
||||||
mapping = {
|
mapping = {
|
||||||
"SMB_USERNAME": get_config("smb_username"),
|
"SMB_USERNAME": get_config("smb_username"),
|
||||||
@@ -43,6 +168,7 @@ def _apply_smb_env_from_config():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/tests")
|
@app.get("/api/tests")
|
||||||
|
@require_auth
|
||||||
def get_tests_route():
|
def get_tests_route():
|
||||||
completed = request.args.get("completed")
|
completed = request.args.get("completed")
|
||||||
interference = request.args.get("interference")
|
interference = request.args.get("interference")
|
||||||
@@ -90,6 +216,7 @@ def get_tests_route():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/stats")
|
@app.get("/api/stats")
|
||||||
|
@require_auth
|
||||||
def get_stats_route():
|
def get_stats_route():
|
||||||
tests = get_all_tests()
|
tests = get_all_tests()
|
||||||
types = ["COE", "P2P", "P3P"]
|
types = ["COE", "P2P", "P3P"]
|
||||||
@@ -197,11 +324,84 @@ def get_stats_route():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/scan-status")
|
@app.get("/api/scan-status")
|
||||||
|
@require_auth
|
||||||
def get_scan_status_route():
|
def get_scan_status_route():
|
||||||
return jsonify({"scanning": is_scan_in_progress()})
|
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")
|
@app.get("/api/config")
|
||||||
|
@require_auth
|
||||||
|
@require_role("admin")
|
||||||
def get_config_route():
|
def get_config_route():
|
||||||
config = {}
|
config = {}
|
||||||
for key in ALLOWED_KEYS:
|
for key in ALLOWED_KEYS:
|
||||||
@@ -210,6 +410,8 @@ def get_config_route():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/config")
|
@app.post("/api/config")
|
||||||
|
@require_auth
|
||||||
|
@require_role("admin")
|
||||||
def set_config_route():
|
def set_config_route():
|
||||||
updates = request.get_json(silent=True)
|
updates = request.get_json(silent=True)
|
||||||
if not isinstance(updates, dict):
|
if not isinstance(updates, dict):
|
||||||
@@ -249,6 +451,8 @@ def set_config_route():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/config/rescan")
|
@app.post("/api/config/rescan")
|
||||||
|
@require_auth
|
||||||
|
@require_role("admin")
|
||||||
def rescan_route():
|
def rescan_route():
|
||||||
_apply_smb_env_from_config()
|
_apply_smb_env_from_config()
|
||||||
target_dir = resolve_runtime_path(get_config("target_dir"))
|
target_dir = resolve_runtime_path(get_config("target_dir"))
|
||||||
@@ -267,6 +471,8 @@ def rescan_route():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/config/rescan-results")
|
@app.post("/api/config/rescan-results")
|
||||||
|
@require_auth
|
||||||
|
@require_role("admin")
|
||||||
def rescan_results_route():
|
def rescan_results_route():
|
||||||
_apply_smb_env_from_config()
|
_apply_smb_env_from_config()
|
||||||
results_dir = resolve_runtime_path(get_config("results_dir"))
|
results_dir = resolve_runtime_path(get_config("results_dir"))
|
||||||
@@ -295,6 +501,7 @@ def static_or_spa(path=""):
|
|||||||
|
|
||||||
|
|
||||||
def bootstrap():
|
def bootstrap():
|
||||||
|
_ensure_default_users()
|
||||||
_apply_smb_env_from_config()
|
_apply_smb_env_from_config()
|
||||||
target_dir = resolve_runtime_path(get_config("target_dir"))
|
target_dir = resolve_runtime_path(get_config("target_dir"))
|
||||||
results_dir = resolve_runtime_path(get_config("results_dir"))
|
results_dir = resolve_runtime_path(get_config("results_dir"))
|
||||||
|
|||||||
Binary file not shown.
@@ -54,8 +54,18 @@ def _init_db():
|
|||||||
duration_seconds REAL
|
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_test_id ON tests (test_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tests_device ON tests (device);
|
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"]
|
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()
|
_init_db()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
Flask>=3.0.0,<4.0.0
|
Flask>=3.0.0,<4.0.0
|
||||||
Flask-Cors>=4.0.1,<5.0.0
|
Flask-Cors>=4.0.1,<5.0.0
|
||||||
smbprotocol>=1.13.0,<2.0.0
|
smbprotocol>=1.13.0,<2.0.0
|
||||||
|
PyJWT>=2.9.0,<3.0.0
|
||||||
|
python-dotenv>=1.0.1,<2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user