remove browse, add image
This commit is contained in:
+13
-2
@@ -3,6 +3,7 @@ import { Settings } from 'lucide-react'
|
|||||||
import { useStats } from './hooks/useStats'
|
import { useStats } from './hooks/useStats'
|
||||||
import { useTests } from './hooks/useTests'
|
import { useTests } from './hooks/useTests'
|
||||||
import { useConfig } from './hooks/useConfig'
|
import { useConfig } from './hooks/useConfig'
|
||||||
|
import cgw453Image from './assets/CGW453.PNG'
|
||||||
import StatCard from './components/StatCard'
|
import StatCard from './components/StatCard'
|
||||||
import CompletionBar from './components/CompletionBar'
|
import CompletionBar from './components/CompletionBar'
|
||||||
import TimeDisplay from './components/TimeDisplay'
|
import TimeDisplay from './components/TimeDisplay'
|
||||||
@@ -97,7 +98,16 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
{!statsLoading && stats && (
|
{!statsLoading && stats && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)] gap-4">
|
||||||
|
<div className="lg:row-span-2 bg-slate-900 border border-slate-800 rounded-xl p-4 flex items-center justify-center min-h-[220px]">
|
||||||
|
<img
|
||||||
|
src={cgw453Image}
|
||||||
|
alt="CGW453 test object"
|
||||||
|
className="max-h-48 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Overall"
|
label="Overall"
|
||||||
@@ -123,8 +133,9 @@ export default function App() {
|
|||||||
<CompletionBar value={d.completionRate} />
|
<CompletionBar value={d.completionRate} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2 sm:col-span-1 lg:col-span-2">
|
<div>
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
elapsedSeconds={stats.timing.elapsedSeconds}
|
elapsedSeconds={stats.timing.elapsedSeconds}
|
||||||
estimatedRemainingSeconds={stats.timing.estimatedRemainingSeconds}
|
estimatedRemainingSeconds={stats.timing.estimatedRemainingSeconds}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import DirectoryBrowser from './DirectoryBrowser'
|
|
||||||
import { useConfig, useSaveConfig } from '../hooks/useConfig'
|
import { useConfig, useSaveConfig } from '../hooks/useConfig'
|
||||||
import { apiFetch } from '../lib/api'
|
import { apiFetch } from '../lib/api'
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ export default function ConfigModal({ onClose }) {
|
|||||||
const { mutate: save, isPending } = useSaveConfig()
|
const { mutate: save, isPending } = useSaveConfig()
|
||||||
|
|
||||||
const [form, setForm] = useState({})
|
const [form, setForm] = useState({})
|
||||||
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 [isRescanning, setIsRescanning] = useState(false)
|
||||||
@@ -158,21 +156,13 @@ export default function ConfigModal({ onClose }) {
|
|||||||
].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>
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form[key] ?? ''}
|
value={form[key] ?? ''}
|
||||||
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||||
placeholder="C:\path\to\folder"
|
placeholder="C:\path\to\folder"
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => setBrowser(key)}
|
|
||||||
className="px-3 py-2 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Browse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,10 +194,7 @@ export default function ConfigModal({ onClose }) {
|
|||||||
|
|
||||||
{/* SMB Credentials */}
|
{/* SMB Credentials */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-1">SMB Credentials</h3>
|
<h3 className="text-slate-300 text-xs uppercase tracking-widest mb-1">Credentials for network shares</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">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -270,13 +257,6 @@ export default function ConfigModal({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{browser && (
|
|
||||||
<DirectoryBrowser
|
|
||||||
onSelect={path => setForm(f => ({ ...f, [browser]: path }))}
|
|
||||||
onClose={() => setBrowser(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { browse } from '../lib/api'
|
|
||||||
|
|
||||||
export default function DirectoryBrowser({ onSelect, onClose }) {
|
|
||||||
const [current, setCurrent] = useState(null) // null = roots
|
|
||||||
const [parent, setParent] = useState(null)
|
|
||||||
const [dirs, setDirs] = useState(null) // null = not loaded yet
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [manualPath, setManualPath] = useState('')
|
|
||||||
|
|
||||||
async function navigate(path) {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const data = await browse(path)
|
|
||||||
setCurrent(data.path)
|
|
||||||
setParent(data.parent)
|
|
||||||
setDirs(data.dirs)
|
|
||||||
setManualPath(data.path ?? '')
|
|
||||||
} catch (e) {
|
|
||||||
setError(e.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToManualPath() {
|
|
||||||
const path = manualPath.trim()
|
|
||||||
if (!path) {
|
|
||||||
navigate(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigate(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 breadcrumbs = normalizedPath ? normalizedPath.split('/').filter(Boolean) : []
|
|
||||||
|
|
||||||
function breadcrumbPathAt(index) {
|
|
||||||
const parts = breadcrumbs.slice(0, index + 1)
|
|
||||||
if (isUnixPath) {
|
|
||||||
return `/${parts.join('/')}`
|
|
||||||
}
|
|
||||||
return parts.join('\\') + (index === 0 ? '\\' : '')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 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 flex flex-col shadow-2xl">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800">
|
|
||||||
<span className="text-slate-200 font-semibold text-sm">Browse Folder</span>
|
|
||||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-200">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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">Roots</button>
|
|
||||||
{breadcrumbs.map((part, i) => {
|
|
||||||
const path = breadcrumbPathAt(i)
|
|
||||||
return (
|
|
||||||
<span key={i} className="flex items-center gap-1">
|
|
||||||
<span>/</span>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(path)}
|
|
||||||
className="hover:text-slate-200 truncate max-w-[120px]"
|
|
||||||
title={path}
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Directory list */}
|
|
||||||
<div className="overflow-y-auto max-h-64 divide-y divide-slate-800">
|
|
||||||
{loading && (
|
|
||||||
<p className="text-slate-500 text-sm text-center py-8">Loading…</p>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p className="text-red-400 text-sm text-center py-8">{error}</p>
|
|
||||||
)}
|
|
||||||
{!loading && !error && parent !== null && (
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(parent)}
|
|
||||||
className="w-full text-left px-4 py-2.5 text-slate-400 hover:bg-slate-800 text-sm transition-colors"
|
|
||||||
>
|
|
||||||
↑ ..
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!loading && !error && dirs?.map(d => (
|
|
||||||
<button
|
|
||||||
key={d.path}
|
|
||||||
onClick={() => navigate(d.path)}
|
|
||||||
className="w-full text-left px-4 py-2.5 text-slate-300 hover:bg-slate-800 text-sm transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="text-slate-500">📁</span>
|
|
||||||
{d.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{!loading && !error && dirs?.length === 0 && (
|
|
||||||
<p className="text-slate-500 text-sm text-center py-8">No subdirectories</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-slate-800">
|
|
||||||
<p className="text-xs text-slate-500 truncate flex-1" title={current ?? ''}>
|
|
||||||
{current ?? 'Select a folder'}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-lg border border-slate-700 text-slate-300 hover:bg-slate-800 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
disabled={!current}
|
|
||||||
onClick={() => { onSelect(current); onClose() }}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
Select This Folder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,7 @@ export default function TimeDisplay({ elapsedSeconds, estimatedRemainingSeconds,
|
|||||||
<div className="flex flex-wrap gap-6">
|
<div className="flex flex-wrap gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-400 text-xs uppercase tracking-widest">Time Elapsed</p>
|
<p className="text-slate-400 text-xs uppercase tracking-widest">Time Elapsed</p>
|
||||||
<p className="text-2xl font-bold text-emerald-400 mt-0.5">
|
<p className="text-2xl font-bold text-blue-400 mt-0.5">
|
||||||
{fmt(elapsedSeconds) ?? '—'}
|
{fmt(elapsedSeconds) ?? '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,4 +21,3 @@ export const getTests = (params = {}) => {
|
|||||||
}
|
}
|
||||||
export const getConfig = () => apiFetch('/config')
|
export const getConfig = () => apiFetch('/config')
|
||||||
export const saveConfig = (body) => apiFetch('/config', { method: 'POST', body: JSON.stringify(body) })
|
export const saveConfig = (body) => apiFetch('/config', { method: 'POST', body: JSON.stringify(body) })
|
||||||
export const browse = (path) => apiFetch(`/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`)
|
|
||||||
|
|||||||
+3
-5
@@ -4,12 +4,10 @@ services:
|
|||||||
container_name: dashboard-backend
|
container_name: dashboard-backend
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
environment:
|
|
||||||
- BROWSE_ROOTS=/host
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./server:/app
|
- type: bind
|
||||||
# Host file browsing root inside container (adjust HOST_BROWSE_ROOT if needed)
|
source: ./server
|
||||||
- ${HOST_BROWSE_ROOT:-C:/Users}:/host
|
target: /app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|||||||
@@ -291,89 +291,6 @@ def rescan_results_route():
|
|||||||
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
|
return jsonify({"ok": True, "testCount": len(tests), "completedCount": completed})
|
||||||
|
|
||||||
|
|
||||||
@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 _configured_roots():
|
|
||||||
raw = os.getenv("BROWSE_ROOTS", "").strip()
|
|
||||||
if not raw:
|
|
||||||
return []
|
|
||||||
roots = []
|
|
||||||
for part in raw.split(";"):
|
|
||||||
p = part.strip()
|
|
||||||
if not p:
|
|
||||||
continue
|
|
||||||
abs_p = os.path.abspath(p)
|
|
||||||
if os.path.isdir(abs_p):
|
|
||||||
roots.append(abs_p)
|
|
||||||
return roots
|
|
||||||
|
|
||||||
def _is_within_allowed_roots(path, roots):
|
|
||||||
if not roots:
|
|
||||||
return True
|
|
||||||
normalized = os.path.normcase(os.path.abspath(path))
|
|
||||||
for root in roots:
|
|
||||||
try:
|
|
||||||
if os.path.commonpath([normalized, root]) == root:
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
roots = _configured_roots()
|
|
||||||
req_path = _normalize_request_path(request.args.get("path"))
|
|
||||||
|
|
||||||
if not req_path:
|
|
||||||
if roots:
|
|
||||||
dirs = []
|
|
||||||
for root in roots:
|
|
||||||
display_name = os.path.basename(root.rstrip("/\\")) or root
|
|
||||||
dirs.append({"name": display_name, "path": root})
|
|
||||||
return jsonify({"path": None, "parent": None, "dirs": dirs})
|
|
||||||
|
|
||||||
if os.name == "nt":
|
|
||||||
dirs = []
|
|
||||||
for drive_idx in range(65, 91):
|
|
||||||
drive = f"{chr(drive_idx)}:\\"
|
|
||||||
if os.path.exists(drive):
|
|
||||||
dirs.append({"name": drive, "path": drive})
|
|
||||||
else:
|
|
||||||
dirs = [{"name": "/", "path": "/"}]
|
|
||||||
|
|
||||||
return jsonify({"path": None, "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):
|
|
||||||
return jsonify({"error": "Path is not a directory"}), 400
|
|
||||||
if not _is_within_allowed_roots(req_path, roots):
|
|
||||||
return jsonify({"error": "Path is outside allowed browse roots"}), 403
|
|
||||||
|
|
||||||
try:
|
|
||||||
dirs = []
|
|
||||||
for name in os.listdir(req_path):
|
|
||||||
child = os.path.join(req_path, name)
|
|
||||||
if os.path.isdir(child) and not name.startswith("."):
|
|
||||||
dirs.append({"name": name, "path": child})
|
|
||||||
|
|
||||||
dirs.sort(key=lambda item: item["name"].lower())
|
|
||||||
except OSError:
|
|
||||||
return jsonify({"error": "Cannot read directory"}), 403
|
|
||||||
|
|
||||||
parent = os.path.dirname(req_path)
|
|
||||||
at_root = os.path.normcase(parent) == os.path.normcase(req_path)
|
|
||||||
if roots and parent and not _is_within_allowed_roots(parent, roots):
|
|
||||||
parent = None
|
|
||||||
return jsonify({"path": req_path, "parent": None if at_root else parent, "dirs": dirs})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/events")
|
@app.get("/api/events")
|
||||||
def events_route():
|
def events_route():
|
||||||
headers = {
|
headers = {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user