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 '
Недостаточно данных
'; + } + + const minValue = Math.min(...points); + const maxValue = Math.max(...points); + const range = maxValue - minValue || 1; + const safeWidth = Math.max(40, Number(width) || 220); + const safeHeight = Math.max(24, Number(height) || 54); + const baseline = safeHeight - padding; + const usableWidth = Math.max(1, safeWidth - padding * 2); + const usableHeight = Math.max(1, safeHeight - padding * 2); + + const normalized = points.map((value, index) => { + const x = padding + (points.length === 1 ? usableWidth / 2 : (index / (points.length - 1)) * usableWidth); + let ratio = (value - minValue) / range; + if (invert) ratio = 1 - ratio; + const y = baseline - ratio * usableHeight; + return { x, y, value }; + }); + + const linePoints = normalized.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" "); + const first = normalized[0]; + const last = normalized[normalized.length - 1]; + const areaPath = `M ${first.x.toFixed(2)} ${baseline.toFixed(2)} L ${linePoints + .replaceAll(" ", " L ")} L ${last.x.toFixed(2)} ${baseline.toFixed(2)} Z`; + const y25 = (padding + usableHeight * 0.25).toFixed(2); + const y50 = (padding + usableHeight * 0.5).toFixed(2); + const y75 = (padding + usableHeight * 0.75).toFixed(2); + + return ` + + + + + + + + + `; +} + +function renderOverviewTrends() { + const rmseRoot = byId("ov-trend-rmse-chart"); + const rssiRoot = byId("ov-trend-rssi-chart"); + const deliveryRoot = byId("ov-trend-delivery-chart"); + if (!rmseRoot && !rssiRoot && !deliveryRoot) return; + + const series = buildTrendSeries(20); + + if (rmseRoot) { + rmseRoot.innerHTML = buildSparklineSvg(series.rmse, { + stroke: "#2b73f0", + area: "rgba(43, 115, 240, 0.22)", + invert: true, + }); + } + if (rssiRoot) { + rssiRoot.innerHTML = buildSparklineSvg(series.avgRssi, { + stroke: "#ff8a3d", + area: "rgba(255, 138, 61, 0.24)", + }); + } + if (deliveryRoot) { + deliveryRoot.innerHTML = buildSparklineSvg(series.delivery, { + stroke: "#14a37f", + area: "rgba(20, 163, 127, 0.22)", + }); + } + + setTextWithPulse("ov-trend-rmse-meta", formatTrendMeta(series.rmse, 2, " м")); + setTextWithPulse("ov-trend-rssi-meta", formatTrendMeta(series.avgRssi, 1, " дБм")); + setTextWithPulse("ov-trend-delivery-meta", formatTrendMeta(series.delivery, 0, "%")); +} + +function renderHistoryTrends() { + const root = byId("history-trends"); + if (!root) return; + + const series = buildTrendSeries(40); + const blocks = [ + { + title: "RMSE, м", + meta: formatTrendMeta(series.rmse, 2, " м"), + chart: buildSparklineSvg(series.rmse, { + stroke: "#2b73f0", + area: "rgba(43, 115, 240, 0.22)", + invert: true, + }), + }, + { + title: "Средний RSSI, дБм", + meta: formatTrendMeta(series.avgRssi, 1, " дБм"), + chart: buildSparklineSvg(series.avgRssi, { + stroke: "#ff8a3d", + area: "rgba(255, 138, 61, 0.24)", + }), + }, + { + title: "Успех доставки, %", + meta: formatTrendMeta(series.delivery, 0, "%"), + chart: buildSparklineSvg(series.delivery, { + stroke: "#14a37f", + area: "rgba(20, 163, 127, 0.22)", + }), + }, + ]; + + root.innerHTML = blocks + .map( + (block) => ` +
+
+ ${escapeHtml(block.title)} + ${escapeHtml(block.meta)} +
+
${block.chart}
+
+ ` + ) + .join(""); +} + +function buildMonitoringSummary() { + const data = state.result?.data || null; + const delivery = state.result?.output_delivery || state.frequencies?.output_delivery || {}; const inputs = Array.isArray(state.mockControls?.inputs) ? state.mockControls.inputs : []; const outputs = Array.isArray(state.mockControls?.outputs) ? state.mockControls.outputs : []; const inputOnline = inputs.filter((item) => Boolean(item?.reachable)).length; const outputOnline = outputs.filter((item) => Boolean(item?.reachable)).length; - const total = state.ioHistory.length; const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length; const success = toPercent(okCount, total); + const selectedHz = Number(data?.selected_frequency_hz); + const frequencyRows = Array.isArray(data?.frequency_table) ? data.frequency_table : []; + const updatedAt = state.result?.updated_at_utc || state.frequencies?.updated_at_utc || ""; + const updatedText = updatedAt ? fmtDateTimeCompact(updatedAt) : "н/д"; - setTextWithPulse("ov-input-online", `${inputOnline}/${inputs.length}`); - setTextWithPulse("ov-output-online", `${outputOnline}/${outputs.length}`); - setTextWithPulse("ov-history-total", total); - setTextWithPulse("ov-success-rate", `${success}%`); + return { + data, + delivery, + inputs, + outputs, + inputOnline, + outputOnline, + inputTotal: inputs.length, + outputTotal: outputs.length, + inputOnlinePercent: toPercent(inputOnline, inputs.length), + outputOnlinePercent: toPercent(outputOnline, outputs.length), + total, + okCount, + success, + selectedHz, + frequencyRows, + receivers: Array.isArray(data?.receivers) ? data.receivers : [], + updatedText, + }; } -function renderHistoryInsights() { - const feedRoot = byId("history-feed"); - const monitorRoot = byId("history-monitor"); +function renderOverviewFrequencyHealth(frequencyRows, selectedHz) { + const root = byId("ov-frequency-health"); + if (!root) return; - if (feedRoot) { - const feedRows = state.ioHistory.slice(0, 8); - if (feedRows.length === 0) { - feedRoot.innerHTML = '
Событий пока нет.
'; - } else { - feedRoot.innerHTML = feedRows - .map( - (row, index) => ` -
-
- ${escapeHtml(fmtDateTimeCompact(row.timestamp))} - ${escapeHtml(localizeStatus(row.statusRaw))} -
-
-
${escapeHtml(fmt(row.frequencyMhz, 3))} МГц
-
${escapeHtml((row.outputItems && row.outputItems[0]) || row.outputSummary || "-")}
-
-
` - ) - .join(""); - } + if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) { + root.innerHTML = '
Расчёты по частотам пока не получены.
'; + return; } - if (monitorRoot) { - const inputs = Array.isArray(state.mockControls?.inputs) ? state.mockControls.inputs : []; - const outputs = Array.isArray(state.mockControls?.outputs) ? state.mockControls.outputs : []; - const inputOnline = inputs.filter((item) => Boolean(item?.reachable)).length; - const outputOnline = outputs.filter((item) => Boolean(item?.reachable)).length; + const finiteRmse = frequencyRows + .map((row) => Number(row?.rmse_m)) + .filter((value) => Number.isFinite(value) && value >= 0); + const minRmse = finiteRmse.length > 0 ? Math.min(...finiteRmse) : 0; + const maxRmse = finiteRmse.length > 0 ? Math.max(...finiteRmse) : 1; + + root.innerHTML = frequencyRows + .map((row) => { + const hz = Number(row?.frequency_hz); + const mhz = row?.frequency_mhz ?? hzToMhz(hz); + const rmse = Number(row?.rmse_m); + const exact = Boolean(row?.exact); + const isSelected = Number.isFinite(hz) && Math.abs(hz - Number(selectedHz)) <= 1; + + let quality = 0; + if (Number.isFinite(rmse) && rmse >= 0) { + if (maxRmse <= minRmse) { + quality = 100; + } else { + quality = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100); + } + } + quality = clamp(quality, 0, 100); + + return ` +
+
+ ${escapeHtml(fmt(mhz, 3))} МГц + ${exact ? "точно" : "оценка"} +
+
+ +
+
+ RMSE: ${escapeHtml(fmt(rmse, 2))} м + Качество: ${quality}% +
+
+ `; + }) + .join(""); +} + +function renderOverviewSignalGrid(receivers, selectedHz) { + const root = byId("ov-signal-grid"); + if (!root) return; + if (!Array.isArray(receivers) || receivers.length === 0) { + root.innerHTML = '
Сигналы ресиверов пока не получены.
'; + return; + } + + root.innerHTML = receivers + .map((receiver) => { + const receiverId = String(receiver?.receiver_id || "n/a"); + const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null; + const row = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null; + + const rssi = Number(sample?.amplitude_dbm); + const radius = Number(row?.radius_m ?? sample?.distance_m); + const residual = Number(row?.residual_m); + const level = Number.isFinite(rssi) ? clamp(((rssi + 120) / 70) * 100, 0, 100) : 0; + + return ` +
+
+ ${escapeHtml(receiverId)} + ${escapeHtml(fmt(rssi, 1))} dBm +
+
+ +
+
+ R=${escapeHtml(fmt(radius, 2))} м + ε=${escapeHtml(fmt(residual, 2))} м +
+
+ `; + }) + .join(""); +} + +function normalizePipelineStatus(status) { + const normalized = String(status || "n/a").toLowerCase(); + if (normalized === "ok") return "ok"; + if (normalized === "error") return "error"; + if (normalized === "partial") return "partial"; + if (normalized === "warming_up") return "warm"; + return "warm"; +} - const total = state.ioHistory.length; - const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length; +function pipelineDotClass(status) { + const normalized = normalizePipelineStatus(status); + if (normalized === "ok") return "pipeline-dot pipeline-dot-ok"; + if (normalized === "error") return "pipeline-dot pipeline-dot-error"; + if (normalized === "partial") return "pipeline-dot pipeline-dot-partial"; + return "pipeline-dot pipeline-dot-warm"; +} + +function renderOverviewPipeline(summary) { + const root = byId("ov-pipeline-stages"); + if (!root) return; + + const hasData = Boolean(summary.data); + const solveStatus = hasData + ? Number.isFinite(Number(summary.data?.rmse_m)) + ? "ok" + : "partial" + : "warming_up"; + + const inputStatus = summary.inputTotal <= 0 + ? "warming_up" + : summary.inputOnline === summary.inputTotal + ? "ok" + : summary.inputOnline > 0 + ? "partial" + : "error"; + + const outputStatus = summary.outputTotal <= 0 + ? "warming_up" + : summary.outputOnline === summary.outputTotal + ? "ok" + : summary.outputOnline > 0 + ? "partial" + : "error"; + + const stages = [ + { + name: "Сбор входов", + status: inputStatus, + value: `${summary.inputOnline}/${summary.inputTotal}`, + note: "доступность входных серверов", + }, + { + name: "Решение", + status: solveStatus, + value: hasData ? `RMSE ${fmt(summary.data?.rmse_m, 2)} м` : "ожидание данных", + note: "оценка точности пересечения сфер", + }, + { + name: "Доставка", + status: summary.delivery?.status || "warming_up", + value: `${summary.outputOnline}/${summary.outputTotal}`, + note: "доступность выходных серверов", + }, + { + name: "История", + status: summary.total > 0 ? (summary.success >= 80 ? "ok" : "partial") : "warming_up", + value: `${summary.success}%`, + note: "успешность доставки по накопленной ленте", + }, + ]; + + root.innerHTML = stages + .map( + (stage) => ` +
+
+ + + ${escapeHtml(stage.name)} + + ${escapeHtml(localizeStatus(stage.status))} +
+
+ +
+
${escapeHtml(stage.value)} • ${escapeHtml(stage.note)}
+
+ ` + ) + .join(""); +} + +function renderOverviewTopFrequencies(frequencyRows) { + const root = byId("ov-top-frequencies"); + if (!root) return; + + if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) { + root.innerHTML = '
Нет данных по частотам для рейтинга.
'; + return; + } + + const rankedRows = [...frequencyRows].sort((a, b) => { + const ra = Number(a?.rmse_m); + const rb = Number(b?.rmse_m); + const aFinite = Number.isFinite(ra); + const bFinite = Number.isFinite(rb); + if (aFinite && bFinite) return ra - rb; + if (aFinite) return -1; + if (bFinite) return 1; + return 0; + }); + + const topRows = rankedRows.slice(0, 5); + const rmseValues = topRows + .map((row) => Number(row?.rmse_m)) + .filter((value) => Number.isFinite(value) && value >= 0); + const minRmse = rmseValues.length > 0 ? Math.min(...rmseValues) : 0; + const maxRmse = rmseValues.length > 0 ? Math.max(...rmseValues) : 1; + + root.innerHTML = topRows + .map((row, index) => { + const rmse = Number(row?.rmse_m); + const mhz = row?.frequency_mhz ?? hzToMhz(row?.frequency_hz); + const exact = Boolean(row?.exact); + let score = 0; + if (Number.isFinite(rmse) && rmse >= 0) { + if (maxRmse <= minRmse) { + score = 100; + } else { + score = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100); + } + } + score = clamp(score, 0, 100); + + return ` +
+
+ + ${index + 1} + ${escapeHtml(fmt(mhz, 3))} МГц + + ${exact ? "точно" : "оценка"} +
+
+ +
+
+ RMSE ${escapeHtml(fmt(rmse, 2))} м + Индекс ${score}% +
+
+ `; + }) + .join(""); +} + +function renderMenuGroupBadges(summary) { + setTextWithPulse("menu-badge-monitoring", `${summary.success}%`); + setTextWithPulse("menu-badge-io", `${summary.inputOnline}/${summary.inputTotal} • ${summary.outputOnline}/${summary.outputTotal}`); + setTextWithPulse("menu-badge-config", `${summary.inputs.length}вх/${summary.outputs.length}вых`); +} + +function renderOverviewMetrics() { + const summary = buildMonitoringSummary(); + + setTextWithPulse("ov-input-online", `${summary.inputOnline}/${summary.inputTotal}`); + setTextWithPulse("ov-output-online", `${summary.outputOnline}/${summary.outputTotal}`); + setTextWithPulse("ov-history-total", summary.total); + setTextWithPulse("ov-success-rate", `${summary.success}%`); + setTextWithPulse("ov-health-chip", localizeStatus(state.health?.status)); + setTextWithPulse("ov-delivery-chip", localizeStatus(summary.delivery?.status)); + setTextWithPulse("ov-updated-at", summary.updatedText); + setTextWithPulse("ov-input-online-bar-text", `${summary.inputOnline}/${summary.inputTotal}`); + setTextWithPulse("ov-output-online-bar-text", `${summary.outputOnline}/${summary.outputTotal}`); + setTextWithPulse("ov-delivery-bar-text", `${summary.success}%`); + setProgressWidth("ov-input-online-bar", summary.inputOnlinePercent); + setProgressWidth("ov-output-online-bar", summary.outputOnlinePercent); + setProgressWidth("ov-delivery-bar", summary.success); + renderMenuGroupBadges(summary); + renderOverviewFrequencyHealth(summary.frequencyRows, summary.selectedHz); + renderOverviewSignalGrid(summary.receivers, summary.selectedHz); + renderOverviewPipeline(summary); + renderOverviewTopFrequencies(summary.frequencyRows); + renderOverviewTrends(); +} + +function renderHistoryInsights() { + const monitorRoot = byId("history-monitor"); + const summary = buildMonitoringSummary(); + renderHistoryTrends(); + + if (monitorRoot) { const problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length; - const success = toPercent(okCount, total); const healthStatus = localizeStatus(state.health?.status); const deliveryStatus = localizeStatus( @@ -659,11 +1344,11 @@ function renderHistoryInsights() { monitorRoot.innerHTML = `
Сервис${escapeHtml(healthStatus)}
Доставка${escapeHtml(deliveryStatus)}
-
Входы online${inputOnline}/${inputs.length}
-
Выходы online${outputOnline}/${outputs.length}
+
Входы online${summary.inputOnline}/${summary.inputTotal}
+
Выходы online${summary.outputOnline}/${summary.outputTotal}
Проблемных событий${problemCount}
-
Успех доставки${success}%
-
+
Успех доставки${summary.success}%
+
`; } } @@ -871,6 +1556,16 @@ function buildControlCardHtml(item, target) { const reachabilityKind = reachable ? "io-status-ok" : "io-status-error"; const reachabilityText = reachable ? "online" : "offline"; const disabledAttr = !id ? "disabled" : ""; + const liveFrequencies = Array.isArray(item?.frequencies_mhz) + ? item.frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ") + : ""; + const configuredFrequencies = Array.isArray(item?.configured_frequencies_mhz) + ? item.configured_frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ") + : ""; + const frequencyText = + target === "input" + ? `активные: ${liveFrequencies || "н/д"} • конфиг: ${configuredFrequencies || "н/д"}` + : ""; return `
@@ -885,6 +1580,7 @@ function buildControlCardHtml(item, target) { ${errorText ? `
Ошибка: ${errorText}
` : ""} + ${frequencyText ? `
частоты (МГц): ${escapeHtml(frequencyText)}
` : ""}
- - - - - + + + + +
- дата: н/д - время: н/д - состояние: н/д - доставка: н/д - +
+ + +
+
+
+ дата: н/д + время: н/д +
+
+ сервис: н/д + доставка: н/д +
+ +
-
+

Панель Радиопозиционирования

Мониторинг и управление расчётом 3D триангуляции.

+ автообновление: вкл (2с)
-
-

Итоговая Позиция

-
-
Выбранная частота: -
-
X: -
-
Y: -
-
Z: -
-
СКО (RMSE): -
-
-
- -
-

Оперативный Мониторинг

-
-
- Входы online - 0/0 +
+
+

Итоговая Позиция

+
+
Выбранная частота: -
+
X: -
+
Y: -
+
Z: -
+
СКО (RMSE): -
-
- Выходы online - 0/0 -
-
- События в истории - 0 +
+ +
+

Оперативный Мониторинг

+
+ Сервис: н/д + Доставка: н/д + Обновлено: н/д
-
- Успех доставки - 0% + +
+
+
+
+ Входы online + 0/0 +
+
+ Выходы online + 0/0 +
+
+ События в истории + 0 +
+
+ Успех доставки + 0% +
+
+
+ +
+

Состояние Потоков

+
+ Входные потоки + 0/0 +
+
+ +
+
+ Выходные потоки + 0/0 +
+
+ +
+
+ Успешная доставка + 0% +
+
+ +
+
-
-
+
+
- +

Таблица По Частотам

@@ -105,6 +232,32 @@
+ +
+

Аналитика Частот

+
+
+

Профиль Частот

+
+
+
+

Лидеры Частот

+
+
+ +
+
@@ -125,89 +278,91 @@

Управление Тестовыми Сбоями

+ +
+

Контур Входа/Выхода

+
+
+

Сигналы Ресиверов

+
+
+
+

Контур Обработки

+
+
+ +
+
-
+

История Входов И Выходов

Связка входных измерений и отправки результата для отладки, SLA и диагностики ошибок.

+
-
-
- Событий - 0 -
-
- Успешно - 0 -
-
- Проблемы - 0 -
-
- Частот - 0 -
-
- Последнее событие - н/д -
-
- -
- -
- +
+
+

История И Фильтры

+
- -
- - Стр. 1/1 • 0 записей - +
+ + + +
+ + Стр. 1/1 • 0 записей + +
+ + + запись: вкл +
- - - запись: вкл -
-
- -
-
-

Лента Последних Событий

-
-
-
-

Диагностика Мониторинга

-
-
-
-
@@ -222,61 +377,175 @@
-
+
+ +
+

Мониторинг Истории

+
+
+ Событий + 0 +
+
+ Успешно + 0 +
+
+ Проблемы + 0 +
+
+ Частот + 0 +
+
+ Последнее событие + н/д +
+
+ +
+
+

Диагностика Мониторинга

+
+
+
+

Тренды Метрик

+ +
+
+
+
-
+

Настройка Серверов

Доступы входа и выхода задаются отдельно. Общий фильтр входа автоматически применяется ко всем входным серверам.

+
-

Доступ К Входным Серверам

-
- -
- - - входов: 0 +
+
+
+

Входные Серверы

+

Управление ресиверами и их геометрией.

- - - - - - -
+
+
+ + входов: 0 +
+
+ + +
+
+ + +
+ +
+ + + +
+
+
-

Общий Фильтр Входа (Авто Для Всех)

-
- - - - - -
+
+
+

Общий Фильтр Входа

+

Применяется автоматически ко всем входным серверам.

+
+
+ +
+ + +
+
+ + +
+
+
-

Выходные Серверы

-
- -
- - - выходов: 0 +
+
+

Выходные Серверы

+

Минимальные параметры для доставки результата.

- - - -
+
+
+ + выходов: 0 +
+
+ + +
+
+ + +
+ +
+ +
+
@@ -286,20 +555,54 @@
-
+

Конфигурация

конфиг: н/д
-
+ +
+
+
+

Редактор JSON

+

Точный режим настройки для прод-конфигурации.

+
+
+
+ JSON + UTF-8 + runtime + input + system +
+ +
+
+
+
+

Памятка По Полям

+

Краткая структура и контрольные точки перед сохранением.

+
+
+

input.receivers[]
входные ресиверы: координаты, URL, частоты.

+

runtime.output_servers[]
список серверов, получающих координаты.

+

input.default_input_filter
общий фильтр частот и RSSI.

+

system
системные таймеры, лимиты и автообновление.

+
+

Советы

+
    +
  • Поддерживайте уникальные `receiver_id` для каждого входа.
  • +
  • Согласуйте диапазоны частот между входными серверами.
  • +
  • Перед сохранением проверяйте JSON на валидность.
  • +
+
+
- +
diff --git a/web/styles.css b/web/styles.css index d559ef4..f929208 100644 --- a/web/styles.css +++ b/web/styles.css @@ -24,6 +24,14 @@ box-sizing: border-box; } +img, +svg, +video, +canvas { + max-width: 100%; + height: auto; +} + html, body { min-height: 100%; @@ -43,11 +51,11 @@ body { .app-shell { width: 100%; margin: 0; - padding: 12px 12px 18px; + padding: clamp(8px, 1.1vw, 14px) clamp(8px, 1.2vw, 16px) clamp(12px, 1.6vw, 20px); display: grid; grid-template-columns: 1fr; align-items: start; - gap: 14px; + gap: clamp(10px, 1.2vw, 16px); position: relative; z-index: 2; } @@ -61,6 +69,7 @@ body { box-shadow: var(--shadow); position: relative; overflow: hidden; + min-width: 0; animation: rise 460ms ease both; transition: transform var(--anim-fast) ease, @@ -139,10 +148,16 @@ body { .content-area { display: grid; min-width: 0; - width: min(1800px, 100%); + width: min(1900px, 100%); margin: 0 auto; } +.content-area > .panel, +.panel > article, +.panel > div { + min-width: 0; +} + .panel { display: none; animation: fadeSlide var(--anim-mid) ease; @@ -150,9 +165,9 @@ body { .panel-active { display: grid; - gap: 16px; + gap: clamp(10px, 1.2vw, 16px); min-width: 0; - width: min(1500px, 100%); + width: 100%; margin: 0 auto; } @@ -166,12 +181,34 @@ body { font-size: clamp(1.3rem, 1rem + 1vw, 1.8rem); } +.overview-hero { + background: + radial-gradient(circle at 8% 0%, rgba(36, 107, 255, 0.18), transparent 40%), + linear-gradient(165deg, var(--card-strong), var(--card)); +} + +.overview-layout { + display: grid; + grid-template-columns: minmax(300px, 420px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.overview-position-card { + min-height: 100%; +} + +.overview-monitor-card { + min-height: 100%; +} + .hero-actions, .editor-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; + width: 100%; } .hero-actions { @@ -187,6 +224,10 @@ body { cursor: pointer; font-family: inherit; font-weight: 600; + max-width: 100%; + text-align: center; + white-space: normal; + overflow-wrap: anywhere; transition: transform var(--anim-fast) ease, background-color var(--anim-fast) ease, @@ -224,26 +265,25 @@ body { } .menu-list { - display: flex; - flex-wrap: nowrap; - align-items: stretch; - justify-content: center; - gap: 8px; - width: min(1380px, 100%); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + align-items: start; + gap: 10px; + width: min(1780px, 100%); max-width: 100%; margin: 0 auto; position: static; border: 1px solid var(--line); - border-radius: 12px; - background: rgba(255, 255, 255, 0.86); + border-radius: 14px; + background: + radial-gradient(130% 180% at 0% 0%, rgba(234, 242, 255, 0.62), transparent 52%), + linear-gradient(170deg, rgba(255, 255, 255, 0.94), rgba(245, 250, 255, 0.8)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), - 0 8px 24px rgba(21, 37, 73, 0.08); - padding: 6px; - max-height: 84px; - overflow-x: auto; - overflow-y: hidden; - overscroll-behavior: contain; + 0 10px 28px rgba(21, 37, 73, 0.1); + padding: 8px; + max-height: none; + overflow: visible; transform-origin: top center; animation: menuIn var(--anim-mid) ease both; transition: @@ -265,51 +305,288 @@ body { pointer-events: none; } +.menu-group { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 24%); + border-radius: 12px; + background: linear-gradient(175deg, rgba(255, 255, 255, 0.94), rgba(243, 249, 255, 0.82)); + padding: 8px; + display: grid; + gap: 8px; + min-width: 0; +} + +.menu-group-toggle { + width: 100%; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 18%); + background: linear-gradient(165deg, rgba(235, 243, 255, 0.88), rgba(255, 255, 255, 0.94)); + border-radius: 10px; + padding: 7px 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + font-family: inherit; + color: #1d3e74; + transition: + transform var(--anim-fast) ease, + box-shadow var(--anim-fast) ease, + border-color var(--anim-fast) ease; +} + +.menu-group-toggle:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent), #ffffff 64%); + box-shadow: 0 8px 18px rgba(26, 63, 126, 0.12); +} + +.menu-group-title { + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 800; +} + +.menu-group-badge { + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 16%); + background: rgba(255, 255, 255, 0.94); + color: #2a4f88; + padding: 3px 8px; + font-size: 0.72rem; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.menu-group-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 8px; + max-height: 300px; + opacity: 1; + transform: translateY(0); + transition: + max-height var(--anim-mid) ease, + opacity var(--anim-fast) ease, + transform var(--anim-fast) ease; +} + +.menu-group.menu-group-collapsed .menu-group-body { + max-height: 0; + opacity: 0; + transform: translateY(-8px); + overflow: hidden; + pointer-events: none; +} + .menu-item { - flex: 1 1 0; - min-width: 136px; - display: inline-flex; + min-width: 0; + min-height: 68px; + display: grid; + grid-template-rows: auto auto auto; + align-content: center; + justify-items: center; + gap: 4px; align-items: center; justify-content: center; - border: 1px solid transparent; - background: #fbfcff; - color: var(--text); - border-radius: 9px; - padding: 8px 12px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 24%); + background: + linear-gradient(178deg, rgba(255, 255, 255, 0.96), rgba(242, 248, 255, 0.86)); + color: #23395f; + border-radius: 11px; + padding: 8px 12px 10px; text-align: center; - white-space: nowrap; + white-space: normal; + line-height: 1.05; + font-size: 0.83rem; + letter-spacing: 0.02em; + font-weight: 700; cursor: pointer; font-family: inherit; + position: relative; + overflow: hidden; transition: transform var(--anim-fast) ease, border-color var(--anim-fast) ease, - background-color var(--anim-fast) ease; + background-color var(--anim-fast) ease, + box-shadow var(--anim-fast) ease, + color var(--anim-fast) ease; +} + +.menu-item-icon { + width: 24px; + height: 24px; + min-width: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 10%); + background: linear-gradient(155deg, #f6f9ff, #e7efff); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.74), + 0 4px 10px rgba(21, 52, 105, 0.12); + color: #2a4f88; + font-size: 0.72rem; + font-weight: 800; + transition: transform var(--anim-fast) ease, box-shadow var(--anim-fast) ease, background var(--anim-fast) ease; +} + +.menu-item-text { + font-size: 0.83rem; + font-weight: 800; + letter-spacing: 0.01em; + color: #1f3860; +} + +.menu-item-note { + font-size: 0.69rem; + text-transform: uppercase; + letter-spacing: 0.09em; + color: #5f7398; + font-weight: 700; +} + +.menu-group.menu-group-collapsed .menu-group-toggle { + border-color: color-mix(in oklab, var(--line), #ffffff 28%); +} + +.menu-item::after { + content: ""; + position: absolute; + left: 14px; + right: 14px; + bottom: 5px; + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(36, 107, 255, 0.1), rgba(36, 107, 255, 0.62), rgba(36, 107, 255, 0.1)); + transform: scaleX(0.28); + opacity: 0; + transition: + transform var(--anim-fast) ease, + opacity var(--anim-fast) ease; } .menu-item:hover { - transform: translateY(-1px); - border-color: color-mix(in oklab, var(--accent), #ffffff 70%); + transform: translateY(-2px); + border-color: color-mix(in oklab, var(--accent), #ffffff 64%); + box-shadow: 0 10px 20px rgba(30, 65, 122, 0.14); + color: #1c3f74; +} + +.menu-item:hover .menu-item-icon { + transform: translateY(-1px) scale(1.03); +} + +.menu-item:hover::after { + transform: scaleX(1); + opacity: 1; } .menu-item-active { - background: linear-gradient(90deg, var(--accent-soft), #f4f7ff); - border-color: color-mix(in oklab, var(--accent), #ffffff 64%); + background: + radial-gradient(120% 150% at 10% -20%, rgba(36, 107, 255, 0.28), transparent 50%), + linear-gradient(158deg, #edf4ff, #ffffff 70%); + border-color: color-mix(in oklab, var(--accent), #ffffff 44%); + color: #133d7b; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.84), + 0 12px 24px rgba(19, 76, 178, 0.16); +} + +.menu-item-active .menu-item-icon { + background: linear-gradient(155deg, #2e6ff1, #1d56cd); + border-color: color-mix(in oklab, #1d56cd, #ffffff 25%); + color: #fff; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.34), + 0 7px 14px rgba(23, 71, 171, 0.28); +} + +.menu-item-active .menu-item-text { + color: #14386b; +} + +.menu-item-active .menu-item-note { + color: #2a5ea8; +} + +.menu-item-active::after { + transform: scaleX(1); + opacity: 1; } .side-meta { + width: 100%; + display: grid; + gap: 8px; + justify-items: center; + min-width: 0; +} + +.datetime-panel-controls { + width: 100%; + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 8px; +} + +.meta-panel { + width: 100%; + display: grid; + gap: 8px; + justify-items: center; + max-height: 240px; + opacity: 1; + transform: translateY(0); + transition: + max-height var(--anim-mid) ease, + opacity var(--anim-fast) ease, + transform var(--anim-fast) ease, + margin var(--anim-fast) ease; +} + +.side-nav.date-time-collapsed .meta-panel { + max-height: 0; + opacity: 0; + transform: translateY(-8px); + overflow: hidden; + margin: 0; + pointer-events: none; +} + +.date-time-panel, +.status-panel { + width: 100%; display: flex; flex-wrap: wrap; gap: 8px; + align-items: center; justify-content: center; } +.meta-pill { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 22%); + background: rgba(247, 251, 255, 0.92); + border-radius: 999px; + padding: 6px 11px; + font-size: 0.78rem; + line-height: 1.2; + color: #30486f; + white-space: normal; + text-align: center; + overflow-wrap: anywhere; +} + .timezone-picker { display: grid; gap: 4px; - text-align: left; + text-align: center; font-size: 0.78rem; color: #3d4f70; - min-width: 240px; + min-width: 220px; } .timezone-picker select { @@ -322,6 +599,26 @@ body { font-family: inherit; } +.refresh-interval-control { + display: grid; + gap: 4px; + text-align: left; + font-size: 0.78rem; + color: #3d4f70; + min-width: 140px; +} + +.refresh-interval-control input { + border: 1px solid var(--line); + border-radius: 9px; + padding: 6px 10px; + background: #fff; + color: var(--text); + font-size: 0.84rem; + font-family: inherit; + width: 100%; +} + .badge { border: 1px solid var(--line); background: rgba(236, 244, 255, 0.72); @@ -336,7 +633,8 @@ body { align-items: center; justify-content: center; gap: 8px; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } .badge::after { @@ -412,43 +710,388 @@ body { color: #1d3258; } -.io-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; +.monitor-board { + gap: 12px; } -.io-block { - border: 1px solid var(--line); - border-radius: 12px; - background: rgba(255, 255, 255, 0.52); - padding: 10px; +.monitor-headline { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; } -.io-list { +.monitor-headline .io-chip b { + font-weight: 800; +} + +.monitor-grid { display: grid; - gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; } -.io-card { - border: 1px solid var(--line); - border-radius: 11px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(247, 250, 255, 0.78)); +.monitor-panel { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 30%); + border-radius: 12px; + background: linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(240, 247, 255, 0.82)); padding: 10px; - text-align: left; - transition: - transform var(--anim-fast) ease, - box-shadow var(--anim-fast) ease, - border-color var(--anim-fast) ease; + display: grid; + gap: 8px; + min-height: 152px; } -.io-card:hover { - transform: translateY(-2px); - border-color: color-mix(in oklab, var(--accent), #ffffff 62%); - box-shadow: 0 10px 22px rgba(21, 45, 92, 0.12); +.monitor-panel h3 { + margin: 0; + text-align: center; + font-size: 0.92rem; } -.io-card-head { +.monitor-kpi-panel { + grid-column: 1 / -1; +} + +.monitor-flow-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 0.84rem; + color: #324766; +} + +.monitor-flow-row b { + color: #14335f; +} + +.monitor-progress { + width: 100%; + height: 9px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 26%); + background: rgba(221, 232, 250, 0.82); + overflow: hidden; +} + +.monitor-progress > span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #2bb48d, #2b73f0); + transition: width var(--anim-mid) ease; +} + +.monitor-progress-accent > span { + background: linear-gradient(90deg, #ff9d49, #2b73f0); +} + +.monitor-progress-signal > span { + background: linear-gradient(90deg, #ff7b5a, #ffc44d, #2bb48d); +} + +.frequency-health-list { + display: grid; + gap: 8px; + max-height: clamp(220px, 44dvh, 520px); + overflow-y: auto; + padding-right: 2px; +} + +.frequency-health-item { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 30%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.86); + padding: 8px; + display: grid; + gap: 6px; +} + +.frequency-health-item-active { + border-color: color-mix(in oklab, var(--accent), #ffffff 44%); + box-shadow: 0 0 0 1px rgba(36, 107, 255, 0.14) inset; +} + +.frequency-health-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.frequency-health-head b { + font-size: 0.9rem; +} + +.frequency-health-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 0.78rem; + color: #435876; +} + +.signal-grid { + display: grid; + gap: 8px; + max-height: clamp(220px, 44dvh, 520px); + overflow-y: auto; + padding-right: 2px; +} + +.signal-item { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 30%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.86); + padding: 8px; + display: grid; + gap: 6px; +} + +.signal-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.82rem; + color: #22395f; +} + +.signal-item-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.76rem; + color: #4a5f7f; +} + +.pipeline-stages { + display: grid; + gap: 8px; +} + +.pipeline-stage { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.9); + padding: 8px; + display: grid; + gap: 6px; +} + +.pipeline-stage-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.82rem; + color: #27406a; +} + +.pipeline-stage-label { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.pipeline-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #95a6c7; + box-shadow: 0 0 0 2px rgba(149, 166, 199, 0.22); +} + +.pipeline-dot-ok { + background: #14a37f; + box-shadow: 0 0 0 2px rgba(20, 163, 127, 0.2); +} + +.pipeline-dot-partial { + background: #ff9d49; + box-shadow: 0 0 0 2px rgba(255, 157, 73, 0.2); +} + +.pipeline-dot-error { + background: #d95c5c; + box-shadow: 0 0 0 2px rgba(217, 92, 92, 0.2); +} + +.pipeline-dot-warm { + background: #8a7ce5; + box-shadow: 0 0 0 2px rgba(138, 124, 229, 0.2); +} + +.pipeline-stage-note { + font-size: 0.74rem; + color: #4d6286; +} + +.top-frequencies { + display: grid; + gap: 8px; + max-height: clamp(220px, 44dvh, 520px); + overflow-y: auto; + padding-right: 2px; +} + +.top-frequency-item { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.9); + padding: 8px; + display: grid; + gap: 6px; +} + +.top-frequency-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.top-frequency-rank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 14%); + background: linear-gradient(160deg, #f2f7ff, #dfeaff); + color: #21457f; + font-size: 0.74rem; + font-weight: 800; +} + +.top-frequency-title { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 0.82rem; + color: #28436e; +} + +.top-frequency-meta { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 0.76rem; + color: #4a5f82; +} + +.trend-stack, +.history-trends { + display: grid; + gap: 8px; +} + +.trend-card { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.9); + padding: 8px; + display: grid; + gap: 6px; +} + +.trend-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.8rem; + color: #2b436e; +} + +.trend-head b { + color: #123c78; + font-size: 0.82rem; +} + +.sparkline-wrap { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 22%); + border-radius: 9px; + background: linear-gradient(180deg, rgba(245, 249, 255, 0.94), rgba(255, 255, 255, 0.94)); + padding: 4px; + min-height: 64px; + display: grid; + align-items: center; +} + +.sparkline-svg { + width: 100%; + height: 54px; + display: block; +} + +.sparkline-grid { + stroke: rgba(59, 85, 131, 0.2); + stroke-width: 1; +} + +.sparkline-area { + opacity: 0.28; +} + +.sparkline-line { + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.sparkline-dot { + stroke: #fff; + stroke-width: 1.5; +} + +.sparkline-empty { + text-align: center; + color: #607295; + font-size: 0.78rem; +} + +.io-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 14px; +} + +.io-block { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.52); + padding: 10px; +} + +.io-list { + display: grid; + gap: 10px; +} + +.io-card { + border: 1px solid var(--line); + border-radius: 11px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(247, 250, 255, 0.78)); + padding: 10px; + text-align: left; + transition: + transform var(--anim-fast) ease, + box-shadow var(--anim-fast) ease, + border-color var(--anim-fast) ease; + overflow-wrap: anywhere; +} + +.io-card:hover { + transform: translateY(-2px); + border-color: color-mix(in oklab, var(--accent), #ffffff 62%); + box-shadow: 0 10px 22px rgba(21, 45, 92, 0.12); +} + +.io-card-head { display: flex; justify-content: space-between; align-items: center; @@ -479,13 +1122,17 @@ body { .io-chip { display: inline-flex; align-items: center; + justify-content: center; border-radius: 999px; border: 1px solid var(--line); padding: 4px 9px; font-size: 0.77rem; line-height: 1.2; background: #f2f6ff; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; + max-width: 100%; + text-align: center; } .io-chip-neutral, @@ -646,9 +1293,35 @@ body { radial-gradient(circle at 100% 0%, rgba(36, 107, 255, 0.14), transparent 55%); } +.history-head-card { + text-align: center; +} + +.history-layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 1fr); + gap: 12px; +} + +.history-data-card, +.history-monitor-card { + background: + linear-gradient(170deg, rgba(255, 255, 255, 0.97), rgba(241, 247, 255, 0.88)); +} + +.history-data-card > h2, +.history-monitor-card > h2 { + margin: 0 0 10px; + text-align: center; +} + +.history-data-card .history-toolbar { + margin-bottom: 10px; +} + .history-kpis { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 12px 0; } @@ -684,7 +1357,8 @@ body { align-items: flex-end; gap: 10px; flex-wrap: wrap; - margin: 8px 0 10px; + margin: 0; + min-width: 0; } .history-toolbar label { @@ -693,6 +1367,8 @@ body { font-size: 0.84rem; color: #3d4f70; text-align: left; + min-width: 0; + flex: 1 1 180px; } .history-toolbar select { @@ -701,7 +1377,8 @@ body { padding: 7px 10px; background: #fff; color: var(--text); - min-width: 180px; + min-width: 0; + width: 100%; } .history-toolbar input[type="datetime-local"] { @@ -710,7 +1387,8 @@ body { padding: 7px 10px; background: #fff; color: var(--text); - min-width: 210px; + min-width: 0; + width: 100%; font-family: inherit; } @@ -720,6 +1398,8 @@ body { gap: 10px; flex-wrap: wrap; justify-content: flex-end; + min-width: 0; + flex: 999 1 700px; } .history-pager { @@ -736,9 +1416,9 @@ body { .history-insights { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 10px; - margin-bottom: 10px; + margin-top: 10px; } .insight-panel { @@ -754,42 +1434,6 @@ body { text-align: center; } -.history-feed { - display: grid; - gap: 8px; - max-height: 250px; - overflow-y: auto; - padding-right: 2px; -} - -.feed-item { - border: 1px solid var(--line); - border-radius: 10px; - background: rgba(255, 255, 255, 0.85); - padding: 8px 9px; - display: grid; - gap: 6px; -} - -.feed-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.feed-time { - font-size: 0.78rem; - color: #4f607c; -} - -.feed-body { - display: grid; - gap: 4px; - font-size: 0.84rem; - color: #233a62; -} - .history-monitor { display: grid; gap: 7px; @@ -828,8 +1472,19 @@ body { } .history-table-wrap { - max-height: min(52dvh, 560px); + max-height: min(74dvh, 920px); overflow: auto; + scrollbar-gutter: stable; +} + +#io-history-table { + table-layout: fixed; +} + +#io-history-table th, +#io-history-table td { + overflow-wrap: anywhere; + word-break: break-word; } .history-table-wrap thead th { @@ -846,7 +1501,7 @@ body { .history-table-wrap td:nth-child(3), .history-table-wrap td:nth-child(4) { text-align: left; - min-width: 290px; + min-width: 180px; } .history-cell-list { @@ -860,6 +1515,7 @@ body { border: 1px solid color-mix(in oklab, var(--line), #ffffff 24%); background: rgba(255, 255, 255, 0.76); font-size: 0.82rem; + overflow-wrap: anywhere; } .history-table-wrap td:first-child { @@ -903,140 +1559,479 @@ body { background: rgba(233, 250, 244, 0.98); } -.toast-error { - border-color: rgba(208, 71, 71, 0.4); - background: rgba(255, 239, 239, 0.98); +.toast-error { + border-color: rgba(208, 71, 71, 0.4); + background: rgba(255, 239, 239, 0.98); +} + +.panel > .card > h2, +.servers-title, +.server-grid label, +.server-actions-row, +.editor-actions { + text-align: center; +} + +.server-actions-row, +.editor-actions { + justify-content: center; +} + +.muted { + color: var(--muted); +} + +.small { + font-size: 0.86rem; +} + +.mono { + font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; +} + +.table-wrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.56); + scrollbar-gutter: stable; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: center; + padding: 9px 10px; + border-bottom: 1px solid var(--line); + font-size: 0.9rem; +} + +thead th { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #4a5a74; + background: rgba(245, 249, 255, 0.9); +} + +tbody tr { + transition: background-color var(--anim-fast) ease; +} + +tbody tr:hover { + background: rgba(230, 239, 255, 0.58); +} + +.row-enter { + opacity: 0; + transform: translateY(6px); + animation: rowEnter var(--anim-mid) ease forwards; +} + +.value-updated { + animation: valuePulse 640ms ease; +} + +.editor { + width: 100%; + min-height: clamp(230px, 42dvh, 560px); + border: 1px solid var(--line); + border-radius: 12px; + padding: 11px; + background: rgba(250, 253, 255, 0.88); + font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 0.85rem; + margin-top: 10px; +} + +.server-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + min-width: 0; +} + +.servers-head-card { + text-align: center; +} + +.servers-layout { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; +} + +.servers-card { + min-height: 100%; + background: + radial-gradient(circle at 0% 0%, rgba(36, 107, 255, 0.14), transparent 42%), + linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(241, 248, 255, 0.86)); +} + +.servers-actions-card { + background: + linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(245, 250, 255, 0.9)); +} + +.servers-title { + margin: 16px 0 10px; + font-size: 0.98rem; + letter-spacing: 0.02em; +} + +.server-actions-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.server-grid label { + display: grid; + justify-items: center; + gap: 6px; + font-size: 0.88rem; + color: #34425c; + min-width: 0; +} + +.server-grid input, +.server-grid select { + border: 1px solid var(--line); + border-radius: 9px; + padding: 8px 10px; + font-size: 0.9rem; + background: #fff; + color: var(--text); + text-align: center; + font-family: inherit; + transition: border-color var(--anim-fast) ease, box-shadow var(--anim-fast) ease; + width: 100%; + min-width: 0; +} + +.server-grid input:focus, +.server-grid select:focus, +.editor:focus { + outline: none; + border-color: color-mix(in oklab, var(--accent), #ffffff 50%); + box-shadow: 0 0 0 3px rgba(36, 107, 255, 0.14); +} + +.config-head-card { + text-align: center; +} + +.config-layout { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); + gap: 12px; + align-items: start; +} + +.config-editor-card { + background: + linear-gradient(165deg, rgba(255, 255, 255, 0.97), rgba(244, 249, 255, 0.9)); +} + +.config-editor-card h3 { + margin: 0 0 8px; + text-align: center; +} + +.config-help-card { + background: + radial-gradient(circle at 100% 0%, rgba(255, 164, 81, 0.18), transparent 42%), + linear-gradient(165deg, rgba(255, 255, 255, 0.97), rgba(250, 246, 239, 0.9)); + text-align: left; +} + +.config-help-card h3 { + margin: 0 0 8px; + text-align: center; +} + +.config-hints { + display: grid; + gap: 8px; +} + +.config-hints p { + margin: 0; + padding: 8px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 26%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.72); + font-size: 0.84rem; + color: #31486f; +} + +.config-tips { + margin: 0; + padding-left: 20px; + display: grid; + gap: 6px; + color: #31486f; + font-size: 0.84rem; +} + +.servers-layout-modern { + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + align-items: stretch; +} + +.servers-card-modern { + padding: 0; + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; +} + +.server-card-head { + display: grid; + gap: 4px; + padding: 14px 14px 12px; + border-bottom: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + background: + radial-gradient(circle at 0% 0%, rgba(36, 107, 255, 0.16), transparent 54%), + linear-gradient(170deg, rgba(255, 255, 255, 0.98), rgba(242, 248, 255, 0.9)); +} + +.server-card-head .servers-title { + margin: 0; +} + +.server-card-head .muted { + margin: 0; + font-size: 0.8rem; +} + +.server-card-body { + padding: 12px 14px 14px; + display: grid; + gap: 10px; + align-content: start; +} + +.selector-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: end; +} + +.chip-counter { + height: fit-content; +} + +.action-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-start; +} + +.field-grid { + display: grid; + gap: 10px; +} + +.field-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.field-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-control { + display: grid; + gap: 6px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + border-radius: 12px; + padding: 9px 10px; + background: linear-gradient(170deg, rgba(255, 255, 255, 0.92), rgba(243, 248, 255, 0.84)); + transition: + border-color var(--anim-fast) ease, + box-shadow var(--anim-fast) ease, + transform var(--anim-fast) ease; +} + +.field-control:focus-within { + border-color: color-mix(in oklab, var(--accent), #ffffff 44%); + box-shadow: 0 0 0 3px rgba(36, 107, 255, 0.14); + transform: translateY(-1px); +} + +.field-label { + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #49608a; + font-weight: 700; +} + +.field-control input, +.field-control select { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 30%); + border-radius: 9px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.96); + color: var(--text); + font-size: 0.88rem; + font-family: inherit; + width: 100%; + min-width: 0; +} + +.field-control input:focus, +.field-control select:focus { + outline: none; + border-color: color-mix(in oklab, var(--accent), #ffffff 50%); +} + +.field-hint { + font-size: 0.76rem; + color: #556b90; +} + +.config-layout-modern { + grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.95fr); + align-items: stretch; +} + +.config-editor-modern, +.config-help-modern { + padding: 0; + overflow: hidden; + display: grid; + grid-template-rows: auto 1fr; +} + +.config-section-head { + padding: 14px 14px 12px; + border-bottom: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + background: + radial-gradient(circle at 100% 0%, rgba(36, 107, 255, 0.14), transparent 58%), + linear-gradient(168deg, rgba(255, 255, 255, 0.97), rgba(243, 249, 255, 0.88)); } -.panel > .card > h2, -.servers-title, -.server-grid label, -.server-actions-row, -.editor-actions { +.config-section-head h3 { + margin: 0; text-align: center; } -.server-actions-row, -.editor-actions { - justify-content: center; +.config-section-head .muted { + margin: 4px 0 0; + text-align: center; + font-size: 0.8rem; } -.muted { - color: var(--muted); +.config-editor-shell { + padding: 12px 14px 14px; + display: grid; + gap: 10px; } -.small { - font-size: 0.86rem; +.editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 7px; } -.mono { - font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; +.editor-chip { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 24%); + border-radius: 999px; + padding: 4px 9px; + font-size: 0.74rem; + letter-spacing: 0.03em; + color: #335078; + background: rgba(242, 248, 255, 0.9); } -.table-wrap { - overflow-x: auto; - border: 1px solid var(--line); - border-radius: 12px; - background: rgba(255, 255, 255, 0.56); +.config-editor-modern .editor { + margin-top: 0; + min-height: clamp(250px, 52dvh, 760px); + resize: vertical; } -table { - width: 100%; - border-collapse: collapse; +.config-help-modern { + align-content: start; } -th, -td { - text-align: center; - padding: 9px 10px; - border-bottom: 1px solid var(--line); - font-size: 0.9rem; +.config-help-modern .config-hints-grid { + grid-template-columns: 1fr; + padding: 12px 14px 0; } -thead th { +.config-help-modern .config-hints-grid p { font-size: 0.82rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #4a5a74; - background: rgba(245, 249, 255, 0.9); } -tbody tr { - transition: background-color var(--anim-fast) ease; +.config-help-modern h3 { + margin: 10px 14px 8px; } -tbody tr:hover { - background: rgba(230, 239, 255, 0.58); +.config-help-modern .config-tips { + margin: 0 14px 14px; } -.row-enter { - opacity: 0; - transform: translateY(6px); - animation: rowEnter var(--anim-mid) ease forwards; +body.ui-compact .panel-active { + gap: 12px; } -.value-updated { - animation: valuePulse 640ms ease; +body.ui-compact .card { + padding: 12px; + border-radius: 14px; } -.editor { - width: 100%; - min-height: 340px; - border: 1px solid var(--line); - border-radius: 12px; - padding: 11px; - background: rgba(250, 253, 255, 0.88); - font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; - font-size: 0.85rem; - margin-top: 10px; +body.ui-compact .hero-actions, +body.ui-compact .editor-actions { + gap: 8px; } -.server-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px 14px; +body.ui-compact .menu-item { + min-height: 58px; + padding: 6px 8px 8px; } -.servers-title { - margin: 16px 0 10px; - font-size: 0.98rem; - letter-spacing: 0.02em; +body.ui-compact .menu-item-text { + font-size: 0.75rem; } -.server-actions-row { - display: flex; - flex-wrap: wrap; +body.ui-compact .menu-item-note { + font-size: 0.62rem; +} + +body.ui-compact .monitor-grid, +body.ui-compact .history-insights, +body.ui-compact .servers-layout, +body.ui-compact .config-layout, +body.ui-compact .history-layout { gap: 8px; - align-items: center; } -.server-grid label { - display: grid; - justify-items: center; - gap: 6px; - font-size: 0.88rem; - color: #34425c; +body.ui-compact .metric-title { + font-size: 0.68rem; } -.server-grid input, -.server-grid select { - border: 1px solid var(--line); - border-radius: 9px; - padding: 8px 10px; - font-size: 0.9rem; - background: #fff; - color: var(--text); - text-align: center; - font-family: inherit; - transition: border-color var(--anim-fast) ease, box-shadow var(--anim-fast) ease; +body.ui-compact .metric-value, +body.ui-compact .kpi-value { + font-size: 0.96rem; } -.server-grid input:focus, -.server-grid select:focus, -.editor:focus { - outline: none; - border-color: color-mix(in oklab, var(--accent), #ffffff 50%); - box-shadow: 0 0 0 3px rgba(36, 107, 255, 0.14); +body.ui-compact .history-table-wrap { + max-height: min(58dvh, 700px); +} + +body.ui-compact th, +body.ui-compact td { + padding: 7px 8px; + font-size: 0.84rem; } .bg-glow { @@ -1162,8 +2157,19 @@ tbody tr:hover { width: 100%; } + .menu-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + max-height: none; + overflow: visible; + } + + .menu-group-body { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .side-meta { - justify-content: center; + justify-items: center; } .timezone-picker { @@ -1174,6 +2180,30 @@ tbody tr:hover { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .overview-layout { + grid-template-columns: 1fr; + } + + .history-layout { + grid-template-columns: 1fr; + } + + .servers-layout { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .servers-layout-modern { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .config-layout { + grid-template-columns: 1fr; + } + + .config-layout-modern { + grid-template-columns: 1fr; + } + .overview-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1187,6 +2217,42 @@ tbody tr:hover { } } +@media (max-width: 980px) { + .menu-list { + grid-template-columns: 1fr; + max-height: min(60dvh, 620px); + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + } + + .monitor-grid { + grid-template-columns: 1fr; + } + + .monitor-kpi-panel { + grid-column: auto; + } + + .history-toolbar-right { + flex: 1 1 100%; + justify-content: stretch; + } + + .history-toolbar-right > * { + flex: 1 1 220px; + } + + .selector-row { + grid-template-columns: 1fr; + align-items: stretch; + } + + .field-grid-3 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (max-width: 760px) { .app-shell { padding: 8px 8px 14px; @@ -1196,19 +2262,75 @@ tbody tr:hover { grid-template-columns: 1fr; } + .servers-layout { + grid-template-columns: 1fr; + } + + .servers-layout-modern { + grid-template-columns: 1fr; + } + .card { padding: 14px; } .menu-item { - padding: 7px 10px; - font-size: 0.88rem; + min-height: 62px; + padding: 7px 9px 9px; + gap: 3px; + } + + .menu-list { + gap: 7px; + padding: 6px; + max-height: 62dvh; + overflow-y: auto; + } + + .menu-group { + padding: 6px; + } + + .menu-group-body { + grid-template-columns: 1fr; + } + + .menu-item-icon { + width: 22px; + height: 22px; + min-width: 22px; + font-size: 0.66rem; + } + + .menu-item[data-section="json"] .menu-item-icon { + font-size: 0.61rem; + } + + .menu-item-text { + font-size: 0.75rem; + } + + .menu-item-note { + font-size: 0.64rem; } .io-grid { grid-template-columns: 1fr; } + .field-grid-2, + .field-grid-3 { + grid-template-columns: 1fr; + } + + .action-group { + justify-content: stretch; + } + + .action-group .btn { + width: 100%; + } + .io-control-grid-compact { grid-template-columns: 1fr; } @@ -1229,6 +2351,36 @@ tbody tr:hover { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .history-layout { + grid-template-columns: 1fr; + } + + .monitor-headline { + justify-content: stretch; + } + + .monitor-headline .io-chip { + width: 100%; + justify-content: space-between; + } + + .frequency-health-meta, + .signal-item-meta, + .top-frequency-meta { + flex-direction: column; + align-items: flex-start; + } + + .trend-head { + flex-direction: column; + align-items: flex-start; + } + + .pipeline-stage-head { + flex-direction: column; + align-items: flex-start; + } + .overview-metrics { grid-template-columns: 1fr; } @@ -1242,6 +2394,29 @@ tbody tr:hover { min-width: 0; } + .meta-panel, + .date-time-panel, + .status-panel { + width: 100%; + } + + .config-layout { + grid-template-columns: 1fr; + } + + .config-layout-modern { + grid-template-columns: 1fr; + } + + .config-tips { + padding-left: 18px; + } + + .refresh-interval-control { + width: 100%; + min-width: 0; + } + .history-toolbar { align-items: stretch; } @@ -1254,6 +2429,10 @@ tbody tr:hover { width: 100%; } + .history-toolbar-right > * { + flex: 1 1 100%; + } + .history-toolbar-right { align-items: stretch; justify-content: stretch; @@ -1271,7 +2450,70 @@ tbody tr:hover { .history-table-wrap td:nth-child(3), .history-table-wrap td:nth-child(4) { - min-width: 240px; + min-width: 140px; + } + + .btn, + .hero-actions .btn, + .editor-actions .btn { + width: 100%; + } + + .hero-actions .badge, + .editor-actions .badge { + width: 100%; + } +} + +@media (max-width: 480px) { + .card { + border-radius: 14px; + padding: 12px; + } + + .hero h2 { + font-size: clamp(1.05rem, 4.8vw, 1.3rem); + } + + .kpi-value, + .metric-value { + font-size: 0.98rem; + } + + .menu-item { + min-height: 56px; + } + + .menu-item-text { + font-size: 0.72rem; + } + + .menu-item-note { + font-size: 0.6rem; + } + + .toast-container { + right: 8px; + left: 8px; + max-width: none; + } +} + +@media (max-height: 880px) { + .history-table-wrap { + max-height: min(64dvh, 760px); + } + + .frequency-health-list, + .signal-grid, + .top-frequencies { + max-height: min(34dvh, 360px); + } + + .menu-list { + max-height: min(34dvh, 360px); + overflow-y: auto; + overflow-x: hidden; } }