diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index d4c9602..3d4f54b 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -124,6 +124,8 @@ class AppWindow( self._project_root = project_root self._root_profile_path = project_root / "run_config.json" self._active_profile_path = self._root_profile_path + # Run-config profiles the web UI can browse and load (it has no file dialog). + self._run_configs_dir = project_root / "run_configs" self._pending_startup_log_entries: list[tuple[str, str, str | None]] = [] # Guards closeEvent against re-entrant teardown (e.g. a second signal). self._closing = False diff --git a/python_app/gui/controllers/app_window_config/live_processing_mixin.py b/python_app/gui/controllers/app_window_config/live_processing_mixin.py index 08c4946..56caaa8 100644 --- a/python_app/gui/controllers/app_window_config/live_processing_mixin.py +++ b/python_app/gui/controllers/app_window_config/live_processing_mixin.py @@ -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) diff --git a/python_app/gui/controllers/app_window_config/profile_io_mixin.py b/python_app/gui/controllers/app_window_config/profile_io_mixin.py index 373338e..f88926e 100644 --- a/python_app/gui/controllers/app_window_config/profile_io_mixin.py +++ b/python_app/gui/controllers/app_window_config/profile_io_mixin.py @@ -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. diff --git a/python_app/gui/controllers/app_window_snapshot_mixin.py b/python_app/gui/controllers/app_window_snapshot_mixin.py index 4e39ff5..271b02f 100644 --- a/python_app/gui/controllers/app_window_snapshot_mixin.py +++ b/python_app/gui/controllers/app_window_snapshot_mixin.py @@ -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: diff --git a/python_app/gui/controllers/app_window_web_mixin.py b/python_app/gui/controllers/app_window_web_mixin.py index 2635379..eab1dce 100644 --- a/python_app/gui/controllers/app_window_web_mixin.py +++ b/python_app/gui/controllers/app_window_web_mixin.py @@ -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(), diff --git a/python_app/storage/npz/paths.py b/python_app/storage/npz/paths.py index 8434b3e..633f62c 100644 --- a/python_app/storage/npz/paths.py +++ b/python_app/storage/npz/paths.py @@ -33,6 +33,43 @@ def radar_key_from_config( ) +def radar_config_filename_prefix( + model_name: str, + sweep_start_hz: float, + sweep_stop_hz: float, + sweep_points: int, + power_dbm: float, +) -> str: + """Build a readable, filesystem-safe prefix describing the radar acquisition setup. + + Encodes the radar model, sweep frequency span, point count, and power so a saved + dataset's name carries the configuration it was captured with. ADC-mode radars have + no fixed sweep, so the point count is reported as ``adc``. + """ + points_token = "adc" if model_name.strip().lower() == "kamil_adc" else f"{int(sweep_points)}pts" + tokens = ( + model_name, + _format_frequency_span(sweep_start_hz, sweep_stop_hz), + points_token, + f"{power_dbm:g}dBm", + ) + return sanitize_path_component("_".join(tokens)) + + +def _format_frequency_span(start_hz: float, stop_hz: float) -> str: + """Format a frequency span compactly, both bounds sharing the larger bound's unit.""" + unit, scale = _frequency_unit(max(start_hz, stop_hz)) + return f"{start_hz / scale:g}-{stop_hz / scale:g}{unit}" + + +def _frequency_unit(hz: float) -> tuple[str, float]: + """Pick the largest unit (GHz/MHz/kHz/Hz) that keeps the magnitude at or above one.""" + for unit, scale in (("GHz", 1e9), ("MHz", 1e6), ("kHz", 1e3)): + if hz >= scale: + return unit, scale + return "Hz", 1.0 + + def collection_dir_name(index: int, collection_id: int, monotonic_ns: int) -> str: """Build canonical collection directory name for snapshot stages.""" return f"{index:04d}_id{int(collection_id)}_ns{int(monotonic_ns)}" diff --git a/python_app/storage/npz/store.py b/python_app/storage/npz/store.py index ce1f0c9..344560d 100644 --- a/python_app/storage/npz/store.py +++ b/python_app/storage/npz/store.py @@ -27,6 +27,15 @@ from python_app.storage.store_api import StoreApi logger = logging.getLogger(__name__) +def _compose_snapshot_stem(name: str, name_prefix: str) -> str: + """Compose a sanitized snapshot stem from an optional prefix and the operator's name. + + The name falls back to a UTC timestamp when blank, so a save is always uniquely named. + """ + base = name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S") + return sanitize_path_component(f"{name_prefix}_{base}" if name_prefix else base) + + class NpzStore(StoreApi): """Persist preprocess sets and runtime snapshots using NumPy files.""" @@ -193,13 +202,14 @@ class NpzStore(StoreApi): preprocessed_history: list[SweepCollection], result_history: list[ResultCollection], last_n: int, + *, + name_prefix: str = "", ) -> tuple[Path, dict[str, Any]]: """Save historical runtime collections in NumPy tree format.""" if last_n <= 0: raise ValueError("last_n must be > 0") - snapshot_stem = snapshot_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S") - snapshot_stem = sanitize_path_component(snapshot_stem) + snapshot_stem = _compose_snapshot_stem(snapshot_name, name_prefix) output_root_dir.mkdir(parents=True, exist_ok=True) snapshot_dir = output_root_dir / snapshot_stem @@ -324,13 +334,13 @@ class NpzStore(StoreApi): *, channel: str = "s21", primary_stage: str = "preprocessed", + name_prefix: str = "", ) -> tuple[list[Path], dict[str, Any]]: """Save one runtime VNA-history JSON per available combo.""" if last_n <= 0: raise ValueError("last_n must be > 0") - output_stem = output_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S") - output_stem = sanitize_path_component(output_stem) + output_stem = _compose_snapshot_stem(output_name, name_prefix) output_root_dir.mkdir(parents=True, exist_ok=True) output_dir = self._vna_json_output_dir(output_root_dir, output_stem) output_dir.mkdir(parents=True, exist_ok=True) diff --git a/python_app/tests/test_storage_webui.py b/python_app/tests/test_storage_webui.py index 5a1857b..8561602 100644 --- a/python_app/tests/test_storage_webui.py +++ b/python_app/tests/test_storage_webui.py @@ -27,6 +27,7 @@ from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402 AppWindowWebMixin, ) from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402 +from python_app.storage.npz.paths import radar_config_filename_prefix # noqa: E402 from python_app.storage.npz.store import NpzStore # noqa: E402 from python_app.storage.npz.vna_history_json import ( # noqa: E402 _complex_to_points, @@ -84,6 +85,29 @@ class NpzStoreRoundTripTest(unittest.TestCase): self.store.load_set("calibration", "radar1", "absent") +# --------------------------------------------------------------------------- # +# Radar-config filename prefix +# --------------------------------------------------------------------------- # +class RadarConfigPrefixTest(unittest.TestCase): + def test_encodes_model_span_points_power(self) -> None: + self.assertEqual( + radar_config_filename_prefix("librevna", 1e9, 6e9, 201, -10.0), + "librevna_1-6GHz_201pts_-10dBm", + ) + + def test_megahertz_span_keeps_fractional_values(self) -> None: + self.assertEqual( + radar_config_filename_prefix("librevna", 1.5e6, 6e6, 51, -3.5), + "librevna_1.5-6MHz_51pts_-3.5dBm", + ) + + def test_adc_model_reports_adc_point_count(self) -> None: + self.assertEqual( + radar_config_filename_prefix("kamil_adc", 1e9, 6e9, 1, 0.0), + "kamil_adc_1-6GHz_adc_0dBm", + ) + + # --------------------------------------------------------------------------- # # vna_history export # --------------------------------------------------------------------------- # @@ -121,7 +145,12 @@ class VnaHistoryTest(unittest.TestCase): # --------------------------------------------------------------------------- # class WebControllerTest(unittest.TestCase): def setUp(self) -> None: - self.controller = AppWindowWebController() + self._cfg_dir = tempfile.TemporaryDirectory() + self.addCleanup(self._cfg_dir.cleanup) + self.configs_dir = Path(self._cfg_dir.name) + (self.configs_dir / "alpha.json").write_text("{}", encoding="utf-8") + (self.configs_dir / "beta.json").write_text("{}", encoding="utf-8") + self.controller = AppWindowWebController(self.configs_dir) self.addCleanup(self.controller.deleteLater) def test_rejects_unknown_field(self) -> None: @@ -160,6 +189,25 @@ class WebControllerTest(unittest.TestCase): self.controller.capture_tmp_reference() self.assertEqual(fired, ["start", "stop", "single", "capture"]) + def test_lists_configs_sorted_and_load_emits_signal(self) -> None: + self.assertEqual(self.controller.list_configs(), ["alpha.json", "beta.json"]) + requested: list[str] = [] + self.controller.load_config_requested.connect(requested.append) + self.controller.load_config("beta.json") + self.assertEqual(requested, ["beta.json"]) + + def test_load_config_rejects_unknown_or_unsafe_name(self) -> None: + # Missing file, traversal, sub-path, and non-json are all refused (HTTP 400 upstream). + for bad in ("ghost.json", "../escape.json", "sub/alpha.json", "alpha.txt"): + with self.assertRaisesRegex(ValueError, "Unknown run config"): + self.controller.load_config(bad) + + def test_save_dataset_forwards_path_and_name(self) -> None: + received: list[tuple[str, str]] = [] + self.controller.save_dataset_requested.connect(lambda p, n: received.append((p, n))) + self.controller.save_dataset("/tmp/out", "run1") + self.assertEqual(received, [("/tmp/out", "run1")]) + class WebPortTest(unittest.TestCase): def _port_with_env(self, value: str | None) -> int: diff --git a/python_app/webui/controller.py b/python_app/webui/controller.py index d346e6a..736186e 100644 --- a/python_app/webui/controller.py +++ b/python_app/webui/controller.py @@ -32,9 +32,18 @@ class WebController(Protocol): def apply_live_settings(self, fields: dict) -> list: """Apply live processor settings; returns the current settings schema.""" + def load_config(self, name: str) -> None: + """Load a run-config profile by file name (the desktop "Load Config" button).""" + + def save_dataset(self, path: str, name: str) -> None: + """Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button).""" + def current_live_settings(self) -> list: """Return the live-settings schema (built from the Qt widgets).""" + def list_configs(self) -> list[str]: + """Return the run-config file names available to load.""" + def status(self) -> dict: """Return a snapshot of pipeline/run state.""" diff --git a/python_app/webui/routes.py b/python_app/webui/routes.py index a3aba98..5ff69ea 100644 --- a/python_app/webui/routes.py +++ b/python_app/webui/routes.py @@ -63,6 +63,35 @@ async def post_tmp_reference(request: Request) -> dict: return controller.status() +@router.get("/api/configs") +async def get_configs(request: Request) -> dict: + return {"names": _controller(request).list_configs()} + + +@router.post("/api/load_config") +async def post_load_config(request: Request, name: str = Body(..., embed=True)) -> dict: + logger.info("Web UI request: load config %r", name) + controller = _controller(request) + try: + controller.load_config(name) + except ValueError as exc: + logger.warning("Web UI rejected config load: %s", exc) + raise HTTPException(status_code=400, detail=str(exc)) from exc + return controller.status() + + +@router.post("/api/save_dataset") +async def post_save_dataset( + request: Request, + path: str = Body("", embed=True), + name: str = Body("", embed=True), +) -> dict: + logger.info("Web UI request: save dataset") + controller = _controller(request) + controller.save_dataset(path, name) + return controller.status() + + @router.get("/api/live_settings") async def get_live_settings(request: Request) -> list: return _controller(request).current_live_settings() diff --git a/python_app/webui/static/app.js b/python_app/webui/static/app.js index 49d5219..90ce412 100644 --- a/python_app/webui/static/app.js +++ b/python_app/webui/static/app.js @@ -15,6 +15,12 @@ const btnStop = document.getElementById("btn-stop"); const btnTmpRef = document.getElementById("btn-tmp-ref"); const btnApply = document.getElementById("btn-apply"); const btnResetHistory = document.getElementById("btn-reset-history"); +const btnLoadConfig = document.getElementById("btn-load-config"); +const configSelect = document.getElementById("config-select"); +const configActiveEl = document.getElementById("config-active"); +const savePathInput = document.getElementById("save-path"); +const saveNameInput = document.getElementById("save-name"); +const btnSaveDataset = document.getElementById("btn-save-dataset"); const settingsToggle = document.getElementById("settings-toggle"); const sidePanel = document.querySelector(".side-panel"); @@ -24,12 +30,15 @@ const settingsNote = document.getElementById("settings-note"); const runningEl = document.getElementById("stat-running"); const processorEl = document.getElementById("stat-processor"); const ringEl = document.getElementById("stat-ring"); +const scansEl = document.getElementById("stat-scans"); const staleEl = document.getElementById("stat-stale"); const toastEl = document.getElementById("toast"); /* ---- state ------------------------------------------------------- */ -let pendingPng = null; // newest PNG (base64), shown on the next animation frame -let lastFrameTs = 0; // performance.now() of the last received frame +let pendingPng = null; // newest PNG (base64), shown on the next animation frame +let lastFrameTs = 0; // performance.now() of the last received frame +let pipelineRunning = false; // gates the config loader (loading requires a stopped pipeline) +let saveFieldsPrefilled = false; // seed the save path/name fields once, then leave the operator's edits /* ---- helpers ----------------------------------------------------- */ function toast(message, isError) { @@ -245,6 +254,63 @@ async function loadSettings() { } } +/* ---- config profile --------------------------------------------- */ +// Loading a config does exactly what the desktop "Load Config" button does. The file +// list comes from the server's run_configs/ directory (the browser has no file access), +// and loading is gated to a stopped pipeline — mirroring the desktop precondition. +function updateLoadButtonState() { + btnLoadConfig.disabled = pipelineRunning || configSelect.options.length === 0; +} + +async function loadConfigList() { + try { + const { names = [] } = await api("/api/configs"); + const previous = configSelect.value; + configSelect.innerHTML = ""; + for (const name of names) { + const opt = document.createElement("option"); + opt.value = name; + opt.textContent = name; + configSelect.appendChild(opt); + } + if (names.includes(previous)) configSelect.value = previous; // keep the operator's choice + updateLoadButtonState(); + } catch (err) { + toast("Could not load config list: " + err.message, true); + } +} + +btnLoadConfig.addEventListener("click", async () => { + const name = configSelect.value; + if (!name) return; + btnLoadConfig.disabled = true; + try { + await api("/api/load_config", { name }); + toast(`Config loaded: ${name}`); // the new settings schema arrives via the live status push + } catch (err) { + toast(err.message, true); + } finally { + updateLoadButtonState(); + } +}); + +/* ---- save dataset ----------------------------------------------- */ +// Saves to the path in the field, mirroring the desktop "Save Dataset" button exactly. +btnSaveDataset.addEventListener("click", async () => { + btnSaveDataset.disabled = true; + try { + await api("/api/save_dataset", { + path: savePathInput.value.trim(), + name: saveNameInput.value.trim(), + }); + toast("Save requested"); // a radar-config prefix is prepended to the name server-side + } catch (err) { + toast(err.message, true); + } finally { + btnSaveDataset.disabled = false; + } +}); + /* ---- status ------------------------------------------------------ */ function setStat(el, label, value, cls) { el.className = "stat" + (cls ? " " + cls : ""); @@ -253,13 +319,26 @@ function setStat(el, label, value, cls) { } function applyStatus(s) { - if ("running" in s) + if ("running" in s) { setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off"); + pipelineRunning = !!s.running; + updateLoadButtonState(); + } + if ("active_config" in s) + configActiveEl.textContent = s.active_config ? `current: ${s.active_config}` : ""; + if ("save_path" in s && !saveFieldsPrefilled) { + savePathInput.value = s.save_path || ""; // seed once; don't clobber later edits + saveNameInput.value = s.save_name || ""; + saveFieldsPrefilled = true; + } if ("processor_running" in s) setStat(processorEl, "processor", s.processor_running ? "yes" : "no", s.processor_running ? "ok" : "off"); if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—", s.ring_name ? "" : "off"); + if ("raw_count" in s) + setStat(scansEl, "scans", + `raw ${s.raw_count} / preprocessed ${s.preprocessed_count} / results ${s.result_count}`); } /* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */ @@ -305,6 +384,7 @@ async function init() { settingsNote.textContent = "Status unavailable: " + err.message; } await loadSettings(); + await loadConfigList(); connectWs(); } init(); diff --git a/python_app/webui/static/index.html b/python_app/webui/static/index.html index 245b9e1..821658d 100644 --- a/python_app/webui/static/index.html +++ b/python_app/webui/static/index.html @@ -28,6 +28,26 @@