some webUI fixes
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user