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
@@ -298,11 +298,23 @@ class AppWindowLiveProcessingMixin:
``_on_processing_live_settings_changed`` handler — one writer, one redraw —
so the browser and the desktop never desync and ``processing_live.json`` has
exactly one author. The per-widget change signals are neutralized by a
suppression flag so the final handler runs once instead of dozens of times;
a history command keeps its own server-managed sequence-bump path.
suppression flag so the final handler runs once instead of dozens of times.
A destructive history command is not a settings edit: it routes to the very
same handler the desktop's "Clear History" / "Remove Last" button uses, so the
browser drains the ring readers, empties the runtime histories, clears the
render caches and the processor's replay state, and redraws — rather than only
nudging the processor while the on-screen history stays put.
"""
try:
history_command = str(fields.get("history_command", "none"))
if history_command == "clear_all":
self._clear_all_runtime_history()
return
if history_command == "remove_last":
self._remove_last_runtime_history()
return
settings = {
name: value
for name, value in fields.items()
@@ -310,10 +322,7 @@ class AppWindowLiveProcessingMixin:
}
# Log only field names (not values) so remote edits are traceable
# without recording arbitrary client-supplied payloads.
self._log_debug(
f"Applying web live settings: fields={sorted(settings)}, "
f"history_command={history_command}."
)
self._log_debug(f"Applying web live settings: fields={sorted(settings)}.")
self._suppress_live_settings_handler = True
try:
# processor_mode first: dual-sourced gpr_* fields route to the gpr or
@@ -324,9 +333,6 @@ class AppWindowLiveProcessingMixin:
_set_web_live_field(self, name, value)
finally:
self._suppress_live_settings_handler = False
if history_command in {"clear_all", "remove_last"}:
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
self._on_processing_live_settings_changed()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to apply web live settings", exc)
@@ -172,32 +172,39 @@ class AppWindowConfigProfileIOMixin:
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to save config profile", exc)
def _load_config_from_dialog(self) -> None:
"""Load full GUI profile from a user-selected JSON file."""
def _ensure_ready_to_load_config(self) -> bool:
"""Return whether a config load may proceed, reporting the blocker if not.
Loading rebuilds every widget and resizes history, so it is refused while a
capture sequence or the pipeline is active — the operator must stop first.
Shared by the desktop dialog and the web loader so both honor the same rule.
"""
if self._capture_session is not None:
self._show_error(
"Cannot load config during active capture sequence",
details=self._capture_state_details(),
)
return
return False
if self._supervisor.is_running():
self._show_error(
"Stop all pipeline processes before loading a config profile",
details=self._process_state_details(),
)
return
return False
return True
selected_path, _selected_filter = QFileDialog.getOpenFileName(
self,
"Load Config Profile",
str(self._active_profile_path),
"JSON Files (*.json);;All Files (*)",
)
if not selected_path:
return
def _load_config_from_path(self, config_path: Path) -> None:
"""Load a full GUI profile from ``config_path`` and apply it to the UI.
This is the single authoritative load path: the desktop "Load Config" dialog
and the web config picker both funnel here, differing only in how they obtain
the path. The pipeline is left untouched; live settings reach a running
data_processor immediately while stable settings stage for the next Start.
"""
if not self._ensure_ready_to_load_config():
return
try:
normalized_path = self._normalize_profile_path(Path(selected_path))
normalized_path = self._normalize_profile_path(config_path)
profile = GuiProfileModel.load_from_path(normalized_path)
processor_running = self._supervisor.is_processor_running()
self._apply_loaded_profile(profile, normalized_path)
@@ -217,6 +224,20 @@ class AppWindowConfigProfileIOMixin:
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to load config profile", exc)
def _load_config_from_dialog(self) -> None:
"""Load a full GUI profile from a user-selected JSON file (desktop button)."""
if not self._ensure_ready_to_load_config():
return
selected_path, _selected_filter = QFileDialog.getOpenFileName(
self,
"Load Config Profile",
str(self._active_profile_path),
"JSON Files (*.json);;All Files (*)",
)
if not selected_path:
return
self._load_config_from_path(Path(selected_path))
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
"""Apply an already parsed profile to GUI state without restarting the pipeline.
@@ -7,11 +7,27 @@ import time
from PyQt6.QtWidgets import QFileDialog
from python_app.gui.runtime.history import record_result_history, remove_last_aligned_histories
from python_app.storage.npz.paths import radar_config_filename_prefix
class AppWindowSnapshotMixin:
"""Saves runtime data snapshots and maintains ring-reader freshness."""
def _radar_config_name_prefix(self) -> str:
"""Filename prefix encoding the current radar setup (model, sweep span, points, power).
Prepended to every saved dataset name so the file records the configuration it was
captured with — identical for desktop and web saves, which share these handlers.
"""
radar = self._build_config().radar
return radar_config_filename_prefix(
radar.model,
radar.sweep.start_hz,
radar.sweep.stop_hz,
radar.sweep.points,
radar.sweep.power_dbm,
)
@staticmethod
def _snapshot_config_profile_path(snapshot_dir: Path) -> Path:
"""Return companion config-profile path inside a saved snapshot directory."""
@@ -41,6 +57,7 @@ class AppWindowSnapshotMixin:
list(self._pre_history),
list(self._result_history),
last_n,
name_prefix=self._radar_config_name_prefix(),
)
config_profile_path = self._snapshot_config_profile_path(snapshot_dir)
try:
@@ -94,6 +111,7 @@ class AppWindowSnapshotMixin:
last_n,
channel=channel,
primary_stage="preprocessed",
name_prefix=self._radar_config_name_prefix(),
)
output_stem = str(summary.get("output_stem", "")).strip()
if not output_stem:
@@ -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(),