diff --git a/dashboard/index.html b/dashboard/index.html index cc627a4..4ce7dff 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -2,9 +2,8 @@ - - dashboard + Test House Dashboard
diff --git a/dashboard/src/components/ConfigModal.jsx b/dashboard/src/components/ConfigModal.jsx index 3c9419f..76b4dc6 100644 --- a/dashboard/src/components/ConfigModal.jsx +++ b/dashboard/src/components/ConfigModal.jsx @@ -1,8 +1,41 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' import { useConfig, useSaveConfig } from '../hooks/useConfig' import { apiFetch } from '../lib/api' +function useScanPoller(onDone) { + const timerRef = useRef(null) + const onDoneRef = useRef(onDone) + onDoneRef.current = onDone + + const stop = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current) + timerRef.current = null + } + }, []) + + const start = useCallback(() => { + stop() + timerRef.current = setInterval(async () => { + try { + const data = await apiFetch('/scan-status') + if (!data?.scanning) { + stop() + onDoneRef.current() + } + } catch { + stop() + onDoneRef.current() + } + }, 2000) + }, [stop]) + + useEffect(() => () => stop(), [stop]) + + return { start, stop } +} + function fmtSeconds(s) { if (s == null) return '' const m = Math.round(parseFloat(s) / 60) @@ -14,12 +47,20 @@ 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) + const [form, setForm] = useState({}) + const [scanResult, setScanResult] = useState(null) + const [saveError, setSaveError] = useState(null) const [isRescanning, setIsRescanning] = useState(false) + const [isScanning, setIsScanning] = useState(false) const [isSavingTimes, setIsSavingTimes] = useState(false) + const scanPoller = useScanPoller(() => { + queryClient.invalidateQueries({ queryKey: ['stats'] }) + queryClient.invalidateQueries({ queryKey: ['tests'] }) + setIsRescanning(false) + setIsScanning(false) + }) + useEffect(() => { if (config) { setForm({ @@ -55,10 +96,9 @@ export default function ConfigModal({ onClose }) { } 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) + if (data?.scanning) { + setIsScanning(true) + scanPoller.start() } else { onClose() } @@ -98,14 +138,15 @@ export default function ConfigModal({ onClose }) { 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 }) + if (data?.scanning) { + scanPoller.start() + } else { + setIsRescanning(false) + queryClient.invalidateQueries({ queryKey: ['stats'] }) + queryClient.invalidateQueries({ queryKey: ['tests'] }) } - queryClient.invalidateQueries({ queryKey: ['stats'] }) - queryClient.invalidateQueries({ queryKey: ['tests'] }) } catch (err) { setSaveError(err?.message ?? 'Rescan failed') - } finally { setIsRescanning(false) } } @@ -134,16 +175,19 @@ export default function ConfigModal({ onClose }) { )} - {/* Scan result banner */} - {scanResult !== null && ( -
- {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).`} + {/* Scanning indicator (save triggered a scan) */} + {isScanning && ( +
+ + Scan in progress — the table will refresh when complete. +
+ )} + + {/* Rescan indicator (rescan button used) */} + {!isScanning && isRescanning && ( +
+ + Results scan in progress…
)} diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js index 3b1b213..f6c5405 100644 --- a/dashboard/vite.config.js +++ b/dashboard/vite.config.js @@ -4,10 +4,5 @@ import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [react(), tailwindcss()], - server: { - host: true, - proxy: { - '/api': 'http://localhost:3001', - }, - }, }) + diff --git a/docker-compose.yml b/docker-compose.yml index f576d7c..2c2b42e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,9 @@ services: backend: build: ./server container_name: dashboard-backend - ports: - - "3001:3001" + expose: + - "3001" # Remove public port, keep internal only environment: - # Root path on the Docker host to mount inside the container. - # Linux VM: HOST_BROWSE_ROOT=/home/wnc - # Windows Docker Desktop: HOST_BROWSE_ROOT=C:/Users - # Then enter paths under /host/... in the dashboard settings. - HOST_BROWSE_ROOT=${HOST_BROWSE_ROOT} - HOST_MOUNT_ROOT=/host volumes: @@ -23,8 +19,21 @@ services: frontend: build: ./dashboard container_name: dashboard-frontend - ports: - - "5173:80" + expose: + - "5173" # Remove public port, keep internal only depends_on: - backend - restart: unless-stopped \ No newline at end of file + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: dashboard-nginx + ports: + - "80:80" # Single public entry point + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - backend + - frontend + restart: unless-stopped + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..ddc22c5 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,48 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { + server backend:3001; + } + + upstream frontend { + server frontend:80; + } + + server { + listen 80; + + # Scan endpoints may run for several minutes on network shares. + location ~ ^/api/config(/rescan|/rescan-results)?$ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + # Frontend (default) + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + + # Required for Vite HMR (hot reload) if in dev mode + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Backend API + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 60s; + } + } +} diff --git a/server/app.py b/server/app.py index 8a4bff6..cdbe015 100644 --- a/server/app.py +++ b/server/app.py @@ -1,4 +1,5 @@ import os +import threading from datetime import datetime, timedelta, timezone from functools import wraps from pathlib import Path @@ -442,10 +443,12 @@ def set_config_route(): if is_scan_in_progress(): return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None}) - full_scan(target_dir, results_dir, results_dir_ref) - tests = get_all_tests() - completed = len([t for t in tests if t.get("completed")]) - return jsonify({"ok": True, "scanning": False, "testCount": len(tests), "completedCount": completed}) + threading.Thread( + target=full_scan, + args=(target_dir, results_dir, results_dir_ref), + daemon=True, + ).start() + return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None}) return jsonify({"ok": True, "testCount": None, "completedCount": None}) @@ -464,10 +467,12 @@ def rescan_route(): if is_scan_in_progress(): return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None}) - full_scan(target_dir, results_dir, results_dir_ref) - tests = get_all_tests() - completed = len([t for t in tests if t.get("completed")]) - return jsonify({"ok": True, "scanning": False, "testCount": len(tests), "completedCount": completed}) + threading.Thread( + target=full_scan, + args=(target_dir, results_dir, results_dir_ref), + daemon=True, + ).start() + return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None}) @app.post("/api/config/rescan-results") @@ -483,10 +488,12 @@ def rescan_results_route(): if is_scan_in_progress(): return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None}) - scan_results_only(results_dir, results_dir_ref) - tests = get_all_tests() - completed = len([t for t in tests if t.get("completed")]) - return jsonify({"ok": True, "scanning": False, "testCount": len(tests), "completedCount": completed}) + threading.Thread( + target=scan_results_only, + args=(results_dir, results_dir_ref), + daemon=True, + ).start() + return jsonify({"ok": True, "scanning": True, "testCount": None, "completedCount": None}) @app.get("/")