fix: allow network paths
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import DirectoryBrowser from './DirectoryBrowser'
|
||||
import { useConfig, useSaveConfig } from '../hooks/useConfig'
|
||||
import { apiFetch } from '../lib/api'
|
||||
|
||||
function fmtSeconds(s) {
|
||||
if (s == null) return ''
|
||||
@@ -16,15 +17,21 @@ export default function ConfigModal({ onClose }) {
|
||||
const [browser, setBrowser] = useState(null) // 'target_dir' | 'results_dir' | null
|
||||
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
|
||||
const [saveError, setSaveError] = useState(null)
|
||||
const [isRescanning, setIsRescanning] = useState(false)
|
||||
const [isSavingTimes, setIsSavingTimes] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setForm({
|
||||
target_dir: config.target_dir ?? '',
|
||||
results_dir: config.results_dir ?? '',
|
||||
target_dir: config.target_dir ?? '',
|
||||
results_dir: config.results_dir ?? '',
|
||||
results_dir_ref: config.results_dir_ref ?? '',
|
||||
avg_time_coe: config.avg_time_coe ? fmtSeconds(config.avg_time_coe) : '',
|
||||
avg_time_p2p: config.avg_time_p2p ? fmtSeconds(config.avg_time_p2p) : '',
|
||||
avg_time_p3p: config.avg_time_p3p ? fmtSeconds(config.avg_time_p3p) : '',
|
||||
smb_username: config.smb_username ?? '',
|
||||
smb_password: config.smb_password ?? '',
|
||||
smb_domain: config.smb_domain ?? '',
|
||||
})
|
||||
}
|
||||
}, [config])
|
||||
@@ -33,12 +40,16 @@ export default function ConfigModal({ onClose }) {
|
||||
setSaveError(null)
|
||||
setScanResult(null)
|
||||
const payload = {
|
||||
target_dir: form.target_dir || null,
|
||||
results_dir: form.results_dir || null,
|
||||
target_dir: form.target_dir || null,
|
||||
results_dir: form.results_dir || null,
|
||||
results_dir_ref: form.results_dir_ref || null,
|
||||
// Convert minutes → seconds; empty/null clears the override
|
||||
avg_time_coe: form.avg_time_coe ? String(parseFloat(form.avg_time_coe) * 60) : null,
|
||||
avg_time_p2p: form.avg_time_p2p ? String(parseFloat(form.avg_time_p2p) * 60) : null,
|
||||
avg_time_p3p: form.avg_time_p3p ? String(parseFloat(form.avg_time_p3p) * 60) : null,
|
||||
smb_username: form.smb_username || null,
|
||||
smb_password: form.smb_password || null,
|
||||
smb_domain: form.smb_domain || null,
|
||||
}
|
||||
save(payload, {
|
||||
onSuccess: (data) => {
|
||||
@@ -56,6 +67,43 @@ export default function ConfigModal({ onClose }) {
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveTimes() {
|
||||
setSaveError(null)
|
||||
setScanResult(null)
|
||||
setIsSavingTimes(true)
|
||||
try {
|
||||
await apiFetch('/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
avg_time_coe: form.avg_time_coe ? String(parseFloat(form.avg_time_coe) * 60) : null,
|
||||
avg_time_p2p: form.avg_time_p2p ? String(parseFloat(form.avg_time_p2p) * 60) : null,
|
||||
avg_time_p3p: form.avg_time_p3p ? String(parseFloat(form.avg_time_p3p) * 60) : null,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
setSaveError(err?.message ?? 'Failed to save')
|
||||
} finally {
|
||||
setIsSavingTimes(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRescanResults() {
|
||||
setSaveError(null)
|
||||
setScanResult(null)
|
||||
setIsRescanning(true)
|
||||
try {
|
||||
const data = await apiFetch('/config/rescan-results', { method: 'POST' })
|
||||
if (data?.testCount !== null && data?.testCount !== undefined) {
|
||||
setScanResult({ testCount: data.testCount, completedCount: data.completedCount })
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveError(err?.message ?? 'Rescan failed')
|
||||
} finally {
|
||||
setIsRescanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const AVG_FIELDS = [
|
||||
{ key: 'avg_time_coe', label: 'COE avg time (min)' },
|
||||
{ key: 'avg_time_p2p', label: 'P2P avg time (min)' },
|
||||
@@ -104,8 +152,9 @@ export default function ConfigModal({ onClose }) {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'target_dir', label: 'Target Tests Directory' },
|
||||
{ key: 'results_dir', label: 'Results Directory' },
|
||||
{ key: 'target_dir', label: 'Target Tests Directory' },
|
||||
{ key: 'results_dir', label: 'Results Directory (DUT)' },
|
||||
{ key: 'results_dir_ref', label: 'Results Directory (Reference)' },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
||||
@@ -152,25 +201,72 @@ export default function ConfigModal({ onClose }) {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SMB Credentials */}
|
||||
<section>
|
||||
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-1">SMB Credentials</h3>
|
||||
<p className="text-slate-500 text-xs mb-3">
|
||||
Optional credentials for network shares used by target/results directories.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={form.smb_domain ?? ''}
|
||||
onChange={e => setForm(f => ({ ...f, smb_domain: e.target.value }))}
|
||||
placeholder="Domain"
|
||||
className="w-full 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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.smb_username ?? ''}
|
||||
onChange={e => setForm(f => ({ ...f, smb_username: e.target.value }))}
|
||||
placeholder="Username"
|
||||
className="w-full 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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={form.smb_password ?? ''}
|
||||
onChange={e => setForm(f => ({ ...f, smb_password: e.target.value }))}
|
||||
placeholder="Password"
|
||||
className="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-5 py-4 border-t border-slate-800">
|
||||
<div className="flex justify-between gap-2 px-5 py-4 border-t border-slate-800">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
onClick={handleRescanResults}
|
||||
disabled={isRescanning || isPending || isSavingTimes}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isPending ? 'Saving…' : 'Save & Rescan'}
|
||||
{isRescanning ? 'Rescanning…' : 'Save Results'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveTimes}
|
||||
disabled={isSavingTimes || isPending || isRescanning}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSavingTimes ? 'Saving…' : 'Save Times'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isPending || isRescanning || isSavingTimes}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isPending ? 'Saving…' : 'Save All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [manualPath, setManualPath] = useState('')
|
||||
const [networkHost, setNetworkHost] = useState('')
|
||||
|
||||
async function navigate(path) {
|
||||
setLoading(true)
|
||||
@@ -35,31 +34,18 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
|
||||
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 normalizedCurrent = current ?? ''
|
||||
const normalizedPath = normalizedCurrent.replace(/\\/g, '/')
|
||||
const isUnixPath = normalizedCurrent.startsWith('/')
|
||||
const isUncPath = normalizedCurrent.startsWith('\\\\')
|
||||
const breadcrumbs = normalizedCurrent
|
||||
? (isUncPath
|
||||
? normalizedCurrent.slice(2).split(/\\+/).filter(Boolean)
|
||||
: normalizedCurrent.replace(/\\/g, '/').split('/').filter(Boolean))
|
||||
: []
|
||||
const breadcrumbs = normalizedPath ? normalizedPath.split('/').filter(Boolean) : []
|
||||
|
||||
function breadcrumbPathAt(index) {
|
||||
const parts = breadcrumbs.slice(0, index + 1)
|
||||
if (isUncPath) {
|
||||
return `\\\\${parts.join('\\')}`
|
||||
}
|
||||
if (isUnixPath) {
|
||||
return `/${parts.join('/')}`
|
||||
}
|
||||
@@ -95,40 +81,6 @@ 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 && (
|
||||
|
||||
@@ -210,6 +210,34 @@ export default function TestTable({ tests = [], allTests = [], isLoading }) {
|
||||
<TagPill label="Direction" value={test.direction} />
|
||||
<TagPill label="Elapsed Time" value={fmtDuration(test.duration_seconds)} />
|
||||
</div>
|
||||
{(() => {
|
||||
const rows = parseTputRows(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>
|
||||
)
|
||||
})()}
|
||||
{(() => {
|
||||
const pairs = parsePairArray(test.coe_pair)
|
||||
if (pairs.length === 0) return null
|
||||
@@ -298,34 +326,6 @@ export default function TestTable({ tests = [], allTests = [], isLoading }) {
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{(() => {
|
||||
const rows = parseTputRows(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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user