> **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.
A web dashboard that monitors test execution progress by comparing a **target tests directory** (what should run) against a **results directory** (what has run). The backend runs on one machine with access to both directories; the frontend is accessible from any device on the network.
A **test is completed** when its **file ID** appears in any result directory name.
**File ID** = the test case code segment extracted from the filename — the segment matching the pattern `R\d+[A-Z0-9]+` (e.g., `R2COERXAC003`, `R5P2PRXAX012`).
**Target files** live inside subdirectories of the target directory. All target files use the prefix `TC_WIFI_` and extension `.ini`. Each parent directory also contains a `GLOBAL.ini` file that must be **skipped** by the scanner:
**Duration per test**: the result directory may contain multiple `.txt` files. Only the `.txt` file(s) whose name includes the `test_id` are scanned. All other `.txt` files are ignored.
The captured group (`1:51:00.660866`) is parsed as `H:MM:SS.microseconds` and converted to total seconds stored in `duration_seconds`. If no matching line is found, `duration_seconds` is left `NULL` and excluded from the elapsed sum and avg calculations.
**Re-runs**: if multiple `.txt` files match the `test_id` filter (re-run logs), use the one with the **latest** timestamp in its filename for both `completed_at` and `duration_seconds`.
| File ID | Regex `R\d+[A-Z0-9]+` | `R2COERXAC003` |
| Test Point | Regex `TPT\w+` | `TPT3E` |
| RSSI | Regex `RSSI\d+` | `RSSI70` |
| Station | Regex `STA\d+` | `STA4` |
| Band | Regex `\dGHZ` | `2GHZ` |
| Channel | Regex `CH\d+` | `CH1` |
| Bandwidth | Regex `BW\d+` | `BW20` |
| Direction | Last segment — fixed enum: `UL`, `DL`, `BI` | `UL` |
**Completion Timestamp**: extracted from the timestamped result file name inside the matching result directory (e.g., `..._2026-05-16-07-30-14` → `2026-05-16 07:30:14`).
**Interference type** is also validated against the result directory name's first segment (`COE_...`, `P2P_...`, `P3P_...`) as a cross-check.
---
## 5. Database Schema
```sql
-- Stores one row per target test file
CREATETABLEtests(
idTEXTPRIMARYKEY,-- full derived ID (filename without prefix/ext)
| `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) |
### `GET /api/tests` Query Parameters
```
?completed=true|false
&interference=COE|P2P|P3P
&device=CGW452
&rotation=ROT1
&testPoint=TPT3E
&station=STA4
&band=2GHZ
&channel=CH1
&bandwidth=BW20
&rssi=RSSI70
&direction=UL
```
### `GET /api/stats` Response Shape
```json
{
"overall":{
"total":200,
"completed":120,
"completionRate":0.60
},
"cgw452":{
"total":100,
"completed":70,
"completionRate":0.70
},
"cgw453":{
"total":100,
"completed":50,
"completionRate":0.50
},
"timing":{
"elapsedSeconds":172800,
"estimatedRemainingSeconds":115200,
"byType":{
"COE":{
"avgSeconds":450,
"avgSource":"calculated",
"remaining":80
},
"P2P":{
"avgSeconds":300,
"avgSource":"manual",
"remaining":50
},
"P3P":{
"avgSeconds":600,
"avgSource":"calculated",
"remaining":30
}
}
}
}
```
**Timing logic**:
-`elapsedSeconds` = `SUM(duration_seconds)` for all completed tests (sum of individual test durations, not wall-clock)
- For each interference type (COE, P2P, P3P):
-`avgSeconds` = `AVG(duration_seconds) WHERE interference = type AND completed = 1`
- If no completed tests exist for that type, `avgSeconds` = value from `config` table (`avg_time_coe` / `avg_time_p2p` / `avg_time_p3p`); `avgSource` = `"manual"`
- If a manual override is set in config even when calculated data exists, the manual value takes precedence; `avgSource` = `"manual_override"`
- 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.
- 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']`
**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.
**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.
### 8.2 Component Breakdown
| Component | Responsibility |
|---|---|
| `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` |
| `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
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
All config values are POSTed to `POST /api/config` as key/value pairs and persisted in SQLite.