2026-05-20 11:52:18 -04:00
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
|
import { useConfig, useSaveConfig } from '../hooks/useConfig'
|
2026-05-27 15:02:32 -04:00
|
|
|
import { apiFetch } from '../lib/api'
|
2026-05-20 11:52:18 -04:00
|
|
|
|
|
|
|
|
function fmtSeconds(s) {
|
|
|
|
|
if (s == null) return ''
|
|
|
|
|
const m = Math.round(parseFloat(s) / 60)
|
|
|
|
|
return String(m)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ConfigModal({ onClose }) {
|
|
|
|
|
const { data: config, isLoading } = useConfig()
|
|
|
|
|
const { mutate: save, isPending } = useSaveConfig()
|
|
|
|
|
|
|
|
|
|
const [form, setForm] = useState({})
|
|
|
|
|
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
|
|
|
|
|
const [saveError, setSaveError] = useState(null)
|
2026-05-27 15:02:32 -04:00
|
|
|
const [isRescanning, setIsRescanning] = useState(false)
|
|
|
|
|
const [isSavingTimes, setIsSavingTimes] = useState(false)
|
2026-05-20 11:52:18 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (config) {
|
|
|
|
|
setForm({
|
2026-05-27 15:02:32 -04:00
|
|
|
target_dir: config.target_dir ?? '',
|
|
|
|
|
results_dir: config.results_dir ?? '',
|
|
|
|
|
results_dir_ref: config.results_dir_ref ?? '',
|
2026-05-20 11:52:18 -04:00
|
|
|
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) : '',
|
2026-05-27 15:02:32 -04:00
|
|
|
smb_username: config.smb_username ?? '',
|
|
|
|
|
smb_password: config.smb_password ?? '',
|
|
|
|
|
smb_domain: config.smb_domain ?? '',
|
2026-05-20 11:52:18 -04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}, [config])
|
|
|
|
|
|
|
|
|
|
function handleSave() {
|
|
|
|
|
setSaveError(null)
|
|
|
|
|
setScanResult(null)
|
|
|
|
|
const payload = {
|
2026-05-27 15:02:32 -04:00
|
|
|
target_dir: form.target_dir || null,
|
|
|
|
|
results_dir: form.results_dir || null,
|
|
|
|
|
results_dir_ref: form.results_dir_ref || null,
|
2026-05-20 11:52:18 -04:00
|
|
|
// 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,
|
2026-05-27 15:02:32 -04:00
|
|
|
smb_username: form.smb_username || null,
|
|
|
|
|
smb_password: form.smb_password || null,
|
|
|
|
|
smb_domain: form.smb_domain || null,
|
2026-05-20 11:52:18 -04:00
|
|
|
}
|
|
|
|
|
save(payload, {
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
if (data?.testCount !== null && data?.testCount !== undefined) {
|
|
|
|
|
setScanResult({ testCount: data.testCount, completedCount: data.completedCount })
|
|
|
|
|
// Auto-close after showing result only if tests were found
|
|
|
|
|
if (data.testCount > 0) setTimeout(onClose, 1500)
|
|
|
|
|
} else {
|
|
|
|
|
onClose()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => {
|
|
|
|
|
setSaveError(err?.message ?? 'Failed to save settings')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 15:02:32 -04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 11:52:18 -04:00
|
|
|
const AVG_FIELDS = [
|
|
|
|
|
{ key: 'avg_time_coe', label: 'COE avg time (min)' },
|
|
|
|
|
{ key: 'avg_time_p2p', label: 'P2P avg time (min)' },
|
|
|
|
|
{ key: 'avg_time_p3p', label: 'P3P avg time (min)' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
|
|
|
|
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-lg mx-4 shadow-2xl">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
|
|
|
|
|
<h2 className="text-slate-100 font-semibold">Settings</h2>
|
|
|
|
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-200">✕</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="px-5 py-4 space-y-6">
|
|
|
|
|
{/* Error banner */}
|
|
|
|
|
{saveError && (
|
|
|
|
|
<div className="bg-red-950/50 border border-red-700 rounded-lg px-4 py-3 text-red-300 text-sm">
|
|
|
|
|
{saveError}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Scan result banner */}
|
|
|
|
|
{scanResult !== null && (
|
|
|
|
|
<div className={`rounded-lg px-4 py-3 text-sm border ${
|
|
|
|
|
scanResult.testCount === 0
|
|
|
|
|
? 'bg-amber-950/50 border-amber-700 text-amber-300'
|
|
|
|
|
: 'bg-emerald-950/50 border-emerald-700 text-emerald-300'
|
|
|
|
|
}`}>
|
|
|
|
|
{scanResult.testCount === 0
|
|
|
|
|
? 'No tests found — check that the target directory contains TC_WIFI_*.ini files in subdirectories.'
|
|
|
|
|
: `Found ${scanResult.testCount} tests (${scanResult.completedCount} completed).`}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<p className="text-slate-500 text-sm">Loading…</p>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* Directories */}
|
|
|
|
|
<section>
|
2026-05-21 14:53:08 -04:00
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
<h3 className="text-slate-300 text-xs uppercase tracking-widest">Directories</h3>
|
|
|
|
|
</div>
|
2026-05-20 11:52:18 -04:00
|
|
|
<div className="space-y-3">
|
|
|
|
|
{[
|
2026-05-27 15:02:32 -04:00
|
|
|
{ key: 'target_dir', label: 'Target Tests Directory' },
|
|
|
|
|
{ key: 'results_dir', label: 'Results Directory (DUT)' },
|
|
|
|
|
{ key: 'results_dir_ref', label: 'Results Directory (Reference)' },
|
2026-05-20 11:52:18 -04:00
|
|
|
].map(({ key, label }) => (
|
|
|
|
|
<div key={key}>
|
|
|
|
|
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
2026-05-28 11:01:47 -04:00
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={form[key] ?? ''}
|
|
|
|
|
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
|
|
|
|
placeholder="C:\path\to\folder"
|
|
|
|
|
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"
|
|
|
|
|
/>
|
2026-05-20 11:52:18 -04:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* Avg Time Overrides */}
|
|
|
|
|
<section>
|
|
|
|
|
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-1">Avg Time Overrides</h3>
|
|
|
|
|
<p className="text-slate-500 text-xs mb-3">
|
|
|
|
|
Manually set avg minutes per test type. Leave blank to use calculated average from completed tests.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
|
|
|
{AVG_FIELDS.map(({ key, label }) => (
|
|
|
|
|
<div key={key}>
|
|
|
|
|
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
step="1"
|
|
|
|
|
value={form[key] ?? ''}
|
|
|
|
|
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
|
|
|
|
placeholder="auto"
|
|
|
|
|
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>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-05-27 15:02:32 -04:00
|
|
|
|
|
|
|
|
{/* SMB Credentials */}
|
|
|
|
|
<section>
|
2026-05-28 11:01:47 -04:00
|
|
|
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-1">Credentials for network shares</h3>
|
2026-05-27 15:02:32 -04:00
|
|
|
<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>
|
2026-05-20 11:52:18 -04:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Footer */}
|
2026-05-27 15:02:32 -04:00
|
|
|
<div className="flex justify-between gap-2 px-5 py-4 border-t border-slate-800">
|
2026-05-20 11:52:18 -04:00
|
|
|
<button
|
2026-05-27 15:02:32 -04:00
|
|
|
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"
|
2026-05-20 11:52:18 -04:00
|
|
|
>
|
2026-05-27 15:02:32 -04:00
|
|
|
{isRescanning ? 'Rescanning…' : 'Save Results'}
|
2026-05-20 11:52:18 -04:00
|
|
|
</button>
|
2026-05-27 15:02:32 -04:00
|
|
|
<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>
|
2026-05-20 11:52:18 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
}
|