diff --git a/dashboard/src/App.jsx b/dashboard/src/App.jsx index c13ad5c..1ce1e2c 100644 --- a/dashboard/src/App.jsx +++ b/dashboard/src/App.jsx @@ -28,7 +28,7 @@ export default function App() { if (t.completed !== want) return false } const strFields = ['interference', 'device', 'rotation', 'test_point', - 'rssi', 'station', 'band', 'channel', 'bandwidth', 'direction'] + 'rssi', 'station', 'band', 'channel', 'bandwidth', 'direction', 'throttled'] for (const f of strFields) { if (filters[f] && t[f] !== filters[f]) return false } @@ -99,6 +99,11 @@ export default function App() { label={d.name} value={`${(d.completionRate * 100).toFixed(1)}%`} sub={`${d.completed} / ${d.total}`} + detailsPosition="right" + details={['COE', 'P2P', 'P3P'].map((type) => { + const typeStats = d.byType?.[type] ?? { completed: 0, total: 0 } + return `${type}: ${typeStats.completed}/${typeStats.total}` + })} /> diff --git a/dashboard/src/components/DirectoryBrowser.jsx b/dashboard/src/components/DirectoryBrowser.jsx index c9a74a8..c2b7da4 100644 --- a/dashboard/src/components/DirectoryBrowser.jsx +++ b/dashboard/src/components/DirectoryBrowser.jsx @@ -7,6 +7,8 @@ export default function DirectoryBrowser({ onSelect, onClose }) { const [dirs, setDirs] = useState(null) // null = not loaded yet const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [manualPath, setManualPath] = useState('') + const [networkHost, setNetworkHost] = useState('') async function navigate(path) { setLoading(true) @@ -16,6 +18,7 @@ export default function DirectoryBrowser({ onSelect, onClose }) { setCurrent(data.path) setParent(data.parent) setDirs(data.dirs) + setManualPath(data.path ?? '') } catch (e) { setError(e.message) } finally { @@ -23,16 +26,40 @@ export default function DirectoryBrowser({ onSelect, onClose }) { } } + function goToManualPath() { + const path = manualPath.trim() + if (!path) { + navigate(null) + return + } + navigate(path) + } + + function goToHost() { + const host = networkHost.trim() + if (!host) return + navigate(`\\\\${host}`) + } + // Load roots on first render if (dirs === null && !loading && !error) { navigate(null) } - const breadcrumbs = current ? current.replace(/\\/g, '/').split('/').filter(Boolean) : [] - const isUnixPath = !!current && current.startsWith('/') + const normalizedCurrent = current ?? '' + const isUnixPath = normalizedCurrent.startsWith('/') + const isUncPath = normalizedCurrent.startsWith('\\\\') + const breadcrumbs = normalizedCurrent + ? (isUncPath + ? normalizedCurrent.slice(2).split(/\\+/).filter(Boolean) + : normalizedCurrent.replace(/\\/g, '/').split('/').filter(Boolean)) + : [] function breadcrumbPathAt(index) { const parts = breadcrumbs.slice(0, index + 1) + if (isUncPath) { + return `\\\\${parts.join('\\')}` + } if (isUnixPath) { return `/${parts.join('/')}` } @@ -50,7 +77,7 @@ export default function DirectoryBrowser({ onSelect, onClose }) { {/* Breadcrumb */}
- + {breadcrumbs.map((part, i) => { const path = breadcrumbPathAt(i) return ( @@ -68,6 +95,40 @@ export default function DirectoryBrowser({ onSelect, onClose }) { })}
+ {/* Jump controls */} +
+
+ setManualPath(e.target.value)} + placeholder="Path (e.g. C:\\data or \\\\192.168.1.10\\share)" + 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" + /> + +
+
+ setNetworkHost(e.target.value)} + placeholder="Network host/IP (e.g. 192.168.1.10)" + 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" + /> + +
+
+ {/* Directory list */}
{loading && ( diff --git a/dashboard/src/components/FilterPanel.jsx b/dashboard/src/components/FilterPanel.jsx index 8b46c82..d2207d1 100644 --- a/dashboard/src/components/FilterPanel.jsx +++ b/dashboard/src/components/FilterPanel.jsx @@ -13,6 +13,7 @@ const DERIVED_FIELDS = [ { key: 'channel', label: 'Channel' }, { key: 'bandwidth', label: 'Bandwidth' }, { key: 'direction', label: 'Direction' }, + { key: 'throttled', label: 'Throttled' }, ] function unique(tests, key) { diff --git a/dashboard/src/components/StatCard.jsx b/dashboard/src/components/StatCard.jsx index e1a4a04..2691fd7 100644 --- a/dashboard/src/components/StatCard.jsx +++ b/dashboard/src/components/StatCard.jsx @@ -1,9 +1,36 @@ -export default function StatCard({ label, value, sub, accent }) { +export default function StatCard({ label, value, sub, accent, details, detailsPosition = 'below' }) { + const showDetails = details?.length > 0 + const detailsOnRight = showDetails && detailsPosition === 'right' + return ( -
-

{label}

-

{value}

- {sub &&

{sub}

} +
+
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ + {detailsOnRight && ( +
+ {details.map((line) => ( +

+ {line} +

+ ))} +
+ )} +
+ + {showDetails && !detailsOnRight && ( +
+ {details.map((line) => ( +

+ {line} +

+ ))} +
+ )}
) } diff --git a/dashboard/src/components/StatusBadge.jsx b/dashboard/src/components/StatusBadge.jsx index 4744623..3a8df79 100644 --- a/dashboard/src/components/StatusBadge.jsx +++ b/dashboard/src/components/StatusBadge.jsx @@ -1,5 +1,5 @@ export default function StatusBadge({ completed }) { return completed - ? ✓ Done + ? ✓ Completed : ○ Pending } diff --git a/dashboard/src/components/TestTable.jsx b/dashboard/src/components/TestTable.jsx index 84aa7de..7bcb263 100644 --- a/dashboard/src/components/TestTable.jsx +++ b/dashboard/src/components/TestTable.jsx @@ -127,6 +127,7 @@ export default function TestTable({ tests = [], isLoading }) { +
diff --git a/dashboard/src/components/TimeDisplay.jsx b/dashboard/src/components/TimeDisplay.jsx index e848cbc..d1397cf 100644 --- a/dashboard/src/components/TimeDisplay.jsx +++ b/dashboard/src/components/TimeDisplay.jsx @@ -6,16 +6,33 @@ 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 estimateDays(seconds) { + const s = Number(seconds) + if (!Number.isFinite(s) || s < 0) return null + + let days = s / 57600 // 16 hours per day + const estDate = new Date(Date.now() + days * 24 * 3600 * 1000) + // Calculate how many weekends + let weekends = 0 + for (let d = new Date(); d < estDate; d.setDate(d.getDate() + 1)) { + if (d.getDay() === 0 || d.getDay() === 6) { + weekends++ + } + } + const weekdays = (s - weekends * 24 * 3600) / 57600 + days = weekdays + weekends + return Number.isFinite(days) ? days : null } -function fmtCompletionDate(seconds) { - if (seconds == null) return null - const days = seconds / 57600 // 16 hours per day +function fmtDaysLabel(days) { + if (days == null || !Number.isFinite(days)) return null + return days < 1 ? `${(days * 24).toFixed(1)}h` : `${days.toFixed(1)}d` +} + +function fmtCompletionDate(days) { + if (days == null || !Number.isFinite(days)) return null const date = new Date(Date.now() + days * 24 * 3600 * 1000) + if (Number.isNaN(date.getTime())) return null return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) } @@ -26,15 +43,16 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds, .map(([t]) => t) : [] - const days = fmtDays(estimatedRemainingSeconds) - const completionDate = fmtCompletionDate(estimatedRemainingSeconds) + const estimatedDays = estimateDays(estimatedRemainingSeconds) + const daysLabel = fmtDaysLabel(estimatedDays) + const completionDate = fmtCompletionDate(estimatedDays) return (

Time Elapsed

-

+

{fmt(elapsedSeconds) ?? '—'}

@@ -43,8 +61,8 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,

{estimatedRemainingSeconds != null ? fmt(estimatedRemainingSeconds) : '—'}

- {days && ( -

{days}

+ {daysLabel && ( +

{daysLabel}

)}
{completionDate && ( diff --git a/server/app.py b/server/app.py index be1fd88..5ad4b50 100644 --- a/server/app.py +++ b/server/app.py @@ -1,4 +1,5 @@ import os +import subprocess from pathlib import Path from flask import Flask, Response, jsonify, request, send_from_directory @@ -29,6 +30,7 @@ CORS(app) def get_tests_route(): completed = request.args.get("completed") interference = request.args.get("interference") + throttled = request.args.get("throttled") device = request.args.get("device") rotation = request.args.get("rotation") test_point = request.args.get("testPoint") @@ -46,6 +48,8 @@ def get_tests_route(): tests = [t for t in tests if t.get("completed") == completed_value] if interference: tests = [t for t in tests if t.get("interference") == interference] + if throttled: + tests = [t for t in tests if t.get("throttled") == throttled] if device: tests = [t for t in tests if t.get("device") == device] if rotation: @@ -72,6 +76,7 @@ def get_tests_route(): @app.get("/api/stats") def get_stats_route(): tests = get_all_tests() + types = ["COE", "P2P", "P3P"] total_completed = sum(1 for t in tests if t.get("completed")) overall = { @@ -87,10 +92,21 @@ def get_stats_route(): continue if name not in device_map: - device_map[name] = {"total": 0, "completed": 0} + device_map[name] = { + "total": 0, + "completed": 0, + "byType": {t: {"total": 0, "completed": 0} for t in types}, + } device_map[name]["total"] += 1 + + interference = test.get("interference") + if interference in device_map[name]["byType"]: + device_map[name]["byType"][interference]["total"] += 1 + if test.get("completed"): device_map[name]["completed"] += 1 + if interference in device_map[name]["byType"]: + device_map[name]["byType"][interference]["completed"] += 1 devices = [] for name in sorted(device_map.keys()): @@ -101,6 +117,7 @@ def get_stats_route(): "total": stats["total"], "completed": stats["completed"], "completionRate": (stats["completed"] / stats["total"]) if stats["total"] else 0, + "byType": stats["byType"], } ) @@ -110,7 +127,6 @@ def get_stats_route(): if test.get("completed") and duration is not None: elapsed_seconds += duration - types = ["COE", "P2P", "P3P"] by_type = {} estimate_possible = True estimated_remaining_seconds = 0 @@ -234,6 +250,56 @@ def rescan_route(): @app.get("/api/browse") def browse_route(): + def _normalize_request_path(path): + if not path: + return path + p = path.strip() + if os.name == "nt": + p = p.replace("/", "\\") + return p + + def _is_unc_host_only(path): + if os.name != "nt" or not path: + return False + p = path.rstrip("\\") + if not p.startswith("\\\\"): + return False + remainder = p[2:] + return bool(remainder) and "\\" not in remainder + + def _shares_for_unc_host(host): + # Enumerate SMB shares with native Windows tooling for host-only UNC paths. + proc = subprocess.run( + ["net", "view", f"\\\\{host}"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "Cannot query network host") + + shares = [] + in_table = False + for raw_line in proc.stdout.splitlines(): + line = raw_line.strip() + if not line: + continue + if line.startswith("---"): + in_table = True + continue + if not in_table: + continue + if line.lower().startswith("the command completed successfully"): + break + + first = line.split()[0] + if first and first.lower() not in {"share", "name"}: + shares.append(first) + + unique = sorted(set(shares), key=str.lower) + return [{"name": share, "path": f"\\\\{host}\\{share}"} for share in unique] + def _configured_roots(): raw = os.getenv("BROWSE_ROOTS", "").strip() if not raw: @@ -260,8 +326,19 @@ def browse_route(): continue return False + def _is_unc_host_allowed(host, roots): + if not roots: + return True + host_prefix = os.path.normcase(f"\\\\{host}\\") + host_exact = host_prefix.rstrip("\\") + for root in roots: + normalized_root = os.path.normcase(root) + if normalized_root == host_exact or normalized_root.startswith(host_prefix): + return True + return False + roots = _configured_roots() - req_path = request.args.get("path") + req_path = _normalize_request_path(request.args.get("path")) if not req_path: if roots: @@ -282,6 +359,16 @@ def browse_route(): return jsonify({"path": None, "parent": None, "dirs": dirs}) + if _is_unc_host_only(req_path): + host = req_path.rstrip("\\")[2:] + if not _is_unc_host_allowed(host, roots): + return jsonify({"error": "Path is outside allowed browse roots"}), 403 + try: + dirs = _shares_for_unc_host(host) + except Exception as exc: + return jsonify({"error": f"Cannot list shares for host: {exc}"}), 403 + return jsonify({"path": f"\\\\{host}", "parent": None, "dirs": dirs}) + if not os.path.exists(req_path): return jsonify({"error": "Path does not exist"}), 400 if not os.path.isdir(req_path): diff --git a/server/dashboard.db b/server/dashboard.db index f937ecf..ed90e1c 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 46874e5..901b3b3 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 b9000fd..cea5806 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 9f6dbba..ae8a927 100644 --- a/server/db_py.py +++ b/server/db_py.py @@ -64,6 +64,7 @@ def _init_db(): "dl_rssi_dbm REAL", "ul_rssi_dbm REAL", "tput_results TEXT", + "throttled TEXT", ]: try: _conn.execute(f"ALTER TABLE tests ADD COLUMN {col_def}") @@ -96,10 +97,10 @@ def upsert_test(test): """ INSERT INTO tests (id, test_id, parent_dir, filename, interference, device, rotation, - test_point, station, band, channel, bandwidth, rssi, direction) + test_point, station, band, channel, bandwidth, rssi, direction, throttled) VALUES (:id, :test_id, :parent_dir, :filename, :interference, :device, :rotation, - :test_point, :station, :band, :channel, :bandwidth, :rssi, :direction) + :test_point, :station, :band, :channel, :bandwidth, :rssi, :direction, :throttled) ON CONFLICT(id) DO UPDATE SET test_id = excluded.test_id, parent_dir = excluded.parent_dir, @@ -113,7 +114,8 @@ def upsert_test(test): channel = excluded.channel, bandwidth = excluded.bandwidth, rssi = excluded.rssi, - direction = excluded.direction + direction = excluded.direction, + throttled = excluded.throttled """, test, ) diff --git a/server/parser.py b/server/parser.py index f243938..76a8b36 100644 --- a/server/parser.py +++ b/server/parser.py @@ -11,6 +11,11 @@ def parse_target_filename(filename, parent_dir): test_identifier = f"{parent_dir}/{base_name}" interference = segments[0] if segments and segments[0] in INTERFERENCE_TYPES else None test_id = next((s for s in segments if re.match(r"^R\d+[A-Z0-9]+$", s, re.IGNORECASE)), None) + throttled = None + if interference == "P3P" and test_id: + # P3P test_id carries throttle marker: TH = throttled, otherwise UT. + throttled = "TH" if "TH" in test_id.upper() else "UT" + device = segments[1] if len(segments) > 1 else None test_point = next((s for s in segments if re.match(r"^TPT\w+$", s)), None) rssi = next((s for s in segments if re.match(r"^RSSI\d+$", s)), None) @@ -39,6 +44,7 @@ def parse_target_filename(filename, parent_dir): "bandwidth": bandwidth, "direction": direction, "rotation": rotation, + "throttled": throttled, } diff --git a/server/scanner.py b/server/scanner.py index 8cd16de..fe5a6b9 100644 --- a/server/scanner.py +++ b/server/scanner.py @@ -77,6 +77,7 @@ def scan_targets(target_dir): "bandwidth": parsed["bandwidth"], "rssi": parsed["rssi"], "direction": parsed["direction"], + "throttled": parsed["throttled"], } )