fix: allow network paths
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import DirectoryBrowser from './DirectoryBrowser'
|
import DirectoryBrowser from './DirectoryBrowser'
|
||||||
import { useConfig, useSaveConfig } from '../hooks/useConfig'
|
import { useConfig, useSaveConfig } from '../hooks/useConfig'
|
||||||
|
import { apiFetch } from '../lib/api'
|
||||||
|
|
||||||
function fmtSeconds(s) {
|
function fmtSeconds(s) {
|
||||||
if (s == null) return ''
|
if (s == null) return ''
|
||||||
@@ -16,15 +17,21 @@ export default function ConfigModal({ onClose }) {
|
|||||||
const [browser, setBrowser] = useState(null) // 'target_dir' | 'results_dir' | null
|
const [browser, setBrowser] = useState(null) // 'target_dir' | 'results_dir' | null
|
||||||
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
|
const [scanResult, setScanResult] = useState(null) // { testCount, completedCount } | null
|
||||||
const [saveError, setSaveError] = useState(null)
|
const [saveError, setSaveError] = useState(null)
|
||||||
|
const [isRescanning, setIsRescanning] = useState(false)
|
||||||
|
const [isSavingTimes, setIsSavingTimes] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setForm({
|
setForm({
|
||||||
target_dir: config.target_dir ?? '',
|
target_dir: config.target_dir ?? '',
|
||||||
results_dir: config.results_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_coe: config.avg_time_coe ? fmtSeconds(config.avg_time_coe) : '',
|
||||||
avg_time_p2p: config.avg_time_p2p ? fmtSeconds(config.avg_time_p2p) : '',
|
avg_time_p2p: config.avg_time_p2p ? fmtSeconds(config.avg_time_p2p) : '',
|
||||||
avg_time_p3p: config.avg_time_p3p ? fmtSeconds(config.avg_time_p3p) : '',
|
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])
|
}, [config])
|
||||||
@@ -33,12 +40,16 @@ export default function ConfigModal({ onClose }) {
|
|||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
setScanResult(null)
|
setScanResult(null)
|
||||||
const payload = {
|
const payload = {
|
||||||
target_dir: form.target_dir || null,
|
target_dir: form.target_dir || null,
|
||||||
results_dir: form.results_dir || null,
|
results_dir: form.results_dir || null,
|
||||||
|
results_dir_ref: form.results_dir_ref || null,
|
||||||
// Convert minutes → seconds; empty/null clears the override
|
// 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_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_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,
|
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, {
|
save(payload, {
|
||||||
onSuccess: (data) => {
|
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 = [
|
const AVG_FIELDS = [
|
||||||
{ key: 'avg_time_coe', label: 'COE avg time (min)' },
|
{ key: 'avg_time_coe', label: 'COE avg time (min)' },
|
||||||
{ key: 'avg_time_p2p', label: 'P2P avg time (min)' },
|
{ key: 'avg_time_p2p', label: 'P2P avg time (min)' },
|
||||||
@@ -104,8 +152,9 @@ export default function ConfigModal({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[
|
{[
|
||||||
{ key: 'target_dir', label: 'Target Tests Directory' },
|
{ key: 'target_dir', label: 'Target Tests Directory' },
|
||||||
{ key: 'results_dir', label: 'Results Directory' },
|
{ key: 'results_dir', label: 'Results Directory (DUT)' },
|
||||||
|
{ key: 'results_dir_ref', label: 'Results Directory (Reference)' },
|
||||||
].map(({ key, label }) => (
|
].map(({ key, label }) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
<label className="text-slate-400 text-xs block mb-1">{label}</label>
|
||||||
@@ -152,25 +201,72 @@ export default function ConfigModal({ onClose }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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
|
<button
|
||||||
onClick={onClose}
|
onClick={handleRescanResults}
|
||||||
className="px-4 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
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
|
{isRescanning ? 'Rescanning…' : 'Save Results'}
|
||||||
</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'}
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [manualPath, setManualPath] = useState('')
|
const [manualPath, setManualPath] = useState('')
|
||||||
const [networkHost, setNetworkHost] = useState('')
|
|
||||||
|
|
||||||
async function navigate(path) {
|
async function navigate(path) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -35,31 +34,18 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
|
|||||||
navigate(path)
|
navigate(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToHost() {
|
|
||||||
const host = networkHost.trim()
|
|
||||||
if (!host) return
|
|
||||||
navigate(`\\\\${host}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load roots on first render
|
// Load roots on first render
|
||||||
if (dirs === null && !loading && !error) {
|
if (dirs === null && !loading && !error) {
|
||||||
navigate(null)
|
navigate(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedCurrent = current ?? ''
|
const normalizedCurrent = current ?? ''
|
||||||
|
const normalizedPath = normalizedCurrent.replace(/\\/g, '/')
|
||||||
const isUnixPath = normalizedCurrent.startsWith('/')
|
const isUnixPath = normalizedCurrent.startsWith('/')
|
||||||
const isUncPath = normalizedCurrent.startsWith('\\\\')
|
const breadcrumbs = normalizedPath ? normalizedPath.split('/').filter(Boolean) : []
|
||||||
const breadcrumbs = normalizedCurrent
|
|
||||||
? (isUncPath
|
|
||||||
? normalizedCurrent.slice(2).split(/\\+/).filter(Boolean)
|
|
||||||
: normalizedCurrent.replace(/\\/g, '/').split('/').filter(Boolean))
|
|
||||||
: []
|
|
||||||
|
|
||||||
function breadcrumbPathAt(index) {
|
function breadcrumbPathAt(index) {
|
||||||
const parts = breadcrumbs.slice(0, index + 1)
|
const parts = breadcrumbs.slice(0, index + 1)
|
||||||
if (isUncPath) {
|
|
||||||
return `\\\\${parts.join('\\')}`
|
|
||||||
}
|
|
||||||
if (isUnixPath) {
|
if (isUnixPath) {
|
||||||
return `/${parts.join('/')}`
|
return `/${parts.join('/')}`
|
||||||
}
|
}
|
||||||
@@ -95,40 +81,6 @@ export default function DirectoryBrowser({ onSelect, onClose }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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 */}
|
{/* Directory list */}
|
||||||
<div className="overflow-y-auto max-h-64 divide-y divide-slate-800">
|
<div className="overflow-y-auto max-h-64 divide-y divide-slate-800">
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|||||||
@@ -210,6 +210,34 @@ export default function TestTable({ tests = [], allTests = [], isLoading }) {
|
|||||||
<TagPill label="Direction" value={test.direction} />
|
<TagPill label="Direction" value={test.direction} />
|
||||||
<TagPill label="Elapsed Time" value={fmtDuration(test.duration_seconds)} />
|
<TagPill label="Elapsed Time" value={fmtDuration(test.duration_seconds)} />
|
||||||
</div>
|
</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)
|
const pairs = parsePairArray(test.coe_pair)
|
||||||
if (pairs.length === 0) return null
|
if (pairs.length === 0) return null
|
||||||
@@ -298,34 +326,6 @@ export default function TestTable({ tests = [], allTests = [], isLoading }) {
|
|||||||
</div>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+51
-69
@@ -1,12 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, request, send_from_directory
|
from flask import Flask, Response, jsonify, request, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
from db_py import count_tests, del_config, get_all_tests, get_config, set_config
|
from db_py import count_tests, del_config, get_all_tests, get_config, set_config
|
||||||
from scanner import full_scan
|
from scanner import full_scan, scan_results_only
|
||||||
from sse_py import broadcast, stream_events
|
from sse_py import broadcast, stream_events
|
||||||
from watcher import start_watching
|
from watcher import start_watching
|
||||||
|
|
||||||
@@ -14,9 +13,13 @@ PORT = int(os.getenv("PORT", "3001"))
|
|||||||
ALLOWED_KEYS = {
|
ALLOWED_KEYS = {
|
||||||
"target_dir",
|
"target_dir",
|
||||||
"results_dir",
|
"results_dir",
|
||||||
|
"results_dir_ref",
|
||||||
"avg_time_coe",
|
"avg_time_coe",
|
||||||
"avg_time_p2p",
|
"avg_time_p2p",
|
||||||
"avg_time_p3p",
|
"avg_time_p3p",
|
||||||
|
"smb_username",
|
||||||
|
"smb_password",
|
||||||
|
"smb_domain",
|
||||||
}
|
}
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
@@ -26,6 +29,20 @@ app = Flask(__name__, static_folder=str(DIST_DIR), static_url_path="")
|
|||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_smb_env_from_config():
|
||||||
|
mapping = {
|
||||||
|
"SMB_USERNAME": get_config("smb_username"),
|
||||||
|
"SMB_PASSWORD": get_config("smb_password"),
|
||||||
|
"SMB_DOMAIN": get_config("smb_domain"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for env_key, value in mapping.items():
|
||||||
|
if value in (None, ""):
|
||||||
|
os.environ.pop(env_key, None)
|
||||||
|
else:
|
||||||
|
os.environ[env_key] = str(value)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tests")
|
@app.get("/api/tests")
|
||||||
def get_tests_route():
|
def get_tests_route():
|
||||||
completed = request.args.get("completed")
|
completed = request.args.get("completed")
|
||||||
@@ -205,14 +222,16 @@ def set_config_route():
|
|||||||
else:
|
else:
|
||||||
set_config(key, str(value))
|
set_config(key, str(value))
|
||||||
|
|
||||||
if key in {"target_dir", "results_dir"}:
|
if key in {"target_dir", "results_dir", "results_dir_ref"}:
|
||||||
dirs_changed = True
|
dirs_changed = True
|
||||||
|
|
||||||
if dirs_changed:
|
if dirs_changed:
|
||||||
|
_apply_smb_env_from_config()
|
||||||
target_dir = get_config("target_dir")
|
target_dir = get_config("target_dir")
|
||||||
results_dir = get_config("results_dir")
|
results_dir = get_config("results_dir")
|
||||||
|
results_dir_ref = get_config("results_dir_ref")
|
||||||
try:
|
try:
|
||||||
full_scan(target_dir, results_dir)
|
full_scan(target_dir, results_dir, results_dir_ref)
|
||||||
start_watching(target_dir, results_dir)
|
start_watching(target_dir, results_dir)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[config] fullScan error: {exc}")
|
print(f"[config] fullScan error: {exc}")
|
||||||
@@ -224,18 +243,21 @@ def set_config_route():
|
|||||||
broadcast({"type": "update"})
|
broadcast({"type": "update"})
|
||||||
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
|
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
|
||||||
|
|
||||||
|
broadcast({"type": "update"})
|
||||||
return jsonify({"ok": True, "testCount": None, "completedCount": None})
|
return jsonify({"ok": True, "testCount": None, "completedCount": None})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/config/rescan")
|
@app.post("/api/config/rescan")
|
||||||
def rescan_route():
|
def rescan_route():
|
||||||
|
_apply_smb_env_from_config()
|
||||||
target_dir = get_config("target_dir")
|
target_dir = get_config("target_dir")
|
||||||
results_dir = get_config("results_dir")
|
results_dir = get_config("results_dir")
|
||||||
|
results_dir_ref = get_config("results_dir_ref")
|
||||||
if not target_dir or not results_dir:
|
if not target_dir or not results_dir:
|
||||||
return jsonify({"error": "Directories not configured"}), 400
|
return jsonify({"error": "Directories not configured"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
full_scan(target_dir, results_dir)
|
full_scan(target_dir, results_dir, results_dir_ref)
|
||||||
start_watching(target_dir, results_dir)
|
start_watching(target_dir, results_dir)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[config] rescan error: {exc}")
|
print(f"[config] rescan error: {exc}")
|
||||||
@@ -248,6 +270,27 @@ def rescan_route():
|
|||||||
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
|
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/config/rescan-results")
|
||||||
|
def rescan_results_route():
|
||||||
|
_apply_smb_env_from_config()
|
||||||
|
results_dir = get_config("results_dir")
|
||||||
|
results_dir_ref = get_config("results_dir_ref")
|
||||||
|
if not results_dir:
|
||||||
|
return jsonify({"error": "Results directory not configured"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
scan_results_only(results_dir, results_dir_ref)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[config] rescan-results error: {exc}")
|
||||||
|
return jsonify({"error": f"Scan failed: {exc}"}), 500
|
||||||
|
|
||||||
|
tests = get_all_tests()
|
||||||
|
completed = len([t for t in tests if t.get("completed")])
|
||||||
|
print(f"[config] Results rescan complete -> {len(tests)} tests, {completed} completed")
|
||||||
|
broadcast({"type": "update"})
|
||||||
|
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/browse")
|
@app.get("/api/browse")
|
||||||
def browse_route():
|
def browse_route():
|
||||||
def _normalize_request_path(path):
|
def _normalize_request_path(path):
|
||||||
@@ -258,48 +301,6 @@ def browse_route():
|
|||||||
p = p.replace("/", "\\")
|
p = p.replace("/", "\\")
|
||||||
return p
|
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():
|
def _configured_roots():
|
||||||
raw = os.getenv("BROWSE_ROOTS", "").strip()
|
raw = os.getenv("BROWSE_ROOTS", "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -326,17 +327,6 @@ def browse_route():
|
|||||||
continue
|
continue
|
||||||
return False
|
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()
|
roots = _configured_roots()
|
||||||
req_path = _normalize_request_path(request.args.get("path"))
|
req_path = _normalize_request_path(request.args.get("path"))
|
||||||
|
|
||||||
@@ -359,16 +349,6 @@ def browse_route():
|
|||||||
|
|
||||||
return jsonify({"path": None, "parent": None, "dirs": dirs})
|
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):
|
if not os.path.exists(req_path):
|
||||||
return jsonify({"error": "Path does not exist"}), 400
|
return jsonify({"error": "Path does not exist"}), 400
|
||||||
if not os.path.isdir(req_path):
|
if not os.path.isdir(req_path):
|
||||||
@@ -416,8 +396,10 @@ def static_or_spa(path=""):
|
|||||||
|
|
||||||
|
|
||||||
def bootstrap():
|
def bootstrap():
|
||||||
|
_apply_smb_env_from_config()
|
||||||
target_dir = get_config("target_dir")
|
target_dir = get_config("target_dir")
|
||||||
results_dir = get_config("results_dir")
|
results_dir = get_config("results_dir")
|
||||||
|
results_dir_ref = get_config("results_dir_ref")
|
||||||
|
|
||||||
if target_dir and results_dir:
|
if target_dir and results_dir:
|
||||||
existing = count_tests()
|
existing = count_tests()
|
||||||
@@ -425,7 +407,7 @@ def bootstrap():
|
|||||||
print(f"[server] Resuming from DB -> {existing} tests already loaded.")
|
print(f"[server] Resuming from DB -> {existing} tests already loaded.")
|
||||||
else:
|
else:
|
||||||
print("[server] No cached data, scanning directories...")
|
print("[server] No cached data, scanning directories...")
|
||||||
full_scan(target_dir, results_dir)
|
full_scan(target_dir, results_dir, results_dir_ref)
|
||||||
tests = get_all_tests()
|
tests = get_all_tests()
|
||||||
completed = len([t for t in tests if t.get("completed")])
|
completed = len([t for t in tests if t.get("completed")])
|
||||||
print(f"[server] Scan complete -> {len(tests)} tests found, {completed} completed")
|
print(f"[server] Scan complete -> {len(tests)} tests found, {completed} completed")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,4 @@
|
|||||||
Flask>=3.0.0,<4.0.0
|
Flask>=3.0.0,<4.0.0
|
||||||
Flask-Cors>=4.0.1,<5.0.0
|
Flask-Cors>=4.0.1,<5.0.0
|
||||||
watchdog>=4.0.1,<5.0.0
|
watchdog>=4.0.1,<5.0.0
|
||||||
|
smbprotocol>=1.13.0,<2.0.0
|
||||||
|
|||||||
+147
-32
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import smbclient
|
||||||
|
|
||||||
from db_py import clear_tests, get_all_tests, mark_completed, set_coe_pair, upsert_test
|
from db_py import clear_tests, get_all_tests, mark_completed, set_coe_pair, upsert_test
|
||||||
from parser import (
|
from parser import (
|
||||||
@@ -11,16 +12,125 @@ from parser import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def full_scan(target_dir, results_dir):
|
_SMB_SESSIONS = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_input_path(path_value):
|
||||||
|
if not path_value:
|
||||||
|
return path_value
|
||||||
|
|
||||||
|
path = str(path_value).strip()
|
||||||
|
|
||||||
|
# Accept //server/share style and normalize to UNC for smbclient.
|
||||||
|
if path.startswith("//"):
|
||||||
|
return "\\\\" + path.lstrip("/").replace("/", "\\")
|
||||||
|
|
||||||
|
# Accept /<ipv4>/<share>/... and normalize to UNC for Linux-hosted inputs.
|
||||||
|
if re.match(r"^/\d{1,3}(?:\.\d{1,3}){3}/[^/]+", path):
|
||||||
|
return "\\\\" + path.lstrip("/").replace("/", "\\")
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _is_unc_path(path):
|
||||||
|
return isinstance(path, str) and path.startswith("\\\\")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_unc_server(path):
|
||||||
|
if not _is_unc_path(path):
|
||||||
|
return None
|
||||||
|
rest = path[2:]
|
||||||
|
return rest.split("\\", 1)[0] if rest else None
|
||||||
|
|
||||||
|
|
||||||
|
def _register_smb_session_if_needed(path):
|
||||||
|
if not _is_unc_path(path):
|
||||||
|
return
|
||||||
|
|
||||||
|
server = _extract_unc_server(path)
|
||||||
|
if not server or server in _SMB_SESSIONS:
|
||||||
|
return
|
||||||
|
|
||||||
|
username = os.getenv("SMB_USERNAME", "").strip()
|
||||||
|
password = os.getenv("SMB_PASSWORD", "")
|
||||||
|
domain = os.getenv("SMB_DOMAIN", "").strip()
|
||||||
|
|
||||||
|
if username and domain and "\\" not in username and "@" not in username:
|
||||||
|
username = f"{domain}\\{username}"
|
||||||
|
|
||||||
|
if username:
|
||||||
|
smbclient.register_session(server, username=username, password=password)
|
||||||
|
else:
|
||||||
|
smbclient.register_session(server)
|
||||||
|
|
||||||
|
_SMB_SESSIONS.add(server)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_dir_entries(path):
|
||||||
|
path = _normalize_input_path(path)
|
||||||
|
if _is_unc_path(path):
|
||||||
|
_register_smb_session_if_needed(path)
|
||||||
|
return list(smbclient.scandir(path))
|
||||||
|
return list(os.scandir(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _join_path(path, name):
|
||||||
|
if _is_unc_path(path):
|
||||||
|
base = path.rstrip("\\")
|
||||||
|
return f"{base}\\{name}"
|
||||||
|
return os.path.join(path, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_text_lines(path):
|
||||||
|
path = _normalize_input_path(path)
|
||||||
|
if _is_unc_path(path):
|
||||||
|
_register_smb_session_if_needed(path)
|
||||||
|
with smbclient.open_file(path, mode="r", encoding="utf-8", errors="ignore") as fh:
|
||||||
|
for line in fh:
|
||||||
|
yield line
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
||||||
|
for line in fh:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
|
||||||
|
def scan_results_only(results_dir, results_dir_ref=None):
|
||||||
|
"""Re-scan only the results directories without clearing or re-scanning targets."""
|
||||||
|
results_dir = _normalize_input_path(results_dir)
|
||||||
|
results_dir_ref = _normalize_input_path(results_dir_ref)
|
||||||
|
|
||||||
|
if not results_dir:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[scanner] results-only scan dir : {results_dir}")
|
||||||
|
if results_dir_ref:
|
||||||
|
print(f"[scanner] results-only scan dir ref : {results_dir_ref}")
|
||||||
|
|
||||||
|
scan_results(results_dir)
|
||||||
|
if results_dir_ref:
|
||||||
|
scan_results(results_dir_ref)
|
||||||
|
update_p2p_coe_pairs()
|
||||||
|
|
||||||
|
|
||||||
|
def full_scan(target_dir, results_dir, results_dir_ref=None):
|
||||||
|
target_dir = _normalize_input_path(target_dir)
|
||||||
|
results_dir = _normalize_input_path(results_dir)
|
||||||
|
results_dir_ref = _normalize_input_path(results_dir_ref)
|
||||||
|
|
||||||
clear_tests()
|
clear_tests()
|
||||||
if not target_dir or not results_dir:
|
if not target_dir or not results_dir:
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"[scanner] target dir : {target_dir}")
|
print(f"[scanner] target dir : {target_dir}")
|
||||||
print(f"[scanner] results dir: {results_dir}")
|
print(f"[scanner] results dir : {results_dir}")
|
||||||
|
if results_dir_ref:
|
||||||
|
print(f"[scanner] results dir ref : {results_dir_ref}")
|
||||||
|
|
||||||
scan_targets(target_dir)
|
scan_targets(target_dir)
|
||||||
scan_results(results_dir)
|
scan_results(results_dir)
|
||||||
|
if results_dir_ref:
|
||||||
|
scan_results(results_dir_ref)
|
||||||
update_p2p_coe_pairs()
|
update_p2p_coe_pairs()
|
||||||
|
|
||||||
|
|
||||||
@@ -66,11 +176,13 @@ def update_p2p_coe_pairs():
|
|||||||
|
|
||||||
|
|
||||||
def scan_targets(target_dir):
|
def scan_targets(target_dir):
|
||||||
|
target_dir = _normalize_input_path(target_dir)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parent_entries = [
|
parent_entries = [
|
||||||
name
|
entry.name
|
||||||
for name in os.listdir(target_dir)
|
for entry in _iter_dir_entries(target_dir)
|
||||||
if os.path.isdir(os.path.join(target_dir, name))
|
if entry.is_dir()
|
||||||
]
|
]
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"[scanner] Cannot read target dir: {exc}")
|
print(f"[scanner] Cannot read target dir: {exc}")
|
||||||
@@ -79,14 +191,14 @@ def scan_targets(target_dir):
|
|||||||
print(f"[scanner] subdirectories found: {len(parent_entries)}")
|
print(f"[scanner] subdirectories found: {len(parent_entries)}")
|
||||||
|
|
||||||
for parent_name in parent_entries:
|
for parent_name in parent_entries:
|
||||||
parent_path = os.path.join(target_dir, parent_name)
|
parent_path = _join_path(target_dir, parent_name)
|
||||||
try:
|
try:
|
||||||
files = [
|
files = [
|
||||||
name
|
entry.name
|
||||||
for name in os.listdir(parent_path)
|
for entry in _iter_dir_entries(parent_path)
|
||||||
if os.path.isfile(os.path.join(parent_path, name))
|
if entry.is_file()
|
||||||
and not name.startswith("GLOBAL")
|
and not entry.name.startswith("GLOBAL")
|
||||||
and name.endswith(".ini")
|
and entry.name.endswith(".ini")
|
||||||
]
|
]
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"[scanner] Cannot read parent dir {parent_name}: {exc}")
|
print(f"[scanner] Cannot read parent dir {parent_name}: {exc}")
|
||||||
@@ -125,11 +237,13 @@ def scan_targets(target_dir):
|
|||||||
|
|
||||||
|
|
||||||
def scan_results(results_dir):
|
def scan_results(results_dir):
|
||||||
|
results_dir = _normalize_input_path(results_dir)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
entries = [
|
entries = [
|
||||||
name
|
entry.name
|
||||||
for name in os.listdir(results_dir)
|
for entry in _iter_dir_entries(results_dir)
|
||||||
if os.path.isdir(os.path.join(results_dir, name))
|
if entry.is_dir()
|
||||||
]
|
]
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"[scanner] Cannot read results dir: {exc}")
|
print(f"[scanner] Cannot read results dir: {exc}")
|
||||||
@@ -141,21 +255,23 @@ def scan_results(results_dir):
|
|||||||
|
|
||||||
|
|
||||||
def process_result_dir(results_dir, result_dir_name):
|
def process_result_dir(results_dir, result_dir_name):
|
||||||
|
results_dir = _normalize_input_path(results_dir)
|
||||||
|
|
||||||
parsed = parse_result_filename(result_dir_name)
|
parsed = parse_result_filename(result_dir_name)
|
||||||
test_id = parsed["test_id"]
|
test_id = parsed["test_id"]
|
||||||
device = parsed["device"]
|
device = parsed["device"]
|
||||||
if not test_id or not device:
|
if not test_id or not device:
|
||||||
return
|
return
|
||||||
|
|
||||||
result_dir_path = os.path.join(results_dir, result_dir_name)
|
result_dir_path = _join_path(results_dir, result_dir_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log_files = [
|
log_files = [
|
||||||
name
|
entry.name
|
||||||
for name in os.listdir(result_dir_path)
|
for entry in _iter_dir_entries(result_dir_path)
|
||||||
if os.path.isfile(os.path.join(result_dir_path, name))
|
if entry.is_file()
|
||||||
and name.endswith(".txt")
|
and entry.name.endswith(".txt")
|
||||||
and test_id in name
|
and test_id in entry.name
|
||||||
]
|
]
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"[scanner] Cannot read result dir {result_dir_name}: {exc}")
|
print(f"[scanner] Cannot read result dir {result_dir_name}: {exc}")
|
||||||
@@ -167,7 +283,7 @@ def process_result_dir(results_dir, result_dir_name):
|
|||||||
return
|
return
|
||||||
|
|
||||||
latest_log = sorted(log_files)[-1]
|
latest_log = sorted(log_files)[-1]
|
||||||
log_path = os.path.join(result_dir_path, latest_log)
|
log_path = _join_path(result_dir_path, latest_log)
|
||||||
completed_at = parse_timestamp(latest_log)
|
completed_at = parse_timestamp(latest_log)
|
||||||
data = extract_log_data(log_path)
|
data = extract_log_data(log_path)
|
||||||
|
|
||||||
@@ -199,17 +315,16 @@ def extract_log_data(log_file_path):
|
|||||||
|
|
||||||
result = {"duration_seconds": None, "tputResults": []}
|
result = {"duration_seconds": None, "tputResults": []}
|
||||||
try:
|
try:
|
||||||
with open(log_file_path, "r", encoding="utf-8", errors="ignore") as fh:
|
for line in _read_text_lines(log_file_path):
|
||||||
for line in fh:
|
time_match = elapsed_regex.search(line)
|
||||||
time_match = elapsed_regex.search(line)
|
if time_match:
|
||||||
if time_match:
|
result["duration_seconds"] = parse_elapsed_time(time_match.group(1))
|
||||||
result["duration_seconds"] = parse_elapsed_time(time_match.group(1))
|
continue
|
||||||
continue
|
|
||||||
|
|
||||||
if tput_regex.search(line):
|
if tput_regex.search(line):
|
||||||
parsed = parse_tput_rssi(line)
|
parsed = parse_tput_rssi(line)
|
||||||
if parsed:
|
if parsed:
|
||||||
result["tputResults"].append(parsed)
|
result["tputResults"].append(parsed)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"[scanner] Cannot read log file {log_file_path}: {exc}")
|
print(f"[scanner] Cannot read log file {log_file_path}: {exc}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user