initial commit
This commit is contained in:
131
reference_data_acquisition/bridge_logger.py
Executable file
131
reference_data_acquisition/bridge_logger.py
Executable file
@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Half-duplex serial bridge & logger.
|
||||||
|
|
||||||
|
- Open two fds:
|
||||||
|
host_fd: readable/writable endpoint the app uses (VIRT_DEV)
|
||||||
|
dev_fd: real serial device (REAL_DEV)
|
||||||
|
- Relay bytes between them.
|
||||||
|
- Build one log record per *directional burst*:
|
||||||
|
- When bytes arrive from host: start/continue a TX frame.
|
||||||
|
- When first byte arrives from device: flush TX (if any), start RX frame.
|
||||||
|
- And vice versa.
|
||||||
|
- Binary log format:
|
||||||
|
MAGIC "VNALOG1\n"
|
||||||
|
Then repeated records: [1 byte dir][4 bytes len (big-endian)][payload]
|
||||||
|
dir 0x01 = host→device ('>'), 0x00 = device→host ('<')
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, fcntl, select, struct
|
||||||
|
|
||||||
|
MAGIC = b"VNALOG1\n"
|
||||||
|
DIR_TX = 0x01 # host→device
|
||||||
|
DIR_RX = 0x00 # device→host
|
||||||
|
|
||||||
|
if len(sys.argv) != 4:
|
||||||
|
print("usage: bridge_logger.py VIRT_DEV REAL_DEV LOGFILE", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
VIRT_DEV, REAL_DEV, LOGFILE = sys.argv[1:4]
|
||||||
|
|
||||||
|
# Open endpoints non-blocking, read+write
|
||||||
|
host_fd = os.open(VIRT_DEV, os.O_RDWR | os.O_NONBLOCK)
|
||||||
|
dev_fd = os.open(REAL_DEV, os.O_RDWR | os.O_NONBLOCK)
|
||||||
|
|
||||||
|
# Prepare log (write header if empty)
|
||||||
|
log_fd = os.open(LOGFILE, os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
if os.lseek(log_fd, 0, os.SEEK_END) == 0:
|
||||||
|
os.write(log_fd, MAGIC)
|
||||||
|
|
||||||
|
def set_nonblock(fd):
|
||||||
|
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||||
|
if not (fl & os.O_NONBLOCK):
|
||||||
|
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
||||||
|
set_nonblock(host_fd); set_nonblock(dev_fd)
|
||||||
|
|
||||||
|
poll = select.poll()
|
||||||
|
poll.register(host_fd, select.POLLIN)
|
||||||
|
poll.register(dev_fd, select.POLLIN)
|
||||||
|
|
||||||
|
# Current frame state
|
||||||
|
cur_dir = None # None / DIR_TX / DIR_RX
|
||||||
|
buf = bytearray()
|
||||||
|
|
||||||
|
READ_CHUNK = 65536
|
||||||
|
|
||||||
|
def write_all(fd, data: bytes):
|
||||||
|
"""Non-blocking write-all with POLLOUT assistance."""
|
||||||
|
view = memoryview(data)
|
||||||
|
off = 0
|
||||||
|
while off < len(view):
|
||||||
|
try:
|
||||||
|
n = os.write(fd, view[off:])
|
||||||
|
if n is None: n = 0
|
||||||
|
except BlockingIOError:
|
||||||
|
# Wait for fd to become writable
|
||||||
|
p = select.poll(); p.register(fd, select.POLLOUT)
|
||||||
|
p.poll(1000)
|
||||||
|
continue
|
||||||
|
off += n
|
||||||
|
|
||||||
|
def flush_record():
|
||||||
|
"""If a frame is open, append it as one binary record and reset buffer."""
|
||||||
|
global cur_dir, buf
|
||||||
|
if cur_dir is None or not buf:
|
||||||
|
return
|
||||||
|
# dir byte + 4-byte big-endian length + payload
|
||||||
|
rec = bytes([cur_dir]) + struct.pack(">I", len(buf)) + bytes(buf)
|
||||||
|
# Single write is fine (kernel will split if huge)
|
||||||
|
os.write(log_fd, rec)
|
||||||
|
buf.clear()
|
||||||
|
cur_dir = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
events = poll.poll(1000) # 1s tick; framing doesn't depend on idle
|
||||||
|
if not events:
|
||||||
|
# If nothing is happening for a while, we could flush a tail,
|
||||||
|
# but with half-duplex the tail will be flushed on direction switch or exit.
|
||||||
|
continue
|
||||||
|
for (fd, ev) in events:
|
||||||
|
if not (ev & select.POLLIN):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chunk = os.read(fd, READ_CHUNK)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
if not chunk:
|
||||||
|
# EOF on one side: flush and exit
|
||||||
|
flush_record()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if fd is host_fd:
|
||||||
|
# Incoming from host: forward to device
|
||||||
|
if cur_dir is None:
|
||||||
|
cur_dir = DIR_TX
|
||||||
|
elif cur_dir != DIR_TX:
|
||||||
|
# Direction switched: flush previous RX frame, start TX
|
||||||
|
flush_record()
|
||||||
|
cur_dir = DIR_TX
|
||||||
|
buf.extend(chunk)
|
||||||
|
write_all(dev_fd, chunk)
|
||||||
|
else:
|
||||||
|
# Incoming from device: forward to host
|
||||||
|
if cur_dir is None:
|
||||||
|
cur_dir = DIR_RX
|
||||||
|
elif cur_dir != DIR_RX:
|
||||||
|
# Direction switched: flush previous TX frame, start RX
|
||||||
|
flush_record()
|
||||||
|
cur_dir = DIR_RX
|
||||||
|
buf.extend(chunk)
|
||||||
|
write_all(host_fd, chunk)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
# Final flush and cleanup
|
||||||
|
flush_record()
|
||||||
|
try: os.close(host_fd)
|
||||||
|
except: pass
|
||||||
|
try: os.close(dev_fd)
|
||||||
|
except: pass
|
||||||
|
try: os.close(log_fd)
|
||||||
|
except: pass
|
||||||
82
reference_data_acquisition/socat_serial_sniffer.sh
Executable file
82
reference_data_acquisition/socat_serial_sniffer.sh
Executable file
@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
IFS=$'\n\t'
|
||||||
|
|
||||||
|
# --- Settings ----------------------------------------------------------------
|
||||||
|
LOGFILE="vna_protocol.bin" # will be normalized to absolute path
|
||||||
|
DEVICE="/dev/ttyACM0"
|
||||||
|
REAL_DEV="${DEVICE}.real"
|
||||||
|
VIRT_DEV="/tmp/vna_log" # PTY endpoint for the app (peer created by socat)
|
||||||
|
|
||||||
|
# Normalize LOGFILE and ensure directory exists
|
||||||
|
if command -v realpath >/dev/null 2>&1; then
|
||||||
|
LOGFILE="$(realpath -m -- "$LOGFILE")"
|
||||||
|
else
|
||||||
|
LOGFILE="$(cd "$(dirname "$LOGFILE")" && pwd -P)/$(basename "$LOGFILE")"
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$LOGFILE")"
|
||||||
|
|
||||||
|
BRIDGE="./bridge_logger.py"
|
||||||
|
|
||||||
|
# --- Helpers -----------------------------------------------------------------
|
||||||
|
restore_device() {
|
||||||
|
[[ -e "$VIRT_DEV" || -L "$VIRT_DEV" ]] && rm -f "$VIRT_DEV" || true
|
||||||
|
if [[ -e "$DEVICE" && ! -c "$DEVICE" ]]; then rm -f "$DEVICE" || true; fi
|
||||||
|
if [[ -e "$REAL_DEV" ]]; then sudo mv -f "$REAL_DEV" "$DEVICE" || true; fi
|
||||||
|
if [[ -e "$REAL_DEV" && -c "$DEVICE" ]]; then rm -f "$REAL_DEV" || true; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
kill_descendants() {
|
||||||
|
pkill -TERM -P $$ 2>/dev/null || true
|
||||||
|
sleep 0.1
|
||||||
|
pkill -KILL -P $$ 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Cleanup -----------------------------------------------------------------
|
||||||
|
CLEANED=0
|
||||||
|
cleanup() {
|
||||||
|
(( CLEANED )) && return 0
|
||||||
|
CLEANED=1
|
||||||
|
set +e
|
||||||
|
echo; echo "Stopping logger..."
|
||||||
|
restore_device
|
||||||
|
kill_descendants
|
||||||
|
echo "Log saved to: $LOGFILE"
|
||||||
|
echo "Cleanup complete."
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM HUP QUIT
|
||||||
|
|
||||||
|
echo "== Preparing direction-switch protocol logging =="
|
||||||
|
echo "Binary log file: $LOGFILE"
|
||||||
|
|
||||||
|
# Fix leftovers from previous run (if any)
|
||||||
|
if [[ -e "$REAL_DEV" ]]; then
|
||||||
|
echo "Found leftover $REAL_DEV — restoring..."
|
||||||
|
restore_device
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Safety checks
|
||||||
|
[[ -e "$DEVICE" ]] || { echo "Error: device $DEVICE not found."; exit 1; }
|
||||||
|
[[ -c "$DEVICE" ]] || { echo "Error: $DEVICE is not a character device."; exit 1; }
|
||||||
|
|
||||||
|
# Park the real device and create a PTY pair for the application
|
||||||
|
sudo mv "$DEVICE" "$REAL_DEV"
|
||||||
|
socat -d -d pty,raw,echo=0,link="$DEVICE",mode=666 \
|
||||||
|
pty,raw,echo=0,link="$VIRT_DEV" &
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Configure the real serial device (adjust if needed)
|
||||||
|
stty -F "$REAL_DEV" 115200 cs8 -cstopb -parenb -ixon -ixoff -crtscts raw -echo
|
||||||
|
|
||||||
|
# Initialize the binary log with a magic header (if empty)
|
||||||
|
if [[ ! -s "$LOGFILE" ]]; then
|
||||||
|
printf 'VNALOG1\n' > "$LOGFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the bridge+logger (single process handles both directions + logging)
|
||||||
|
"$BRIDGE" "$VIRT_DEV" "$REAL_DEV" "$LOGFILE" &
|
||||||
|
|
||||||
|
echo "Logging active. Press Ctrl+C to stop."
|
||||||
|
wait
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pyserial==3.5
|
||||||
|
websockets==15.0.1
|
||||||
|
# ??? kaleido==1.1.0
|
||||||
1
vna_system/api/__init__.py
Normal file
1
vna_system/api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""VNA System API module."""
|
||||||
9
vna_system/api/api_config.json
Normal file
9
vna_system/api/api_config.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8000
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
vna_system/api/endpoints/__init__.py
Normal file
1
vna_system/api/endpoints/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""API endpoints module."""
|
||||||
43
vna_system/api/endpoints/health.py
Normal file
43
vna_system/api/endpoints/health.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Health and status endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
import vna_system.core.singletons as singletons
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Root endpoint."""
|
||||||
|
return {"message": "VNA System API", "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"acquisition_running": singletons.vna_data_acquisition_instance.is_running,
|
||||||
|
"processing_running": singletons.processing_manager.is_running,
|
||||||
|
"processing_stats": singletons.processing_manager.get_processing_stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/device-status")
|
||||||
|
async def device_status():
|
||||||
|
"""Get device connection status and details."""
|
||||||
|
acquisition = singletons.vna_data_acquisition_instance
|
||||||
|
device_info = acquisition.get_device_info()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"device_info": device_info,
|
||||||
|
"acquisition_running": acquisition.is_running,
|
||||||
|
"last_sweep": acquisition.sweep_buffer.get_latest_sweep_number(),
|
||||||
|
"total_sweeps": len(acquisition.sweep_buffer.get_all_sweeps()),
|
||||||
|
"buffer_stats": acquisition.sweep_buffer.get_stats(),
|
||||||
|
}
|
||||||
27
vna_system/api/endpoints/processing.py
Normal file
27
vna_system/api/endpoints/processing.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
import vna_system.core.singletons as singletons
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["processing"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats():
|
||||||
|
"""Get processing statistics."""
|
||||||
|
return singletons.processing_manager.get_processing_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_history(processor_name: str | None = None, limit: int = 10):
|
||||||
|
"""Get processing results history."""
|
||||||
|
results = singletons.processing_manager.get_latest_results(processor_name)[-limit:]
|
||||||
|
return [result.to_dict() for result in results]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sweep/{sweep_number}")
|
||||||
|
async def get_sweep(sweep_number: int, processor_name: str | None = None):
|
||||||
|
"""Get results for specific sweep."""
|
||||||
|
results = singletons.processing_manager.get_result_by_sweep(sweep_number, processor_name)
|
||||||
|
if not results:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No results found for sweep {sweep_number}")
|
||||||
|
|
||||||
|
return [result.to_dict() for result in results]
|
||||||
57
vna_system/api/endpoints/web_ui.py
Normal file
57
vna_system/api/endpoints/web_ui.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Web UI endpoints for serving the dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create router
|
||||||
|
router = APIRouter(prefix="", tags=["web-ui"])
|
||||||
|
|
||||||
|
# Get the web UI directory path
|
||||||
|
WEB_UI_DIR = Path(__file__).parent.parent.parent / "web_ui"
|
||||||
|
STATIC_DIR = WEB_UI_DIR / "static"
|
||||||
|
TEMPLATES_DIR = WEB_UI_DIR / "templates"
|
||||||
|
|
||||||
|
# Static files will be mounted in main.py
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard():
|
||||||
|
"""Serve the main dashboard page."""
|
||||||
|
try:
|
||||||
|
index_file = TEMPLATES_DIR / "index.html"
|
||||||
|
|
||||||
|
if not index_file.exists():
|
||||||
|
logger.error(f"Dashboard template not found: {index_file}")
|
||||||
|
return HTMLResponse(
|
||||||
|
content="<h1>Dashboard Not Available</h1><p>The web UI template could not be found.</p>",
|
||||||
|
status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(index_file, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
return HTMLResponse(content=content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error serving dashboard: {e}")
|
||||||
|
return HTMLResponse(
|
||||||
|
content=f"<h1>Error</h1><p>Unable to load dashboard: {e}</p>",
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/health-ui")
|
||||||
|
async def health_ui():
|
||||||
|
"""Health check endpoint for web UI."""
|
||||||
|
return {
|
||||||
|
"service": "VNA Web UI",
|
||||||
|
"status": "healthy",
|
||||||
|
"web_ui_dir": str(WEB_UI_DIR),
|
||||||
|
"static_dir_exists": STATIC_DIR.exists(),
|
||||||
|
"templates_dir_exists": TEMPLATES_DIR.exists(),
|
||||||
|
"index_exists": (TEMPLATES_DIR / "index.html").exists()
|
||||||
|
}
|
||||||
143
vna_system/api/main.py
Normal file
143
vna_system/api/main.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import vna_system.core.singletons as singletons
|
||||||
|
from vna_system.core.processing.sweep_processor import SweepProcessingManager
|
||||||
|
from vna_system.api.websockets.websocket_handler import WebSocketManager
|
||||||
|
from vna_system.api.endpoints import health, processing, web_ui
|
||||||
|
from vna_system.api.websockets import processing as ws_processing
|
||||||
|
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Disable noisy third-party loggers
|
||||||
|
logging.getLogger('kaleido').setLevel(logging.ERROR)
|
||||||
|
logging.getLogger('choreographer').setLevel(logging.ERROR)
|
||||||
|
logging.getLogger('kaleido.kaleido').setLevel(logging.ERROR)
|
||||||
|
logging.getLogger('choreographer.browsers.chromium').setLevel(logging.ERROR)
|
||||||
|
logging.getLogger('choreographer.browser_async').setLevel(logging.ERROR)
|
||||||
|
logging.getLogger('choreographer.utils._tmpfile').setLevel(logging.ERROR)
|
||||||
|
logging.getLogger('kaleido._kaleido_tab').setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str = "vna_system/api/api_config.json") -> Dict[str, Any]:
|
||||||
|
"""Load API configuration from file."""
|
||||||
|
try:
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
logger.info(f"Loaded API config from {config_path}")
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load config: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""FastAPI lifespan events."""
|
||||||
|
# Startup
|
||||||
|
logger.info("Starting VNA API Server...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load config
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
# Set log level
|
||||||
|
log_level = config.get("logging", {}).get("level", "INFO")
|
||||||
|
logging.getLogger().setLevel(getattr(logging, log_level))
|
||||||
|
|
||||||
|
# Start acquisition
|
||||||
|
logger.info("Starting data acquisition...")
|
||||||
|
singletons.vna_data_acquisition_instance.start()
|
||||||
|
|
||||||
|
# Connect processing to acquisition
|
||||||
|
singletons.processing_manager.set_sweep_buffer(singletons.vna_data_acquisition_instance.sweep_buffer)
|
||||||
|
singletons.processing_manager.start()
|
||||||
|
logger.info("Sweep processing started")
|
||||||
|
|
||||||
|
logger.info("VNA API Server started successfully")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during startup: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Shutting down VNA API Server...")
|
||||||
|
|
||||||
|
if singletons.processing_manager:
|
||||||
|
singletons.processing_manager.stop()
|
||||||
|
logger.info("Processing stopped")
|
||||||
|
|
||||||
|
if singletons.vna_data_acquisition_instance and singletons.vna_data_acquisition_instance.is_running:
|
||||||
|
singletons.vna_data_acquisition_instance.stop()
|
||||||
|
logger.info("Acquisition stopped")
|
||||||
|
|
||||||
|
logger.info("VNA API Server shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
# Create FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title="VNA System API",
|
||||||
|
description="Real-time VNA data acquisition and processing API",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount static files for web UI
|
||||||
|
WEB_UI_DIR = Path(__file__).parent.parent / "web_ui"
|
||||||
|
STATIC_DIR = WEB_UI_DIR / "static"
|
||||||
|
|
||||||
|
if STATIC_DIR.exists():
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
logger.info(f"Mounted static files from: {STATIC_DIR}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Static directory not found: {STATIC_DIR}")
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(web_ui.router) # Web UI should be first for root path
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(processing.router)
|
||||||
|
app.include_router(ws_processing.router)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
server_config = config.get("server", {})
|
||||||
|
host = server_config.get("host", "0.0.0.0")
|
||||||
|
port = server_config.get("port", 8000)
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
uvicorn.run(
|
||||||
|
"vna_system.api.main:app",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
log_level="info",
|
||||||
|
reload=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
vna_system/api/websockets/__init__.py
Normal file
1
vna_system/api/websockets/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""WebSocket routes module."""
|
||||||
16
vna_system/api/websockets/processing.py
Normal file
16
vna_system/api/websockets/processing.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
WebSocket routes for processing data streaming.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket
|
||||||
|
import vna_system.core.singletons as singletons
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/processing")
|
||||||
|
async def processing_websocket_endpoint(websocket: WebSocket):
|
||||||
|
"""WebSocket endpoint for real-time processing data streaming."""
|
||||||
|
|
||||||
|
await singletons.websocket_manager.handle_websocket(websocket)
|
||||||
165
vna_system/api/websockets/websocket_handler.py
Normal file
165
vna_system/api/websockets/websocket_handler.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
WebSocket handler for real-time sweep data processing results using FastAPI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from vna_system.core.processing.sweep_processor import SweepProcessingManager
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketManager:
|
||||||
|
"""WebSocket manager for streaming processing results to clients."""
|
||||||
|
|
||||||
|
def __init__(self, processing_manager: SweepProcessingManager) -> None:
|
||||||
|
self.processing_manager = processing_manager
|
||||||
|
self.active_connections: List[WebSocket] = []
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket) -> None:
|
||||||
|
"""Accept a new WebSocket connection."""
|
||||||
|
await websocket.accept()
|
||||||
|
self.active_connections.append(websocket)
|
||||||
|
logger.info(f"Client connected. Total clients: {len(self.active_connections)}")
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket) -> None:
|
||||||
|
"""Remove WebSocket connection."""
|
||||||
|
if websocket in self.active_connections:
|
||||||
|
self.active_connections.remove(websocket)
|
||||||
|
logger.info(f"Client disconnected. Total clients: {len(self.active_connections)}")
|
||||||
|
|
||||||
|
async def send_personal_message(self, message: Dict[str, Any], websocket: WebSocket) -> None:
|
||||||
|
"""Send message to specific websocket."""
|
||||||
|
try:
|
||||||
|
await websocket.send_text(json.dumps(message))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending message to client: {e}")
|
||||||
|
|
||||||
|
async def broadcast(self, message: Dict[str, Any]) -> None:
|
||||||
|
"""Send message to all connected clients."""
|
||||||
|
if not self.active_connections:
|
||||||
|
return
|
||||||
|
|
||||||
|
disconnected = []
|
||||||
|
for connection in self.active_connections:
|
||||||
|
try:
|
||||||
|
await connection.send_text(json.dumps(message))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error broadcasting to client: {e}")
|
||||||
|
disconnected.append(connection)
|
||||||
|
|
||||||
|
# Remove failed connections
|
||||||
|
for connection in disconnected:
|
||||||
|
self.disconnect(connection)
|
||||||
|
|
||||||
|
async def handle_websocket(self, websocket: WebSocket) -> None:
|
||||||
|
"""Handle WebSocket connection."""
|
||||||
|
await self.connect(websocket)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send initial status
|
||||||
|
await self.send_personal_message({
|
||||||
|
"type": "status",
|
||||||
|
"data": {
|
||||||
|
"connected": True,
|
||||||
|
"processing_stats": self.processing_manager.get_processing_stats()
|
||||||
|
}
|
||||||
|
}, websocket)
|
||||||
|
|
||||||
|
# Start processing results stream
|
||||||
|
stream_task = asyncio.create_task(self._stream_results(websocket))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Handle incoming messages
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
try:
|
||||||
|
message = json.loads(data)
|
||||||
|
await self._handle_message(websocket, message)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.send_personal_message({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Invalid JSON format"
|
||||||
|
}, websocket)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("WebSocket disconnected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket message handling error: {e}")
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("WebSocket disconnected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket error: {e}")
|
||||||
|
finally:
|
||||||
|
stream_task.cancel()
|
||||||
|
self.disconnect(websocket)
|
||||||
|
|
||||||
|
async def _handle_message(self, websocket: WebSocket, message: Dict[str, Any]) -> None:
|
||||||
|
"""Handle incoming client message."""
|
||||||
|
message_type = message.get("type")
|
||||||
|
|
||||||
|
if message_type == "get_stats":
|
||||||
|
await self.send_personal_message({
|
||||||
|
"type": "stats",
|
||||||
|
"data": self.processing_manager.get_processing_stats()
|
||||||
|
}, websocket)
|
||||||
|
|
||||||
|
elif message_type == "get_history":
|
||||||
|
processor_name = message.get("processor_name")
|
||||||
|
limit = message.get("limit", 10)
|
||||||
|
|
||||||
|
results = self.processing_manager.get_latest_results(processor_name)[-limit:]
|
||||||
|
await self.send_personal_message({
|
||||||
|
"type": "history",
|
||||||
|
"data": [result.to_dict() for result in results]
|
||||||
|
}, websocket)
|
||||||
|
|
||||||
|
elif message_type == "get_sweep":
|
||||||
|
sweep_number = message.get("sweep_number")
|
||||||
|
processor_name = message.get("processor_name")
|
||||||
|
|
||||||
|
if sweep_number is not None:
|
||||||
|
results = self.processing_manager.get_result_by_sweep(sweep_number, processor_name)
|
||||||
|
await self.send_personal_message({
|
||||||
|
"type": "sweep_data",
|
||||||
|
"data": [result.to_dict() for result in results]
|
||||||
|
}, websocket)
|
||||||
|
else:
|
||||||
|
await self.send_personal_message({
|
||||||
|
"type": "error",
|
||||||
|
"message": "sweep_number is required"
|
||||||
|
}, websocket)
|
||||||
|
|
||||||
|
else:
|
||||||
|
await self.send_personal_message({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"Unknown message type: {message_type}"
|
||||||
|
}, websocket)
|
||||||
|
|
||||||
|
async def _stream_results(self, websocket: WebSocket) -> None:
|
||||||
|
"""Stream processing results to websocket."""
|
||||||
|
while websocket in self.active_connections:
|
||||||
|
try:
|
||||||
|
result = self.processing_manager.get_next_result(timeout=0.1)
|
||||||
|
if result:
|
||||||
|
await self.send_personal_message({
|
||||||
|
"type": "processing_result",
|
||||||
|
"data": result.to_dict()
|
||||||
|
}, websocket)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error streaming results: {e}")
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
Binary file not shown.
BIN
vna_system/binary_logs/current_log.bin
Normal file
BIN
vna_system/binary_logs/current_log.bin
Normal file
Binary file not shown.
120
vna_system/config/config.py
Normal file
120
vna_system/config/config.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Configuration file for VNA data acquisition system
|
||||||
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
# Serial communication settings
|
||||||
|
DEFAULT_BAUD_RATE = 115200
|
||||||
|
DEFAULT_PORT = "/dev/ttyACM0"
|
||||||
|
|
||||||
|
# VNA device identification
|
||||||
|
VNA_VID = 0x0483 # STMicroelectronics
|
||||||
|
VNA_PID = 0x5740 # STM32 Virtual ComPort
|
||||||
|
VNA_MANUFACTURER = "STMicroelectronics"
|
||||||
|
VNA_PRODUCT = "STM32 Virtual ComPort"
|
||||||
|
RX_TIMEOUT = 5.0
|
||||||
|
TX_CHUNK_SIZE = 64 * 1024
|
||||||
|
|
||||||
|
# Sweep detection and parsing constants
|
||||||
|
SWEEP_CMD_LEN = 515
|
||||||
|
SWEEP_CMD_PREFIX = bytes([0xAA, 0x00, 0xDA])
|
||||||
|
MEAS_HEADER_LEN = 21
|
||||||
|
MEAS_CMDS_PER_SWEEP = 17
|
||||||
|
EXPECTED_POINTS_PER_SWEEP = 1000
|
||||||
|
|
||||||
|
# Buffer settings
|
||||||
|
SWEEP_BUFFER_MAX_SIZE = 100 # Maximum number of sweeps to store in circular buffer
|
||||||
|
SERIAL_BUFFER_SIZE = 512 * 1024
|
||||||
|
|
||||||
|
# Log file settings
|
||||||
|
BIN_LOG_FILE_PATH = "./vna_system/binary_logs/current_log.bin" # Symbolic link to the current log file
|
||||||
|
|
||||||
|
# Binary log format constants
|
||||||
|
MAGIC = b"VNALOG1\n"
|
||||||
|
DIR_TO_DEV = 0x01 # '>'
|
||||||
|
DIR_FROM_DEV = 0x00 # '<'
|
||||||
|
|
||||||
|
# File I/O settings
|
||||||
|
FILE_CHUNK_SIZE = 256 * 1024
|
||||||
|
SERIAL_PEEK_SIZE = 32
|
||||||
|
|
||||||
|
# Timeout settings
|
||||||
|
SERIAL_IDLE_TIMEOUT = 0.5
|
||||||
|
SERIAL_DRAIN_DELAY = 0.05
|
||||||
|
SERIAL_DRAIN_CHECK_DELAY = 0.01
|
||||||
|
SERIAL_CONNECT_DELAY = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def find_vna_port():
|
||||||
|
"""
|
||||||
|
Automatically find VNA device port.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Port path (e.g., '/dev/ttyACM1') or None if not found
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Method 1: Use pyserial port detection by VID/PID
|
||||||
|
try:
|
||||||
|
ports = list(serial.tools.list_ports.comports())
|
||||||
|
logger.debug(f"Found {len(ports)} serial ports")
|
||||||
|
|
||||||
|
for port in ports:
|
||||||
|
logger.debug(f"Checking port {port.device}: VID={port.vid:04X} PID={port.pid:04X} "
|
||||||
|
f"Manufacturer='{port.manufacturer}' Product='{port.description}'")
|
||||||
|
|
||||||
|
# Check by VID/PID
|
||||||
|
if port.vid == VNA_VID and port.pid == VNA_PID:
|
||||||
|
logger.info(f"Found VNA device by VID/PID at {port.device}")
|
||||||
|
return port.device
|
||||||
|
|
||||||
|
# Fallback: Check by manufacturer/product strings
|
||||||
|
if (port.manufacturer and VNA_MANUFACTURER.lower() in port.manufacturer.lower() and
|
||||||
|
port.description and VNA_PRODUCT.lower() in port.description.lower()):
|
||||||
|
logger.info(f"Found VNA device by description at {port.device}")
|
||||||
|
return port.device
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error during VID/PID port detection: {e}")
|
||||||
|
|
||||||
|
# Method 2: Search ttyACM devices (Linux-specific)
|
||||||
|
try:
|
||||||
|
acm_ports = glob.glob('/dev/ttyACM*')
|
||||||
|
logger.debug(f"Found ACM ports: {acm_ports}")
|
||||||
|
|
||||||
|
if acm_ports:
|
||||||
|
# Sort to get consistent ordering (ttyACM0, ttyACM1, etc.)
|
||||||
|
acm_ports.sort()
|
||||||
|
logger.info(f"Using first available ACM port: {acm_ports[0]}")
|
||||||
|
return acm_ports[0]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error during ACM port detection: {e}")
|
||||||
|
|
||||||
|
# Method 3: Fallback to default
|
||||||
|
logger.warning(f"VNA device not found, using default port: {DEFAULT_PORT}")
|
||||||
|
return DEFAULT_PORT
|
||||||
|
|
||||||
|
|
||||||
|
def get_vna_port():
|
||||||
|
"""
|
||||||
|
Get VNA port, trying auto-detection first, then falling back to default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Port path to use for VNA connection
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
port = find_vna_port()
|
||||||
|
if port and port != DEFAULT_PORT:
|
||||||
|
logger.info(f"Auto-detected VNA port: {port}")
|
||||||
|
return port
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Port detection failed: {e}")
|
||||||
|
logger.info(f"Using default port: {DEFAULT_PORT}")
|
||||||
|
return DEFAULT_PORT
|
||||||
405
vna_system/core/acquisition/data_acquisition.py
Normal file
405
vna_system/core/acquisition/data_acquisition.py
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import BinaryIO, List, Optional, Tuple
|
||||||
|
|
||||||
|
import serial
|
||||||
|
|
||||||
|
# Avoid wildcard imports: make config access explicit and discoverable.
|
||||||
|
from vna_system.config import config as cfg
|
||||||
|
from vna_system.core.acquisition.sweep_buffer import SweepBuffer
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VNADataAcquisition:
|
||||||
|
"""Main data acquisition class with asynchronous sweep collection."""
|
||||||
|
|
||||||
|
def __init__(self, port: str = None, baud: int = cfg.DEFAULT_BAUD_RATE) -> None:
|
||||||
|
self.bin_log_path: str = cfg.BIN_LOG_FILE_PATH
|
||||||
|
|
||||||
|
# Auto-detect port if not specified
|
||||||
|
if port is None:
|
||||||
|
self.port: str = cfg.get_vna_port()
|
||||||
|
logger.info(f"Using auto-detected port: {self.port}")
|
||||||
|
else:
|
||||||
|
self.port: str = port
|
||||||
|
logger.info(f"Using specified port: {self.port}")
|
||||||
|
|
||||||
|
self.baud: int = baud
|
||||||
|
|
||||||
|
self._sweep_buffer = SweepBuffer()
|
||||||
|
self.ser: Optional[serial.SerialBase] = None
|
||||||
|
|
||||||
|
# Control flags
|
||||||
|
self._running: bool = False
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_event: threading.Event = threading.Event()
|
||||||
|
|
||||||
|
# Sweep collection state
|
||||||
|
self._collecting: bool = False
|
||||||
|
self._collected_rx_payloads: List[bytes] = []
|
||||||
|
self._meas_cmds_in_sweep: int = 0
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# Lifecycle
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the data acquisition background thread."""
|
||||||
|
if self._running:
|
||||||
|
logger.debug("Acquisition already running; start() call ignored.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = threading.Thread(target=self._acquisition_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Acquisition thread started.")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the data acquisition background thread."""
|
||||||
|
if not self._running:
|
||||||
|
logger.debug("Acquisition not running; stop() call ignored.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
self._thread.join(timeout=5.0)
|
||||||
|
logger.info("Acquisition thread joined.")
|
||||||
|
|
||||||
|
self._close_serial()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Return True if the acquisition thread is alive."""
|
||||||
|
return self._running and (self._thread is not None and self._thread.is_alive())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sweep_buffer(self) -> SweepBuffer:
|
||||||
|
"""Return a reference to the sweep buffer."""
|
||||||
|
return self._sweep_buffer
|
||||||
|
|
||||||
|
def get_device_info(self) -> dict:
|
||||||
|
"""Get information about the connected VNA device."""
|
||||||
|
info = {
|
||||||
|
'port': self.port,
|
||||||
|
'baud_rate': self.baud,
|
||||||
|
'is_connected': self.ser is not None and self.ser.is_open if self.ser else False,
|
||||||
|
'is_running': self.is_running,
|
||||||
|
'device_detected': False,
|
||||||
|
'device_details': None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to get device details from port detection
|
||||||
|
try:
|
||||||
|
from serial.tools.list_ports import comports
|
||||||
|
for port in comports():
|
||||||
|
if port.device == self.port:
|
||||||
|
info['device_detected'] = True
|
||||||
|
info['device_details'] = {
|
||||||
|
'vid': f"0x{port.vid:04X}" if port.vid else None,
|
||||||
|
'pid': f"0x{port.pid:04X}" if port.pid else None,
|
||||||
|
'manufacturer': port.manufacturer,
|
||||||
|
'product': port.description,
|
||||||
|
'serial_number': port.serial_number
|
||||||
|
}
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get device details: {e}")
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# Serial management
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _connect_serial(self) -> bool:
|
||||||
|
"""Establish the serial connection."""
|
||||||
|
try:
|
||||||
|
logger.info(f"Attempting to connect to VNA device at {self.port} @ {self.baud} baud")
|
||||||
|
|
||||||
|
# Check if port exists
|
||||||
|
import os
|
||||||
|
if not os.path.exists(self.port):
|
||||||
|
logger.error(f"Port {self.port} does not exist. Device not connected?")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not os.access(self.port, os.R_OK | os.W_OK):
|
||||||
|
logger.error(f"No read/write permissions for {self.port}. Try: sudo chmod 666 {self.port}")
|
||||||
|
logger.error("Or add your user to the dialout group: sudo usermod -a -G dialout $USER")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.ser = serial.Serial(self.port, baudrate=self.baud, timeout=0)
|
||||||
|
time.sleep(cfg.SERIAL_CONNECT_DELAY)
|
||||||
|
self._drain_serial_input()
|
||||||
|
logger.info(f"Serial connection established to {self.port}")
|
||||||
|
return True
|
||||||
|
except PermissionError as exc:
|
||||||
|
logger.error(f"Permission denied for {self.port}: {exc}")
|
||||||
|
logger.error("Try: sudo chmod 666 {self.port} or add user to dialout group")
|
||||||
|
return False
|
||||||
|
except serial.SerialException as exc:
|
||||||
|
logger.error(f"Serial connection failed for {self.port}: {exc}")
|
||||||
|
return False
|
||||||
|
except Exception as exc: # noqa: BLE001 (keep broad to preserve behavior)
|
||||||
|
logger.error(f"Unexpected error connecting to {self.port}: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _close_serial(self) -> None:
|
||||||
|
"""Close the serial connection if open."""
|
||||||
|
if self.ser:
|
||||||
|
try:
|
||||||
|
self.ser.close()
|
||||||
|
logger.debug("Serial connection closed.")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
# Preserve original behavior: swallow any serial close errors.
|
||||||
|
logger.debug("Ignoring error while closing serial.", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self.ser = None
|
||||||
|
|
||||||
|
def _drain_serial_input(self) -> None:
|
||||||
|
"""Drain any pending bytes from the serial input buffer."""
|
||||||
|
if not self.ser:
|
||||||
|
return
|
||||||
|
|
||||||
|
drained = 0
|
||||||
|
while True:
|
||||||
|
bytes_waiting = getattr(self.ser, "in_waiting", 0)
|
||||||
|
if bytes_waiting <= 0:
|
||||||
|
break
|
||||||
|
drained += len(self.ser.read(bytes_waiting))
|
||||||
|
time.sleep(cfg.SERIAL_DRAIN_CHECK_DELAY)
|
||||||
|
if drained:
|
||||||
|
logger.debug("Drained %d pending byte(s) from serial input.", drained)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# Acquisition loop
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _acquisition_loop(self) -> None:
|
||||||
|
"""Main acquisition loop executed by the background thread."""
|
||||||
|
while self._running and not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
if not self._connect_serial():
|
||||||
|
time.sleep(1.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with open(self.bin_log_path, "rb") as raw:
|
||||||
|
buffered = io.BufferedReader(raw, buffer_size=cfg.SERIAL_BUFFER_SIZE)
|
||||||
|
self._drain_serial_input()
|
||||||
|
self._process_sweep_data(buffered)
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("Acquisition error: %s", exc)
|
||||||
|
time.sleep(1.0)
|
||||||
|
finally:
|
||||||
|
self._close_serial()
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# Log processing
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _process_sweep_data(self, f: BinaryIO) -> None:
|
||||||
|
"""Process the binary log file and collect sweep data one sweep at a time."""
|
||||||
|
while self._running and not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
# Start from beginning of file for each sweep
|
||||||
|
f.seek(0)
|
||||||
|
|
||||||
|
# Validate header
|
||||||
|
header = self._read_exact(f, len(cfg.MAGIC))
|
||||||
|
if header != cfg.MAGIC:
|
||||||
|
raise ValueError("Invalid log format: MAGIC header mismatch.")
|
||||||
|
|
||||||
|
self._reset_sweep_state()
|
||||||
|
|
||||||
|
# Process one complete sweep
|
||||||
|
sweep_completed = False
|
||||||
|
while not sweep_completed and self._running and not self._stop_event.is_set():
|
||||||
|
# Read record header
|
||||||
|
dir_byte = f.read(1)
|
||||||
|
if not dir_byte:
|
||||||
|
# EOF reached without completing sweep - wait and retry
|
||||||
|
logger.debug("EOF reached, waiting for more data...")
|
||||||
|
time.sleep(0.1)
|
||||||
|
break
|
||||||
|
|
||||||
|
direction = dir_byte[0]
|
||||||
|
(length,) = struct.unpack(">I", self._read_exact(f, 4))
|
||||||
|
|
||||||
|
if direction == cfg.DIR_TO_DEV:
|
||||||
|
# TX path: stream to device and inspect for sweep start
|
||||||
|
first = self._serial_write_from_file(f, length)
|
||||||
|
|
||||||
|
if not self._collecting and self._is_sweep_start_command(length, first):
|
||||||
|
self._collecting = True
|
||||||
|
self._collected_rx_payloads = []
|
||||||
|
self._meas_cmds_in_sweep = 0
|
||||||
|
logger.info("Starting sweep data collection from device")
|
||||||
|
|
||||||
|
elif direction == cfg.DIR_FROM_DEV:
|
||||||
|
# RX path: read exact number of bytes from device
|
||||||
|
rx_bytes = self._serial_read_exact(length, capture=self._collecting)
|
||||||
|
self._skip_bytes(f, length) # Keep log file pointer in sync
|
||||||
|
|
||||||
|
if self._collecting:
|
||||||
|
self._collected_rx_payloads.append(rx_bytes)
|
||||||
|
self._meas_cmds_in_sweep += 1
|
||||||
|
|
||||||
|
# Check for sweep completion
|
||||||
|
if self._meas_cmds_in_sweep >= cfg.MEAS_CMDS_PER_SWEEP:
|
||||||
|
self._finalize_sweep()
|
||||||
|
sweep_completed = True
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("Processing error: %s", exc)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
def _finalize_sweep(self) -> None:
|
||||||
|
"""Parse collected payloads into points and push to the buffer."""
|
||||||
|
all_points: List[Tuple[float, float]] = []
|
||||||
|
for payload in self._collected_rx_payloads:
|
||||||
|
all_points.extend(self._parse_measurement_data(payload))
|
||||||
|
|
||||||
|
if all_points:
|
||||||
|
sweep_number = self._sweep_buffer.add_sweep(all_points)
|
||||||
|
logger.info(f"Collected sweep #{sweep_number} with {len(all_points)} data points")
|
||||||
|
if len(all_points) != cfg.EXPECTED_POINTS_PER_SWEEP:
|
||||||
|
logger.warning(
|
||||||
|
"Expected %d points, got %d.",
|
||||||
|
cfg.EXPECTED_POINTS_PER_SWEEP,
|
||||||
|
len(all_points),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._reset_sweep_state()
|
||||||
|
|
||||||
|
def _reset_sweep_state(self) -> None:
|
||||||
|
"""Reset internal state for the next sweep collection."""
|
||||||
|
self._collecting = False
|
||||||
|
self._collected_rx_payloads = []
|
||||||
|
self._meas_cmds_in_sweep = 0
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# I/O helpers
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _read_exact(self, f: BinaryIO, n: int) -> bytes:
|
||||||
|
"""Read exactly *n* bytes from a file-like object or raise EOFError."""
|
||||||
|
buf = bytearray()
|
||||||
|
while len(buf) < n:
|
||||||
|
chunk = f.read(n - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError(f"Unexpected EOF while reading {n} bytes.")
|
||||||
|
buf += chunk
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
def _skip_bytes(self, f: BinaryIO, n: int) -> None:
|
||||||
|
"""Skip *n* bytes in a file-like object efficiently."""
|
||||||
|
if hasattr(f, "seek"):
|
||||||
|
try:
|
||||||
|
f.seek(n, os.SEEK_CUR)
|
||||||
|
return
|
||||||
|
except (OSError, io.UnsupportedOperation):
|
||||||
|
# Fall back to manual skipping below.
|
||||||
|
pass
|
||||||
|
|
||||||
|
remaining = n
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = f.read(min(cfg.FILE_CHUNK_SIZE, remaining))
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError(f"Unexpected EOF while skipping {n} bytes.")
|
||||||
|
remaining -= len(chunk)
|
||||||
|
|
||||||
|
def _serial_write_from_file(self, f: BinaryIO, nbytes: int) -> bytes:
|
||||||
|
"""
|
||||||
|
Stream *nbytes* from a file-like object to the serial port.
|
||||||
|
|
||||||
|
Returns the first few bytes (up to cfg.SERIAL_PEEK_SIZE) for command inspection.
|
||||||
|
"""
|
||||||
|
first = bytearray()
|
||||||
|
remaining = nbytes
|
||||||
|
|
||||||
|
while remaining > 0:
|
||||||
|
to_read = min(cfg.TX_CHUNK_SIZE, remaining)
|
||||||
|
chunk = f.read(to_read)
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError("Log truncated while sending.")
|
||||||
|
|
||||||
|
# Capture a peek for command inspection
|
||||||
|
needed = max(0, cfg.SERIAL_PEEK_SIZE - len(first))
|
||||||
|
if needed:
|
||||||
|
first.extend(chunk[:needed])
|
||||||
|
|
||||||
|
# Write to serial
|
||||||
|
written = 0
|
||||||
|
while written < len(chunk):
|
||||||
|
if not self.ser:
|
||||||
|
break
|
||||||
|
n = self.ser.write(chunk[written:])
|
||||||
|
if n is None:
|
||||||
|
n = 0
|
||||||
|
written += n
|
||||||
|
|
||||||
|
remaining -= len(chunk)
|
||||||
|
|
||||||
|
return bytes(first)
|
||||||
|
|
||||||
|
def _serial_read_exact(self, nbytes: int, capture: bool = False) -> bytes:
|
||||||
|
"""Read exactly *nbytes* from the serial port; optionally capture and return them."""
|
||||||
|
if not self.ser:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
deadline = time.monotonic() + cfg.RX_TIMEOUT
|
||||||
|
total = 0
|
||||||
|
out = bytearray() if capture else None
|
||||||
|
old_timeout = self.ser.timeout
|
||||||
|
self.ser.timeout = min(cfg.SERIAL_IDLE_TIMEOUT, cfg.RX_TIMEOUT)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while total < nbytes:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise TimeoutError(f"Timeout while waiting for {nbytes} bytes.")
|
||||||
|
chunk = self.ser.read(nbytes - total)
|
||||||
|
if chunk:
|
||||||
|
total += len(chunk)
|
||||||
|
if capture and out is not None:
|
||||||
|
out.extend(chunk)
|
||||||
|
return bytes(out) if (capture and out is not None) else b""
|
||||||
|
finally:
|
||||||
|
self.ser.timeout = old_timeout
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# Parsing & detection
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _parse_measurement_data(self, payload: bytes) -> List[Tuple[float, float]]:
|
||||||
|
"""Parse complex measurement samples (float32 pairs) from a payload."""
|
||||||
|
if len(payload) <= cfg.MEAS_HEADER_LEN:
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = memoryview(payload)[cfg.MEAS_HEADER_LEN:]
|
||||||
|
out: List[Tuple[float, float]] = []
|
||||||
|
n_pairs = len(data) // 8 # 2 × float32 per point
|
||||||
|
|
||||||
|
for i in range(n_pairs):
|
||||||
|
off = i * 8
|
||||||
|
real = struct.unpack_from("<f", data, off)[0]
|
||||||
|
imag = struct.unpack_from("<f", data, off + 4)[0]
|
||||||
|
out.append((real, imag))
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _is_sweep_start_command(self, tx_len: int, first_bytes: bytes) -> bool:
|
||||||
|
"""Return True if a TX command indicates the start of a sweep."""
|
||||||
|
return tx_len == cfg.SWEEP_CMD_LEN and first_bytes.startswith(cfg.SWEEP_CMD_PREFIX)
|
||||||
|
|
||||||
91
vna_system/core/acquisition/sweep_buffer.py
Normal file
91
vna_system/core/acquisition/sweep_buffer.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Sweep data structures and buffer management for VNA data acquisition.
|
||||||
|
|
||||||
|
This module contains classes for storing and managing sweep data in a thread-safe manner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Tuple, Optional, Dict, Any
|
||||||
|
|
||||||
|
from vna_system.config.config import SWEEP_BUFFER_MAX_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SweepData:
|
||||||
|
"""Container for a single sweep with metadata"""
|
||||||
|
sweep_number: int
|
||||||
|
timestamp: float
|
||||||
|
points: List[Tuple[float, float]] # Complex pairs (real, imag)
|
||||||
|
total_points: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def magnitude_phase_data(self) -> List[Tuple[float, float, float, float]]:
|
||||||
|
"""Convert to magnitude/phase representation"""
|
||||||
|
result = []
|
||||||
|
for real, imag in self.points:
|
||||||
|
magnitude = (real * real + imag * imag) ** 0.5
|
||||||
|
phase = math.atan2(imag, real) if (real != 0.0 or imag != 0.0) else 0.0
|
||||||
|
result.append((real, imag, magnitude, phase))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class SweepBuffer:
|
||||||
|
"""Thread-safe circular buffer for sweep data"""
|
||||||
|
|
||||||
|
def __init__(self, max_size: int = SWEEP_BUFFER_MAX_SIZE, initial_sweep_number: int = 0):
|
||||||
|
self._buffer = deque(maxlen=max_size)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._sweep_counter = initial_sweep_number
|
||||||
|
|
||||||
|
def add_sweep(self, points: List[Tuple[float, float]]) -> int:
|
||||||
|
"""Add a new sweep to the buffer and return its number"""
|
||||||
|
with self._lock:
|
||||||
|
self._sweep_counter += 1
|
||||||
|
sweep = SweepData(
|
||||||
|
sweep_number=self._sweep_counter,
|
||||||
|
timestamp=time.time(),
|
||||||
|
points=points,
|
||||||
|
total_points=len(points)
|
||||||
|
)
|
||||||
|
self._buffer.append(sweep)
|
||||||
|
return self._sweep_counter
|
||||||
|
|
||||||
|
def get_latest_sweep(self) -> Optional[SweepData]:
|
||||||
|
"""Get the most recent sweep"""
|
||||||
|
with self._lock:
|
||||||
|
return self._buffer[-1] if self._buffer else None
|
||||||
|
|
||||||
|
def get_sweep_by_number(self, sweep_number: int) -> Optional[SweepData]:
|
||||||
|
"""Get a specific sweep by its number"""
|
||||||
|
with self._lock:
|
||||||
|
for sweep in reversed(self._buffer):
|
||||||
|
if sweep.sweep_number == sweep_number:
|
||||||
|
return sweep
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all_sweeps(self) -> List[SweepData]:
|
||||||
|
"""Get all sweeps currently in buffer"""
|
||||||
|
with self._lock:
|
||||||
|
return list(self._buffer)
|
||||||
|
|
||||||
|
def get_buffer_info(self) -> Dict[str, Any]:
|
||||||
|
"""Get buffer status information"""
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
'current_size': len(self._buffer),
|
||||||
|
'max_size': self._buffer.maxlen,
|
||||||
|
'total_sweeps_processed': self._sweep_counter,
|
||||||
|
'latest_sweep_number': self._buffer[-1].sweep_number if self._buffer else None,
|
||||||
|
'oldest_sweep_number': self._buffer[0].sweep_number if self._buffer else None,
|
||||||
|
'next_sweep_number': self._sweep_counter + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_sweep_counter(self, sweep_number: int) -> None:
|
||||||
|
"""Set the sweep counter to continue from a specific number."""
|
||||||
|
with self._lock:
|
||||||
|
self._sweep_counter = sweep_number
|
||||||
1
vna_system/core/processing/__init__.py
Normal file
1
vna_system/core/processing/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Sweep data processing module."""
|
||||||
63
vna_system/core/processing/base_processor.py
Normal file
63
vna_system/core/processing/base_processor.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Base sweep processor interface and utilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from vna_system.core.acquisition.sweep_buffer import SweepData
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSweepProcessor(abc.ABC):
|
||||||
|
"""Abstract base class for sweep data processors."""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
|
"""Initialize processor with configuration."""
|
||||||
|
self.config = config
|
||||||
|
self.enabled = config.get("enabled", True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return processor name."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def process(self, sweep: SweepData) -> Optional[ProcessingResult]:
|
||||||
|
"""Process a sweep and return results."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def should_process(self, sweep: SweepData) -> bool:
|
||||||
|
"""Check if this processor should process the given sweep."""
|
||||||
|
return self.enabled
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingResult:
|
||||||
|
"""Result of sweep processing."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
processor_name: str,
|
||||||
|
sweep_number: int,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
plotly_figure: Optional[Dict[str, Any]] = None,
|
||||||
|
file_path: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
self.processor_name = processor_name
|
||||||
|
self.sweep_number = sweep_number
|
||||||
|
self.data = data
|
||||||
|
self.plotly_figure = plotly_figure
|
||||||
|
self.file_path = file_path
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert result to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"processor_name": self.processor_name,
|
||||||
|
"sweep_number": self.sweep_number,
|
||||||
|
"data": self.data,
|
||||||
|
"plotly_figure": self.plotly_figure,
|
||||||
|
"file_path": self.file_path,
|
||||||
|
}
|
||||||
20
vna_system/core/processing/config.json
Normal file
20
vna_system/core/processing/config.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"processors": {
|
||||||
|
"magnitude_plot": {
|
||||||
|
"enabled": true,
|
||||||
|
"output_dir": "./plots/magnitude",
|
||||||
|
"save_image": true,
|
||||||
|
"image_format": "png",
|
||||||
|
"width": 800,
|
||||||
|
"height": 600
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"processing": {
|
||||||
|
"queue_max_size": 1000
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"dir": "./processing_results",
|
||||||
|
"max_sweeps": 10000,
|
||||||
|
"sweeps_per_subdir": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
1
vna_system/core/processing/processors/__init__.py
Normal file
1
vna_system/core/processing/processors/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Sweep data processors."""
|
||||||
158
vna_system/core/processing/processors/magnitude_plot.py
Normal file
158
vna_system/core/processing/processors/magnitude_plot.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Magnitude plot processor for sweep data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg') # Use non-interactive backend
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
from vna_system.core.acquisition.sweep_buffer import SweepData
|
||||||
|
from vna_system.core.processing.base_processor import BaseSweepProcessor, ProcessingResult
|
||||||
|
|
||||||
|
|
||||||
|
class MagnitudePlotProcessor(BaseSweepProcessor):
|
||||||
|
"""Processor that creates magnitude plots from sweep data."""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
|
super().__init__(config)
|
||||||
|
self.output_dir = Path(config.get("output_dir", "./plots"))
|
||||||
|
self.save_image = config.get("save_image", True)
|
||||||
|
self.image_format = config.get("image_format", "png")
|
||||||
|
self.width = config.get("width", 800)
|
||||||
|
self.height = config.get("height", 600)
|
||||||
|
|
||||||
|
# Create output directory if it doesn't exist
|
||||||
|
if self.save_image:
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "magnitude_plot"
|
||||||
|
|
||||||
|
def process(self, sweep: SweepData) -> Optional[ProcessingResult]:
|
||||||
|
"""Process sweep data and create magnitude plot."""
|
||||||
|
if not self.should_process(sweep):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract magnitude data
|
||||||
|
magnitude_data = self._extract_magnitude_data(sweep)
|
||||||
|
if not magnitude_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create plotly figure for websocket/API consumption
|
||||||
|
plotly_fig = self._create_plotly_figure(sweep, magnitude_data)
|
||||||
|
|
||||||
|
# Save image if requested (using matplotlib)
|
||||||
|
file_path = None
|
||||||
|
if self.save_image:
|
||||||
|
file_path = self._save_matplotlib_image(sweep, magnitude_data)
|
||||||
|
|
||||||
|
# Prepare result data
|
||||||
|
result_data = {
|
||||||
|
"sweep_number": sweep.sweep_number,
|
||||||
|
"timestamp": sweep.timestamp,
|
||||||
|
"total_points": sweep.total_points,
|
||||||
|
"magnitude_stats": self._calculate_magnitude_stats(magnitude_data),
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProcessingResult(
|
||||||
|
processor_name=self.name,
|
||||||
|
sweep_number=sweep.sweep_number,
|
||||||
|
data=result_data,
|
||||||
|
plotly_figure=plotly_fig.to_dict(),
|
||||||
|
file_path=str(file_path) if file_path else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_magnitude_data(self, sweep: SweepData) -> List[Tuple[float, float]]:
|
||||||
|
"""Extract magnitude data from sweep points."""
|
||||||
|
magnitude_data = []
|
||||||
|
for i, (real, imag) in enumerate(sweep.points):
|
||||||
|
magnitude = np.sqrt(real**2 + imag**2)
|
||||||
|
magnitude_data.append((i, magnitude))
|
||||||
|
return magnitude_data
|
||||||
|
|
||||||
|
def _create_plotly_figure(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]]) -> go.Figure:
|
||||||
|
"""Create plotly figure for magnitude plot."""
|
||||||
|
indices = [point[0] for point in magnitude_data]
|
||||||
|
magnitudes = [point[1] for point in magnitude_data]
|
||||||
|
|
||||||
|
fig = go.Figure()
|
||||||
|
|
||||||
|
fig.add_trace(go.Scatter(
|
||||||
|
x=indices,
|
||||||
|
y=magnitudes,
|
||||||
|
mode='lines',
|
||||||
|
name='Magnitude',
|
||||||
|
line=dict(color='blue', width=2)
|
||||||
|
))
|
||||||
|
|
||||||
|
fig.update_layout(
|
||||||
|
title=f'Magnitude Plot - Sweep #{sweep.sweep_number}',
|
||||||
|
xaxis_title='Point Index',
|
||||||
|
yaxis_title='Magnitude',
|
||||||
|
width=self.width,
|
||||||
|
height=self.height,
|
||||||
|
template='plotly_white',
|
||||||
|
showlegend=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return fig
|
||||||
|
|
||||||
|
def _save_matplotlib_image(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]]) -> Optional[Path]:
|
||||||
|
"""Save plot as image file using matplotlib."""
|
||||||
|
try:
|
||||||
|
filename = f"magnitude_sweep_{sweep.sweep_number:06d}.{self.image_format}"
|
||||||
|
file_path = self.output_dir / filename
|
||||||
|
|
||||||
|
# Extract data for plotting
|
||||||
|
indices = [point[0] for point in magnitude_data]
|
||||||
|
magnitudes = [point[1] for point in magnitude_data]
|
||||||
|
|
||||||
|
# Create matplotlib figure
|
||||||
|
fig, ax = plt.subplots(figsize=(self.width/100, self.height/100), dpi=100)
|
||||||
|
ax.plot(indices, magnitudes, 'b-', linewidth=2, label='Magnitude')
|
||||||
|
|
||||||
|
ax.set_title(f'Magnitude Plot - Sweep #{sweep.sweep_number}')
|
||||||
|
ax.set_xlabel('Point Index')
|
||||||
|
ax.set_ylabel('Magnitude')
|
||||||
|
ax.legend()
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# Add info text
|
||||||
|
info_text = f'Timestamp: {sweep.timestamp:.3f}s\nTotal Points: {sweep.total_points}'
|
||||||
|
ax.text(0.02, 0.98, info_text, transform=ax.transAxes,
|
||||||
|
verticalalignment='top', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
|
||||||
|
|
||||||
|
# Save figure
|
||||||
|
plt.savefig(str(file_path), bbox_inches='tight', dpi=100)
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
return file_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to save matplotlib image: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _calculate_magnitude_stats(self, magnitude_data: List[Tuple[float, float]]) -> Dict[str, float]:
|
||||||
|
"""Calculate basic statistics for magnitude data."""
|
||||||
|
if not magnitude_data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
magnitudes = [point[1] for point in magnitude_data]
|
||||||
|
magnitudes_array = np.array(magnitudes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"min": float(np.min(magnitudes_array)),
|
||||||
|
"max": float(np.max(magnitudes_array)),
|
||||||
|
"mean": float(np.mean(magnitudes_array)),
|
||||||
|
"std": float(np.std(magnitudes_array)),
|
||||||
|
"median": float(np.median(magnitudes_array)),
|
||||||
|
}
|
||||||
295
vna_system/core/processing/results_storage.py
Normal file
295
vna_system/core/processing/results_storage.py
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from vna_system.core.processing.base_processor import ProcessingResult
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ResultsStorage:
|
||||||
|
"""Sweep-based file storage for processing results."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
storage_dir: str = "processing_results",
|
||||||
|
max_sweeps: int = 10000,
|
||||||
|
sweeps_per_subdir: int = 1000
|
||||||
|
) -> None:
|
||||||
|
self.storage_dir = Path(storage_dir)
|
||||||
|
self.max_sweeps = max_sweeps
|
||||||
|
self.sweeps_per_subdir = sweeps_per_subdir
|
||||||
|
|
||||||
|
# Thread safety
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
# Create storage directory
|
||||||
|
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# In-memory cache for quick lookup: {sweep_number: {processor_name: file_path, ...}}
|
||||||
|
self._cache: Dict[int, Dict[str, str]] = {}
|
||||||
|
self._build_cache()
|
||||||
|
|
||||||
|
def _build_cache(self) -> None:
|
||||||
|
"""Build in-memory cache by scanning storage directory."""
|
||||||
|
logger.info("Building results cache...")
|
||||||
|
self._cache = {}
|
||||||
|
|
||||||
|
# Scan all sweep subdirectories
|
||||||
|
for subdir in self.storage_dir.iterdir():
|
||||||
|
if subdir.is_dir() and subdir.name.startswith("sweeps_"):
|
||||||
|
# Scan sweep directories within subdirectory
|
||||||
|
for sweep_dir in subdir.iterdir():
|
||||||
|
if sweep_dir.is_dir() and sweep_dir.name.startswith("sweep_"):
|
||||||
|
try:
|
||||||
|
sweep_number = int(sweep_dir.name.split("_")[1])
|
||||||
|
self._cache[sweep_number] = {}
|
||||||
|
|
||||||
|
# Find all processor files in this sweep
|
||||||
|
for result_file in sweep_dir.glob("*.json"):
|
||||||
|
if result_file.name != "metadata.json":
|
||||||
|
processor_name = result_file.stem
|
||||||
|
self._cache[sweep_number][processor_name] = str(result_file)
|
||||||
|
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logger.warning(f"Invalid sweep directory name: {sweep_dir.name}")
|
||||||
|
|
||||||
|
logger.info(f"Built cache with {len(self._cache)} sweeps")
|
||||||
|
|
||||||
|
def _get_sweep_dir(self, sweep_number: int) -> Path:
|
||||||
|
"""Get directory path for specific sweep."""
|
||||||
|
# Group sweeps in subdirectories to avoid too many directories
|
||||||
|
subdir_index = sweep_number // self.sweeps_per_subdir
|
||||||
|
subdir_name = f"sweeps_{subdir_index:03d}_{(subdir_index + 1) * self.sweeps_per_subdir - 1:06d}"
|
||||||
|
|
||||||
|
sweep_name = f"sweep_{sweep_number:06d}"
|
||||||
|
sweep_dir = self.storage_dir / subdir_name / sweep_name
|
||||||
|
sweep_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return sweep_dir
|
||||||
|
|
||||||
|
def _get_result_file_path(self, sweep_number: int, processor_name: str) -> Path:
|
||||||
|
"""Get file path for specific sweep and processor result."""
|
||||||
|
sweep_dir = self._get_sweep_dir(sweep_number)
|
||||||
|
return sweep_dir / f"{processor_name}.json"
|
||||||
|
|
||||||
|
def _get_metadata_file_path(self, sweep_number: int) -> Path:
|
||||||
|
"""Get metadata file path for specific sweep."""
|
||||||
|
sweep_dir = self._get_sweep_dir(sweep_number)
|
||||||
|
return sweep_dir / "metadata.json"
|
||||||
|
|
||||||
|
def store_result(self, result: ProcessingResult, overwrite: bool = False) -> None:
|
||||||
|
"""Store a processing result."""
|
||||||
|
with self._lock:
|
||||||
|
sweep_number = result.sweep_number
|
||||||
|
processor_name = result.processor_name
|
||||||
|
|
||||||
|
# Get file paths
|
||||||
|
result_file = self._get_result_file_path(sweep_number, processor_name)
|
||||||
|
metadata_file = self._get_metadata_file_path(sweep_number)
|
||||||
|
|
||||||
|
# Skip if result file already exists (don't overwrite unless forced)
|
||||||
|
if result_file.exists() and not overwrite: # SHOULD NOT HAPPEND
|
||||||
|
logger.debug(f"Result file already exists, skipping: {result_file}")
|
||||||
|
# Still update cache for existing file
|
||||||
|
if sweep_number not in self._cache:
|
||||||
|
self._cache[sweep_number] = {}
|
||||||
|
self._cache[sweep_number][processor_name] = str(result_file)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Store processor result
|
||||||
|
with open(result_file, 'w') as f:
|
||||||
|
json.dump(result.to_dict(), f, indent=2)
|
||||||
|
|
||||||
|
# Update/create metadata
|
||||||
|
metadata = {}
|
||||||
|
if metadata_file.exists():
|
||||||
|
try:
|
||||||
|
with open(metadata_file, 'r') as f:
|
||||||
|
metadata = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read metadata: {e}")
|
||||||
|
|
||||||
|
# Update metadata
|
||||||
|
metadata.update({
|
||||||
|
"sweep_number": sweep_number,
|
||||||
|
"timestamp": result.data.get("timestamp", 0),
|
||||||
|
"total_points": result.data.get("total_points", 0),
|
||||||
|
"processors": metadata.get("processors", [])
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add processor to list if not already there
|
||||||
|
if processor_name not in metadata["processors"]:
|
||||||
|
metadata["processors"].append(processor_name)
|
||||||
|
|
||||||
|
# Save metadata
|
||||||
|
with open(metadata_file, 'w') as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
if sweep_number not in self._cache:
|
||||||
|
self._cache[sweep_number] = {}
|
||||||
|
|
||||||
|
self._cache[sweep_number][processor_name] = str(result_file)
|
||||||
|
|
||||||
|
# Cleanup old sweeps if needed
|
||||||
|
self._cleanup_old_sweeps()
|
||||||
|
|
||||||
|
logger.debug(f"Stored result for sweep {sweep_number}, processor {processor_name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store result: {e}")
|
||||||
|
|
||||||
|
def _cleanup_old_sweeps(self) -> None:
|
||||||
|
"""Remove old sweeps if we exceed the limit."""
|
||||||
|
if len(self._cache) > self.max_sweeps:
|
||||||
|
# Sort by sweep number and remove oldest
|
||||||
|
sorted_sweeps = sorted(self._cache.keys())
|
||||||
|
sweeps_to_remove = sorted_sweeps[:len(sorted_sweeps) - self.max_sweeps]
|
||||||
|
|
||||||
|
for sweep_number in sweeps_to_remove:
|
||||||
|
try:
|
||||||
|
sweep_dir = self._get_sweep_dir(sweep_number)
|
||||||
|
|
||||||
|
# Remove all files in sweep directory
|
||||||
|
if sweep_dir.exists():
|
||||||
|
for file in sweep_dir.iterdir():
|
||||||
|
file.unlink()
|
||||||
|
sweep_dir.rmdir()
|
||||||
|
logger.debug(f"Removed sweep directory: {sweep_dir}")
|
||||||
|
|
||||||
|
# Remove from cache
|
||||||
|
del self._cache[sweep_number]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to remove sweep {sweep_number}: {e}")
|
||||||
|
|
||||||
|
def get_latest_results(self, processor_name: Optional[str] = None, limit: int = 10) -> List[ProcessingResult]:
|
||||||
|
"""Get latest processing results."""
|
||||||
|
with self._lock:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Get latest sweep numbers
|
||||||
|
sorted_sweeps = sorted(self._cache.keys(), reverse=True)
|
||||||
|
|
||||||
|
for sweep_number in sorted_sweeps[:limit * 2]: # Get more sweeps to find enough results
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
sweep_results = self.get_result_by_sweep(sweep_number, processor_name)
|
||||||
|
results.extend(sweep_results)
|
||||||
|
|
||||||
|
return results[:limit]
|
||||||
|
|
||||||
|
def get_result_by_sweep(self, sweep_number: int, processor_name: Optional[str] = None) -> List[ProcessingResult]:
|
||||||
|
"""Get processing results for specific sweep number."""
|
||||||
|
with self._lock:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if sweep_number not in self._cache:
|
||||||
|
return results
|
||||||
|
|
||||||
|
sweep_processors = self._cache[sweep_number]
|
||||||
|
|
||||||
|
# Filter by processor name if specified
|
||||||
|
if processor_name:
|
||||||
|
if processor_name in sweep_processors:
|
||||||
|
processors_to_load = [processor_name]
|
||||||
|
else:
|
||||||
|
processors_to_load = []
|
||||||
|
else:
|
||||||
|
processors_to_load = list(sweep_processors.keys())
|
||||||
|
|
||||||
|
# Load results for each processor
|
||||||
|
for proc_name in processors_to_load:
|
||||||
|
file_path = Path(sweep_processors[proc_name])
|
||||||
|
try:
|
||||||
|
if file_path.exists():
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
result_data = json.load(f)
|
||||||
|
|
||||||
|
result = ProcessingResult(
|
||||||
|
processor_name=result_data["processor_name"],
|
||||||
|
sweep_number=result_data["sweep_number"],
|
||||||
|
data=result_data["data"],
|
||||||
|
plotly_figure=result_data.get("plotly_figure"),
|
||||||
|
file_path=result_data.get("file_path")
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load result from {file_path}: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_sweep_metadata(self, sweep_number: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get metadata for specific sweep."""
|
||||||
|
with self._lock:
|
||||||
|
if sweep_number not in self._cache:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata_file = self._get_metadata_file_path(sweep_number)
|
||||||
|
try:
|
||||||
|
if metadata_file.exists():
|
||||||
|
with open(metadata_file, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read metadata for sweep {sweep_number}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_available_sweeps(self, limit: Optional[int] = None) -> List[int]:
|
||||||
|
"""Get list of available sweep numbers."""
|
||||||
|
with self._lock:
|
||||||
|
sorted_sweeps = sorted(self._cache.keys(), reverse=True)
|
||||||
|
if limit:
|
||||||
|
return sorted_sweeps[:limit]
|
||||||
|
return sorted_sweeps
|
||||||
|
|
||||||
|
def get_processors_for_sweep(self, sweep_number: int) -> List[str]:
|
||||||
|
"""Get list of processors that have results for specific sweep."""
|
||||||
|
with self._lock:
|
||||||
|
if sweep_number in self._cache:
|
||||||
|
return list(self._cache[sweep_number].keys())
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_storage_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get storage statistics."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._cache:
|
||||||
|
return {
|
||||||
|
"storage_dir": str(self.storage_dir),
|
||||||
|
"total_sweeps": 0,
|
||||||
|
"processors": {},
|
||||||
|
"oldest_sweep": None,
|
||||||
|
"newest_sweep": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate processor statistics
|
||||||
|
processor_counts = {}
|
||||||
|
for sweep_processors in self._cache.values():
|
||||||
|
for processor_name in sweep_processors.keys():
|
||||||
|
processor_counts[processor_name] = processor_counts.get(processor_name, 0) + 1
|
||||||
|
|
||||||
|
sorted_sweeps = sorted(self._cache.keys())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"storage_dir": str(self.storage_dir),
|
||||||
|
"total_sweeps": len(self._cache),
|
||||||
|
"processors": processor_counts,
|
||||||
|
"oldest_sweep": sorted_sweeps[0] if sorted_sweeps else None,
|
||||||
|
"newest_sweep": sorted_sweeps[-1] if sorted_sweeps else None,
|
||||||
|
"storage_structure": "sweep-based"
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_max_sweep_number(self) -> int:
|
||||||
|
"""Get the maximum sweep number currently stored."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._cache:
|
||||||
|
return 0
|
||||||
|
return max(self._cache.keys())
|
||||||
231
vna_system/core/processing/sweep_processor.py
Normal file
231
vna_system/core/processing/sweep_processor.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from vna_system.core.acquisition.sweep_buffer import SweepBuffer, SweepData
|
||||||
|
from vna_system.core.processing.base_processor import BaseSweepProcessor, ProcessingResult
|
||||||
|
from vna_system.core.processing.processors.magnitude_plot import MagnitudePlotProcessor
|
||||||
|
from vna_system.core.processing.results_storage import ResultsStorage
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SweepProcessingManager:
|
||||||
|
"""Manager for sweep data processing with configurable processors and queue-based results."""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str = "vna_system/core/processing/config.json") -> None:
|
||||||
|
self.processors: List[BaseSweepProcessor] = []
|
||||||
|
self.config: Dict[str, Any] = {}
|
||||||
|
self.sweep_buffer: Optional[SweepBuffer] = None
|
||||||
|
|
||||||
|
# Processing control
|
||||||
|
self._running = False
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._last_processed_sweep = 0
|
||||||
|
|
||||||
|
# Results queue for websocket consumption
|
||||||
|
self.results_queue: queue.Queue[ProcessingResult] = queue.Queue(maxsize=10)
|
||||||
|
|
||||||
|
# Load configuration first
|
||||||
|
self.load_config(config_path)
|
||||||
|
|
||||||
|
# File-based results storage (after config is loaded)
|
||||||
|
storage_config = self.config.get("storage", {})
|
||||||
|
self.results_storage = ResultsStorage(
|
||||||
|
storage_dir=storage_config.get("dir", "processing_results"),
|
||||||
|
max_sweeps=storage_config.get("max_sweeps", 10000),
|
||||||
|
sweeps_per_subdir=storage_config.get("sweeps_per_subdir", 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(self, config_path: str) -> None:
|
||||||
|
"""Load processing configuration from JSON file."""
|
||||||
|
try:
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
self.config = json.load(f)
|
||||||
|
self._initialize_processors()
|
||||||
|
logger.info(f"Loaded processing config from {config_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load config from {config_path}: {e}")
|
||||||
|
|
||||||
|
def _initialize_processors(self) -> None:
|
||||||
|
"""Initialize processors based on configuration."""
|
||||||
|
self.processors.clear()
|
||||||
|
|
||||||
|
processor_configs = self.config.get("processors", {})
|
||||||
|
|
||||||
|
# Initialize magnitude plot processor
|
||||||
|
if "magnitude_plot" in processor_configs:
|
||||||
|
magnitude_config = processor_configs["magnitude_plot"]
|
||||||
|
self.processors.append(MagnitudePlotProcessor(magnitude_config))
|
||||||
|
|
||||||
|
logger.info(f"Initialized {len(self.processors)} processors")
|
||||||
|
|
||||||
|
|
||||||
|
def set_sweep_buffer(self, sweep_buffer: SweepBuffer) -> None:
|
||||||
|
"""Set the sweep buffer to process data from."""
|
||||||
|
self.sweep_buffer = sweep_buffer
|
||||||
|
|
||||||
|
# Initialize sweep counter to continue from existing data
|
||||||
|
try:
|
||||||
|
max_sweep = self.results_storage.get_max_sweep_number()
|
||||||
|
if max_sweep > 0:
|
||||||
|
self.sweep_buffer.set_sweep_counter(max_sweep)
|
||||||
|
self._last_processed_sweep = max_sweep # Sync our tracking
|
||||||
|
logger.info(f"Set sweep buffer to continue from sweep #{max_sweep + 1}")
|
||||||
|
else:
|
||||||
|
logger.info("Sweep buffer starts from #1")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to set sweep counter: {e}")
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the processing thread."""
|
||||||
|
if self._running:
|
||||||
|
logger.debug("Processing already running; start() call ignored.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.sweep_buffer:
|
||||||
|
logger.error("Cannot start processing without sweep buffer")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = threading.Thread(target=self._processing_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Sweep processing started")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the processing thread."""
|
||||||
|
if not self._running:
|
||||||
|
logger.debug("Processing not running; stop() call ignored.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
self._thread.join(timeout=5.0)
|
||||||
|
logger.info("Processing thread joined")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Check if processing is currently running."""
|
||||||
|
return self._running and (self._thread is not None and self._thread.is_alive())
|
||||||
|
|
||||||
|
def _processing_loop(self) -> None:
|
||||||
|
"""Main processing loop executed by the background thread."""
|
||||||
|
while self._running and not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
if not self.sweep_buffer:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get latest sweep
|
||||||
|
latest_sweep = self.sweep_buffer.get_latest_sweep()
|
||||||
|
if not latest_sweep:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process new sweeps
|
||||||
|
if latest_sweep.sweep_number > self._last_processed_sweep:
|
||||||
|
self._process_sweep(latest_sweep)
|
||||||
|
self._last_processed_sweep = latest_sweep.sweep_number
|
||||||
|
|
||||||
|
time.sleep(0.05) # Small delay to avoid busy waiting
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Processing loop error: {e}")
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
def _process_sweep(self, sweep: SweepData) -> None:
|
||||||
|
"""Process a single sweep with all configured processors."""
|
||||||
|
logger.debug(f"Processing sweep #{sweep.sweep_number}")
|
||||||
|
|
||||||
|
for processor in self.processors:
|
||||||
|
try:
|
||||||
|
result = processor.process(sweep)
|
||||||
|
if result:
|
||||||
|
self._store_result(result)
|
||||||
|
self._queue_result(result)
|
||||||
|
logger.debug(f"Processed sweep #{sweep.sweep_number} with {processor.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in processor {processor.name}: {e}")
|
||||||
|
|
||||||
|
def _store_result(self, result: ProcessingResult) -> None:
|
||||||
|
"""Store processing result in file storage."""
|
||||||
|
self.results_storage.store_result(result)
|
||||||
|
|
||||||
|
def _queue_result(self, result: ProcessingResult) -> None:
|
||||||
|
"""Queue processing result for websocket consumption."""
|
||||||
|
try:
|
||||||
|
self.results_queue.put_nowait(result)
|
||||||
|
except queue.Full:
|
||||||
|
logger.warning("Results queue is full, dropping oldest result")
|
||||||
|
try:
|
||||||
|
self.results_queue.get_nowait() # Remove oldest
|
||||||
|
self.results_queue.put_nowait(result) # Add new
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_next_result(self, timeout: Optional[float] = None) -> Optional[ProcessingResult]:
|
||||||
|
"""Get next processing result from queue. Used by websocket handler."""
|
||||||
|
try:
|
||||||
|
return self.results_queue.get(timeout=timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_pending_results_count(self) -> int:
|
||||||
|
"""Get number of pending results in queue."""
|
||||||
|
return self.results_queue.qsize()
|
||||||
|
|
||||||
|
def drain_results_queue(self) -> List[ProcessingResult]:
|
||||||
|
"""Drain all pending results from queue. Used for batch processing."""
|
||||||
|
results = []
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
result = self.results_queue.get_nowait()
|
||||||
|
results.append(result)
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_latest_results(self, processor_name: Optional[str] = None, limit: int = 10) -> List[ProcessingResult]:
|
||||||
|
"""Get latest processing results from storage, optionally filtered by processor name."""
|
||||||
|
return self.results_storage.get_latest_results(processor_name, limit)
|
||||||
|
|
||||||
|
def get_result_by_sweep(self, sweep_number: int, processor_name: Optional[str] = None) -> List[ProcessingResult]:
|
||||||
|
"""Get processing results for a specific sweep number from storage."""
|
||||||
|
return self.results_storage.get_result_by_sweep(sweep_number, processor_name)
|
||||||
|
|
||||||
|
def add_processor(self, processor: BaseSweepProcessor) -> None:
|
||||||
|
"""Add a processor manually."""
|
||||||
|
self.processors.append(processor)
|
||||||
|
logger.info(f"Added processor: {processor.name}")
|
||||||
|
|
||||||
|
def remove_processor(self, processor_name: str) -> bool:
|
||||||
|
"""Remove a processor by name."""
|
||||||
|
for i, processor in enumerate(self.processors):
|
||||||
|
if processor.name == processor_name:
|
||||||
|
del self.processors[i]
|
||||||
|
logger.info(f"Removed processor: {processor_name}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_processing_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get processing statistics."""
|
||||||
|
storage_stats = self.results_storage.get_storage_stats()
|
||||||
|
return {
|
||||||
|
"is_running": self.is_running,
|
||||||
|
"processors_count": len(self.processors),
|
||||||
|
"processor_names": [p.name for p in self.processors],
|
||||||
|
"last_processed_sweep": self._last_processed_sweep,
|
||||||
|
"results_queue_size": self.results_queue.qsize(),
|
||||||
|
"storage_stats": storage_stats,
|
||||||
|
}
|
||||||
15
vna_system/core/singletons.py
Normal file
15
vna_system/core/singletons.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Global singleton instances for the VNA system.
|
||||||
|
|
||||||
|
This module centralizes all singleton instances to avoid global variables
|
||||||
|
scattered throughout the codebase.
|
||||||
|
"""
|
||||||
|
from vna_system.core.acquisition.data_acquisition import VNADataAcquisition
|
||||||
|
from vna_system.core.processing.sweep_processor import SweepProcessingManager
|
||||||
|
from vna_system.api.websockets.websocket_handler import WebSocketManager
|
||||||
|
|
||||||
|
# Global singleton instances
|
||||||
|
vna_data_acquisition_instance: VNADataAcquisition = VNADataAcquisition()
|
||||||
|
processing_manager: SweepProcessingManager = SweepProcessingManager()
|
||||||
|
websocket_manager: WebSocketManager = WebSocketManager(processing_manager)
|
||||||
1
vna_system/scripts/__init__.py
Normal file
1
vna_system/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""VNA System scripts module."""
|
||||||
92
vna_system/scripts/start.sh
Executable file
92
vna_system/scripts/start.sh
Executable file
@ -0,0 +1,92 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# VNA System Startup Script
|
||||||
|
#
|
||||||
|
# This script starts the VNA System API server with proper environment setup
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
# Get script directory
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging functions
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [ ! -f "$PROJECT_ROOT/vna_system/api/main.py" ]; then
|
||||||
|
log_error "VNA System main.py not found. Please run this script from the project directory."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Change to project root
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
log_info "Starting VNA System from: $PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Check Python version
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
log_error "Python 3 is not installed or not in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PYTHON_VERSION=$(python3 --version 2>&1 | cut -d' ' -f2)
|
||||||
|
log_info "Using Python version: $PYTHON_VERSION"
|
||||||
|
|
||||||
|
# Check if virtual environment exists
|
||||||
|
if [ -d "venv" ] || [ -d ".venv" ]; then
|
||||||
|
if [ -d "venv" ]; then
|
||||||
|
VENV_PATH="venv"
|
||||||
|
else
|
||||||
|
VENV_PATH=".venv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Found virtual environment at: $VENV_PATH"
|
||||||
|
log_info "Activating virtual environment..."
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
source "$VENV_PATH/bin/activate"
|
||||||
|
log_success "Virtual environment activated"
|
||||||
|
else
|
||||||
|
log_warning "No virtual environment found. Using system Python."
|
||||||
|
log_warning "Consider creating a virtual environment: python3 -m venv venv"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check configuration files
|
||||||
|
if [ ! -f "vna_system/api/api_config.json" ]; then
|
||||||
|
log_warning "API config not found. Using defaults."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "vna_system/core/processing/config.json" ]; then
|
||||||
|
log_warning "Processing config not found. Some processors may not work."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
log_info "Starting VNA System API server..."
|
||||||
|
log_info "Press Ctrl+C to stop the server"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Run the main application
|
||||||
|
exec python3 -m vna_system.api.main
|
||||||
8
vna_system/web_ui/static/assets/favicon.svg
Normal file
8
vna_system/web_ui/static/assets/favicon.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#1e293b"/>
|
||||||
|
<path d="M8 24L12 12L16 20L20 8L24 16" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<circle cx="12" cy="12" r="1.5" fill="#22c55e"/>
|
||||||
|
<circle cx="16" cy="20" r="1.5" fill="#22c55e"/>
|
||||||
|
<circle cx="20" cy="8" r="1.5" fill="#22c55e"/>
|
||||||
|
<circle cx="24" cy="16" r="1.5" fill="#22c55e"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 498 B |
242
vna_system/web_ui/static/css/charts.css
Normal file
242
vna_system/web_ui/static/css/charts.css
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
/* Chart-specific styles */
|
||||||
|
.plotly-chart {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Plotly theme overrides */
|
||||||
|
.js-plotly-plot .plotly {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plotly modebar styling - simplified to ensure visibility */
|
||||||
|
.modebar {
|
||||||
|
background-color: rgba(51, 65, 85, 0.9) !important;
|
||||||
|
border: 1px solid rgba(71, 85, 105, 0.8) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 4px !important;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modebar-btn {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
color: #cbd5e1 !important;
|
||||||
|
padding: 3px !important;
|
||||||
|
margin: 1px !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modebar-btn:hover {
|
||||||
|
background-color: rgba(71, 85, 105, 0.8) !important;
|
||||||
|
color: #f1f5f9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modebar-btn.active {
|
||||||
|
background-color: #3b82f6 !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fullscreen chart styles */
|
||||||
|
.chart-card:fullscreen {
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
background-color: var(--bg-primary) !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card:fullscreen .chart-card__header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card:fullscreen .chart-card__content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-2);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card:fullscreen .chart-card__plot {
|
||||||
|
flex: 1 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card:fullscreen .chart-card__plot .js-plotly-plot {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card:fullscreen .chart-card__plot .plotly .svg-container {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card:fullscreen .chart-card__meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart loading state */
|
||||||
|
.chart-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-loading__spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid var(--border-primary);
|
||||||
|
border-top: 3px solid var(--color-primary-500);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-loading__text {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart error state */
|
||||||
|
.chart-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-error__icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--color-error-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-error__title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-error__message {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 300px;
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart animations */
|
||||||
|
@keyframes chartFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card--animated {
|
||||||
|
animation: chartFadeIn 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plotly theme customization */
|
||||||
|
.chart-card .js-plotly-plot {
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for chart containers */
|
||||||
|
.chart-card__content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__content::-webkit-scrollbar-track {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__content::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-secondary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive chart adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chart-card__plot {
|
||||||
|
height: 320px !important;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__content {
|
||||||
|
min-height: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-loading,
|
||||||
|
.chart-error {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart grid responsive behavior */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.charts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
.charts-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1800px) {
|
||||||
|
.charts-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print styles */
|
||||||
|
@media print {
|
||||||
|
.chart-card__header,
|
||||||
|
.chart-card__meta,
|
||||||
|
.modebar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
461
vna_system/web_ui/static/css/components.css
Normal file
461
vna_system/web_ui/static/css/components.css
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2-5) var(--space-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--sm {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--lg {
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Variants */
|
||||||
|
.btn--primary {
|
||||||
|
background-color: var(--color-primary-600);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover {
|
||||||
|
background-color: var(--color-primary-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--bordered {
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary.btn--bordered {
|
||||||
|
border-color: var(--color-primary-500);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary-600), var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary.btn--bordered:hover {
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary-500), var(--shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary:hover {
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost:hover {
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--sm i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--lg i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Processor Toggles */
|
||||||
|
.processor-toggles {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle:hover {
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle--active {
|
||||||
|
background-color: var(--color-primary-900);
|
||||||
|
border-color: var(--color-primary-600);
|
||||||
|
color: var(--color-primary-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle__checkbox {
|
||||||
|
position: relative;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle--active .processor-toggle__checkbox {
|
||||||
|
background-color: var(--color-primary-500);
|
||||||
|
border-color: var(--color-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle__checkbox::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 4px;
|
||||||
|
width: 4px;
|
||||||
|
height: 8px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle--active .processor-toggle__checkbox::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle__label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle--active .processor-toggle__label {
|
||||||
|
color: var(--color-primary-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processor-toggle__count {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
padding: var(--space-0-5) var(--space-1-5);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display Controls */
|
||||||
|
.display-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection Info */
|
||||||
|
.connection-info {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart Card */
|
||||||
|
.chart-card {
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: visible;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card:hover {
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--color-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__action {
|
||||||
|
padding: var(--space-1-5);
|
||||||
|
border-radius: var(--radius-default);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__action:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card:hover .chart-card__action {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__action i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__content {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--space-4);
|
||||||
|
min-height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__plot {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 420px !important;
|
||||||
|
min-height: 400px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__plot .js-plotly-plot {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__plot .plotly .svg-container {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border-top: 1px solid var(--border-primary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__timestamp {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card__sweep {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notifications */
|
||||||
|
.notifications {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
z-index: var(--z-toast);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 480px;
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: slideInRight 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification--success {
|
||||||
|
border-left: 4px solid var(--color-success-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification--error {
|
||||||
|
border-left: 4px solid var(--color-error-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification--warning {
|
||||||
|
border-left: 4px solid var(--color-warning-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification--info {
|
||||||
|
border-left: 4px solid var(--color-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification__icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: var(--space-0-5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification--success .notification__icon {
|
||||||
|
color: var(--color-success-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification--error .notification__icon {
|
||||||
|
color: var(--color-error-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification--warning .notification__icon {
|
||||||
|
color: var(--color-warning-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification--info .notification__icon {
|
||||||
|
color: var(--color-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification__title {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification__message {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification__close {
|
||||||
|
padding: var(--space-1);
|
||||||
|
border-radius: var(--radius-default);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
margin: calc(-1 * var(--space-1)) calc(-1 * var(--space-2)) calc(-1 * var(--space-1)) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification__close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification__close i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Elements (for future use) */
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: var(--space-2-5) var(--space-3);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary-500);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
377
vna_system/web_ui/static/css/layout.css
Normal file
377
vna_system/web_ui/static/css/layout.css
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
/* Base Layout */
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: var(--header-height);
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 var(--space-6);
|
||||||
|
max-width: var(--max-content-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--color-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__subtitle {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__status {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__stats {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__nav {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background-color: transparent;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn--active {
|
||||||
|
color: var(--color-primary-400);
|
||||||
|
background-color: var(--color-primary-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Indicator */
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-error-500);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator--connected .status-indicator__dot {
|
||||||
|
background-color: var(--color-success-500);
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator--connecting .status-indicator__dot {
|
||||||
|
background-color: var(--color-warning-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator__text {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.main {
|
||||||
|
min-height: calc(100vh - var(--header-height));
|
||||||
|
max-width: var(--max-content-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Views */
|
||||||
|
.view {
|
||||||
|
display: none;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view--active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls Panel */
|
||||||
|
.controls-panel {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-panel__container {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-6);
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-label {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts Container */
|
||||||
|
.charts-container {
|
||||||
|
position: relative;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(550px, 1fr));
|
||||||
|
gap: var(--space-6);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__content {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto var(--space-6);
|
||||||
|
color: var(--color-primary-500);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__title {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__description {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Spinner */
|
||||||
|
.loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--border-primary);
|
||||||
|
border-top: 3px solid var(--color-primary-500);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings & Logs Views */
|
||||||
|
.settings-container,
|
||||||
|
.logs-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container h2,
|
||||||
|
.logs-container h2 {
|
||||||
|
font-size: var(--font-size-3xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container p,
|
||||||
|
.logs-container p {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.charts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-panel__container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header__container {
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__brand {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__nav {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.header__status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
vna_system/web_ui/static/css/normalize.css
vendored
Normal file
54
vna_system/web_ui/static/css/normalize.css
vendored
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/* Modern CSS Reset */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
img, picture, video, canvas, svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, h1, h2, h3, h4, h5, h6 {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root, #__next {
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default button styles */
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default list styles */
|
||||||
|
ul, ol {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default link styles */
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
134
vna_system/web_ui/static/css/variables.css
Normal file
134
vna_system/web_ui/static/css/variables.css
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
:root {
|
||||||
|
/* Colors - Modern dark theme with blue accents */
|
||||||
|
--color-primary-50: #eff6ff;
|
||||||
|
--color-primary-100: #dbeafe;
|
||||||
|
--color-primary-200: #bfdbfe;
|
||||||
|
--color-primary-300: #93c5fd;
|
||||||
|
--color-primary-400: #60a5fa;
|
||||||
|
--color-primary-500: #3b82f6;
|
||||||
|
--color-primary-600: #2563eb;
|
||||||
|
--color-primary-700: #1d4ed8;
|
||||||
|
--color-primary-800: #1e40af;
|
||||||
|
--color-primary-900: #1e3a8a;
|
||||||
|
|
||||||
|
--color-gray-50: #f8fafc;
|
||||||
|
--color-gray-100: #f1f5f9;
|
||||||
|
--color-gray-200: #e2e8f0;
|
||||||
|
--color-gray-300: #cbd5e1;
|
||||||
|
--color-gray-400: #94a3b8;
|
||||||
|
--color-gray-500: #64748b;
|
||||||
|
--color-gray-600: #475569;
|
||||||
|
--color-gray-700: #334155;
|
||||||
|
--color-gray-800: #1e293b;
|
||||||
|
--color-gray-900: #0f172a;
|
||||||
|
|
||||||
|
--color-success-400: #4ade80;
|
||||||
|
--color-success-500: #22c55e;
|
||||||
|
--color-success-600: #16a34a;
|
||||||
|
|
||||||
|
--color-warning-400: #facc15;
|
||||||
|
--color-warning-500: #eab308;
|
||||||
|
--color-warning-600: #ca8a04;
|
||||||
|
|
||||||
|
--color-error-400: #f87171;
|
||||||
|
--color-error-500: #ef4444;
|
||||||
|
--color-error-600: #dc2626;
|
||||||
|
|
||||||
|
/* Dark Theme Colors */
|
||||||
|
--bg-primary: var(--color-gray-900);
|
||||||
|
--bg-secondary: var(--color-gray-800);
|
||||||
|
--bg-tertiary: var(--color-gray-700);
|
||||||
|
--bg-surface: var(--color-gray-800);
|
||||||
|
--bg-surface-hover: var(--color-gray-700);
|
||||||
|
--bg-overlay: rgba(15, 23, 42, 0.95);
|
||||||
|
|
||||||
|
--text-primary: var(--color-gray-50);
|
||||||
|
--text-secondary: var(--color-gray-300);
|
||||||
|
--text-tertiary: var(--color-gray-400);
|
||||||
|
|
||||||
|
--border-primary: var(--color-gray-700);
|
||||||
|
--border-secondary: var(--color-gray-600);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
|
||||||
|
--font-size-xs: 0.75rem; /* 12px */
|
||||||
|
--font-size-sm: 0.875rem; /* 14px */
|
||||||
|
--font-size-base: 1rem; /* 16px */
|
||||||
|
--font-size-lg: 1.125rem; /* 18px */
|
||||||
|
--font-size-xl: 1.25rem; /* 20px */
|
||||||
|
--font-size-2xl: 1.5rem; /* 24px */
|
||||||
|
--font-size-3xl: 1.875rem; /* 30px */
|
||||||
|
|
||||||
|
--font-weight-light: 300;
|
||||||
|
--font-weight-normal: 400;
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
--font-weight-bold: 700;
|
||||||
|
|
||||||
|
--line-height-tight: 1.25;
|
||||||
|
--line-height-normal: 1.5;
|
||||||
|
--line-height-relaxed: 1.75;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--space-px: 1px;
|
||||||
|
--space-0-5: 0.125rem;
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-1-5: 0.375rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-2-5: 0.625rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-5: 1.25rem;
|
||||||
|
--space-6: 1.5rem;
|
||||||
|
--space-8: 2rem;
|
||||||
|
--space-10: 2.5rem;
|
||||||
|
--space-12: 3rem;
|
||||||
|
--space-16: 4rem;
|
||||||
|
--space-20: 5rem;
|
||||||
|
--space-24: 6rem;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--radius-none: 0px;
|
||||||
|
--radius-sm: 0.125rem;
|
||||||
|
--radius-default: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-xl: 0.75rem;
|
||||||
|
--radius-2xl: 1rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-default: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-normal: 0.2s ease;
|
||||||
|
--transition-slow: 0.3s ease;
|
||||||
|
|
||||||
|
/* Z-index */
|
||||||
|
--z-dropdown: 10;
|
||||||
|
--z-modal: 50;
|
||||||
|
--z-toast: 100;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--header-height: 4rem;
|
||||||
|
--sidebar-width: 16rem;
|
||||||
|
--max-content-width: 1400px;
|
||||||
|
|
||||||
|
/* Chart Colors */
|
||||||
|
--chart-color-1: var(--color-primary-500);
|
||||||
|
--chart-color-2: var(--color-success-500);
|
||||||
|
--chart-color-3: var(--color-warning-500);
|
||||||
|
--chart-color-4: var(--color-error-500);
|
||||||
|
--chart-color-5: #8b5cf6;
|
||||||
|
--chart-color-6: #06b6d4;
|
||||||
|
--chart-color-7: #f59e0b;
|
||||||
|
--chart-color-8: #10b981;
|
||||||
|
}
|
||||||
394
vna_system/web_ui/static/js/main.js
Normal file
394
vna_system/web_ui/static/js/main.js
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* VNA System Web UI - Main Entry Point
|
||||||
|
* Modern ES6+ JavaScript with modular architecture
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WebSocketManager } from './modules/websocket.js';
|
||||||
|
import { ChartManager } from './modules/charts.js';
|
||||||
|
import { UIManager } from './modules/ui.js';
|
||||||
|
import { NotificationManager } from './modules/notifications.js';
|
||||||
|
import { StorageManager } from './modules/storage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Application Class
|
||||||
|
* Coordinates all modules and manages application lifecycle
|
||||||
|
*/
|
||||||
|
class VNADashboard {
|
||||||
|
constructor() {
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.config = {
|
||||||
|
websocket: {
|
||||||
|
url: this.getWebSocketURL(),
|
||||||
|
reconnectInterval: 3000,
|
||||||
|
maxReconnectAttempts: 10
|
||||||
|
},
|
||||||
|
charts: {
|
||||||
|
theme: 'dark',
|
||||||
|
updateInterval: 100,
|
||||||
|
maxDataPoints: 1000,
|
||||||
|
animation: true
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
autoHideNotifications: true,
|
||||||
|
notificationTimeout: 5000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize managers
|
||||||
|
this.storage = new StorageManager();
|
||||||
|
this.notifications = new NotificationManager();
|
||||||
|
this.ui = new UIManager(this.notifications);
|
||||||
|
this.charts = new ChartManager(this.config.charts, this.notifications);
|
||||||
|
this.websocket = new WebSocketManager(this.config.websocket, this.notifications);
|
||||||
|
|
||||||
|
// Bind methods
|
||||||
|
this.handleWebSocketData = this.handleWebSocketData.bind(this);
|
||||||
|
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
|
||||||
|
this.handleBeforeUnload = this.handleBeforeUnload.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the application
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
console.log('🚀 Initializing VNA Dashboard...');
|
||||||
|
|
||||||
|
// Initialize Lucide icons
|
||||||
|
if (typeof lucide !== 'undefined') {
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize modules in correct order
|
||||||
|
await this.initializeModules();
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// Connect WebSocket
|
||||||
|
await this.websocket.connect();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log('✅ VNA Dashboard initialized successfully');
|
||||||
|
|
||||||
|
// Show welcome notification
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'info',
|
||||||
|
title: 'Dashboard Ready',
|
||||||
|
message: 'Connected to VNA System. Waiting for sweep data...'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to initialize VNA Dashboard:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Initialization Failed',
|
||||||
|
message: error.message || 'Failed to initialize dashboard'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all modules
|
||||||
|
*/
|
||||||
|
async initializeModules() {
|
||||||
|
// Initialize UI manager first
|
||||||
|
await this.ui.init();
|
||||||
|
|
||||||
|
// Initialize chart manager
|
||||||
|
await this.charts.init();
|
||||||
|
|
||||||
|
// Set up UI event handlers
|
||||||
|
this.setupUIHandlers();
|
||||||
|
|
||||||
|
// Load and apply user preferences after UI is initialized
|
||||||
|
const preferences = await this.storage.getPreferences();
|
||||||
|
if (preferences) {
|
||||||
|
// Clean up old invalid processors from preferences
|
||||||
|
this.cleanupPreferences(preferences);
|
||||||
|
this.applyPreferences(preferences);
|
||||||
|
|
||||||
|
// Save cleaned preferences back to storage
|
||||||
|
this.savePreferences();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up event listeners
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
// WebSocket events
|
||||||
|
this.websocket.on('data', this.handleWebSocketData);
|
||||||
|
this.websocket.on('connected', () => {
|
||||||
|
this.ui.setConnectionStatus('connected');
|
||||||
|
});
|
||||||
|
this.websocket.on('disconnected', () => {
|
||||||
|
this.ui.setConnectionStatus('disconnected');
|
||||||
|
});
|
||||||
|
this.websocket.on('connecting', () => {
|
||||||
|
this.ui.setConnectionStatus('connecting');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Browser events
|
||||||
|
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
||||||
|
window.addEventListener('beforeunload', this.handleBeforeUnload);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', this.handleKeyboardShortcuts.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up UI-specific event handlers
|
||||||
|
*/
|
||||||
|
setupUIHandlers() {
|
||||||
|
// Navigation
|
||||||
|
this.ui.onViewChange((view) => {
|
||||||
|
console.log(`📱 Switched to view: ${view}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Processor toggles
|
||||||
|
this.ui.onProcessorToggle((processor, enabled) => {
|
||||||
|
console.log(`🔧 Processor ${processor}: ${enabled ? 'enabled' : 'disabled'}`);
|
||||||
|
this.charts.toggleProcessor(processor, enabled);
|
||||||
|
this.savePreferences();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chart clearing removed - users can disable processors individually
|
||||||
|
|
||||||
|
this.ui.onExportData(() => {
|
||||||
|
console.log('📊 Exporting chart data...');
|
||||||
|
this.exportData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle WebSocket data
|
||||||
|
*/
|
||||||
|
handleWebSocketData(data) {
|
||||||
|
try {
|
||||||
|
if (data.type === 'processing_result') {
|
||||||
|
// Add chart data
|
||||||
|
this.charts.addData(data.data);
|
||||||
|
|
||||||
|
// Update processor in UI
|
||||||
|
this.ui.updateProcessorFromData(data.data.processor_name);
|
||||||
|
|
||||||
|
// Update general stats
|
||||||
|
this.ui.updateStats({
|
||||||
|
sweepCount: data.data.sweep_number,
|
||||||
|
lastUpdate: new Date()
|
||||||
|
});
|
||||||
|
} else if (data.type === 'system_status') {
|
||||||
|
this.ui.updateSystemStatus(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error handling WebSocket data:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Data Processing Error',
|
||||||
|
message: 'Failed to process incoming data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle visibility changes (tab switch, minimize, etc.)
|
||||||
|
*/
|
||||||
|
handleVisibilityChange() {
|
||||||
|
if (document.hidden) {
|
||||||
|
// Pause expensive operations when hidden
|
||||||
|
this.charts.pause();
|
||||||
|
} else {
|
||||||
|
// Resume when visible
|
||||||
|
this.charts.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle before unload (page close/refresh)
|
||||||
|
*/
|
||||||
|
handleBeforeUnload() {
|
||||||
|
this.savePreferences();
|
||||||
|
this.websocket.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard shortcuts
|
||||||
|
*/
|
||||||
|
handleKeyboardShortcuts(event) {
|
||||||
|
// Ctrl/Cmd + E: Export data
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'e') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.ui.triggerExportData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + Shift + R: Reconnect WebSocket
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'r' && event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.websocket.reconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export data functionality
|
||||||
|
*/
|
||||||
|
exportData() {
|
||||||
|
try {
|
||||||
|
const data = this.charts.exportData();
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `vna-data-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Export Complete',
|
||||||
|
message: 'Chart data exported successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Export failed:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Export Failed',
|
||||||
|
message: 'Failed to export chart data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old/invalid preferences
|
||||||
|
*/
|
||||||
|
cleanupPreferences(preferences) {
|
||||||
|
// List of invalid/old processor names that should be removed
|
||||||
|
const invalidProcessors = ['sweep_counter'];
|
||||||
|
|
||||||
|
if (preferences.disabledProcessors && Array.isArray(preferences.disabledProcessors)) {
|
||||||
|
// Remove invalid processors
|
||||||
|
preferences.disabledProcessors = preferences.disabledProcessors.filter(
|
||||||
|
processor => !invalidProcessors.includes(processor)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (preferences.disabledProcessors.length === 0) {
|
||||||
|
delete preferences.disabledProcessors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply user preferences
|
||||||
|
*/
|
||||||
|
applyPreferences(preferences) {
|
||||||
|
try {
|
||||||
|
if (preferences.disabledProcessors && Array.isArray(preferences.disabledProcessors)) {
|
||||||
|
preferences.disabledProcessors.forEach(processor => {
|
||||||
|
if (this.ui && typeof this.ui.setProcessorEnabled === 'function') {
|
||||||
|
this.ui.setProcessorEnabled(processor, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.theme) {
|
||||||
|
if (this.ui && typeof this.ui.setTheme === 'function') {
|
||||||
|
this.ui.setTheme(preferences.theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Error applying preferences:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current preferences
|
||||||
|
*/
|
||||||
|
async savePreferences() {
|
||||||
|
try {
|
||||||
|
const preferences = {
|
||||||
|
disabledProcessors: this.charts.getDisabledProcessors(),
|
||||||
|
theme: this.ui.getCurrentTheme(),
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
await this.storage.savePreferences(preferences);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Failed to save preferences:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WebSocket URL based on current location
|
||||||
|
*/
|
||||||
|
getWebSocketURL() {
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = location.host;
|
||||||
|
return `${protocol}//${host}/ws/processing`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
|
console.log('🧹 Cleaning up VNA Dashboard...');
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
||||||
|
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
||||||
|
|
||||||
|
// Disconnect WebSocket
|
||||||
|
this.websocket.disconnect();
|
||||||
|
|
||||||
|
// Cleanup managers
|
||||||
|
this.charts.destroy();
|
||||||
|
this.ui.destroy();
|
||||||
|
this.notifications.destroy();
|
||||||
|
|
||||||
|
this.isInitialized = false;
|
||||||
|
console.log('✅ VNA Dashboard cleanup complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize dashboard when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.vnaDashboard = new VNADashboard();
|
||||||
|
window.vnaDashboard.init();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.vnaDashboard = new VNADashboard();
|
||||||
|
window.vnaDashboard.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global error handling
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
console.error('🚨 Global error:', event.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
console.error('🚨 Unhandled promise rejection:', event.reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Development helpers
|
||||||
|
if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') {
|
||||||
|
window.debug = {
|
||||||
|
dashboard: () => window.vnaDashboard,
|
||||||
|
websocket: () => window.vnaDashboard?.websocket,
|
||||||
|
charts: () => window.vnaDashboard?.charts,
|
||||||
|
ui: () => window.vnaDashboard?.ui
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Browser environment debug helpers
|
||||||
|
window.debug = {
|
||||||
|
dashboard: () => window.vnaDashboard,
|
||||||
|
websocket: () => window.vnaDashboard?.websocket,
|
||||||
|
charts: () => window.vnaDashboard?.charts,
|
||||||
|
ui: () => window.vnaDashboard?.ui
|
||||||
|
};
|
||||||
|
}
|
||||||
693
vna_system/web_ui/static/js/modules/charts.js
Normal file
693
vna_system/web_ui/static/js/modules/charts.js
Normal file
@ -0,0 +1,693 @@
|
|||||||
|
/**
|
||||||
|
* Chart Manager
|
||||||
|
* Handles Plotly.js chart creation, updates, and management
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ChartManager {
|
||||||
|
constructor(config, notifications) {
|
||||||
|
this.config = config;
|
||||||
|
this.notifications = notifications;
|
||||||
|
|
||||||
|
// Chart storage
|
||||||
|
this.charts = new Map(); // processor_name -> chart instance
|
||||||
|
this.chartData = new Map(); // processor_name -> data array
|
||||||
|
this.disabledProcessors = new Set();
|
||||||
|
|
||||||
|
// UI elements
|
||||||
|
this.chartsGrid = null;
|
||||||
|
this.emptyState = null;
|
||||||
|
|
||||||
|
// Update management
|
||||||
|
this.updateQueue = new Map();
|
||||||
|
this.isUpdating = false;
|
||||||
|
this.isPaused = false;
|
||||||
|
|
||||||
|
// Performance tracking
|
||||||
|
this.performanceStats = {
|
||||||
|
chartsCreated: 0,
|
||||||
|
updatesProcessed: 0,
|
||||||
|
avgUpdateTime: 0,
|
||||||
|
lastUpdateTime: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plotly theme configuration
|
||||||
|
this.plotlyConfig = {
|
||||||
|
displayModeBar: true,
|
||||||
|
modeBarButtonsToRemove: [
|
||||||
|
'select2d', 'lasso2d', 'hoverClosestCartesian',
|
||||||
|
'hoverCompareCartesian', 'toggleSpikelines'
|
||||||
|
],
|
||||||
|
displaylogo: false,
|
||||||
|
responsive: false, // Disable responsive to avoid resize issues
|
||||||
|
doubleClick: 'reset',
|
||||||
|
toImageButtonOptions: {
|
||||||
|
format: 'png',
|
||||||
|
filename: 'vna_chart',
|
||||||
|
height: 600,
|
||||||
|
width: 800,
|
||||||
|
scale: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.plotlyLayout = {
|
||||||
|
plot_bgcolor: 'transparent',
|
||||||
|
paper_bgcolor: 'transparent',
|
||||||
|
font: {
|
||||||
|
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||||
|
size: 12,
|
||||||
|
color: '#f1f5f9' // --text-secondary
|
||||||
|
},
|
||||||
|
colorway: [
|
||||||
|
'#3b82f6', '#22c55e', '#eab308', '#ef4444',
|
||||||
|
'#8b5cf6', '#06b6d4', '#f59e0b', '#10b981'
|
||||||
|
],
|
||||||
|
margin: { l: 60, r: 40, t: 40, b: 60 },
|
||||||
|
showlegend: false,
|
||||||
|
xaxis: {
|
||||||
|
gridcolor: '#334155',
|
||||||
|
zerolinecolor: '#475569',
|
||||||
|
color: '#cbd5e1',
|
||||||
|
fixedrange: false // Allow zoom/pan
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
gridcolor: '#334155',
|
||||||
|
zerolinecolor: '#475569',
|
||||||
|
color: '#cbd5e1',
|
||||||
|
fixedrange: false // Allow zoom/pan
|
||||||
|
},
|
||||||
|
autosize: true,
|
||||||
|
width: null, // Let CSS control width
|
||||||
|
height: null // Let CSS control height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize chart manager
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
console.log('📊 Initializing Chart Manager...');
|
||||||
|
|
||||||
|
// Get DOM elements
|
||||||
|
this.chartsGrid = document.getElementById('chartsGrid');
|
||||||
|
this.emptyState = document.getElementById('emptyState');
|
||||||
|
|
||||||
|
if (!this.chartsGrid || !this.emptyState) {
|
||||||
|
throw new Error('Required DOM elements not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Plotly is available
|
||||||
|
if (typeof Plotly === 'undefined') {
|
||||||
|
throw new Error('Plotly.js not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Chart Manager initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new data from WebSocket
|
||||||
|
*/
|
||||||
|
addData(processingResult) {
|
||||||
|
try {
|
||||||
|
const { processor_name, sweep_number, plotly_figure, data } = processingResult;
|
||||||
|
|
||||||
|
if (!processor_name || !plotly_figure) {
|
||||||
|
console.warn('⚠️ Invalid processing result:', processingResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if processor is disabled
|
||||||
|
if (this.disabledProcessors.has(processor_name)) {
|
||||||
|
console.log(`⏸️ Skipping disabled processor: ${processor_name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store data
|
||||||
|
if (!this.chartData.has(processor_name)) {
|
||||||
|
this.chartData.set(processor_name, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataArray = this.chartData.get(processor_name);
|
||||||
|
dataArray.push({
|
||||||
|
sweep_number,
|
||||||
|
plotly_figure,
|
||||||
|
data,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limit data points for performance
|
||||||
|
if (dataArray.length > this.config.maxDataPoints) {
|
||||||
|
dataArray.splice(0, dataArray.length - this.config.maxDataPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update chart
|
||||||
|
if (!this.charts.has(processor_name)) {
|
||||||
|
this.createChart(processor_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateChart(processor_name, plotly_figure);
|
||||||
|
this.hideEmptyState();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error adding chart data:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Chart Error',
|
||||||
|
message: `Failed to update ${processingResult?.processor_name} chart`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new chart
|
||||||
|
*/
|
||||||
|
createChart(processorName) {
|
||||||
|
console.log(`📊 Creating chart for processor: ${processorName}`);
|
||||||
|
|
||||||
|
// Create chart card HTML
|
||||||
|
const chartCard = this.createChartCard(processorName);
|
||||||
|
this.chartsGrid.appendChild(chartCard);
|
||||||
|
|
||||||
|
// Get plot container
|
||||||
|
const plotContainer = chartCard.querySelector('.chart-card__plot');
|
||||||
|
|
||||||
|
// Create empty chart with proper sizing
|
||||||
|
const layout = {
|
||||||
|
...this.plotlyLayout,
|
||||||
|
title: {
|
||||||
|
text: this.formatProcessorName(processorName),
|
||||||
|
font: { size: 16, color: '#f1f5f9' }
|
||||||
|
},
|
||||||
|
width: plotContainer.clientWidth || 500,
|
||||||
|
height: plotContainer.clientHeight || 420
|
||||||
|
};
|
||||||
|
|
||||||
|
Plotly.newPlot(plotContainer, [], layout, this.plotlyConfig);
|
||||||
|
|
||||||
|
// Set up resize observer for proper sizing
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (plotContainer && plotContainer.clientWidth > 0) {
|
||||||
|
Plotly.Plots.resize(plotContainer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(plotContainer);
|
||||||
|
|
||||||
|
// Store observer for cleanup
|
||||||
|
plotContainer._resizeObserver = resizeObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store chart reference
|
||||||
|
this.charts.set(processorName, {
|
||||||
|
element: chartCard,
|
||||||
|
plotContainer: plotContainer,
|
||||||
|
isVisible: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.performanceStats.chartsCreated++;
|
||||||
|
|
||||||
|
// Add animation class
|
||||||
|
if (this.config.animation) {
|
||||||
|
setTimeout(() => {
|
||||||
|
chartCard.classList.add('chart-card--animated');
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing chart
|
||||||
|
*/
|
||||||
|
updateChart(processorName, plotlyFigure) {
|
||||||
|
if (this.isPaused) return;
|
||||||
|
|
||||||
|
const chart = this.charts.get(processorName);
|
||||||
|
if (!chart || !chart.plotContainer) {
|
||||||
|
console.warn(`⚠️ Chart not found for processor: ${processorName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
// Queue update to avoid blocking UI
|
||||||
|
this.queueUpdate(processorName, () => {
|
||||||
|
// Prepare layout without overriding size
|
||||||
|
const updateLayout = {
|
||||||
|
...this.plotlyLayout,
|
||||||
|
...plotlyFigure.layout,
|
||||||
|
title: {
|
||||||
|
text: this.formatProcessorName(processorName),
|
||||||
|
font: { size: 16, color: '#f1f5f9' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't override existing width/height if they're working
|
||||||
|
delete updateLayout.width;
|
||||||
|
delete updateLayout.height;
|
||||||
|
|
||||||
|
// Update plot with new data
|
||||||
|
Plotly.react(
|
||||||
|
chart.plotContainer,
|
||||||
|
plotlyFigure.data,
|
||||||
|
updateLayout,
|
||||||
|
this.plotlyConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
this.updateChartMetadata(processorName);
|
||||||
|
|
||||||
|
// Update performance stats
|
||||||
|
const updateTime = performance.now() - startTime;
|
||||||
|
this.updatePerformanceStats(updateTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error updating chart ${processorName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue chart update to avoid blocking UI
|
||||||
|
*/
|
||||||
|
queueUpdate(processorName, updateFn) {
|
||||||
|
this.updateQueue.set(processorName, updateFn);
|
||||||
|
|
||||||
|
if (!this.isUpdating) {
|
||||||
|
this.processUpdateQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process queued updates
|
||||||
|
*/
|
||||||
|
async processUpdateQueue() {
|
||||||
|
if (this.isPaused) return;
|
||||||
|
|
||||||
|
this.isUpdating = true;
|
||||||
|
|
||||||
|
while (this.updateQueue.size > 0 && !this.isPaused) {
|
||||||
|
const [processorName, updateFn] = this.updateQueue.entries().next().value;
|
||||||
|
this.updateQueue.delete(processorName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateFn();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error in queued update for ${processorName}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield to browser between updates
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isUpdating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create chart card HTML
|
||||||
|
*/
|
||||||
|
createChartCard(processorName) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'chart-card';
|
||||||
|
card.dataset.processor = processorName;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="chart-card__header">
|
||||||
|
<div class="chart-card__title">
|
||||||
|
<i data-lucide="bar-chart-3" class="chart-card__icon"></i>
|
||||||
|
${this.formatProcessorName(processorName)}
|
||||||
|
</div>
|
||||||
|
<div class="chart-card__actions">
|
||||||
|
<button class="chart-card__action" data-action="fullscreen" title="Fullscreen">
|
||||||
|
<i data-lucide="expand"></i>
|
||||||
|
</button>
|
||||||
|
<button class="chart-card__action" data-action="download" title="Download">
|
||||||
|
<i data-lucide="download"></i>
|
||||||
|
</button>
|
||||||
|
<button class="chart-card__action" data-action="hide" title="Hide">
|
||||||
|
<i data-lucide="eye-off"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card__content">
|
||||||
|
<div class="chart-card__plot" id="plot-${processorName}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card__meta">
|
||||||
|
<div class="chart-card__timestamp" data-timestamp="">
|
||||||
|
Last update: --
|
||||||
|
</div>
|
||||||
|
<div class="chart-card__sweep" data-sweep="">
|
||||||
|
Sweep: --
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
this.setupChartCardEvents(card, processorName);
|
||||||
|
|
||||||
|
// Initialize Lucide icons
|
||||||
|
if (typeof lucide !== 'undefined') {
|
||||||
|
lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up chart card event listeners
|
||||||
|
*/
|
||||||
|
setupChartCardEvents(card, processorName) {
|
||||||
|
card.addEventListener('click', (event) => {
|
||||||
|
const action = event.target.closest('[data-action]')?.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'fullscreen':
|
||||||
|
this.toggleFullscreen(processorName);
|
||||||
|
break;
|
||||||
|
case 'download':
|
||||||
|
this.downloadChart(processorName);
|
||||||
|
break;
|
||||||
|
case 'hide':
|
||||||
|
this.hideChart(processorName);
|
||||||
|
// Also disable the processor toggle
|
||||||
|
if (window.vnaDashboard && window.vnaDashboard.ui) {
|
||||||
|
window.vnaDashboard.ui.setProcessorEnabled(processorName, false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update chart metadata
|
||||||
|
*/
|
||||||
|
updateChartMetadata(processorName) {
|
||||||
|
const chart = this.charts.get(processorName);
|
||||||
|
const data = this.chartData.get(processorName);
|
||||||
|
|
||||||
|
if (!chart || !data || data.length === 0) return;
|
||||||
|
|
||||||
|
const latestData = data[data.length - 1];
|
||||||
|
const timestampEl = chart.element.querySelector('[data-timestamp]');
|
||||||
|
const sweepEl = chart.element.querySelector('[data-sweep]');
|
||||||
|
|
||||||
|
if (timestampEl) {
|
||||||
|
timestampEl.textContent = `Last update: ${latestData.timestamp.toLocaleTimeString()}`;
|
||||||
|
timestampEl.dataset.timestamp = latestData.timestamp.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sweepEl) {
|
||||||
|
sweepEl.textContent = `Sweep: #${latestData.sweep_number}`;
|
||||||
|
sweepEl.dataset.sweep = latestData.sweep_number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format processor name for display
|
||||||
|
*/
|
||||||
|
formatProcessorName(processorName) {
|
||||||
|
return processorName
|
||||||
|
.split('_')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle processor visibility
|
||||||
|
*/
|
||||||
|
toggleProcessor(processorName, enabled) {
|
||||||
|
if (enabled) {
|
||||||
|
this.disabledProcessors.delete(processorName);
|
||||||
|
this.showChart(processorName);
|
||||||
|
} else {
|
||||||
|
this.disabledProcessors.add(processorName);
|
||||||
|
this.hideChart(processorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show chart
|
||||||
|
*/
|
||||||
|
showChart(processorName) {
|
||||||
|
const chart = this.charts.get(processorName);
|
||||||
|
if (chart) {
|
||||||
|
chart.element.classList.remove('chart-card--hidden');
|
||||||
|
chart.isVisible = true;
|
||||||
|
|
||||||
|
// Trigger resize to ensure proper rendering
|
||||||
|
setTimeout(() => {
|
||||||
|
if (chart.plotContainer) {
|
||||||
|
Plotly.Plots.resize(chart.plotContainer);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateEmptyStateVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide chart
|
||||||
|
*/
|
||||||
|
hideChart(processorName) {
|
||||||
|
const chart = this.charts.get(processorName);
|
||||||
|
if (chart) {
|
||||||
|
chart.element.classList.add('chart-card--hidden');
|
||||||
|
chart.isVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateEmptyStateVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove chart completely
|
||||||
|
*/
|
||||||
|
removeChart(processorName) {
|
||||||
|
const chart = this.charts.get(processorName);
|
||||||
|
if (chart) {
|
||||||
|
// Clean up ResizeObserver
|
||||||
|
if (chart.plotContainer && chart.plotContainer._resizeObserver) {
|
||||||
|
chart.plotContainer._resizeObserver.disconnect();
|
||||||
|
chart.plotContainer._resizeObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up Plotly
|
||||||
|
if (chart.plotContainer) {
|
||||||
|
Plotly.purge(chart.plotContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from DOM
|
||||||
|
chart.element.remove();
|
||||||
|
|
||||||
|
// Clean up data
|
||||||
|
this.charts.delete(processorName);
|
||||||
|
this.chartData.delete(processorName);
|
||||||
|
this.disabledProcessors.delete(processorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateEmptyStateVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all charts
|
||||||
|
*/
|
||||||
|
clearAll() {
|
||||||
|
console.log('🗑️ Clearing all charts...');
|
||||||
|
|
||||||
|
for (const [processorName] of this.charts) {
|
||||||
|
this.removeChart(processorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.charts.clear();
|
||||||
|
this.chartData.clear();
|
||||||
|
this.updateQueue.clear();
|
||||||
|
this.updateEmptyStateVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download chart as image
|
||||||
|
*/
|
||||||
|
downloadChart(processorName) {
|
||||||
|
const chart = this.charts.get(processorName);
|
||||||
|
if (!chart || !chart.plotContainer) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Plotly.downloadImage(chart.plotContainer, {
|
||||||
|
format: 'png',
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
filename: `${processorName}-${Date.now()}`
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Download Started',
|
||||||
|
message: `Chart image download started`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Chart download failed:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Download Failed',
|
||||||
|
message: 'Failed to download chart image'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle fullscreen mode
|
||||||
|
*/
|
||||||
|
toggleFullscreen(processorName) {
|
||||||
|
const chart = this.charts.get(processorName);
|
||||||
|
if (!chart || !chart.element) return;
|
||||||
|
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
chart.element.requestFullscreen().then(() => {
|
||||||
|
// Resize chart for fullscreen after CSS transitions complete
|
||||||
|
setTimeout(() => {
|
||||||
|
if (chart.plotContainer) {
|
||||||
|
// Get fullscreen dimensions
|
||||||
|
const rect = chart.plotContainer.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Update layout for fullscreen
|
||||||
|
Plotly.relayout(chart.plotContainer, {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force resize
|
||||||
|
Plotly.Plots.resize(chart.plotContainer);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}).catch(console.error);
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().then(() => {
|
||||||
|
// Resize chart back to normal view
|
||||||
|
setTimeout(() => {
|
||||||
|
if (chart.plotContainer) {
|
||||||
|
Plotly.Plots.resize(chart.plotContainer);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide empty state
|
||||||
|
*/
|
||||||
|
hideEmptyState() {
|
||||||
|
if (this.emptyState) {
|
||||||
|
this.emptyState.classList.add('empty-state--hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update empty state visibility
|
||||||
|
*/
|
||||||
|
updateEmptyStateVisibility() {
|
||||||
|
if (!this.emptyState) return;
|
||||||
|
|
||||||
|
const hasVisibleCharts = Array.from(this.charts.values())
|
||||||
|
.some(chart => chart.isVisible);
|
||||||
|
|
||||||
|
if (hasVisibleCharts) {
|
||||||
|
this.emptyState.classList.add('empty-state--hidden');
|
||||||
|
} else {
|
||||||
|
this.emptyState.classList.remove('empty-state--hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update performance statistics
|
||||||
|
*/
|
||||||
|
updatePerformanceStats(updateTime) {
|
||||||
|
this.performanceStats.updatesProcessed++;
|
||||||
|
this.performanceStats.lastUpdateTime = new Date();
|
||||||
|
|
||||||
|
// Calculate rolling average
|
||||||
|
const totalTime = this.performanceStats.avgUpdateTime *
|
||||||
|
(this.performanceStats.updatesProcessed - 1) + updateTime;
|
||||||
|
this.performanceStats.avgUpdateTime = totalTime / this.performanceStats.updatesProcessed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause updates (for performance when tab is hidden)
|
||||||
|
*/
|
||||||
|
pause() {
|
||||||
|
this.isPaused = true;
|
||||||
|
console.log('⏸️ Chart updates paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume updates
|
||||||
|
*/
|
||||||
|
resume() {
|
||||||
|
this.isPaused = false;
|
||||||
|
console.log('▶️ Chart updates resumed');
|
||||||
|
|
||||||
|
// Process any queued updates
|
||||||
|
if (this.updateQueue.size > 0) {
|
||||||
|
this.processUpdateQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export all chart data
|
||||||
|
*/
|
||||||
|
exportData() {
|
||||||
|
const exportData = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
charts: {},
|
||||||
|
stats: this.performanceStats
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [processorName, dataArray] of this.chartData) {
|
||||||
|
exportData.charts[processorName] = dataArray.map(item => ({
|
||||||
|
sweep_number: item.sweep_number,
|
||||||
|
data: item.data,
|
||||||
|
timestamp: item.timestamp.toISOString()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return exportData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get disabled processors list
|
||||||
|
*/
|
||||||
|
getDisabledProcessors() {
|
||||||
|
return Array.from(this.disabledProcessors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart statistics
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
...this.performanceStats,
|
||||||
|
totalCharts: this.charts.size,
|
||||||
|
visibleCharts: Array.from(this.charts.values()).filter(c => c.isVisible).length,
|
||||||
|
disabledProcessors: this.disabledProcessors.size,
|
||||||
|
queuedUpdates: this.updateQueue.size,
|
||||||
|
isPaused: this.isPaused
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
console.log('🧹 Cleaning up Chart Manager...');
|
||||||
|
|
||||||
|
// Clear all charts (this also cleans up Plotly instances)
|
||||||
|
this.clearAll();
|
||||||
|
|
||||||
|
// Clear update queue
|
||||||
|
this.updateQueue.clear();
|
||||||
|
this.isUpdating = false;
|
||||||
|
this.isPaused = true;
|
||||||
|
|
||||||
|
console.log('✅ Chart Manager cleanup complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
466
vna_system/web_ui/static/js/modules/notifications.js
Normal file
466
vna_system/web_ui/static/js/modules/notifications.js
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* Notification Manager
|
||||||
|
* Handles toast notifications and user feedback
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class NotificationManager {
|
||||||
|
constructor() {
|
||||||
|
this.container = null;
|
||||||
|
this.notifications = new Map(); // id -> notification element
|
||||||
|
this.nextId = 1;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
this.config = {
|
||||||
|
maxNotifications: 5,
|
||||||
|
defaultTimeout: 5000,
|
||||||
|
animationDuration: 300,
|
||||||
|
position: 'top-right' // top-right, top-left, bottom-right, bottom-left
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notification types configuration
|
||||||
|
this.typeConfig = {
|
||||||
|
success: {
|
||||||
|
icon: 'check-circle',
|
||||||
|
timeout: 3000,
|
||||||
|
class: 'notification--success'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: 'alert-circle',
|
||||||
|
timeout: 7000,
|
||||||
|
class: 'notification--error'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: 'alert-triangle',
|
||||||
|
timeout: 5000,
|
||||||
|
class: 'notification--warning'
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: 'info',
|
||||||
|
timeout: 4000,
|
||||||
|
class: 'notification--info'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize notification system
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
// Create or find notification container
|
||||||
|
this.container = document.getElementById('notifications');
|
||||||
|
|
||||||
|
if (!this.container) {
|
||||||
|
this.container = document.createElement('div');
|
||||||
|
this.container.id = 'notifications';
|
||||||
|
this.container.className = 'notifications';
|
||||||
|
document.body.appendChild(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📢 Notification Manager initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a notification
|
||||||
|
*/
|
||||||
|
show(options) {
|
||||||
|
const notification = this.createNotification(options);
|
||||||
|
this.addNotification(notification);
|
||||||
|
return notification.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create notification object
|
||||||
|
*/
|
||||||
|
createNotification(options) {
|
||||||
|
const {
|
||||||
|
type = 'info',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
timeout = null,
|
||||||
|
persistent = false,
|
||||||
|
actions = []
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const id = this.nextId++;
|
||||||
|
const config = this.typeConfig[type] || this.typeConfig.info;
|
||||||
|
const finalTimeout = persistent ? null : (timeout ?? config.timeout);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
timeout: finalTimeout,
|
||||||
|
persistent,
|
||||||
|
actions,
|
||||||
|
config,
|
||||||
|
element: null,
|
||||||
|
timer: null,
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add notification to DOM and manage queue
|
||||||
|
*/
|
||||||
|
addNotification(notification) {
|
||||||
|
// Remove oldest notifications if we exceed the limit
|
||||||
|
this.enforceMaxNotifications();
|
||||||
|
|
||||||
|
// Create DOM element
|
||||||
|
notification.element = this.createNotificationElement(notification);
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
this.container.appendChild(notification.element);
|
||||||
|
|
||||||
|
// Store notification
|
||||||
|
this.notifications.set(notification.id, notification);
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.element.classList.add('notification--visible');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Set up auto-dismiss timer
|
||||||
|
if (notification.timeout) {
|
||||||
|
notification.timer = setTimeout(() => {
|
||||||
|
this.dismiss(notification.id);
|
||||||
|
}, notification.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Lucide icons
|
||||||
|
if (typeof lucide !== 'undefined') {
|
||||||
|
lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📢 Showing ${notification.type} notification:`, notification.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create notification DOM element
|
||||||
|
*/
|
||||||
|
createNotificationElement(notification) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = `notification ${notification.config.class}`;
|
||||||
|
element.dataset.id = notification.id;
|
||||||
|
|
||||||
|
const iconHtml = `<i data-lucide="${notification.config.icon}" class="notification__icon"></i>`;
|
||||||
|
|
||||||
|
const titleHtml = notification.title ?
|
||||||
|
`<div class="notification__title">${this.escapeHtml(notification.title)}</div>` : '';
|
||||||
|
|
||||||
|
const messageHtml = notification.message ?
|
||||||
|
`<div class="notification__message">${this.escapeHtml(notification.message)}</div>` : '';
|
||||||
|
|
||||||
|
const actionsHtml = notification.actions.length > 0 ?
|
||||||
|
this.createActionsHtml(notification.actions) : '';
|
||||||
|
|
||||||
|
const closeHtml = `
|
||||||
|
<button class="notification__close" data-action="close" title="Close">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
element.innerHTML = `
|
||||||
|
${iconHtml}
|
||||||
|
<div class="notification__content">
|
||||||
|
${titleHtml}
|
||||||
|
${messageHtml}
|
||||||
|
${actionsHtml}
|
||||||
|
</div>
|
||||||
|
${closeHtml}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
this.setupNotificationEvents(element, notification);
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create actions HTML for notification
|
||||||
|
*/
|
||||||
|
createActionsHtml(actions) {
|
||||||
|
if (actions.length === 0) return '';
|
||||||
|
|
||||||
|
const actionsHtml = actions.map(action => {
|
||||||
|
const buttonClass = action.primary ? 'btn btn--primary btn--sm' : 'btn btn--ghost btn--sm';
|
||||||
|
return `
|
||||||
|
<button class="${buttonClass}" data-action="custom" data-action-id="${action.id}">
|
||||||
|
${action.icon ? `<i data-lucide="${action.icon}"></i>` : ''}
|
||||||
|
${this.escapeHtml(action.label)}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<div class="notification__actions">${actionsHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up notification event listeners
|
||||||
|
*/
|
||||||
|
setupNotificationEvents(element, notification) {
|
||||||
|
element.addEventListener('click', (event) => {
|
||||||
|
const action = event.target.closest('[data-action]')?.dataset.action;
|
||||||
|
|
||||||
|
if (action === 'close') {
|
||||||
|
this.dismiss(notification.id);
|
||||||
|
} else if (action === 'custom') {
|
||||||
|
const actionId = event.target.closest('[data-action-id]')?.dataset.actionId;
|
||||||
|
const actionConfig = notification.actions.find(a => a.id === actionId);
|
||||||
|
|
||||||
|
if (actionConfig && actionConfig.handler) {
|
||||||
|
try {
|
||||||
|
actionConfig.handler(notification);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in notification action handler:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-dismiss after action unless specified otherwise
|
||||||
|
if (!actionConfig || actionConfig.dismissOnClick !== false) {
|
||||||
|
this.dismiss(notification.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pause auto-dismiss on hover
|
||||||
|
element.addEventListener('mouseenter', () => {
|
||||||
|
if (notification.timer) {
|
||||||
|
clearTimeout(notification.timer);
|
||||||
|
notification.timer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resume auto-dismiss on leave (if not persistent)
|
||||||
|
element.addEventListener('mouseleave', () => {
|
||||||
|
if (notification.timeout && !notification.persistent && !notification.timer) {
|
||||||
|
notification.timer = setTimeout(() => {
|
||||||
|
this.dismiss(notification.id);
|
||||||
|
}, 1000); // Shorter timeout after hover
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss notification by ID
|
||||||
|
*/
|
||||||
|
dismiss(id) {
|
||||||
|
const notification = this.notifications.get(id);
|
||||||
|
if (!notification) return;
|
||||||
|
|
||||||
|
console.log(`📢 Dismissing notification: ${id}`);
|
||||||
|
|
||||||
|
// Clear timer
|
||||||
|
if (notification.timer) {
|
||||||
|
clearTimeout(notification.timer);
|
||||||
|
notification.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate out
|
||||||
|
if (notification.element) {
|
||||||
|
notification.element.classList.add('notification--dismissing');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.element && notification.element.parentNode) {
|
||||||
|
notification.element.parentNode.removeChild(notification.element);
|
||||||
|
}
|
||||||
|
this.notifications.delete(id);
|
||||||
|
}, this.config.animationDuration);
|
||||||
|
} else {
|
||||||
|
this.notifications.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss all notifications
|
||||||
|
*/
|
||||||
|
dismissAll() {
|
||||||
|
console.log('📢 Dismissing all notifications');
|
||||||
|
|
||||||
|
for (const id of this.notifications.keys()) {
|
||||||
|
this.dismiss(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss notifications by type
|
||||||
|
*/
|
||||||
|
dismissByType(type) {
|
||||||
|
console.log(`📢 Dismissing all ${type} notifications`);
|
||||||
|
|
||||||
|
for (const notification of this.notifications.values()) {
|
||||||
|
if (notification.type === type) {
|
||||||
|
this.dismiss(notification.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing notification
|
||||||
|
*/
|
||||||
|
update(id, updates) {
|
||||||
|
const notification = this.notifications.get(id);
|
||||||
|
if (!notification) return false;
|
||||||
|
|
||||||
|
// Update properties
|
||||||
|
Object.assign(notification, updates);
|
||||||
|
|
||||||
|
// Update DOM if needed
|
||||||
|
if (updates.title || updates.message) {
|
||||||
|
const titleEl = notification.element.querySelector('.notification__title');
|
||||||
|
const messageEl = notification.element.querySelector('.notification__message');
|
||||||
|
|
||||||
|
if (titleEl && updates.title !== undefined) {
|
||||||
|
titleEl.textContent = updates.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageEl && updates.message !== undefined) {
|
||||||
|
messageEl.textContent = updates.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timeout
|
||||||
|
if (updates.timeout !== undefined) {
|
||||||
|
if (notification.timer) {
|
||||||
|
clearTimeout(notification.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.timeout > 0) {
|
||||||
|
notification.timer = setTimeout(() => {
|
||||||
|
this.dismiss(id);
|
||||||
|
}, updates.timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce maximum number of notifications
|
||||||
|
*/
|
||||||
|
enforceMaxNotifications() {
|
||||||
|
const notifications = Array.from(this.notifications.values())
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
|
||||||
|
while (notifications.length >= this.config.maxNotifications) {
|
||||||
|
const oldest = notifications.shift();
|
||||||
|
if (oldest && !oldest.persistent) {
|
||||||
|
this.dismiss(oldest.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience methods for different notification types
|
||||||
|
*/
|
||||||
|
success(title, message, options = {}) {
|
||||||
|
return this.show({
|
||||||
|
type: 'success',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
error(title, message, options = {}) {
|
||||||
|
return this.show({
|
||||||
|
type: 'error',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
warning(title, message, options = {}) {
|
||||||
|
return this.show({
|
||||||
|
type: 'warning',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
info(title, message, options = {}) {
|
||||||
|
return this.show({
|
||||||
|
type: 'info',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification statistics
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
total: this.notifications.size,
|
||||||
|
byType: this.getNotificationsByType(),
|
||||||
|
oldestTimestamp: this.getOldestNotificationTime(),
|
||||||
|
newestTimestamp: this.getNewestNotificationTime()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notifications grouped by type
|
||||||
|
*/
|
||||||
|
getNotificationsByType() {
|
||||||
|
const byType = {};
|
||||||
|
|
||||||
|
for (const notification of this.notifications.values()) {
|
||||||
|
byType[notification.type] = (byType[notification.type] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return byType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get oldest notification timestamp
|
||||||
|
*/
|
||||||
|
getOldestNotificationTime() {
|
||||||
|
const notifications = Array.from(this.notifications.values());
|
||||||
|
if (notifications.length === 0) return null;
|
||||||
|
|
||||||
|
return Math.min(...notifications.map(n => n.createdAt.getTime()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get newest notification timestamp
|
||||||
|
*/
|
||||||
|
getNewestNotificationTime() {
|
||||||
|
const notifications = Array.from(this.notifications.values());
|
||||||
|
if (notifications.length === 0) return null;
|
||||||
|
|
||||||
|
return Math.max(...notifications.map(n => n.createdAt.getTime()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
*/
|
||||||
|
escapeHtml(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
console.log('🧹 Cleaning up Notification Manager...');
|
||||||
|
|
||||||
|
this.dismissAll();
|
||||||
|
|
||||||
|
if (this.container && this.container.parentNode) {
|
||||||
|
this.container.parentNode.removeChild(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Notification Manager cleanup complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
453
vna_system/web_ui/static/js/modules/storage.js
Normal file
453
vna_system/web_ui/static/js/modules/storage.js
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* Storage Manager
|
||||||
|
* Handles localStorage persistence and user preferences
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class StorageManager {
|
||||||
|
constructor() {
|
||||||
|
this.prefix = 'vna_dashboard_';
|
||||||
|
this.isAvailable = this.checkAvailability();
|
||||||
|
|
||||||
|
// Storage keys
|
||||||
|
this.keys = {
|
||||||
|
preferences: 'preferences',
|
||||||
|
chartData: 'chart_data',
|
||||||
|
session: 'session_data',
|
||||||
|
debug: 'debug_info'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`💾 Storage Manager initialized (${this.isAvailable ? 'available' : 'not available'})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if localStorage is available
|
||||||
|
*/
|
||||||
|
checkAvailability() {
|
||||||
|
try {
|
||||||
|
const test = '__storage_test__';
|
||||||
|
localStorage.setItem(test, test);
|
||||||
|
localStorage.removeItem(test);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ localStorage not available:', e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full storage key with prefix
|
||||||
|
*/
|
||||||
|
getKey(key) {
|
||||||
|
return `${this.prefix}${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save data to localStorage
|
||||||
|
*/
|
||||||
|
async save(key, data) {
|
||||||
|
if (!this.isAvailable) {
|
||||||
|
console.warn('⚠️ Storage not available, cannot save:', key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify({
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
version: '1.0'
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem(this.getKey(key), serialized);
|
||||||
|
console.log(`💾 Saved to storage: ${key}`);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to save to storage:', error);
|
||||||
|
|
||||||
|
// Handle quota exceeded error
|
||||||
|
if (error.name === 'QuotaExceededError') {
|
||||||
|
console.log('🧹 Storage quota exceeded, cleaning up...');
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
// Try again after cleanup
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.getKey(key), serialized);
|
||||||
|
console.log(`💾 Saved to storage after cleanup: ${key}`);
|
||||||
|
return true;
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error('❌ Still failed after cleanup:', retryError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data from localStorage
|
||||||
|
*/
|
||||||
|
async load(key) {
|
||||||
|
if (!this.isAvailable) {
|
||||||
|
console.warn('⚠️ Storage not available, cannot load:', key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serialized = localStorage.getItem(this.getKey(key));
|
||||||
|
if (!serialized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(serialized);
|
||||||
|
|
||||||
|
// Validate structure
|
||||||
|
if (!parsed.data || !parsed.timestamp) {
|
||||||
|
console.warn('⚠️ Invalid storage data format:', key);
|
||||||
|
this.remove(key); // Clean up invalid data
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`💾 Loaded from storage: ${key} (${new Date(parsed.timestamp).toLocaleString()})`);
|
||||||
|
return parsed.data;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to load from storage:', error);
|
||||||
|
this.remove(key); // Clean up corrupted data
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove data from localStorage
|
||||||
|
*/
|
||||||
|
async remove(key) {
|
||||||
|
if (!this.isAvailable) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(this.getKey(key));
|
||||||
|
console.log(`🗑️ Removed from storage: ${key}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to remove from storage:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key exists in storage
|
||||||
|
*/
|
||||||
|
async exists(key) {
|
||||||
|
if (!this.isAvailable) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.getKey(key)) !== null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to check storage existence:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save user preferences
|
||||||
|
*/
|
||||||
|
async savePreferences(preferences) {
|
||||||
|
const data = {
|
||||||
|
...preferences,
|
||||||
|
lastUpdated: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.save(this.keys.preferences, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load user preferences
|
||||||
|
*/
|
||||||
|
async getPreferences() {
|
||||||
|
return await this.load(this.keys.preferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save chart data for offline viewing
|
||||||
|
*/
|
||||||
|
async saveChartData(chartData) {
|
||||||
|
// Limit size to prevent storage overflow
|
||||||
|
const maxCharts = 10;
|
||||||
|
const limitedData = {};
|
||||||
|
|
||||||
|
const chartNames = Object.keys(chartData).slice(-maxCharts);
|
||||||
|
chartNames.forEach(name => {
|
||||||
|
// Keep only last 100 data points per chart
|
||||||
|
const data = chartData[name];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
limitedData[name] = data.slice(-100);
|
||||||
|
} else {
|
||||||
|
limitedData[name] = data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.save(this.keys.chartData, limitedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load saved chart data
|
||||||
|
*/
|
||||||
|
async getChartData() {
|
||||||
|
return await this.load(this.keys.chartData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save session data (temporary data that expires)
|
||||||
|
*/
|
||||||
|
async saveSession(sessionData) {
|
||||||
|
const data = {
|
||||||
|
...sessionData,
|
||||||
|
expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.save(this.keys.session, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load session data (with expiration check)
|
||||||
|
*/
|
||||||
|
async getSession() {
|
||||||
|
const data = await this.load(this.keys.session);
|
||||||
|
|
||||||
|
if (data && data.expiresAt) {
|
||||||
|
if (Date.now() > data.expiresAt) {
|
||||||
|
console.log('🕒 Session data expired, removing...');
|
||||||
|
await this.remove(this.keys.session);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save debug information
|
||||||
|
*/
|
||||||
|
async saveDebugInfo(debugInfo) {
|
||||||
|
const data = {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
url: window.location.href,
|
||||||
|
...debugInfo
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.save(this.keys.debug, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get debug information
|
||||||
|
*/
|
||||||
|
async getDebugInfo() {
|
||||||
|
return await this.load(this.keys.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage usage statistics
|
||||||
|
*/
|
||||||
|
getStorageStats() {
|
||||||
|
if (!this.isAvailable) {
|
||||||
|
return { available: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Calculate used storage
|
||||||
|
let totalSize = 0;
|
||||||
|
const itemSizes = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(this.prefix)) {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
const size = value ? value.length : 0;
|
||||||
|
totalSize += size;
|
||||||
|
itemSizes[key.replace(this.prefix, '')] = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
totalSize,
|
||||||
|
totalSizeFormatted: this.formatBytes(totalSize),
|
||||||
|
itemSizes,
|
||||||
|
itemCount: Object.keys(itemSizes).length,
|
||||||
|
storageQuotaMB: this.estimateStorageQuota()
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to calculate storage stats:', error);
|
||||||
|
return { available: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate storage quota (very rough estimate)
|
||||||
|
*/
|
||||||
|
estimateStorageQuota() {
|
||||||
|
// Most browsers have 5-10MB localStorage quota
|
||||||
|
return 5; // MB
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human readable format
|
||||||
|
*/
|
||||||
|
formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup old or unnecessary data
|
||||||
|
*/
|
||||||
|
async cleanup() {
|
||||||
|
if (!this.isAvailable) return;
|
||||||
|
|
||||||
|
console.log('🧹 Cleaning up storage...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keysToRemove = [];
|
||||||
|
const cutoffTime = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days ago
|
||||||
|
|
||||||
|
// Find keys to remove
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(this.prefix)) {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
|
||||||
|
// Remove old data
|
||||||
|
if (parsed.timestamp && parsed.timestamp < cutoffTime) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (parseError) {
|
||||||
|
// Remove corrupted data
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove identified keys
|
||||||
|
keysToRemove.forEach(key => {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
console.log(`🗑️ Cleaned up: ${key}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🧹 Cleanup complete. Removed ${keysToRemove.length} items.`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Cleanup failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all application data
|
||||||
|
*/
|
||||||
|
async clearAll() {
|
||||||
|
if (!this.isAvailable) return false;
|
||||||
|
|
||||||
|
console.log('🗑️ Clearing all storage data...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keysToRemove = [];
|
||||||
|
|
||||||
|
// Find all our keys
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(this.prefix)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all keys
|
||||||
|
keysToRemove.forEach(key => {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🗑️ Cleared ${keysToRemove.length} items from storage`);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to clear storage:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export all data for backup
|
||||||
|
*/
|
||||||
|
async exportData() {
|
||||||
|
if (!this.isAvailable) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exportData = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
version: '1.0',
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all our data
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith(this.prefix)) {
|
||||||
|
const shortKey = key.replace(this.prefix, '');
|
||||||
|
const value = localStorage.getItem(key);
|
||||||
|
exportData.data[shortKey] = JSON.parse(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📤 Exported storage data');
|
||||||
|
return exportData;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to export data:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import data from backup
|
||||||
|
*/
|
||||||
|
async importData(importData) {
|
||||||
|
if (!this.isAvailable) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!importData.data || typeof importData.data !== 'object') {
|
||||||
|
throw new Error('Invalid import data format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import each item
|
||||||
|
for (const [key, value] of Object.entries(importData.data)) {
|
||||||
|
const fullKey = this.getKey(key);
|
||||||
|
localStorage.setItem(fullKey, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📥 Imported ${Object.keys(importData.data).length} items`);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to import data:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup on destroy
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
console.log('🧹 Storage Manager cleanup...');
|
||||||
|
// Perform any necessary cleanup
|
||||||
|
// For now, just run a cleanup to free up space
|
||||||
|
this.cleanup();
|
||||||
|
console.log('✅ Storage Manager cleanup complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
529
vna_system/web_ui/static/js/modules/ui.js
Normal file
529
vna_system/web_ui/static/js/modules/ui.js
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
/**
|
||||||
|
* UI Manager
|
||||||
|
* Handles user interface interactions and state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class UIManager {
|
||||||
|
constructor(notifications) {
|
||||||
|
this.notifications = notifications;
|
||||||
|
|
||||||
|
// UI Elements
|
||||||
|
this.elements = {
|
||||||
|
connectionStatus: null,
|
||||||
|
processorToggles: null,
|
||||||
|
sweepCount: null,
|
||||||
|
dataRate: null,
|
||||||
|
clearChartsBtn: null,
|
||||||
|
exportBtn: null,
|
||||||
|
navButtons: null,
|
||||||
|
views: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// State
|
||||||
|
this.currentView = 'dashboard';
|
||||||
|
this.connectionStatus = 'disconnected';
|
||||||
|
this.processors = new Map(); // processor_name -> { enabled, count }
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
this.eventHandlers = {
|
||||||
|
viewChange: [],
|
||||||
|
processorToggle: [],
|
||||||
|
clearCharts: [],
|
||||||
|
exportData: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Statistics tracking
|
||||||
|
this.stats = {
|
||||||
|
sweepCount: 0,
|
||||||
|
dataRate: 0,
|
||||||
|
lastUpdate: null,
|
||||||
|
rateCalculation: {
|
||||||
|
samples: [],
|
||||||
|
windowMs: 10000 // 10 second window
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize UI Manager
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
console.log('🎨 Initializing UI Manager...');
|
||||||
|
|
||||||
|
// Get DOM elements
|
||||||
|
this.findElements();
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// Initialize UI state
|
||||||
|
this.initializeUIState();
|
||||||
|
|
||||||
|
// Start update loop
|
||||||
|
this.startUpdateLoop();
|
||||||
|
|
||||||
|
console.log('✅ UI Manager initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and cache DOM elements
|
||||||
|
*/
|
||||||
|
findElements() {
|
||||||
|
this.elements.connectionStatus = document.getElementById('connectionStatus');
|
||||||
|
this.elements.processorToggles = document.getElementById('processorToggles');
|
||||||
|
this.elements.sweepCount = document.getElementById('sweepCount');
|
||||||
|
this.elements.dataRate = document.getElementById('dataRate');
|
||||||
|
// clearChartsBtn removed
|
||||||
|
this.elements.exportBtn = document.getElementById('exportBtn');
|
||||||
|
this.elements.navButtons = document.querySelectorAll('.nav-btn[data-view]');
|
||||||
|
this.elements.views = document.querySelectorAll('.view');
|
||||||
|
|
||||||
|
// Validate required elements
|
||||||
|
const required = [
|
||||||
|
'connectionStatus', 'processorToggles', 'exportBtn'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const element of required) {
|
||||||
|
if (!this.elements[element]) {
|
||||||
|
throw new Error(`Required UI element not found: ${element}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up event listeners
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
// Navigation
|
||||||
|
this.elements.navButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
const view = e.currentTarget.dataset.view;
|
||||||
|
this.switchView(view);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Clear charts functionality removed - use processor toggles instead
|
||||||
|
|
||||||
|
// Export button
|
||||||
|
this.elements.exportBtn.addEventListener('click', () => {
|
||||||
|
this.triggerExportData();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Processor toggles container (event delegation)
|
||||||
|
this.elements.processorToggles.addEventListener('click', (e) => {
|
||||||
|
const toggle = e.target.closest('.processor-toggle');
|
||||||
|
if (toggle) {
|
||||||
|
const processor = toggle.dataset.processor;
|
||||||
|
const isEnabled = toggle.classList.contains('processor-toggle--active');
|
||||||
|
this.toggleProcessor(processor, !isEnabled);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Window resize
|
||||||
|
window.addEventListener('resize', this.debounce(() => {
|
||||||
|
this.handleResize();
|
||||||
|
}, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize UI state
|
||||||
|
*/
|
||||||
|
initializeUIState() {
|
||||||
|
// Set initial connection status
|
||||||
|
this.setConnectionStatus('disconnected');
|
||||||
|
|
||||||
|
// Initialize stats display
|
||||||
|
this.updateStatsDisplay();
|
||||||
|
|
||||||
|
// Set initial view
|
||||||
|
this.switchView(this.currentView);
|
||||||
|
|
||||||
|
// Clean up any invalid processors
|
||||||
|
this.cleanupInvalidProcessors();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up invalid/old processors
|
||||||
|
*/
|
||||||
|
cleanupInvalidProcessors() {
|
||||||
|
const invalidProcessors = ['sweep_counter'];
|
||||||
|
|
||||||
|
invalidProcessors.forEach(processor => {
|
||||||
|
if (this.processors.has(processor)) {
|
||||||
|
console.log(`🧹 Removing invalid processor: ${processor}`);
|
||||||
|
this.processors.delete(processor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start update loop for real-time UI updates
|
||||||
|
*/
|
||||||
|
startUpdateLoop() {
|
||||||
|
setInterval(() => {
|
||||||
|
this.updateDataRateDisplay();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch between views (Dashboard, Settings, Logs)
|
||||||
|
*/
|
||||||
|
switchView(viewName) {
|
||||||
|
if (this.currentView === viewName) return;
|
||||||
|
|
||||||
|
console.log(`🔄 Switching to view: ${viewName}`);
|
||||||
|
|
||||||
|
// Update navigation buttons
|
||||||
|
this.elements.navButtons.forEach(button => {
|
||||||
|
const isActive = button.dataset.view === viewName;
|
||||||
|
button.classList.toggle('nav-btn--active', isActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update views
|
||||||
|
this.elements.views.forEach(view => {
|
||||||
|
const isActive = view.id === `${viewName}View`;
|
||||||
|
view.classList.toggle('view--active', isActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentView = viewName;
|
||||||
|
this.emitEvent('viewChange', viewName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update connection status
|
||||||
|
*/
|
||||||
|
setConnectionStatus(status) {
|
||||||
|
if (this.connectionStatus === status) return;
|
||||||
|
|
||||||
|
console.log(`🔌 Connection status: ${status}`);
|
||||||
|
this.connectionStatus = status;
|
||||||
|
|
||||||
|
const statusElement = this.elements.connectionStatus;
|
||||||
|
const textElement = statusElement.querySelector('.status-indicator__text');
|
||||||
|
|
||||||
|
// Remove existing status classes
|
||||||
|
statusElement.classList.remove(
|
||||||
|
'status-indicator--connected',
|
||||||
|
'status-indicator--connecting',
|
||||||
|
'status-indicator--disconnected'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new status class and update text
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
statusElement.classList.add('status-indicator--connected');
|
||||||
|
textElement.textContent = 'Connected';
|
||||||
|
break;
|
||||||
|
case 'connecting':
|
||||||
|
statusElement.classList.add('status-indicator--connecting');
|
||||||
|
textElement.textContent = 'Connecting...';
|
||||||
|
break;
|
||||||
|
case 'disconnected':
|
||||||
|
default:
|
||||||
|
statusElement.classList.add('status-indicator--disconnected');
|
||||||
|
textElement.textContent = 'Disconnected';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update processor toggles
|
||||||
|
*/
|
||||||
|
updateProcessorToggles(processors) {
|
||||||
|
if (!this.elements.processorToggles) return;
|
||||||
|
|
||||||
|
// Clear existing toggles
|
||||||
|
this.elements.processorToggles.innerHTML = '';
|
||||||
|
|
||||||
|
// Create toggles for each processor
|
||||||
|
processors.forEach(processor => {
|
||||||
|
const toggle = this.createProcessorToggle(processor);
|
||||||
|
this.elements.processorToggles.appendChild(toggle);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Lucide icons
|
||||||
|
if (typeof lucide !== 'undefined') {
|
||||||
|
lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create processor toggle element
|
||||||
|
*/
|
||||||
|
createProcessorToggle(processor) {
|
||||||
|
const toggle = document.createElement('div');
|
||||||
|
toggle.className = `processor-toggle ${processor.enabled ? 'processor-toggle--active' : ''}`;
|
||||||
|
toggle.dataset.processor = processor.name;
|
||||||
|
|
||||||
|
toggle.innerHTML = `
|
||||||
|
<div class="processor-toggle__checkbox"></div>
|
||||||
|
<div class="processor-toggle__label">${this.formatProcessorName(processor.name)}</div>
|
||||||
|
<div class="processor-toggle__count">${processor.count || 0}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return toggle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle processor state
|
||||||
|
*/
|
||||||
|
toggleProcessor(processorName, enabled) {
|
||||||
|
console.log(`🔧 Toggle processor ${processorName}: ${enabled}`);
|
||||||
|
|
||||||
|
// Update processor state
|
||||||
|
const processor = this.processors.get(processorName) || { count: 0 };
|
||||||
|
processor.enabled = enabled;
|
||||||
|
this.processors.set(processorName, processor);
|
||||||
|
|
||||||
|
// Update UI only if elements exist
|
||||||
|
if (this.elements.processorToggles) {
|
||||||
|
const toggle = this.elements.processorToggles.querySelector(`[data-processor="${processorName}"]`);
|
||||||
|
if (toggle) {
|
||||||
|
toggle.classList.toggle('processor-toggle--active', enabled);
|
||||||
|
} else {
|
||||||
|
// Toggle element doesn't exist yet, refresh toggles
|
||||||
|
this.refreshProcessorToggles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
this.emitEvent('processorToggle', processorName, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update processor from incoming data
|
||||||
|
*/
|
||||||
|
updateProcessorFromData(processorName) {
|
||||||
|
let processor = this.processors.get(processorName);
|
||||||
|
|
||||||
|
if (!processor) {
|
||||||
|
// New processor discovered
|
||||||
|
processor = { enabled: true, count: 0 };
|
||||||
|
this.processors.set(processorName, processor);
|
||||||
|
this.refreshProcessorToggles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count
|
||||||
|
processor.count++;
|
||||||
|
|
||||||
|
// Update count display
|
||||||
|
const toggle = this.elements.processorToggles.querySelector(`[data-processor="${processorName}"]`);
|
||||||
|
if (toggle) {
|
||||||
|
const countElement = toggle.querySelector('.processor-toggle__count');
|
||||||
|
if (countElement) {
|
||||||
|
countElement.textContent = processor.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh processor toggles display
|
||||||
|
*/
|
||||||
|
refreshProcessorToggles() {
|
||||||
|
const processors = Array.from(this.processors.entries()).map(([name, data]) => ({
|
||||||
|
name,
|
||||||
|
enabled: data.enabled,
|
||||||
|
count: data.count
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.updateProcessorToggles(processors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update statistics display
|
||||||
|
*/
|
||||||
|
updateStats(newStats) {
|
||||||
|
if (newStats.sweepCount !== undefined) {
|
||||||
|
this.stats.sweepCount = newStats.sweepCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newStats.lastUpdate) {
|
||||||
|
this.updateDataRate(newStats.lastUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateStatsDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update data rate calculation
|
||||||
|
*/
|
||||||
|
updateDataRate(timestamp) {
|
||||||
|
const now = timestamp.getTime();
|
||||||
|
this.stats.rateCalculation.samples.push(now);
|
||||||
|
|
||||||
|
// Remove samples outside the window
|
||||||
|
const cutoff = now - this.stats.rateCalculation.windowMs;
|
||||||
|
this.stats.rateCalculation.samples = this.stats.rateCalculation.samples
|
||||||
|
.filter(time => time > cutoff);
|
||||||
|
|
||||||
|
// Calculate rate (samples per second)
|
||||||
|
const samplesInWindow = this.stats.rateCalculation.samples.length;
|
||||||
|
const windowSeconds = this.stats.rateCalculation.windowMs / 1000;
|
||||||
|
this.stats.dataRate = Math.round(samplesInWindow / windowSeconds * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update statistics display elements
|
||||||
|
*/
|
||||||
|
updateStatsDisplay() {
|
||||||
|
if (this.elements.sweepCount) {
|
||||||
|
this.elements.sweepCount.textContent = this.stats.sweepCount.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateDataRateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update data rate display
|
||||||
|
*/
|
||||||
|
updateDataRateDisplay() {
|
||||||
|
if (this.elements.dataRate) {
|
||||||
|
this.elements.dataRate.textContent = `${this.stats.dataRate}/s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set processor enabled state (external API)
|
||||||
|
*/
|
||||||
|
setProcessorEnabled(processorName, enabled) {
|
||||||
|
if (!processorName) return;
|
||||||
|
|
||||||
|
// If processor doesn't exist yet, store the preference for later
|
||||||
|
if (!this.processors.has(processorName)) {
|
||||||
|
const processor = { enabled, count: 0 };
|
||||||
|
this.processors.set(processorName, processor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toggleProcessor(processorName, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format processor name for display
|
||||||
|
*/
|
||||||
|
formatProcessorName(processorName) {
|
||||||
|
return processorName
|
||||||
|
.split('_')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear charts functionality removed - use processor toggles
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger export data action
|
||||||
|
*/
|
||||||
|
triggerExportData() {
|
||||||
|
this.emitEvent('exportData');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle window resize
|
||||||
|
*/
|
||||||
|
handleResize() {
|
||||||
|
console.log('📱 Window resized');
|
||||||
|
// Trigger chart resize if needed
|
||||||
|
// This is handled by the chart manager
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update system status (for future use)
|
||||||
|
*/
|
||||||
|
updateSystemStatus(statusData) {
|
||||||
|
console.log('📊 System status update:', statusData);
|
||||||
|
// Future implementation for system health monitoring
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set theme (for future use)
|
||||||
|
*/
|
||||||
|
setTheme(theme) {
|
||||||
|
console.log(`🎨 Setting theme: ${theme}`);
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current theme
|
||||||
|
*/
|
||||||
|
getCurrentTheme() {
|
||||||
|
return document.documentElement.getAttribute('data-theme') || 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event system
|
||||||
|
*/
|
||||||
|
onViewChange(callback) {
|
||||||
|
this.eventHandlers.viewChange.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProcessorToggle(callback) {
|
||||||
|
this.eventHandlers.processorToggle.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClearCharts(callback) {
|
||||||
|
this.eventHandlers.clearCharts.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onExportData(callback) {
|
||||||
|
this.eventHandlers.exportData.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit event to registered handlers
|
||||||
|
*/
|
||||||
|
emitEvent(eventType, ...args) {
|
||||||
|
if (this.eventHandlers[eventType]) {
|
||||||
|
this.eventHandlers[eventType].forEach(handler => {
|
||||||
|
try {
|
||||||
|
handler(...args);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error in ${eventType} handler:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility: Debounce function
|
||||||
|
*/
|
||||||
|
debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get UI statistics
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
currentView: this.currentView,
|
||||||
|
connectionStatus: this.connectionStatus,
|
||||||
|
processorsCount: this.processors.size,
|
||||||
|
...this.stats
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
console.log('🧹 Cleaning up UI Manager...');
|
||||||
|
|
||||||
|
// Clear event handlers
|
||||||
|
Object.keys(this.eventHandlers).forEach(key => {
|
||||||
|
this.eventHandlers[key] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear processors
|
||||||
|
this.processors.clear();
|
||||||
|
|
||||||
|
console.log('✅ UI Manager cleanup complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
460
vna_system/web_ui/static/js/modules/websocket.js
Normal file
460
vna_system/web_ui/static/js/modules/websocket.js
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket Manager
|
||||||
|
* Handles real-time communication with the VNA backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class WebSocketManager {
|
||||||
|
constructor(config, notifications) {
|
||||||
|
this.config = config;
|
||||||
|
this.notifications = notifications;
|
||||||
|
this.ws = null;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.pingTimer = null;
|
||||||
|
this.lastPing = null;
|
||||||
|
this.eventListeners = new Map();
|
||||||
|
|
||||||
|
// Message queue for when disconnected
|
||||||
|
this.messageQueue = [];
|
||||||
|
this.maxQueueSize = 100;
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
this.stats = {
|
||||||
|
messagesReceived: 0,
|
||||||
|
messagesPerSecond: 0,
|
||||||
|
lastMessageTime: null,
|
||||||
|
connectionTime: null,
|
||||||
|
bytesReceived: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate limiting for message processing
|
||||||
|
this.messageRateCounter = [];
|
||||||
|
this.rateWindowMs = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to WebSocket server
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this.isConnected || this.isConnecting) {
|
||||||
|
console.log('🔌 WebSocket already connected/connecting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isConnecting = true;
|
||||||
|
this.emit('connecting');
|
||||||
|
|
||||||
|
console.log(`🔌 Connecting to WebSocket: ${this.config.url}`);
|
||||||
|
|
||||||
|
this.ws = new WebSocket(this.config.url);
|
||||||
|
this.setupWebSocketEvents();
|
||||||
|
|
||||||
|
// Wait for connection or timeout
|
||||||
|
await this.waitForConnection(5000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.isConnecting = false;
|
||||||
|
console.error('❌ WebSocket connection failed:', error);
|
||||||
|
this.handleConnectionError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for WebSocket connection with timeout
|
||||||
|
*/
|
||||||
|
waitForConnection(timeoutMs) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error('WebSocket connection timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const checkConnection = () => {
|
||||||
|
if (this.isConnected) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
} else if (this.ws?.readyState === WebSocket.CLOSED ||
|
||||||
|
this.ws?.readyState === WebSocket.CLOSING) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error('WebSocket connection failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately and then every 100ms
|
||||||
|
checkConnection();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
checkConnection();
|
||||||
|
if (this.isConnected) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Clean up interval on resolve/reject
|
||||||
|
const originalResolve = resolve;
|
||||||
|
const originalReject = reject;
|
||||||
|
resolve = (...args) => {
|
||||||
|
clearInterval(interval);
|
||||||
|
originalResolve(...args);
|
||||||
|
};
|
||||||
|
reject = (...args) => {
|
||||||
|
clearInterval(interval);
|
||||||
|
originalReject(...args);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up WebSocket event handlers
|
||||||
|
*/
|
||||||
|
setupWebSocketEvents() {
|
||||||
|
if (!this.ws) return;
|
||||||
|
|
||||||
|
this.ws.onopen = (event) => {
|
||||||
|
console.log('✅ WebSocket connected');
|
||||||
|
this.isConnected = true;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.stats.connectionTime = new Date();
|
||||||
|
|
||||||
|
this.startPingPong();
|
||||||
|
this.processPendingMessages();
|
||||||
|
this.emit('connected', event);
|
||||||
|
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Connected',
|
||||||
|
message: 'Real-time connection established'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
this.handleMessage(event.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error processing WebSocket message:', error);
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Message Error',
|
||||||
|
message: 'Failed to process received data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('❌ WebSocket error:', error);
|
||||||
|
this.handleConnectionError(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
console.log('🔌 WebSocket closed:', event.code, event.reason);
|
||||||
|
this.handleDisconnection(event);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming messages
|
||||||
|
*/
|
||||||
|
handleMessage(data) {
|
||||||
|
// Update statistics
|
||||||
|
this.stats.messagesReceived++;
|
||||||
|
this.stats.lastMessageTime = new Date();
|
||||||
|
this.stats.bytesReceived += data.length;
|
||||||
|
|
||||||
|
// Rate limiting check
|
||||||
|
this.updateMessageRate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let parsedData;
|
||||||
|
|
||||||
|
// Handle different message types
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
if (data === 'ping') {
|
||||||
|
this.handlePing();
|
||||||
|
return;
|
||||||
|
} else if (data === 'pong') {
|
||||||
|
this.handlePong();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
parsedData = JSON.parse(data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle binary data if needed
|
||||||
|
console.warn('⚠️ Received binary data, not implemented');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit data event
|
||||||
|
this.emit('data', parsedData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to parse WebSocket message:', error);
|
||||||
|
console.log('📝 Raw message:', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update message rate statistics
|
||||||
|
*/
|
||||||
|
updateMessageRate() {
|
||||||
|
const now = Date.now();
|
||||||
|
this.messageRateCounter.push(now);
|
||||||
|
|
||||||
|
// Remove messages outside the rate window
|
||||||
|
this.messageRateCounter = this.messageRateCounter.filter(
|
||||||
|
time => now - time < this.rateWindowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
this.stats.messagesPerSecond = this.messageRateCounter.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle ping message
|
||||||
|
*/
|
||||||
|
handlePing() {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send('pong');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle pong message
|
||||||
|
*/
|
||||||
|
handlePong() {
|
||||||
|
if (this.lastPing) {
|
||||||
|
const latency = Date.now() - this.lastPing;
|
||||||
|
console.log(`🏓 WebSocket latency: ${latency}ms`);
|
||||||
|
this.lastPing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start ping-pong mechanism
|
||||||
|
*/
|
||||||
|
startPingPong() {
|
||||||
|
this.pingTimer = setInterval(() => {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.lastPing = Date.now();
|
||||||
|
this.ws.send('ping');
|
||||||
|
}
|
||||||
|
}, 30000); // Ping every 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle connection errors
|
||||||
|
*/
|
||||||
|
handleConnectionError(error) {
|
||||||
|
console.error('❌ WebSocket connection error:', error);
|
||||||
|
|
||||||
|
if (!this.isConnected) {
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Connection Failed',
|
||||||
|
message: 'Unable to establish real-time connection'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle disconnection
|
||||||
|
*/
|
||||||
|
handleDisconnection(event) {
|
||||||
|
const wasConnected = this.isConnected;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
|
||||||
|
// Clean up timers
|
||||||
|
if (this.pingTimer) {
|
||||||
|
clearInterval(this.pingTimer);
|
||||||
|
this.pingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('disconnected', event);
|
||||||
|
|
||||||
|
if (wasConnected) {
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Disconnected',
|
||||||
|
message: 'Real-time connection lost. Attempting to reconnect...'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-reconnect
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule automatic reconnection
|
||||||
|
*/
|
||||||
|
scheduleReconnect() {
|
||||||
|
if (this.reconnectTimer) return;
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts),
|
||||||
|
30000 // Max 30 seconds
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`🔄 Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger reconnection
|
||||||
|
*/
|
||||||
|
async reconnect() {
|
||||||
|
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
||||||
|
console.error('❌ Max reconnection attempts reached');
|
||||||
|
this.notifications.show({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Connection Failed',
|
||||||
|
message: 'Unable to reconnect after multiple attempts'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
|
||||||
|
// Close existing connection
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Reconnection attempt ${this.reconnectAttempts} failed:`, error);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message
|
||||||
|
*/
|
||||||
|
send(data) {
|
||||||
|
if (!this.isConnected || !this.ws) {
|
||||||
|
// Queue message for later
|
||||||
|
if (this.messageQueue.length < this.maxQueueSize) {
|
||||||
|
this.messageQueue.push(data);
|
||||||
|
console.log('📤 Message queued (not connected)');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Message queue full, dropping message');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
|
this.ws.send(message);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to send message:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process pending messages after reconnection
|
||||||
|
*/
|
||||||
|
processPendingMessages() {
|
||||||
|
if (this.messageQueue.length > 0) {
|
||||||
|
console.log(`📤 Processing ${this.messageQueue.length} queued messages`);
|
||||||
|
|
||||||
|
const messages = [...this.messageQueue];
|
||||||
|
this.messageQueue = [];
|
||||||
|
|
||||||
|
messages.forEach(message => {
|
||||||
|
this.send(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect WebSocket
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
console.log('🔌 Disconnecting WebSocket');
|
||||||
|
|
||||||
|
// Clear reconnection timer
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear ping timer
|
||||||
|
if (this.pingTimer) {
|
||||||
|
clearInterval(this.pingTimer);
|
||||||
|
this.pingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close WebSocket
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close(1000, 'Manual disconnect');
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connection statistics
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
...this.stats,
|
||||||
|
isConnected: this.isConnected,
|
||||||
|
isConnecting: this.isConnecting,
|
||||||
|
reconnectAttempts: this.reconnectAttempts,
|
||||||
|
queuedMessages: this.messageQueue.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event listener management
|
||||||
|
*/
|
||||||
|
on(event, callback) {
|
||||||
|
if (!this.eventListeners.has(event)) {
|
||||||
|
this.eventListeners.set(event, []);
|
||||||
|
}
|
||||||
|
this.eventListeners.get(event).push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event, callback) {
|
||||||
|
if (this.eventListeners.has(event)) {
|
||||||
|
const listeners = this.eventListeners.get(event);
|
||||||
|
const index = listeners.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event, data) {
|
||||||
|
if (this.eventListeners.has(event)) {
|
||||||
|
this.eventListeners.get(event).forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error in event listener for ${event}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.disconnect();
|
||||||
|
this.eventListeners.clear();
|
||||||
|
this.messageQueue = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
139
vna_system/web_ui/templates/index.html
Normal file
139
vna_system/web_ui/templates/index.html
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Real-time VNA (Vector Network Analyzer) data acquisition and processing dashboard">
|
||||||
|
<meta name="keywords" content="VNA, Vector Network Analyzer, RF, Microwave, Data Acquisition, Real-time">
|
||||||
|
<meta name="author" content="VNA System">
|
||||||
|
<title>VNA System Dashboard</title>
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/assets/favicon.svg">
|
||||||
|
<link rel="alternate icon" href="/static/assets/favicon.svg">
|
||||||
|
|
||||||
|
<!-- Theme color for mobile browsers -->
|
||||||
|
<meta name="theme-color" content="#1e293b">
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Icons will be loaded with JavaScript -->
|
||||||
|
|
||||||
|
<!-- Plotly.js -->
|
||||||
|
<script src="https://cdn.plot.ly/plotly-2.26.0.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Styles -->
|
||||||
|
<link rel="stylesheet" href="/static/css/normalize.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/variables.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/layout.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/components.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/charts.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header__container">
|
||||||
|
<div class="header__brand">
|
||||||
|
<i data-lucide="activity" class="header__icon"></i>
|
||||||
|
<h1 class="header__title">VNA System</h1>
|
||||||
|
<span class="header__subtitle">Real-time Analysis</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header__status">
|
||||||
|
<div class="status-indicator" id="connectionStatus">
|
||||||
|
<div class="status-indicator__dot"></div>
|
||||||
|
<span class="status-indicator__text">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header__stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Sweeps:</span>
|
||||||
|
<span class="stat-value" id="sweepCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Rate:</span>
|
||||||
|
<span class="stat-value" id="dataRate">0/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="header__nav">
|
||||||
|
<button class="nav-btn nav-btn--active" data-view="dashboard">
|
||||||
|
<i data-lucide="bar-chart-3"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-view="settings">
|
||||||
|
<i data-lucide="settings"></i>
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main">
|
||||||
|
<!-- Dashboard View -->
|
||||||
|
<div class="view view--active" id="dashboardView">
|
||||||
|
<!-- Controls Panel -->
|
||||||
|
<div class="controls-panel">
|
||||||
|
<div class="controls-panel__container">
|
||||||
|
<div class="controls-group">
|
||||||
|
<label class="controls-label">Processors</label>
|
||||||
|
<div class="processor-toggles" id="processorToggles">
|
||||||
|
<!-- Processor toggles will be dynamically generated -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls-group">
|
||||||
|
<label class="controls-label">Actions</label>
|
||||||
|
<div class="display-controls">
|
||||||
|
<button class="btn btn--primary btn--bordered" id="exportBtn">
|
||||||
|
<i data-lucide="download"></i>
|
||||||
|
Export Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Grid -->
|
||||||
|
<div class="charts-container">
|
||||||
|
<div class="charts-grid" id="chartsGrid">
|
||||||
|
<!-- Charts will be dynamically generated -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" id="emptyState">
|
||||||
|
<div class="empty-state__content">
|
||||||
|
<i data-lucide="activity" class="empty-state__icon"></i>
|
||||||
|
<h3 class="empty-state__title">No Data Yet</h3>
|
||||||
|
<p class="empty-state__description">
|
||||||
|
Waiting for sweep data from the VNA system...
|
||||||
|
</p>
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings View -->
|
||||||
|
<div class="view" id="settingsView">
|
||||||
|
<div class="settings-container">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<p>Settings panel will be implemented in future versions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Notification System -->
|
||||||
|
<div class="notifications" id="notifications"></div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
|
||||||
|
<script type="module" src="/static/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user