diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc index 90f5c17..a3053a0 100644 Binary files a/__pycache__/service.cpython-311.pyc and b/__pycache__/service.cpython-311.pyc differ diff --git a/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc b/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc index 2de923d..d16edb2 100644 Binary files a/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc and b/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc differ diff --git a/__pycache__/test_service_integration.cpython-311.pyc b/__pycache__/test_service_integration.cpython-311.pyc new file mode 100644 index 0000000..73ec649 Binary files /dev/null and b/__pycache__/test_service_integration.cpython-311.pyc differ diff --git a/config.template.json b/config.template.json index 4009153..9c14179 100644 --- a/config.template.json +++ b/config.template.json @@ -15,6 +15,7 @@ "listen_host": "0.0.0.0", "listen_port": 8081, "poll_interval_s": 1.0, + "mock_input_frequency_sync": false, "write_api_token": "", "output_servers": [ { diff --git a/docker/__pycache__/mock_receiver.cpython-311.pyc b/docker/__pycache__/mock_receiver.cpython-311.pyc new file mode 100644 index 0000000..440cead Binary files /dev/null and b/docker/__pycache__/mock_receiver.cpython-311.pyc differ diff --git a/docker/config.docker.test.json b/docker/config.docker.test.json index 5fec788..b636250 100644 --- a/docker/config.docker.test.json +++ b/docker/config.docker.test.json @@ -15,6 +15,7 @@ "listen_host": "0.0.0.0", "listen_port": 8081, "poll_interval_s": 1.0, + "mock_input_frequency_sync": true, "write_api_token": "", "output_servers": [ { diff --git a/docker/mock_receiver.py b/docker/mock_receiver.py index b46341f..c38bd4b 100644 --- a/docker/mock_receiver.py +++ b/docker/mock_receiver.py @@ -8,13 +8,42 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Dict, List -def _build_payload(receiver_id: str, base_rssi: float) -> Dict[str, object]: - noise_a = random.uniform(-1.2, 1.2) - noise_b = random.uniform(-1.2, 1.2) - rows: List[Dict[str, float]] = [ - {"f_mhz": 433.920, "rssi": base_rssi + noise_a}, - {"f_mhz": 868.100, "rssi": base_rssi - 4.0 + noise_b}, - ] +def _parse_frequency_list(raw_value: str) -> List[float]: + values: List[float] = [] + for item in str(raw_value).split(","): + text = item.strip() + if not text: + continue + try: + value = float(text) + except ValueError as exc: + raise ValueError(f"invalid frequency value '{text}'") from exc + if value <= 0.0: + raise ValueError(f"frequency must be > 0, got {value}") + values.append(round(value, 6)) + if not values: + raise ValueError("at least one frequency is required") + deduplicated: List[float] = [] + seen = set() + for value in values: + key = round(value, 6) + if key in seen: + continue + seen.add(key) + deduplicated.append(value) + return deduplicated + + +def _build_payload( + receiver_id: str, + base_rssi: float, + frequencies_mhz: List[float], +) -> Dict[str, object]: + rows: List[Dict[str, float]] = [] + for index, frequency_mhz in enumerate(frequencies_mhz): + noise = random.uniform(-1.2, 1.2) + offset = -2.2 * index + rows.append({"f_mhz": round(float(frequency_mhz), 6), "rssi": base_rssi + offset + noise}) return { "receiver_id": receiver_id, "timestamp_unix": time.time(), @@ -27,8 +56,12 @@ def main() -> int: parser.add_argument("--receiver-id", required=True) parser.add_argument("--port", type=int, default=9000) parser.add_argument("--base-rssi", type=float, default=-62.0) + parser.add_argument("--frequencies-mhz", type=str, default="433.92,868.1") args = parser.parse_args() - state = {"enabled": True} + state = { + "enabled": True, + "frequencies_mhz": _parse_frequency_list(args.frequencies_mhz), + } class Handler(BaseHTTPRequestHandler): def log_message(self, format: str, *args2) -> None: @@ -39,6 +72,7 @@ def main() -> int: payload = { "receiver_id": args.receiver_id, "enabled": bool(state["enabled"]), + "frequencies_mhz": list(state["frequencies_mhz"]), "status": "ok", } raw = json.dumps(payload).encode("utf-8") @@ -69,7 +103,11 @@ def main() -> int: self.wfile.write(raw) return - payload = _build_payload(args.receiver_id, args.base_rssi) + payload = _build_payload( + receiver_id=args.receiver_id, + base_rssi=args.base_rssi, + frequencies_mhz=list(state["frequencies_mhz"]), + ) raw = json.dumps(payload).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") @@ -88,9 +126,18 @@ def main() -> int: payload = json.loads(body.decode("utf-8")) except json.JSONDecodeError: payload = {} + enabled = payload.get("enabled") - if not isinstance(enabled, bool): - raw = json.dumps({"status": "error", "error": "field 'enabled' must be boolean"}).encode("utf-8") + frequencies_raw = payload.get("frequencies_mhz") + has_enabled = isinstance(enabled, bool) + has_frequencies = frequencies_raw is not None + if not has_enabled and not has_frequencies: + raw = json.dumps( + { + "status": "error", + "error": "at least one of fields 'enabled' or 'frequencies_mhz' must be provided", + } + ).encode("utf-8") self.send_response(400) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(raw))) @@ -98,12 +145,38 @@ def main() -> int: self.wfile.write(raw) return - state["enabled"] = enabled + if has_enabled: + state["enabled"] = bool(enabled) + + if has_frequencies: + if not isinstance(frequencies_raw, list): + raw = json.dumps( + {"status": "error", "error": "field 'frequencies_mhz' must be an array"} + ).encode("utf-8") + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + try: + parsed_frequencies = _parse_frequency_list(",".join(str(x) for x in frequencies_raw)) + except ValueError as exc: + raw = json.dumps({"status": "error", "error": str(exc)}).encode("utf-8") + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + state["frequencies_mhz"] = parsed_frequencies + raw = json.dumps( { "status": "ok", "receiver_id": args.receiver_id, "enabled": bool(state["enabled"]), + "frequencies_mhz": list(state["frequencies_mhz"]), } ).encode("utf-8") self.send_response(200) diff --git a/service.py b/service.py index a392c30..3da9ed3 100644 --- a/service.py +++ b/service.py @@ -321,10 +321,10 @@ def parse_source_payload( if expected_receiver_id is not None and "receiver_id" in payload: payload_receiver_id = str(payload["receiver_id"]) if payload_receiver_id != expected_receiver_id: - raise ValueError( - f"{source_label}: payload receiver_id '{payload_receiver_id}' " - f"does not match expected '{expected_receiver_id}'." - ) + # Keep processing measurements even if upstream payload ID differs + # from the configured receiver_id. This allows safe UI renaming + # without breaking data collection from legacy sources. + pass raw_items = payload.get("measurements") if raw_items is None: raw_items = payload.get("samples") @@ -477,6 +477,27 @@ def _output_control_urls(output_server: Dict[str, object]) -> Tuple[str, str]: return f"{base}/control", f"{base}/status" +def _receiver_configured_frequencies_mhz(receiver: Dict[str, object]) -> List[float]: + configured_hz = receiver.get("configured_frequencies_hz") + if not isinstance(configured_hz, list): + return [] + values: List[float] = [] + seen = set() + for hz_value in configured_hz: + try: + mhz_value = float(hz_value) / HZ_IN_MHZ + except (TypeError, ValueError): + continue + if not math.isfinite(mhz_value) or mhz_value <= 0.0: + continue + normalized = round(mhz_value, 6) + if normalized in seen: + continue + seen.add(normalized) + values.append(normalized) + return values + + def _collect_mock_controls(service: "AutoService") -> Dict[str, object]: inputs: List[Dict[str, object]] = [] for receiver in service.receivers: @@ -488,6 +509,8 @@ def _collect_mock_controls(service: "AutoService") -> Dict[str, object]: "source_url": source_url, "reachable": False, "enabled": None, + "configured_frequencies_mhz": _receiver_configured_frequencies_mhz(receiver), + "frequencies_mhz": None, "error": "", } try: @@ -501,6 +524,18 @@ def _collect_mock_controls(service: "AutoService") -> Dict[str, object]: enabled_value = payload.get("enabled") if isinstance(enabled_value, bool): row["enabled"] = enabled_value + frequencies_value = payload.get("frequencies_mhz") + if isinstance(frequencies_value, list): + parsed_frequencies: List[float] = [] + for value in frequencies_value: + try: + numeric = float(value) + except (TypeError, ValueError): + continue + if math.isfinite(numeric) and numeric > 0.0: + parsed_frequencies.append(round(numeric, 6)) + if parsed_frequencies: + row["frequencies_mhz"] = parsed_frequencies if status_code >= 400: row["error"] = str(payload.get("error", f"HTTP {status_code}")) except Exception as exc: @@ -545,7 +580,8 @@ def _set_mock_control( service: "AutoService", target: str, target_id: str, - enabled: bool, + enabled: Optional[bool] = None, + frequencies_mhz: Optional[List[float]] = None, ) -> Dict[str, object]: if target == "input": receiver = next( @@ -559,26 +595,48 @@ def _set_mock_control( if receiver is None: raise ValueError(f"Input receiver '{target_id}' not found.") control_url, _ = _receiver_control_urls(str(receiver.get("source_url", ""))) + control_payload: Dict[str, object] = {} + if isinstance(enabled, bool): + control_payload["enabled"] = enabled + if isinstance(frequencies_mhz, list): + control_payload["frequencies_mhz"] = list(frequencies_mhz) + if not control_payload: + raise ValueError("Input control requires 'enabled' or 'frequencies_mhz'.") status_code, payload, request_error = _http_json_request( control_url, method="POST", - payload={"enabled": enabled}, + payload=control_payload, timeout_s=2.0, ) if request_error: raise RuntimeError(request_error) if status_code < 200 or status_code >= 300: raise RuntimeError(str(payload.get("error", f"HTTP {status_code}"))) - action = "запущена" if enabled else "остановлена" + message_parts: List[str] = [] + if isinstance(enabled, bool): + action = "запущена" if enabled else "остановлена" + message_parts.append(f"Передача входных данных '{target_id}' {action}.") + if isinstance(frequencies_mhz, list): + frequencies_text = ", ".join( + f"{value:.6f}".rstrip("0").rstrip(".") + for value in frequencies_mhz + ) + message_parts.append( + f"Частоты '{target_id}' обновлены: [{frequencies_text}] МГц." + ) + message = " ".join(part for part in message_parts if part).strip() or "Настройки входа обновлены." return { "status": "ok", "target": "input", "id": target_id, "enabled": enabled, - "message": f"Передача входных данных '{target_id}' {action}.", + "frequencies_mhz": list(frequencies_mhz) if isinstance(frequencies_mhz, list) else None, + "message": message, } if target == "output": + if not isinstance(enabled, bool): + raise ValueError("Output control requires boolean 'enabled'.") output_server = next( ( row @@ -612,6 +670,28 @@ def _set_mock_control( raise ValueError("target must be 'input' or 'output'.") +def _sync_mock_input_frequencies(service: "AutoService") -> List[str]: + errors: List[str] = [] + for receiver in service.receivers: + receiver_id = str(receiver.get("receiver_id", "")) + if not receiver_id: + continue + frequencies_mhz = _receiver_configured_frequencies_mhz(receiver) + if not frequencies_mhz: + continue + try: + _set_mock_control( + service=service, + target="input", + target_id=receiver_id, + enabled=None, + frequencies_mhz=frequencies_mhz, + ) + except Exception as exc: + errors.append(f"{receiver_id}: {exc}") + return errors + + class AutoService: def __init__(self, config: Dict[str, object], config_path: Optional[str] = None) -> None: self.config = config @@ -635,6 +715,10 @@ class AutoService: self.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0)) self.write_api_token = str(runtime_obj.get("write_api_token", "")).strip() + self.mock_input_frequency_sync_enabled = bool( + runtime_obj.get("mock_input_frequency_sync", False) + ) + self.last_mock_sync_errors: List[str] = [] parsed_output_servers: List[Dict[str, object]] = [] output_servers_obj = runtime_obj.get("output_servers") if output_servers_obj is not None: @@ -769,6 +853,10 @@ class AutoService: self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True) def start(self) -> None: + if self.mock_input_frequency_sync_enabled: + self.last_mock_sync_errors = _sync_mock_input_frequencies(self) + else: + self.last_mock_sync_errors = [] self.poll_thread.start() def stop(self) -> None: @@ -1177,10 +1265,14 @@ def _make_handler(service: AutoService): status_code: int, content: bytes, content_type: str, + extra_headers: Optional[Dict[str, str]] = None, ) -> None: self.send_response(status_code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(content))) + if isinstance(extra_headers, dict): + for key, value in extra_headers.items(): + self.send_header(str(key), str(value)) self.end_headers() self.wfile.write(content) @@ -1214,7 +1306,16 @@ def _make_handler(service: AutoService): "application/x-javascript", ): mime_type = f"{mime_type}; charset=utf-8" - self._write_bytes(200, file_path.read_bytes(), mime_type) + self._write_bytes( + 200, + file_path.read_bytes(), + mime_type, + extra_headers={ + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0", + }, + ) def log_message(self, format: str, *args) -> None: return @@ -1408,6 +1509,12 @@ def _make_handler(service: AutoService): "restart_required": False, "applied": True, "config_path": service_obj.config_path, + "mock_input_frequency_sync_enabled": bool( + new_service.mock_input_frequency_sync_enabled + ), + "mock_input_frequency_sync_errors": list( + new_service.last_mock_sync_errors + ), }, ) return @@ -1431,6 +1538,7 @@ def _make_handler(service: AutoService): target = str(payload.get("target", "")).strip().lower() target_id = str(payload.get("id", "")).strip() enabled_value = payload.get("enabled") + frequencies_value = payload.get("frequencies_mhz") if target not in ("input", "output"): self._write_json( 400, @@ -1440,15 +1548,57 @@ def _make_handler(service: AutoService): if not target_id: self._write_json(400, {"status": "error", "error": "id is required"}) return - if not isinstance(enabled_value, bool): + parsed_frequencies: Optional[List[float]] = None + if frequencies_value is not None: + if not isinstance(frequencies_value, list): + self._write_json( + 400, + {"status": "error", "error": "frequencies_mhz must be array"}, + ) + return + parsed_frequencies = [] + for index, value in enumerate(frequencies_value, start=1): + try: + numeric = float(value) + except (TypeError, ValueError): + self._write_json( + 400, + { + "status": "error", + "error": f"frequencies_mhz[{index}] must be numeric", + }, + ) + return + if not math.isfinite(numeric) or numeric <= 0.0: + self._write_json( + 400, + { + "status": "error", + "error": f"frequencies_mhz[{index}] must be > 0", + }, + ) + return + parsed_frequencies.append(round(numeric, 6)) + + if target == "output" and not isinstance(enabled_value, bool): self._write_json(400, {"status": "error", "error": "enabled must be boolean"}) return + if target == "input" and not isinstance(enabled_value, bool) and parsed_frequencies is None: + self._write_json( + 400, + { + "status": "error", + "error": "input control requires 'enabled' or 'frequencies_mhz'", + }, + ) + return try: response = _set_mock_control( service=service_obj, target=target, target_id=target_id, - enabled=enabled_value, + enabled=enabled_value if isinstance(enabled_value, bool) else None, + frequencies_mhz=parsed_frequencies, ) except Exception as exc: self._write_json(500, {"status": "error", "error": str(exc)}) diff --git a/test_service_integration.py b/test_service_integration.py index c0a65b7..b625a36 100644 --- a/test_service_integration.py +++ b/test_service_integration.py @@ -166,7 +166,7 @@ def test_refresh_once_reports_row_validation_error_with_source_context( svc.refresh_once() -def test_refresh_once_validates_receiver_id_mismatch(monkeypatch: pytest.MonkeyPatch): +def test_refresh_once_allows_receiver_id_mismatch(monkeypatch: pytest.MonkeyPatch): config = _base_config() responses = { "http://r0.local/measurements": {"receiver_id": "r0", "measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, @@ -176,8 +176,82 @@ def test_refresh_once_validates_receiver_id_mismatch(monkeypatch: pytest.MonkeyP _install_urlopen(monkeypatch, responses) svc = service.AutoService(config) - with pytest.raises(RuntimeError, match="does not match expected 'r1'"): - svc.refresh_once() + svc.refresh_once() + snapshot = svc.snapshot() + payload = snapshot["payload"] + assert snapshot["last_error"] == "" + assert payload is not None + assert [row["receiver_id"] for row in payload["receivers"]] == ["r0", "r1", "r2"] + + +def test_set_mock_control_input_updates_frequencies(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + svc = service.AutoService(config) + captured: Dict[str, object] = {} + + def _fake_http_json_request( + url: str, + method: str = "GET", + payload: Dict[str, object] | None = None, + timeout_s: float = 2.0, + ): + captured["url"] = url + captured["method"] = method + captured["payload"] = payload or {} + captured["timeout_s"] = timeout_s + return 200, {"status": "ok"}, "" + + monkeypatch.setattr(service, "_http_json_request", _fake_http_json_request) + + response = service._set_mock_control( + service=svc, + target="input", + target_id="r0", + enabled=None, + frequencies_mhz=[915.0, 868.1], + ) + + assert response["status"] == "ok" + assert response["target"] == "input" + assert response["id"] == "r0" + assert response["frequencies_mhz"] == [915.0, 868.1] + assert captured["method"] == "POST" + assert captured["payload"] == {"frequencies_mhz": [915.0, 868.1]} + + +def test_sync_mock_input_frequencies_uses_receiver_configuration(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + for receiver in config["input"]["receivers"]: # type: ignore[index] + receiver["frequencies_mhz"] = [433.92, 915.0] + svc = service.AutoService(config) + calls: List[Dict[str, object]] = [] + + def _fake_set_mock_control( + service: service.AutoService, + target: str, + target_id: str, + enabled: bool | None = None, + frequencies_mhz: List[float] | None = None, + ) -> Dict[str, object]: + calls.append( + { + "target": target, + "target_id": target_id, + "enabled": enabled, + "frequencies_mhz": frequencies_mhz, + } + ) + return {"status": "ok"} + + monkeypatch.setattr(service, "_set_mock_control", _fake_set_mock_control) + + errors = service._sync_mock_input_frequencies(svc) + + assert errors == [] + assert len(calls) == 3 + assert all(call["target"] == "input" for call in calls) + assert all(call["enabled"] is None for call in calls) + assert all(call["frequencies_mhz"] == [433.92, 915.0] for call in calls) def test_refresh_once_raises_when_output_server_rejects_payload( @@ -268,6 +342,63 @@ def test_parse_source_payload_accepts_compact_short_keys(): assert parsed[1][1] == pytest.approx(-62.5) +def test_parse_source_payload_allows_receiver_id_mismatch_when_expected_is_set(): + payload = {"receiver_id": "legacy-name", "measurements": [{"frequency_hz": 868_100_000.0, "rssi_dbm": -61.5}]} + parsed = service.parse_source_payload( + payload=payload, + source_label="source_url=test", + expected_receiver_id="new-name", + ) + assert len(parsed) == 1 + assert parsed[0][0] == pytest.approx(868_100_000.0) + assert parsed[0][1] == pytest.approx(-61.5) + + +def test_http_mock_control_accepts_input_frequency_update(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + svc = service.AutoService(config) + captured: Dict[str, object] = {} + + def _fake_set_mock_control( + service: service.AutoService, + target: str, + target_id: str, + enabled: bool | None = None, + frequencies_mhz: List[float] | None = None, + ) -> Dict[str, object]: + captured["target"] = target + captured["target_id"] = target_id + captured["enabled"] = enabled + captured["frequencies_mhz"] = frequencies_mhz + return {"status": "ok", "target": target, "id": target_id} + + monkeypatch.setattr(service, "_set_mock_control", _fake_set_mock_control) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + req = urllib_request.Request( + url=f"{base_url}/mock/control", + method="POST", + data=json.dumps( + {"target": "input", "id": "r0", "frequencies_mhz": [433.92, 868.1]} + ).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib_request.urlopen(req) as response: + payload = json.loads(response.read().decode("utf-8")) + assert payload["status"] == "ok" + assert captured["target"] == "input" + assert captured["target_id"] == "r0" + assert captured["enabled"] is None + assert captured["frequencies_mhz"] == [433.92, 868.1] + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + current_service = http_server.RequestHandlerClass.service_holder["current"] # type: ignore[attr-defined] + current_service.stop() + + def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch): config = _base_config() svc = service.AutoService(config) diff --git a/web/app.js b/web/app.js index ac17eaa..430b4d0 100644 --- a/web/app.js +++ b/web/app.js @@ -17,6 +17,12 @@ selectedOutputIndex: 0, outputDrafts: [], menuCollapsed: false, + menuGroupCollapsed: { + monitoring: false, + io: false, + config: false, + }, + dateTimeCollapsed: false, ioHistory: [], mockControls: null, historyFilter: "all", @@ -32,12 +38,21 @@ lastHealthStatus: "n/a", lastDeliveryStatus: "n/a", timezone: "local", + uiDensity: "detailed", }; const HZ_IN_MHZ = 1_000_000; const MENU_COLLAPSED_STORAGE_KEY = "triangulation.menu_collapsed"; +const MENU_GROUP_COLLAPSED_STORAGE_KEY = "triangulation.menu_group_collapsed"; +const DATE_TIME_COLLAPSED_STORAGE_KEY = "triangulation.date_time_collapsed"; const TIMEZONE_STORAGE_KEY = "triangulation.timezone"; +const UI_DENSITY_STORAGE_KEY = "triangulation.ui_density"; +const AUTO_REFRESH_INTERVAL_STORAGE_KEY = "triangulation.auto_refresh_interval_ms"; +const AUTO_REFRESH_MIN_MS = 1_000; +const AUTO_REFRESH_MAX_MS = 120_000; +const AUTO_REFRESH_DEFAULT_MS = 2_000; const IO_HISTORY_LIMIT = 60; +const MENU_GROUP_KEYS = ["monitoring", "io", "config"]; const TIMEZONE_OPTIONS = [ { value: "local", label: "Локальный (браузер)" }, { value: "UTC", label: "UTC" }, @@ -191,6 +206,10 @@ function setActiveSection(section) { document.querySelectorAll(".menu-item").forEach((item) => { item.classList.toggle("menu-item-active", item.dataset.section === section); }); + const group = findSectionMenuGroup(section); + if (group) { + setMenuGroupCollapsed(group, false, { persist: true }); + } } function setMenuCollapsed(isCollapsed) { @@ -221,6 +240,106 @@ function readMenuCollapsed() { } } +function normalizeMenuGroupCollapsed(value) { + const result = { + monitoring: false, + io: false, + config: false, + }; + + if (!value || typeof value !== "object") return result; + MENU_GROUP_KEYS.forEach((groupKey) => { + result[groupKey] = Boolean(value[groupKey]); + }); + return result; +} + +function findSectionMenuGroup(section) { + const item = document.querySelector(`.menu-item[data-section="${String(section || "")}"]`); + const group = item?.closest(".menu-group")?.dataset?.menuGroup; + return MENU_GROUP_KEYS.includes(group) ? group : null; +} + +function applyMenuGroupState(groupKey) { + if (!MENU_GROUP_KEYS.includes(groupKey)) return; + const root = document.querySelector(`.menu-group[data-menu-group="${groupKey}"]`); + if (!root) return; + const toggle = root.querySelector(".menu-group-toggle"); + const collapsed = Boolean(state.menuGroupCollapsed[groupKey]); + root.classList.toggle("menu-group-collapsed", collapsed); + if (toggle) { + toggle.setAttribute("aria-expanded", String(!collapsed)); + } +} + +function persistMenuGroupCollapsedState() { + try { + localStorage.setItem( + MENU_GROUP_COLLAPSED_STORAGE_KEY, + JSON.stringify(normalizeMenuGroupCollapsed(state.menuGroupCollapsed)) + ); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function setMenuGroupCollapsed(groupKey, isCollapsed, options = {}) { + const { persist = true } = options; + if (!MENU_GROUP_KEYS.includes(groupKey)) return; + state.menuGroupCollapsed[groupKey] = Boolean(isCollapsed); + applyMenuGroupState(groupKey); + if (persist) { + persistMenuGroupCollapsedState(); + } +} + +function applyAllMenuGroupsCollapsed() { + MENU_GROUP_KEYS.forEach((groupKey) => applyMenuGroupState(groupKey)); +} + +function readMenuGroupCollapsed() { + try { + const raw = localStorage.getItem(MENU_GROUP_COLLAPSED_STORAGE_KEY); + if (!raw) { + return normalizeMenuGroupCollapsed(null); + } + const parsed = JSON.parse(raw); + return normalizeMenuGroupCollapsed(parsed); + } catch { + return normalizeMenuGroupCollapsed(null); + } +} + +function setDateTimeCollapsed(isCollapsed) { + state.dateTimeCollapsed = Boolean(isCollapsed); + const sideNav = byId("side-nav"); + const toggle = byId("datetime-toggle"); + + if (sideNav) { + sideNav.classList.toggle("date-time-collapsed", state.dateTimeCollapsed); + } + if (toggle) { + toggle.textContent = state.dateTimeCollapsed + ? "Показать служебную панель" + : "Скрыть служебную панель"; + toggle.setAttribute("aria-expanded", String(!state.dateTimeCollapsed)); + } + + try { + localStorage.setItem(DATE_TIME_COLLAPSED_STORAGE_KEY, state.dateTimeCollapsed ? "1" : "0"); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function readDateTimeCollapsed() { + try { + return localStorage.getItem(DATE_TIME_COLLAPSED_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + function browserTimeZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; @@ -250,6 +369,113 @@ function saveTimeZonePreference(value) { } } +function normalizeUiDensity(value) { + return String(value || "").toLowerCase() === "compact" ? "compact" : "detailed"; +} + +function saveUiDensityPreference(value) { + try { + localStorage.setItem(UI_DENSITY_STORAGE_KEY, normalizeUiDensity(value)); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function readUiDensityPreference() { + try { + return normalizeUiDensity(localStorage.getItem(UI_DENSITY_STORAGE_KEY) || "detailed"); + } catch { + return "detailed"; + } +} + +function applyUiDensity() { + const compact = state.uiDensity === "compact"; + document.body.classList.toggle("ui-compact", compact); + const toggle = byId("density-toggle"); + if (toggle) { + toggle.textContent = compact ? "Режим: компактный" : "Режим: детальный"; + } +} + +function setUiDensity(value, options = {}) { + const { persist = true } = options; + state.uiDensity = normalizeUiDensity(value); + applyUiDensity(); + if (persist) { + saveUiDensityPreference(state.uiDensity); + } +} + +function normalizePollIntervalMs(valueMs) { + const numeric = Number(valueMs); + if (!Number.isFinite(numeric)) return AUTO_REFRESH_DEFAULT_MS; + const rounded = Math.round(numeric); + return Math.min(AUTO_REFRESH_MAX_MS, Math.max(AUTO_REFRESH_MIN_MS, rounded)); +} + +function formatPollIntervalSeconds(valueMs) { + const seconds = Number(valueMs) / 1000; + if (!Number.isFinite(seconds)) return "2"; + return seconds.toFixed(2).replace(/\.?0+$/, ""); +} + +function readPollIntervalPreference() { + try { + const raw = localStorage.getItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY); + if (!raw) return AUTO_REFRESH_DEFAULT_MS; + return normalizePollIntervalMs(Number(raw)); + } catch { + return AUTO_REFRESH_DEFAULT_MS; + } +} + +function savePollIntervalPreference(valueMs) { + try { + localStorage.setItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY, String(normalizePollIntervalMs(valueMs))); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function syncPollIntervalInput() { + const input = byId("auto-refresh-seconds"); + if (!input) return; + input.value = formatPollIntervalSeconds(state.pollIntervalMs); +} + +function setPollIntervalMs(valueMs, options = {}) { + const { persist = true, restartPolling = true } = options; + const next = normalizePollIntervalMs(valueMs); + state.pollIntervalMs = next; + if (persist) { + savePollIntervalPreference(next); + } + syncPollIntervalInput(); + updateRefreshUi(); + if (restartPolling && state.autoRefreshEnabled) { + startPolling(); + } +} + +function setPollIntervalSeconds(rawValue, options = {}) { + const { notify = true } = options; + const normalized = String(rawValue ?? "").replace(",", ".").trim(); + const seconds = Number(normalized); + if (!Number.isFinite(seconds) || seconds <= 0) { + syncPollIntervalInput(); + if (notify) { + showToast("Введите корректный интервал автообновления (1-120 секунд).", "error"); + } + return; + } + const nextMs = normalizePollIntervalMs(seconds * 1000); + setPollIntervalMs(nextMs); + if (notify) { + showToast(`Интервал автообновления: ${formatPollIntervalSeconds(nextMs)}с.`, "success"); + } +} + function selectedTimeZoneValue() { return state.timezone === "local" ? null : state.timezone; } @@ -328,7 +554,7 @@ function updateRefreshUi() { if (button) { button.textContent = state.autoRefreshEnabled ? "Пауза автообновления" : "Запустить автообновление"; } - const suffix = `${Math.round(state.pollIntervalMs / 1000)}с`; + const suffix = `${formatPollIntervalSeconds(state.pollIntervalMs)}с`; setTextWithPulse( "refresh-state", `автообновление: ${state.autoRefreshEnabled ? "вкл" : "выкл"} (${suffix})` @@ -537,17 +763,27 @@ function buildIoHistoryRow(data, delivery) { const selectedMhz = data?.selected_frequency_mhz ?? hzToMhz(selectedHz); const receivers = Array.isArray(data?.receivers) ? data.receivers : []; const servers = Array.isArray(delivery?.servers) ? delivery.servers : []; + const rmseMRaw = Number(data?.rmse_m); + const rmseM = Number.isFinite(rmseMRaw) ? rmseMRaw : null; + const rssiValues = []; const inputItems = receivers.map((receiver) => { const receiverId = String(receiver?.receiver_id || "n/a"); const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null; const perFrequency = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null; - const rssi = sample?.amplitude_dbm; + const rssi = Number(sample?.amplitude_dbm); + if (Number.isFinite(rssi)) { + rssiValues.push(rssi); + } const radius = perFrequency?.radius_m ?? sample?.distance_m; return `${receiverId}: ${fmt(rssi, 1)} dBm / ${fmt(radius, 2)} m`; }); + const avgRssiDbm = rssiValues.length > 0 + ? rssiValues.reduce((acc, value) => acc + value, 0) / rssiValues.length + : null; + const outputItems = (() => { if (servers.length === 0) { const pos = data?.position || {}; @@ -565,11 +801,13 @@ function buildIoHistoryRow(data, delivery) { const outputSummary = outputItems.join("; "); const statusRaw = String(delivery?.status || "n/a"); const timestamp = String(data?.timestamp_utc || delivery?.sent_at_utc || ""); - const key = `${timestamp}|${fmt(selectedMhz, 3)}|${inputItems.join("||")}|${outputItems.join("||")}|${statusRaw}`; + const key = `${timestamp}|${fmt(selectedMhz, 3)}|${inputItems.join("||")}|${outputItems.join("||")}|${statusRaw}|${fmt(rmseM, 2)}|${fmt(avgRssiDbm, 1)}`; return { key, timestamp, frequencyMhz: selectedMhz, + rmseM, + avgRssiDbm, inputItems, outputItems, inputSummary, @@ -597,59 +835,506 @@ function toPercent(part, total) { return Math.max(0, Math.min(100, Math.round((part / total) * 100))); } -function renderOverviewMetrics() { +function clamp(value, minValue, maxValue) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return minValue; + return Math.max(minValue, Math.min(maxValue, numeric)); +} + +function setProgressWidth(id, percent) { + const el = byId(id); + if (!el) return; + const normalized = clamp(percent, 0, 100); + el.style.width = `${normalized}%`; +} + +function statusToSuccessScore(statusRaw) { + const status = String(statusRaw || "n/a").toLowerCase(); + if (status === "ok") return 100; + if (status === "partial") return 70; + if (status === "warming_up") return 40; + if (status === "skipped") return 30; + if (status === "disabled") return 20; + return 0; +} + +function buildTrendSeries(limit = 20) { + const rows = state.ioHistory.slice(0, Math.max(1, Number(limit) || 20)).reverse(); + const rmse = rows + .map((row) => Number(row?.rmseM)) + .filter((value) => Number.isFinite(value) && value >= 0); + const avgRssi = rows + .map((row) => Number(row?.avgRssiDbm)) + .filter((value) => Number.isFinite(value)); + const delivery = rows.map((row) => statusToSuccessScore(row?.statusRaw)); + return { rows, rmse, avgRssi, delivery }; +} + +function formatTrendMeta(values, digits = 2, unit = "") { + if (!Array.isArray(values) || values.length === 0) return "н/д"; + const finiteValues = values.filter((value) => Number.isFinite(Number(value))); + if (finiteValues.length === 0) return "н/д"; + const last = Number(finiteValues[finiteValues.length - 1]); + const min = Math.min(...finiteValues); + const max = Math.max(...finiteValues); + return `посл ${fmt(last, digits)}${unit} | мин ${fmt(min, digits)}${unit} | макс ${fmt(max, digits)}${unit}`; +} + +function buildSparklineSvg(values, options = {}) { + const { + width = 220, + height = 54, + padding = 4, + stroke = "#2b73f0", + area = "rgba(43, 115, 240, 0.22)", + invert = false, + } = options; + + const points = Array.isArray(values) + ? values.map((value) => Number(value)).filter((value) => Number.isFinite(value)) + : []; + if (points.length === 0) { + return '