initial commit

This commit is contained in:
Ayzen
2025-09-23 18:42:55 +03:00
parent 90e5ae38c6
commit 6c54bbd16e
41 changed files with 6582 additions and 0 deletions

View File

@ -0,0 +1 @@
"""VNA System API module."""

View File

@ -0,0 +1,9 @@
{
"server": {
"host": "0.0.0.0",
"port": 8000
},
"logging": {
"level": "INFO"
}
}

View File

@ -0,0 +1 @@
"""API endpoints module."""

View 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(),
}

View 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]

View 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
View 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()

View File

@ -0,0 +1 @@
"""WebSocket routes module."""

View 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)

View 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.

120
vna_system/config/config.py Normal file
View 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

View 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)

View 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

View File

@ -0,0 +1 @@
"""Sweep data processing module."""

View 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,
}

View 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
}
}

View File

@ -0,0 +1 @@
"""Sweep data processors."""

View 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)),
}

View 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())

View 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,
}

View 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)

View File

@ -0,0 +1 @@
"""VNA System scripts module."""

92
vna_system/scripts/start.sh Executable file
View 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

View 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

View 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;
}
}

View 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);
}

View 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);
}
}

View 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;
}

View 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;
}

View 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
};
}

View 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');
}
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* 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');
}
}

View 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');
}
}

View 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');
}
}

View 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 = [];
}
}

View 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>