some webUI fixes

This commit is contained in:
Ayzen
2026-06-10 14:39:25 +03:00
parent c12ae05148
commit 21f76d7cd2
14 changed files with 767 additions and 32 deletions
@@ -19,6 +19,7 @@ import base64
import contextlib
import os
import time
from pathlib import Path
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
@@ -36,6 +37,26 @@ _GRAB_INTERVAL_S = 0.2
_LIVE_FIELD_NAMES = web_apply_field_names()
def _safe_run_config_path(run_configs_dir: Path, name: str) -> Path | None:
"""Resolve a client-supplied config name to a file strictly inside ``run_configs_dir``.
The web sends only a bare file name; this is the trust boundary. The name must be a
plain ``.json`` file living directly in the directory — ``name != Path(name).name``
rejects any separator or ``..`` traversal. Returns the path, or ``None`` if invalid.
"""
if not name or name != Path(name).name or not name.lower().endswith(".json"):
return None
candidate = run_configs_dir / name
return candidate if candidate.is_file() else None
def _list_run_config_names(run_configs_dir: Path) -> list[str]:
"""Return the sorted ``.json`` file names directly inside ``run_configs_dir``."""
if not run_configs_dir.is_dir():
return []
return sorted(entry.name for entry in run_configs_dir.glob("*.json") if entry.is_file())
class AppWindowWebController(QObject):
"""Qt bridge satisfying the web contract: streams the plot, forwards controls.
@@ -50,9 +71,12 @@ class AppWindowWebController(QObject):
single_capture_requested = pyqtSignal()
capture_requested = pyqtSignal()
apply_settings_requested = pyqtSignal(dict)
load_config_requested = pyqtSignal(str)
save_dataset_requested = pyqtSignal(str, str)
def __init__(self, parent: QObject | None = None) -> None:
def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
super().__init__(parent)
self._run_configs_dir = run_configs_dir
self._status: dict = {}
self._live_settings: list = []
self._frame: dict | None = None
@@ -74,6 +98,10 @@ class AppWindowWebController(QObject):
def current_live_settings(self) -> list:
return list(self._live_settings)
def list_configs(self) -> list[str]:
"""Return the run-config file names available to load (a plain directory scan)."""
return _list_run_config_names(self._run_configs_dir)
def peek_frame(self) -> dict | None:
"""Return the most recent rendered-plot frame (or None before the first)."""
return self._frame
@@ -99,6 +127,21 @@ class AppWindowWebController(QObject):
self.apply_settings_requested.emit(dict(fields))
return self.current_live_settings()
def load_config(self, name: str) -> None:
"""Request loading the run-config named ``name`` (the desktop "Load Config" action).
Validates the name against the directory here — on the web thread — so an invalid
or unsafe name fails the HTTP request immediately instead of silently doing nothing
on the Qt side; the actual load runs through the queued signal.
"""
if _safe_run_config_path(self._run_configs_dir, name) is None:
raise ValueError(f"Unknown run config: {name}")
self.load_config_requested.emit(name)
def save_dataset(self, path: str, name: str) -> None:
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
self.save_dataset_requested.emit(path, name)
class AppWindowWebMixin:
"""Start/stop the embedded web server and feed it the live plot + settings."""
@@ -118,12 +161,17 @@ class AppWindowWebMixin:
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
self.resize(*_HEADLESS_PLOT_SIZE)
controller = AppWindowWebController(parent=self)
# The web picker browses this directory; ensure it exists on fresh deploys.
self._run_configs_dir.mkdir(parents=True, exist_ok=True)
controller = AppWindowWebController(self._run_configs_dir, parent=self)
controller.start_requested.connect(self._start_run)
controller.stop_requested.connect(self._stop_run)
controller.single_capture_requested.connect(self._start_single_capture)
controller.capture_requested.connect(self._capture_tmp_reference)
controller.apply_settings_requested.connect(self._apply_web_live_settings)
controller.load_config_requested.connect(self._load_web_config)
controller.save_dataset_requested.connect(self._save_web_dataset)
self._web_controller = controller
self._web_update_snapshot() # seed snapshots before the first request
@@ -137,6 +185,32 @@ class AppWindowWebMixin:
self._web_controller = None
self._web_server = None
def _load_web_config(self, name: str) -> None:
"""Load a run config chosen in the browser through the shared desktop load path.
Runs on the Qt main thread (queued from the web thread). The name is re-resolved
against the directory here as the authoritative trust boundary before any file
access, then handed to the same loader the desktop "Load Config" button uses.
"""
config_path = _safe_run_config_path(self._run_configs_dir, name)
if config_path is None:
self._log_warning(f"Ignored web request to load unknown run config: {name}")
return
self._load_config_from_path(config_path)
def _save_web_dataset(self, path: str, name: str) -> None:
"""Save the dataset from the browser via the same handler as the desktop button.
The web fields are remote editors of the shared save-path/name widgets: the name
is mirrored as-is (blank is valid — it yields a timestamped file), while a blank
path is ignored to avoid wiping the configured destination. The save itself — and
the radar-config filename prefix — is the unchanged desktop action.
"""
if path.strip():
self._save_path_input.setText(path)
self._save_name_input.setText(name)
self._save_snapshot()
def _web_update_snapshot(self) -> None:
"""Refresh the snapshots the bridge serves (called on the Qt poll tick).
@@ -152,6 +226,17 @@ class AppWindowWebMixin:
"running": self._supervisor.is_running(),
"processor_running": self._supervisor.is_processor_running(),
"ring_name": self._defaults_config.rings.results.name,
# File name of the currently loaded config profile, so the web picker
# can show what is active (the desktop tracks the full path).
"active_config": self._active_profile_path.name,
# Current save path/name, so the web fields can prefill the desktop values.
"save_path": self._save_path_input.text(),
"save_name": self._save_name_input.text(),
# Per-stage capture counts, identical to the desktop history
# label (raw -> preprocessed -> results), mirrored to the browser.
"raw_count": len(self._raw_history),
"preprocessed_count": len(self._pre_history),
"result_count": len(self._result_history),
},
live_settings=self._web_settings_schema(),
frame=self._web_grab_frame_if_due(),