feat: refactor parsing and scanning logic for test files

- Updated `parseFilename` to `parseTargetFilename` and modified its return structure to include `test_id` instead of `file_id`.
- Introduced `parseResultFilename` to extract `test_id` and `device` from result file names.
- Enhanced `fullScan` to separately handle target and results directories, improving clarity and functionality.
- Updated database interactions to use `test_id` instead of `file_id` across various modules.
- Added a new `/rescan` endpoint to trigger a full scan of target and results directories.
- Improved logging and error handling throughout the scanning process.
- Introduced `parseTputRssi` to extract throughput and RSSI data from log files.
This commit is contained in:
2026-05-21 14:53:08 -04:00
parent ed0f93036d
commit a67815c61a
22 changed files with 9791 additions and 228 deletions
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-32
View File
@@ -1,32 +0,0 @@
## Design Plan
**Inputs**: target tests directory, 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
Front end UI requirements:
- Completion rate
- Overall completion rate
- CGW 452 completion rate
- CGW453 completion rate
- Time elapsed
- Estimated Time remaining
- List of all tests
- Can filter by Tags:
- Completed: Boolean
- Device: String
- Rotation: String
- Test Point: String
- Station: String
- Band: String
- Channel: String
- Bandwidth: String
- RSSI: String
- Direction: String
-465
View File
@@ -1,465 +0,0 @@
# Implementation Plan — Test Dashboard
## 1. Overview
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.
---
## 2. Tech Stack
| Layer | Technology | Rationale |
|---|---|---|
| Frontend | React + Vite (existing) | Already scaffolded |
| Backend | Node.js + Express | Lightweight API + static file serving |
| Real-time | Server-Sent Events (SSE) | Simpler than WebSocket for one-way push |
| Directory watching | chokidar | Cross-platform file system watcher |
| Database | SQLite (via `better-sqlite3`) | Embedded, no separate process, fast reads |
| Frontend state | React Query (TanStack Query) | Cache, refetch, and SSE invalidation |
| UI | TailwindCSS + shadcn/ui | Rapid, consistent component styling |
---
## 3. Project Structure
```
Projects/
├── dashboard/ ← React frontend (existing)
│ ├── src/
│ │ ├── components/
│ │ │ ├── StatCard.jsx metric display card
│ │ │ ├── CompletionBar.jsx progress bar with percentage
│ │ │ ├── TestTable.jsx filterable test list
│ │ │ ├── FilterPanel.jsx tag filter controls
│ │ │ └── TimeDisplay.jsx elapsed / estimated time
│ │ ├── hooks/
│ │ │ ├── useTests.js fetch + SSE subscription
│ │ │ └── useStats.js derived stats from test data
│ │ ├── lib/
│ │ │ └── api.js axios/fetch base client
│ │ ├── App.jsx
│ │ └── main.jsx
│ ├── package.json
│ └── vite.config.js proxy /api → backend port
└── server/ ← NEW: Node.js backend
├── index.js Express app entry point
├── db.js SQLite schema + query helpers
├── watcher.js chokidar setup + change handlers
├── parser.js filename/foldername → tags
├── scanner.js full directory scan on startup
├── sse.js SSE client registry + broadcast
├── routes/
│ ├── tests.js GET /api/tests, GET /api/tests/:id
│ ├── stats.js GET /api/stats
│ ├── config.js GET/POST /api/config (directory paths)
│ ├── browse.js GET /api/browse (server-side directory browser)
│ └── events.js GET /api/events (SSE stream)
├── package.json
└── .env TARGET_DIR, RESULTS_DIR, PORT=3001
```
---
## 4. File ID & Tag Parsing
### 4.1 File ID & Completion Matching
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:
```
TP_WIFI_ATT_CDR_GRP1_ROT1_CGW452_WNC_REGRESSION/
GLOBAL.ini ← skip
TC_WIFI_COE_CGW452_R2COERXAC003_TPT3E_RSSI70_STA4_2GHZ_CH1_BW20_TCP_MIMOFD_SONFD_MESHFD_LPI_UL.ini
file_id = R2COERXAC003
```
Scanner filter: include only files where `filename.startsWith('TC_WIFI_') && filename.endsWith('.ini')`.
**Result directories** live as top-level subdirectories of the results directory. The directory name contains the same file ID:
```
COE_CGW452_R2COERXAC003_TPT3E_RSSI70_STA4_2GHZ_CH1_BW20_TCP_MIMOFD_SONFD_MESHFD_LPI_UL/
file_id = R2COERXAC003
COE_CGW452_R2COERXAC003_..._2026-05-16-07-30-14 ← target log file (contains file_id in name)
other_file.txt ← ignored
...other files
```
A test is **completed** when a result directory name contains the target's `file_id`.
**Duration per test**: the result directory may contain multiple `.txt` files. Only the `.txt` file(s) whose name includes the `file_id` are scanned. All other `.txt` files are ignored.
Filter: `filename.endsWith('.txt') && filename.includes(file_id)`
Scan the matched file for the line:
```
[2026-05-16 09:21:02,105 INFO] Elapsed time : 1:51:00.660866
```
Regex: `/\[.*?INFO\]\s+Elapsed time\s*:\s*([\d]+:[\d]{2}:[\d]{2}\.[\d]+)/`
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 `file_id` filter (re-run logs), use the one with the **latest** timestamp in its filename for both `completed_at` and `duration_seconds`.
### 4.2 Tag Parsing
Tags are parsed from two sources:
**From the parent folder name** (e.g., `TP_WIFI_ATT_CDR_GRP1_ROT1_CGW452_WNC_REGRESSION`):
| Tag | Pattern | Example |
|---|---|---|
| Rotation | `ROT\d+` | `ROT1` |
| Device | `CGW\d+` | `CGW452` |
**From the target filename** (after stripping `TC_WIFI_` prefix and `.ini` extension):
```
COE_CGW452_R2COERXAC003_TPT3E_RSSI70_STA4_2GHZ_CH1_BW20_..._UL
first segment = interference type
```
| Tag | Extraction | Example |
|---|---|---|
| **Interference** | First segment — fixed enum: `COE`, `P2P`, `P3P` | `COE` |
| Device | Regex `CGW\d+` | `CGW452` |
| 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
CREATE TABLE tests (
id TEXT PRIMARY KEY, -- full derived ID (filename without prefix/ext)
file_id TEXT NOT NULL, -- short test case code, e.g. R2COERXAC003
parent_dir TEXT NOT NULL, -- parent folder name (TP_WIFI_...)
filename TEXT NOT NULL, -- original .ini filename
completed INTEGER NOT NULL DEFAULT 0, -- 0 or 1
completed_at TEXT, -- ISO timestamp from result file name
duration_seconds REAL, -- per-test duration in seconds (from result file or fs timestamps)
-- parsed tags
interference TEXT, -- COE | P2P | P3P
device TEXT, -- CGW452 | CGW453
rotation TEXT,
test_point TEXT,
station TEXT,
band TEXT,
channel TEXT,
bandwidth TEXT,
rssi TEXT,
direction TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Stores configuration: directory paths AND manual avg time overrides
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Config keys:
-- target_dir path to target tests directory
-- results_dir path to 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)
```
---
## 6. Backend API
### Endpoints
| Method | Path | Description |
|---|---|---|
| `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/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"`
- `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
- The backend registers an SSE endpoint at `GET /api/events`
- chokidar watches both the target and results directories for `add`, `unlink`, and `change` events
- On any change, the backend re-scans the affected path, updates SQLite, and broadcasts an SSE event: `data: {"type":"update"}`
- The React frontend subscribes to the SSE stream; on receiving an `update` event it invalidates and refetches stats and test list via React Query
**`unlink` handling for result directories**: when chokidar detects that a result directory has been deleted, the corresponding test row is reset — `completed = 0`, `completed_at = NULL`, `duration_seconds = NULL`. This ensures the dashboard always reflects the actual state of the filesystem.
---
## 8. Frontend Components
### 8.1 Layout
```
┌──────────────────────────────────────────────────────────────┐
│ Test Dashboard [⟳ Refresh] [⚙ Settings] │
├──────────┬──────────┬──────────┬────────────┬───────────────┤
│ Overall │ CGW452 │ CGW453 │ Time │ Est. Remaining │
│ 60% │ 70% │ 50% │ Elapsed │ │
│ 120/200 │ 70/100 │ 50/100 │ 48h 0m │ 32h 0m │
├──────────┴──────────┴──────────┴────────────┴───────────────┤
│ ⚠ No P2P results yet — avg time required for estimate │
│ COE avg: 7m 30s (calc) P2P avg: [___] min P3P avg: 10m │
├──────────────────────────────────────────────────────────────┤
│ [Completed ▾] [Interference ▾] [Device ▾] [Band ▾] ... │
├──────────────────────────────────────────────────────────────┤
│ File ID │ Type │ Device │ Rotation │ TP │ ... │ Status │
│ R2COERXAC003│ COE │ CGW452 │ ROT1 │ TPT3E│ ... │ ✓ │
│ R5P2PRXAX012│ P2P │ CGW452 │ ROT1 │ TPT1C│ ... │ ○ │
└──────────────────────────────────────────────────────────────┘
```
**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.
---
## 9. Vite Proxy Configuration
`vite.config.js` is updated to proxy all `/api` requests to the backend, so the React dev server and production build do not need CORS configuration:
```js
// vite.config.js
server: {
proxy: {
'/api': 'http://localhost:3001'
}
}
```
In production, Express serves the built React `dist/` as static files.
---
## 10. Implementation Phases
### Phase 1 — Backend Foundation
1. Initialize `server/package.json`; install `express`, `better-sqlite3`, `chokidar`, `dotenv`
2. Implement `db.js` — create schema, upsert helpers
3. Implement `parser.js` — regex-based tag extraction from filenames and folder names
4. Implement `scanner.js` — walk target dir to build test list; walk results dir to mark completions
5. Implement `watcher.js` — chokidar watchers for both directories; call scanner on change
6. Implement `sse.js` — maintain SSE client set; broadcast on update
7. Wire up Express routes and start server
### Phase 2 — Frontend Core
1. Update `vite.config.js` with API proxy
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`
6. Build `App.jsx` layout with stats row
### Phase 3 — Test List & Filters
1. Build `FilterPanel` with controlled filter state
2. Build `TestTable` with all columns + sort
3. Wire filters to `GET /api/tests` query params
4. Add `ConfigModal` for directory path configuration
### Phase 4 — Real-time & Polish
1. Connect SSE stream in `useTests` / `useStats` to auto-invalidate queries
2. Add manual refresh button
3. Add loading skeletons and error states
4. Mobile-responsive layout adjustments
5. Test on secondary device over local network
### Phase 5 — SSO (Future)
- Add authentication middleware to the Express server (e.g., `passport.js` with an OAuth/OIDC provider)
- Protect all `/api` routes and the static frontend behind the auth middleware
- Add session management (`express-session` + a session store)
---
## 11. Key Dependencies
### Server (`server/package.json`)
```json
{
"dependencies": {
"better-sqlite3": "^9.x",
"chokidar": "^4.x",
"cors": "^2.x",
"dotenv": "^16.x",
"express": "^4.x"
}
}
```
### Client (`dashboard/package.json` additions)
```json
{
"dependencies": {
"@tanstack/react-query": "^5.x",
"axios": "^1.x",
"lucide-react": "^0.x",
"tailwindcss": "^4.x"
}
}
```
---
## 12. Resolved Design Decisions
| # | Decision |
|---|---|
| 1 | Target prefix is always `TC_WIFI_`, extension always `.ini`. `GLOBAL.ini` present in each parent directory is skipped by the scanner. |
| 2 | Interference types are a fixed enum: `COE`, `P2P`, `P3P`. |
| 3 | Duration parsed from result `.txt` log line `[... INFO] Elapsed time : H:MM:SS.ffffff`. Tests with no parseable line excluded from avg/sum. |
| 4 | If multiple result log files exist in a result directory (re-run), use the file with the **latest** timestamp in its name. |
| 5 | Backend runs on port **3001**. |
| 6 | When a result directory is deleted, the matching test is reset to pending (`completed=0`, `completed_at=NULL`, `duration_seconds=NULL`). |
-16
View File
@@ -1,16 +0,0 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+1 -6
View File
@@ -36,11 +36,6 @@ export default function App() {
})
}, [allTests, filters])
function refresh() {
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ['tests'] })
}
const noConfig = !configLoading && !config?.target_dir && !config?.results_dir
const configuredButEmpty = !configLoading && config?.target_dir && config?.results_dir &&
!statsLoading && stats?.overall.total === 0
@@ -49,7 +44,7 @@ export default function App() {
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col">
{/* Top bar */}
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
<h1 className="text-lg font-bold tracking-tight">Test Dashboard</h1>
<h1 className="text-lg font-bold tracking-tight">CGW453 Test Dashboard</h1>
<div className="flex items-center gap-2">
<button
onClick={() => setShowConfig(true)}
+33 -14
View File
@@ -17,6 +17,9 @@ export default function ConfigModal({ onClose }) {
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
const [saveError, setSaveError] = useState(null)
// Directories are locked once both are saved — restart server to change them
const dirsLocked = !!(config?.target_dir && config?.results_dir)
useEffect(() => {
if (config) {
setForm({
@@ -99,7 +102,12 @@ export default function ConfigModal({ onClose }) {
<>
{/* Directories */}
<section>
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-3">Directories</h3>
<div className="flex items-center justify-between mb-3">
<h3 className="text-slate-300 text-xs uppercase tracking-widest">Directories</h3>
{dirsLocked && (
<span className="text-xs text-slate-500">🔒 Restart server to change directories</span>
)}
</div>
<div className="space-y-3">
{[
{ key: 'target_dir', label: 'Target Tests Directory' },
@@ -108,19 +116,30 @@ export default function ConfigModal({ onClose }) {
<div key={key}>
<label className="text-slate-400 text-xs block mb-1">{label}</label>
<div className="flex gap-2">
<input
type="text"
value={form[key] ?? ''}
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
placeholder="C:\path\to\folder"
className="flex-1 bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
/>
<button
onClick={() => setBrowser(key)}
className="px-3 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors whitespace-nowrap"
>
Browse
</button>
{dirsLocked ? (
<div
title={form[key] ?? ''}
className="flex-1 border border-slate-700/50 bg-slate-800/40 text-slate-500 text-sm rounded-lg px-3 py-2 truncate cursor-default select-all font-mono"
>
{(form[key] ?? '').replace(/^(.+[/\\])([^/\\]+[/\\][^/\\]*)$/, '…$2')}
</div>
) : (
<>
<input
type="text"
value={form[key] ?? ''}
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
placeholder="C:\path\to\folder"
className="flex-1 bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
/>
<button
onClick={() => setBrowser(key)}
className="px-3 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors whitespace-nowrap"
>
Browse
</button>
</>
)}
</div>
</div>
))}
+118 -63
View File
@@ -1,24 +1,14 @@
import { useState } from 'react'
import React, { useState } from 'react'
import StatusBadge from './StatusBadge'
const COLS = [
{ key: 'file_id', label: 'File ID' },
{ key: 'interference', label: 'Type' },
{ key: 'device', label: 'Device' },
{ key: 'rotation', label: 'Rotation' },
{ key: 'test_point', label: 'Test Point' },
{ key: 'rssi', label: 'RSSI' },
{ key: 'station', label: 'Station' },
{ key: 'band', label: 'Band' },
{ key: 'channel', label: 'Channel' },
{ key: 'bandwidth', label: 'BW' },
{ key: 'direction', label: 'Dir' },
{ key: 'completed', label: 'Status' },
{ key: 'duration_seconds', label: 'Duration' },
]
const TYPE_COLORS = {
COE: 'bg-blue-900/50 text-blue-300',
P2P: 'bg-purple-900/50 text-purple-300',
P3P: 'bg-orange-900/50 text-orange-300',
}
function fmtDuration(s) {
if (s == null) return '—'
if (s == null) return null
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = Math.floor(s % 60)
@@ -27,13 +17,46 @@ function fmtDuration(s) {
return `${sec}s`
}
function TagPill({ label, value, colorClass }) {
if (value == null || value === '') return null
return (
<div className="flex flex-col gap-0.5 min-w-[60px]">
<span className="text-[10px] text-slate-500 uppercase tracking-wide leading-none">{label}</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded whitespace-nowrap ${colorClass ?? 'bg-slate-800 text-slate-300'}`}>
{value}
</span>
</div>
)
}
function SortTh({ colKey, label, sort, onSort }) {
return (
<th
onClick={() => onSort(colKey)}
className="px-3 py-3 cursor-pointer select-none whitespace-nowrap hover:text-slate-200 transition-colors"
>
{label}
{sort.key === colKey && <span className="ml-1">{sort.dir === 1 ? '↑' : '↓'}</span>}
</th>
)
}
export default function TestTable({ tests = [], isLoading }) {
const [sort, setSort] = useState({ key: 'file_id', dir: 1 })
const [sort, setSort] = useState({ key: 'filename', dir: 1 })
const [expanded, setExpanded] = useState(new Set())
function toggleSort(key) {
setSort(s => ({ key, dir: s.key === key ? -s.dir : 1 }))
}
function toggleExpand(id) {
setExpanded(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const sorted = [...tests].sort((a, b) => {
let av = a[sort.key] ?? ''
let bv = b[sort.key] ?? ''
@@ -58,57 +81,89 @@ export default function TestTable({ tests = [], isLoading }) {
}
return (
<div className="overflow-x-auto rounded-xl border border-slate-800">
<div className="rounded-xl border border-slate-800 overflow-hidden">
<table className="w-full text-sm text-left text-slate-300 border-collapse">
<thead className="bg-slate-800 text-slate-400 text-xs uppercase tracking-wider">
<tr>
{COLS.map(col => (
<th
key={col.key}
onClick={() => toggleSort(col.key)}
className="px-3 py-3 cursor-pointer select-none whitespace-nowrap hover:text-slate-200 transition-colors"
>
{col.label}
{sort.key === col.key && (
<span className="ml-1">{sort.dir === 1 ? '↑' : '↓'}</span>
)}
</th>
))}
<th className="px-3 py-3 w-6" />
<SortTh colKey="filename" label="File" sort={sort} onSort={toggleSort} />
<SortTh colKey="completed" label="Status" sort={sort} onSort={toggleSort} />
</tr>
</thead>
<tbody>
{sorted.map((test, i) => (
<tr
key={test.id}
className={`border-t border-slate-800 transition-colors ${
test.completed
? 'bg-emerald-950/20 hover:bg-emerald-950/40'
: i % 2 === 0 ? 'bg-slate-900 hover:bg-slate-800' : 'bg-slate-900/60 hover:bg-slate-800'
}`}
>
<td className="px-3 py-2 font-mono text-xs text-slate-200 whitespace-nowrap">{test.file_id ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${
test.interference === 'COE' ? 'bg-blue-900/50 text-blue-300' :
test.interference === 'P2P' ? 'bg-purple-900/50 text-purple-300' :
test.interference === 'P3P' ? 'bg-orange-900/50 text-orange-300' : ''
}`}>
{test.interference ?? '—'}
</span>
</td>
<td className="px-3 py-2 whitespace-nowrap">{test.device ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.rotation ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.test_point ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.rssi ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.station ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.band ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.channel ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.bandwidth ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{test.direction ?? '—'}</td>
<td className="px-3 py-2 whitespace-nowrap"><StatusBadge completed={test.completed} /></td>
<td className="px-3 py-2 whitespace-nowrap text-slate-400">{fmtDuration(test.duration_seconds)}</td>
</tr>
))}
{sorted.map((test, i) => {
const isOpen = expanded.has(test.id)
const rowBase = test.completed
? 'bg-emerald-950/20 hover:bg-emerald-950/40'
: i % 2 === 0 ? 'bg-slate-900 hover:bg-slate-800' : 'bg-slate-900/60 hover:bg-slate-800'
return (
<React.Fragment key={test.id}>
<tr
onClick={() => toggleExpand(test.id)}
className={`border-t border-slate-800 cursor-pointer transition-colors ${rowBase}`}
>
<td className="px-3 py-2 text-slate-500 text-xs select-none">
{isOpen ? '' : '▸'}
</td>
<td className="px-3 py-2 text-xs text-slate-200 whitespace-nowrap">
{test.filename ?? '—'}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<StatusBadge completed={test.completed} />
</td>
</tr>
{isOpen && (
<tr className={`border-t border-slate-700/60 ${test.completed ? 'bg-emerald-950/10' : 'bg-slate-900/80'}`}>
<td colSpan={3} className="px-6 py-4">
<div className="flex flex-wrap gap-3">
<TagPill label="File ID" value={test.test_id} />
<TagPill label="Type" value={test.interference} colorClass={TYPE_COLORS[test.interference]} />
<TagPill label="Device" value={test.device} />
<TagPill label="Rotation" value={test.rotation} />
<TagPill label="Test Point" value={test.test_point} />
<TagPill label="RSSI" value={test.rssi} />
<TagPill label="Station" value={test.station} />
<TagPill label="Band" value={test.band} />
<TagPill label="Channel" value={test.channel} />
<TagPill label="Bandwidth" value={test.bandwidth} />
<TagPill label="Direction" value={test.direction} />
<TagPill label="Elapsed Time" value={fmtDuration(test.duration_seconds)} />
</div>
{(() => {
const rows = test.tput_results ? JSON.parse(test.tput_results) : []
if (!test.completed || rows.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-slate-700/50">
<table className="text-xs w-auto border-collapse">
<thead>
<tr className="text-slate-500 uppercase tracking-wide">
<th className="pr-6 pb-1 text-left font-medium">Station</th>
<th className="pr-6 pb-1 text-right font-medium">Throughput</th>
<th className="pr-6 pb-1 text-right font-medium">DL RSSI</th>
<th className="pb-1 text-right font-medium">UL RSSI</th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.station} className="text-slate-300">
<td className="pr-6 py-0.5 text-cyan-400 font-medium">STA{r.station}</td>
<td className="pr-6 py-0.5 text-right">{r.tput} Mbps</td>
<td className="pr-6 py-0.5 text-right">{r.dlRssi} dBm</td>
<td className="py-0.5 text-right">{r.ulRssi} dBm</td>
</tr>
))}
</tbody>
</table>
</div>
)
})()}
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
+27 -2
View File
@@ -6,6 +6,19 @@ function fmt(seconds) {
return `${m}m`
}
function fmtDays(seconds) {
if (seconds == null) return null
const days = seconds / 57600 // 16 hours per day
return days < 1 ? `${(days * 24).toFixed(1)}h` : `${days.toFixed(1)}d (16h/day)`
}
function fmtCompletionDate(seconds) {
if (seconds == null) return null
const days = seconds / 57600 // 16 hours per day
const date = new Date(Date.now() + days * 24 * 3600 * 1000)
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
}
export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds, byType }) {
const missingTypes = byType
? Object.entries(byType)
@@ -13,9 +26,12 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,
.map(([t]) => t)
: []
const days = fmtDays(estimatedRemainingSeconds)
const completionDate = fmtCompletionDate(estimatedRemainingSeconds)
return (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-3">
<div className="flex gap-6">
<div className="flex flex-wrap gap-6">
<div>
<p className="text-slate-400 text-xs uppercase tracking-widest">Time Elapsed</p>
<p className="text-2xl font-bold text-slate-100 mt-0.5">
@@ -23,11 +39,20 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,
</p>
</div>
<div>
<p className="text-slate-400 text-xs uppercase tracking-widest">Est. Remaining</p>
<p className="text-slate-400 text-xs uppercase tracking-widest">Est. Time Remaining</p>
<p className={`text-2xl font-bold mt-0.5 ${estimatedRemainingSeconds != null ? 'text-slate-100' : 'text-amber-400'}`}>
{estimatedRemainingSeconds != null ? fmt(estimatedRemainingSeconds) : '—'}
</p>
{days && (
<p className="text-slate-400 text-xs mt-0.5">{days}</p>
)}
</div>
{completionDate && (
<div>
<p className="text-slate-400 text-xs uppercase tracking-widest">Est. Completion</p>
<p className="text-lg font-semibold text-slate-100 mt-0.5">{completionDate}</p>
</div>
)}
</div>
{missingTypes.length > 0 && (