diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index 1ce1e2c..c0aa07b 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -1,5 +1,4 @@ import { useState, useMemo } from 'react' -import { useQueryClient } from '@tanstack/react-query' import { Settings } from 'lucide-react' import { useStats } from './hooks/useStats' import { useTests } from './hooks/useTests' @@ -11,8 +10,19 @@ import FilterPanel from './components/FilterPanel' import TestTable from './components/TestTable' import ConfigModal from './components/ConfigModal' +function hasCoePairs(value) { + if (!value) return false + if (Array.isArray(value)) return value.length > 0 + if (typeof value !== 'string') return false + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) && parsed.length > 0 + } catch { + return false + } +} + export default function App() { - const queryClient = useQueryClient() const [showConfig, setShowConfig] = useState(false) const [filters, setFilters] = useState({}) @@ -32,6 +42,11 @@ export default function App() { for (const f of strFields) { if (filters[f] && t[f] !== filters[f]) return false } + if (filters.coePair === 'yes' || filters.coePair === 'no') { + const paired = hasCoePairs(t.coe_pair) + if (filters.coePair === 'yes' && !paired) return false + if (filters.coePair === 'no' && paired) return false + } return true }) }, [allTests, filters]) @@ -125,7 +140,7 @@ export default function App() {

Showing {filteredTests.length} of {allTests.length} tests

- + diff --git a/dashboard/src/components/FilterPanel.jsx b/dashboard/src/components/FilterPanel.jsx index d2207d1..597f4f4 100644 --- a/dashboard/src/components/FilterPanel.jsx +++ b/dashboard/src/components/FilterPanel.jsx @@ -1,6 +1,7 @@ const FILTER_FIELDS = [ { key: 'completed', label: 'Status', options: [{ value: '', label: 'All' }, { value: 'true', label: 'Completed' }, { value: 'false', label: 'Pending' }] }, { key: 'interference', label: 'Type', options: [{ value: '', label: 'All' }, { value: 'COE', label: 'COE' }, { value: 'P2P', label: 'P2P' }, { value: 'P3P', label: 'P3P' }] }, + { key: 'coePair', label: 'COE Pairs', options: [{ value: '', label: 'All' }, { value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }] }, ] const DERIVED_FIELDS = [ diff --git a/dashboard/src/components/TestTable.jsx b/dashboard/src/components/TestTable.jsx index a192b7f..561e4c1 100644 --- a/dashboard/src/components/TestTable.jsx +++ b/dashboard/src/components/TestTable.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useMemo, useState } from 'react' import StatusBadge from './StatusBadge' const TYPE_COLORS = { @@ -7,6 +7,35 @@ const TYPE_COLORS = { P3P: 'bg-orange-900/50 text-orange-300', } +function parsePairArray(raw) { + if (!raw) return [] + if (Array.isArray(raw)) return raw + if (typeof raw !== 'string') return [] + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function parseTputRows(raw) { + if (!raw) return [] + if (Array.isArray(raw)) return raw + if (typeof raw !== 'string') return [] + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function testLookupKey(test) { + if (!test?.device || !test?.test_id) return null + return `${test.device}_${test.test_id}` +} + function fmtDuration(s) { if (s == null) return null const h = Math.floor(s / 3600) @@ -41,9 +70,21 @@ function SortTh({ colKey, label, sort, onSort }) { ) } -export default function TestTable({ tests = [], isLoading }) { +export default function TestTable({ tests = [], allTests = [], isLoading }) { const [sort, setSort] = useState({ key: 'filename', dir: 1 }) const [expanded, setExpanded] = useState(new Set()) + const [pairedExpanded, setPairedExpanded] = useState(new Set()) + + const sourceTests = allTests.length > 0 ? allTests : tests + + const testsByLookupKey = useMemo(() => { + const map = new Map() + for (const t of sourceTests) { + const key = testLookupKey(t) + if (key) map.set(key, t) + } + return map + }, [sourceTests]) function toggleSort(key) { setSort(s => ({ key, dir: s.key === key ? -s.dir : 1 })) @@ -57,6 +98,15 @@ export default function TestTable({ tests = [], isLoading }) { }) } + function togglePairedExpand(parentId, pairKey) { + const key = `${parentId}::${pairKey}` + setPairedExpanded(prev => { + const next = new Set(prev) + next.has(key) ? next.delete(key) : next.add(key) + return next + }) + } + const sorted = [...tests].sort((a, b) => { let av = a[sort.key] ?? '' let bv = b[sort.key] ?? '' @@ -142,6 +192,10 @@ export default function TestTable({ tests = [], isLoading }) {
+ { + const pairs = parsePairArray(test.coe_pair) + return pairs.length > 0 ? String(pairs.length) : null + })()} colorClass="bg-cyan-900/50 text-cyan-300" /> @@ -157,7 +211,95 @@ export default function TestTable({ tests = [], isLoading }) {
{(() => { - const rows = test.tput_results ? JSON.parse(test.tput_results) : [] + const pairs = parsePairArray(test.coe_pair) + if (pairs.length === 0) return null + return ( +
+

Paired COE Tests

+
+ {pairs.map(pairKey => { + const pairTest = testsByLookupKey.get(pairKey) + const itemKey = `${test.id}-${pairKey}` + const open = pairedExpanded.has(`${test.id}::${pairKey}`) + + if (!pairTest) { + return ( +
+ {pairKey} +
+ ) + } + + return ( +
+ + + {open && ( +
+
+ + + + + + + + + + + + + +
+ {(() => { + const rows = parseTputRows(pairTest.tput_results) + if (!pairTest.completed || rows.length === 0) return null + return ( +
+ + + + + + + + + + + {rows.map(r => ( + + + + + + + ))} + +
StationThroughputDL RSSIUL RSSI
STA{r.station}{r.tput} Mbps{r.dlRssi} dBm{r.ulRssi} dBm
+
+ ) + })()} +
+ )} +
+ ) + })} +
+
+ ) + })()} + {(() => { + const rows = parseTputRows(test.tput_results) if (!test.completed || rows.length === 0) return null return (
diff --git a/server/dashboard.db b/server/dashboard.db index ed90e1c..f95366c 100644 Binary files a/server/dashboard.db and b/server/dashboard.db differ diff --git a/server/dashboard.db-shm b/server/dashboard.db-shm index db8bdf8..59c5e99 100644 Binary files a/server/dashboard.db-shm and b/server/dashboard.db-shm differ diff --git a/server/dashboard.db-wal b/server/dashboard.db-wal index cea5806..c442712 100644 Binary files a/server/dashboard.db-wal and b/server/dashboard.db-wal differ diff --git a/server/db_py.py b/server/db_py.py index ae8a927..bd3d7b5 100644 --- a/server/db_py.py +++ b/server/db_py.py @@ -65,6 +65,7 @@ def _init_db(): "ul_rssi_dbm REAL", "tput_results TEXT", "throttled TEXT", + "coe_pair TEXT", ]: try: _conn.execute(f"ALTER TABLE tests ADD COLUMN {col_def}") @@ -138,6 +139,19 @@ def mark_completed(test_id, device, completed_at, duration_seconds, tput_results ) +def set_coe_pair(test_row_id, coe_pair): + json_value = json.dumps(coe_pair or []) + with _tx(): + _conn.execute( + """ + UPDATE tests + SET coe_pair = ? + WHERE id = ? + """, + (json_value, test_row_id), + ) + + def get_station_for_test(test_id, device): with _lock: row = _conn.execute( diff --git a/server/scanner.py b/server/scanner.py index fe5a6b9..76304ba 100644 --- a/server/scanner.py +++ b/server/scanner.py @@ -1,7 +1,7 @@ import os import re -from db_py import clear_tests, mark_completed, upsert_test +from db_py import clear_tests, get_all_tests, mark_completed, set_coe_pair, upsert_test from parser import ( parse_elapsed_time, parse_result_filename, @@ -21,6 +21,48 @@ def full_scan(target_dir, results_dir): scan_targets(target_dir) scan_results(results_dir) + update_p2p_coe_pairs() + + +def update_p2p_coe_pairs(): + pair_fields = [ + "device", + "rotation", + "test_point", + "rssi", + "station", + "band", + "channel", + "bandwidth", + "direction", + ] + + tests = get_all_tests() + coe_by_key = {} + + for test in tests: + if test.get("interference") != "COE": + continue + + key = tuple(test.get(field) for field in pair_fields) + device = test.get("device") + test_id = test.get("test_id") + if not device or not test_id: + continue + + coe_by_key.setdefault(key, []).append(f"{device}_{test_id}") + + updated = 0 + for test in tests: + if test.get("interference") != "P2P": + continue + + key = tuple(test.get(field) for field in pair_fields) + pairs = sorted(set(coe_by_key.get(key, []))) + set_coe_pair(test.get("id"), pairs) + updated += 1 + + print(f"[scanner] coe_pair updated for {updated} P2P test(s)") def scan_targets(target_dir):