UI changes
This commit is contained in:
@@ -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}`
|
||||
})}
|
||||
/>
|
||||
<CompletionBar value={d.completionRate} />
|
||||
</div>
|
||||
|
||||
@@ -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 */}
|
||||
<div className="px-4 py-2 border-b border-slate-800 flex items-center gap-1 text-xs text-slate-400 flex-wrap min-h-[36px]">
|
||||
<button onClick={() => navigate(null)} className="hover:text-slate-200">Drives</button>
|
||||
<button onClick={() => navigate(null)} className="hover:text-slate-200">Roots</button>
|
||||
{breadcrumbs.map((part, i) => {
|
||||
const path = breadcrumbPathAt(i)
|
||||
return (
|
||||
@@ -68,6 +95,40 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Jump controls */}
|
||||
<div className="px-4 py-3 border-b border-slate-800 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={manualPath}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={goToManualPath}
|
||||
className="px-3 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={networkHost}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={goToHost}
|
||||
className="px-3 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Open Host
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory list */}
|
||||
<div className="overflow-y-auto max-h-64 divide-y divide-slate-800">
|
||||
{loading && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-1 min-w-0">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 min-w-0">
|
||||
<div className={`flex ${detailsOnRight ? 'items-start justify-between gap-4' : 'flex-col gap-1'}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-slate-400 text-xs uppercase tracking-widest truncate">{label}</p>
|
||||
<p className={`text-3xl font-bold ${accent ?? 'text-slate-100'}`}>{value}</p>
|
||||
{sub && <p className="text-slate-400 text-sm">{sub}</p>}
|
||||
</div>
|
||||
|
||||
{detailsOnRight && (
|
||||
<div className="space-y-0.5 text-right shrink-0">
|
||||
{details.map((line) => (
|
||||
<p key={line} className="text-slate-500 text-xs font-medium">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDetails && !detailsOnRight && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{details.map((line) => (
|
||||
<p key={line} className="text-slate-500 text-xs">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default function StatusBadge({ completed }) {
|
||||
return completed
|
||||
? <span className="inline-flex items-center gap-1 text-emerald-400 text-sm font-medium">✓ Done</span>
|
||||
? <span className="inline-flex items-center gap-1 text-emerald-400 text-sm font-medium">✓ Completed</span>
|
||||
: <span className="inline-flex items-center gap-1 text-slate-500 text-sm">○ Pending</span>
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ export default function TestTable({ tests = [], isLoading }) {
|
||||
<TagPill label="Band" value={test.band} />
|
||||
<TagPill label="Channel" value={test.channel} />
|
||||
<TagPill label="Bandwidth" value={test.bandwidth} />
|
||||
<TagPill label="Throttle" value={test.throttled} />
|
||||
<TagPill label="Direction" value={test.direction} />
|
||||
<TagPill label="Elapsed Time" value={fmtDuration(test.duration_seconds)} />
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-3">
|
||||
<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">
|
||||
<p className="text-2xl font-bold text-emerald-400 mt-0.5">
|
||||
{fmt(elapsedSeconds) ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -43,8 +61,8 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,
|
||||
<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>
|
||||
{daysLabel && (
|
||||
<p className="text-slate-400 text-xs mt-0.5">{daysLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
{completionDate && (
|
||||
|
||||
+90
-3
@@ -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):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
-3
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ def scan_targets(target_dir):
|
||||
"bandwidth": parsed["bandwidth"],
|
||||
"rssi": parsed["rssi"],
|
||||
"direction": parsed["direction"],
|
||||
"throttled": parsed["throttled"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user