From e494215015a6ab4687caca8df37cbc8ff15e70ab Mon Sep 17 00:00:00 2001 From: AlexsandrSnytkin Date: Wed, 11 Mar 2026 17:56:00 +0700 Subject: [PATCH] Revisioning frontend part --- .idea/.gitignore | 8 + .idea/developer-tools.xml | 6 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/material_theme_project_new.xml | 17 + .idea/misc.xml | 4 + .idea/modules.xml | 8 + .idea/triangulation.iml | 11 + .idea/vcs.xml | 6 + Dockerfile | 2 +- README.md | 15 +- __pycache__/service.cpython-311.pyc | Bin 61063 -> 72603 bytes docker/mock_output_sink.py | 57 +- docker/mock_receiver.py | 65 ++ service.py | 236 +++++ web/app.js | 990 +++++++++++++++++- web/favicon.svg | 12 + web/index.html | 165 ++- web/styles.css | 831 ++++++++++++++- 18 files changed, 2351 insertions(+), 88 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/developer-tools.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/material_theme_project_new.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/triangulation.iml create mode 100644 .idea/vcs.xml create mode 100644 web/favicon.svg diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/developer-tools.xml b/.idea/developer-tools.xml new file mode 100644 index 0000000..5421cc5 --- /dev/null +++ b/.idea/developer-tools.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..d90d5bd --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..cab0bef --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..b670314 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/triangulation.iml b/.idea/triangulation.iml new file mode 100644 index 0000000..af51e41 --- /dev/null +++ b/.idea/triangulation.iml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 746eb73..5e15fe5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM python:3.12-slim WORKDIR /app diff --git a/README.md b/README.md index 618eb77..0f54caf 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ - Поддержка нескольких выходных серверов `runtime.output_servers[]` с настройкой по имени и IP. - Горячее применение нового конфига через `POST /config` (без ручного рестарта процесса). - Защита write-endpoints токеном (`runtime.write_api_token`). -- Русскоязычный UI (`/ui`) с вкладками: обзор, частоты, ресиверы, доставка, серверы, JSON-конфиг. +- Русскоязычный UI (`/ui`) с вкладками: обзор, частоты, вход/выход, история, серверы, JSON-конфиг. +- В тестовом docker-профиле: управляемые mock-сбои (пауза входной передачи/выходного приема) + короткие всплывающие подсказки (toast) в UI. - Интеграционные и юнит-тесты. ## Структура проекта @@ -112,7 +113,12 @@ python service.py --config config.json Что доступно: - обзор итоговой позиции, - таблица всех частотных решений, -- просмотр сырых данных ресиверов и статуса доставки, +- вкладка `Вход/Выход`: читаемые карточки входных и выходных данных + управление mock-сбоями, +- отдельная вкладка `История`: соответствие «вход -> выход», KPI по событиям, фильтр по статусу, ручная очистка истории, +- управление mock-сбоями в тестовом режиме: + - остановка/запуск передачи с входных mock-ресиверов, + - остановка/запуск приема на mock output-sink, + - короткие toast-уведомления об ошибках и изменении состояния, - настройка входных/выходных серверов (добавление/удаление, имена, URL, IP), - редактирование сырого JSON-конфига, - сохранение конфига в рантайме через API. @@ -127,6 +133,11 @@ python service.py --config config.json - `POST /refresh` — принудительное обновление. - `GET /config` — текущий конфиг (с редактированием секрета токена). - `POST /config` — валидация + применение + попытка сохранения в файл. +- `GET /mock/controls` — состояние mock-входов/mock-выходов (тестовый режим). +- `POST /mock/control` — переключение mock-потока: + - `target: "input" | "output"` + - `id: ""` + - `enabled: true|false` Полные примеры запросов/ответов: [docs/API.md](./docs/API.md). diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc index cd031ed3302db64599107678aadfd9c50b1289dd..90f5c1776b0fe750fcde3b3023a9c88111603aed 100644 GIT binary patch delta 13744 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 F{4Wtmkh=f? delta 4913 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 D%9Syx diff --git a/docker/mock_output_sink.py b/docker/mock_output_sink.py index 32e9970..a0d0d6a 100644 --- a/docker/mock_output_sink.py +++ b/docker/mock_output_sink.py @@ -10,13 +10,28 @@ def main() -> int: parser.add_argument("--port", type=int, default=8080) args = parser.parse_args() - latest = {"count": 0, "last_payload": None} + latest = {"count": 0, "last_payload": None, "accept_writes": True} class Handler(BaseHTTPRequestHandler): def log_message(self, format: str, *args2) -> None: return def do_GET(self) -> None: + if self.path == "/status": + raw = json.dumps( + { + "status": "ok", + "accept_writes": bool(latest["accept_writes"]), + "count": int(latest["count"]), + } + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + if self.path != "/latest": self.send_response(404) self.end_headers() @@ -29,11 +44,51 @@ def main() -> int: self.wfile.write(raw) def do_POST(self) -> None: + if self.path == "/control": + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) if content_length > 0 else b"{}" + try: + payload = json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + payload = {} + accept_writes = payload.get("accept_writes") + if not isinstance(accept_writes, bool): + raw = json.dumps( + {"status": "error", "error": "field 'accept_writes' must be boolean"} + ).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 + latest["accept_writes"] = accept_writes + raw = json.dumps( + {"status": "ok", "accept_writes": bool(latest["accept_writes"])} + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + if self.path != "/triangulation": self.send_response(404) self.end_headers() return + if not bool(latest["accept_writes"]): + raw = json.dumps( + {"status": "error", "error": "output sink receive is paused"} + ).encode("utf-8") + self.send_response(503) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + content_length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(content_length) if content_length > 0 else b"{}" payload = json.loads(body.decode("utf-8")) diff --git a/docker/mock_receiver.py b/docker/mock_receiver.py index 27510e1..b46341f 100644 --- a/docker/mock_receiver.py +++ b/docker/mock_receiver.py @@ -28,17 +28,47 @@ def main() -> int: parser.add_argument("--port", type=int, default=9000) parser.add_argument("--base-rssi", type=float, default=-62.0) args = parser.parse_args() + state = {"enabled": True} class Handler(BaseHTTPRequestHandler): def log_message(self, format: str, *args2) -> None: return def do_GET(self) -> None: + if self.path == "/status": + payload = { + "receiver_id": args.receiver_id, + "enabled": bool(state["enabled"]), + "status": "ok", + } + raw = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + if self.path != "/measurements": self.send_response(404) self.end_headers() return + if not bool(state["enabled"]): + raw = json.dumps( + { + "status": "error", + "error": "receiver transmission is paused", + "receiver_id": args.receiver_id, + } + ).encode("utf-8") + self.send_response(503) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + payload = _build_payload(args.receiver_id, args.base_rssi) raw = json.dumps(payload).encode("utf-8") self.send_response(200) @@ -47,6 +77,41 @@ def main() -> int: self.end_headers() self.wfile.write(raw) + def do_POST(self) -> None: + if self.path != "/control": + self.send_response(404) + self.end_headers() + return + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) if content_length > 0 else b"{}" + try: + 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") + 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["enabled"] = enabled + raw = json.dumps( + { + "status": "ok", + "receiver_id": args.receiver_id, + "enabled": bool(state["enabled"]), + } + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler) print(f"mock_receiver({args.receiver_id}) listening on :{args.port}") server.serve_forever() diff --git a/service.py b/service.py index 319b0bc..a392c30 100644 --- a/service.py +++ b/service.py @@ -424,6 +424,194 @@ def _fetch_measurements( raise RuntimeError(str(exc)) from None +def _parse_json_object(raw_text: str) -> Dict[str, object]: + if not raw_text.strip(): + return {} + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError: + return {"raw": raw_text} + if isinstance(parsed, dict): + return parsed + return {"value": parsed} + + +def _http_json_request( + url: str, + method: str = "GET", + payload: Optional[Dict[str, object]] = None, + timeout_s: float = 2.0, +) -> Tuple[int, Dict[str, object], str]: + headers = {"Accept": "application/json"} + body: Optional[bytes] = None + if payload is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = request.Request(url=url, method=method, headers=headers, data=body) + try: + with request.urlopen(req, timeout=timeout_s) as response: + text = response.read().decode("utf-8", errors="replace") + return int(response.status), _parse_json_object(text), "" + except error.HTTPError as exc: + text = exc.read().decode("utf-8", errors="replace") + return int(exc.code), _parse_json_object(text), "" + except Exception as exc: # pragma: no cover - network/IO branches + return 0, {}, str(exc) + + +def _receiver_control_urls(source_url: str) -> Tuple[str, str]: + parts = parse.urlsplit(source_url) + if parts.scheme not in ("http", "https") or not parts.netloc: + raise ValueError(f"Unsupported source URL: {source_url}") + control_url = parse.urlunsplit((parts.scheme, parts.netloc, "/control", "", "")) + status_url = parse.urlunsplit((parts.scheme, parts.netloc, "/status", "", "")) + return control_url, status_url + + +def _output_control_urls(output_server: Dict[str, object]) -> Tuple[str, str]: + ip = str(output_server.get("ip", "")).strip() + port = int(output_server.get("port", 8080)) + if not ip: + raise ValueError("Output server has empty ip.") + base = f"http://{ip}:{port}" + return f"{base}/control", f"{base}/status" + + +def _collect_mock_controls(service: "AutoService") -> Dict[str, object]: + inputs: List[Dict[str, object]] = [] + for receiver in service.receivers: + receiver_id = str(receiver.get("receiver_id", "")) + source_url = str(receiver.get("source_url", "")) + row: Dict[str, object] = { + "id": receiver_id, + "name": receiver_id, + "source_url": source_url, + "reachable": False, + "enabled": None, + "error": "", + } + try: + _, status_url = _receiver_control_urls(source_url) + status_code, payload, request_error = _http_json_request(status_url, timeout_s=1.5) + row["status_url"] = status_url + if request_error: + row["error"] = request_error + else: + row["reachable"] = status_code > 0 + enabled_value = payload.get("enabled") + if isinstance(enabled_value, bool): + row["enabled"] = enabled_value + if status_code >= 400: + row["error"] = str(payload.get("error", f"HTTP {status_code}")) + except Exception as exc: + row["error"] = str(exc) + inputs.append(row) + + outputs: List[Dict[str, object]] = [] + for output_server in service.output_servers: + name = str(output_server.get("name", "output")) + row = { + "id": name, + "name": name, + "reachable": False, + "accept_writes": None, + "error": "", + } + try: + _, status_url = _output_control_urls(output_server) + status_code, payload, request_error = _http_json_request(status_url, timeout_s=1.5) + row["status_url"] = status_url + if request_error: + row["error"] = request_error + else: + row["reachable"] = status_code > 0 + accept_value = payload.get("accept_writes") + if isinstance(accept_value, bool): + row["accept_writes"] = accept_value + if status_code >= 400: + row["error"] = str(payload.get("error", f"HTTP {status_code}")) + except Exception as exc: + row["error"] = str(exc) + outputs.append(row) + + return { + "status": "ok", + "inputs": inputs, + "outputs": outputs, + } + + +def _set_mock_control( + service: "AutoService", + target: str, + target_id: str, + enabled: bool, +) -> Dict[str, object]: + if target == "input": + receiver = next( + ( + row + for row in service.receivers + if str(row.get("receiver_id", "")) == target_id + ), + None, + ) + if receiver is None: + raise ValueError(f"Input receiver '{target_id}' not found.") + control_url, _ = _receiver_control_urls(str(receiver.get("source_url", ""))) + status_code, payload, request_error = _http_json_request( + control_url, + method="POST", + payload={"enabled": enabled}, + 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 "остановлена" + return { + "status": "ok", + "target": "input", + "id": target_id, + "enabled": enabled, + "message": f"Передача входных данных '{target_id}' {action}.", + } + + if target == "output": + output_server = next( + ( + row + for row in service.output_servers + if str(row.get("name", "")) == target_id + ), + None, + ) + if output_server is None: + raise ValueError(f"Output server '{target_id}' not found.") + control_url, _ = _output_control_urls(output_server) + status_code, payload, request_error = _http_json_request( + control_url, + method="POST", + payload={"accept_writes": enabled}, + 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 "остановлен" + return { + "status": "ok", + "target": "output", + "id": target_id, + "enabled": enabled, + "message": f"Приём на выходе '{target_id}' {action}.", + } + + raise ValueError("target must be 'input' or 'output'.") + + class AutoService: def __init__(self, config: Dict[str, object], config_path: Optional[str] = None) -> None: self.config = config @@ -1122,6 +1310,10 @@ def _make_handler(service: AutoService): ) return + if path == "/mock/controls": + self._write_json(200, _collect_mock_controls(service_obj)) + return + self._write_json(404, {"error": "not_found"}) def do_POST(self) -> None: @@ -1220,6 +1412,50 @@ def _make_handler(service: AutoService): ) return + if path == "/mock/control": + service_obj = self._current_service() + try: + content_length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self._write_json(400, {"status": "error", "error": "Invalid Content-Length"}) + return + body = self.rfile.read(content_length) if content_length > 0 else b"{}" + try: + payload = json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + self._write_json(400, {"status": "error", "error": "Invalid JSON"}) + return + if not isinstance(payload, dict): + self._write_json(400, {"status": "error", "error": "JSON body must be object"}) + return + target = str(payload.get("target", "")).strip().lower() + target_id = str(payload.get("id", "")).strip() + enabled_value = payload.get("enabled") + if target not in ("input", "output"): + self._write_json( + 400, + {"status": "error", "error": "target must be 'input' or 'output'"}, + ) + return + if not target_id: + self._write_json(400, {"status": "error", "error": "id is required"}) + return + if not isinstance(enabled_value, bool): + self._write_json(400, {"status": "error", "error": "enabled must be boolean"}) + return + try: + response = _set_mock_control( + service=service_obj, + target=target, + target_id=target_id, + enabled=enabled_value, + ) + except Exception as exc: + self._write_json(500, {"status": "error", "error": str(exc)}) + return + self._write_json(200, response) + return + if path != "/refresh": self._write_json(404, {"error": "not_found"}) return diff --git a/web/app.js b/web/app.js index dbd2a9d..ac17eaa 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,4 @@ -const state = { +const state = { result: null, frequencies: null, health: null, @@ -16,9 +16,42 @@ const state = { }, selectedOutputIndex: 0, outputDrafts: [], + menuCollapsed: false, + ioHistory: [], + mockControls: null, + historyFilter: "all", + historyPage: 1, + historyPageSize: 10, + historyDateFrom: "", + historyDateTo: "", + historyRecordingEnabled: true, + autoRefreshEnabled: true, + pollIntervalMs: 2000, + pollTimer: null, + initialized: false, + lastHealthStatus: "n/a", + lastDeliveryStatus: "n/a", + timezone: "local", }; const HZ_IN_MHZ = 1_000_000; +const MENU_COLLAPSED_STORAGE_KEY = "triangulation.menu_collapsed"; +const TIMEZONE_STORAGE_KEY = "triangulation.timezone"; +const IO_HISTORY_LIMIT = 60; +const TIMEZONE_OPTIONS = [ + { value: "local", label: "Локальный (браузер)" }, + { value: "UTC", label: "UTC" }, + { value: "Europe/Moscow", label: "Москва (Europe/Moscow)" }, + { value: "Asia/Novosibirsk", label: "Новосибирск (Asia/Novosibirsk)" }, + { value: "Asia/Yekaterinburg", label: "Екатеринбург (Asia/Yekaterinburg)" }, + { value: "Europe/London", label: "Лондон (Europe/London)" }, + { value: "Europe/Berlin", label: "Берлин (Europe/Berlin)" }, + { value: "Asia/Dubai", label: "Дубай (Asia/Dubai)" }, + { value: "Asia/Tokyo", label: "Токио (Asia/Tokyo)" }, + { value: "America/New_York", label: "Нью-Йорк (America/New_York)" }, + { value: "America/Chicago", label: "Чикаго (America/Chicago)" }, + { value: "America/Los_Angeles", label: "Лос-Анджелес (America/Los_Angeles)" }, +]; function byId(id) { return document.getElementById(id); @@ -70,6 +103,8 @@ function localizeErrorMessage(message) { warming_up: "прогрев", not_found: "не найдено", "no data yet": "данные пока не получены", + "receiver transmission is paused": "передача входных данных остановлена", + "output sink receive is paused": "приём на выходном сервере остановлен", }; if (known[text]) return known[text]; if (text.startsWith("HTTP ")) return `ошибка HTTP: ${text.slice(5)}`; @@ -84,14 +119,33 @@ function formatUpdatedTimestamp(value) { return { date: "дата: н/д", time: "время: н/д" }; } const text = String(value); - const match = text.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:\d{2})?$/); - if (!match) { + const dateObj = new Date(text); + if (Number.isNaN(dateObj.getTime())) { return { date: `дата: ${text}`, time: "время: н/д" }; } - const datePart = `${match[3]}.${match[2]}.${match[1]}`; - const zone = match[5] || ""; - const zoneLabel = zone === "Z" || zone === "+00:00" ? " UTC" : zone ? ` ${zone}` : ""; - return { date: `дата: ${datePart}`, time: `время: ${match[4]}${zoneLabel}` }; + + const zone = selectedTimeZoneValue(); + const dateOptions = { year: "numeric", month: "2-digit", day: "2-digit" }; + const timeOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }; + if (zone) { + dateOptions.timeZone = zone; + timeOptions.timeZone = zone; + } + + let datePart = ""; + let timePart = ""; + try { + datePart = new Intl.DateTimeFormat("ru-RU", dateOptions).format(dateObj); + timePart = new Intl.DateTimeFormat("ru-RU", timeOptions).format(dateObj); + } catch { + datePart = new Intl.DateTimeFormat("ru-RU", { year: "numeric", month: "2-digit", day: "2-digit" }).format(dateObj); + timePart = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(dateObj); + } + + return { + date: `дата: ${datePart}`, + time: `время: ${timePart} (${selectedTimeZoneLabel()})`, + }; } function hzToMhz(value) { @@ -139,8 +193,778 @@ function setActiveSection(section) { }); } -function setMenuOpen(isOpen) { - byId("menu-list").classList.toggle("menu-list-open", isOpen); +function setMenuCollapsed(isCollapsed) { + state.menuCollapsed = Boolean(isCollapsed); + const sideNav = byId("side-nav"); + const toggle = byId("menu-toggle"); + + if (sideNav) { + sideNav.classList.toggle("menu-collapsed", state.menuCollapsed); + } + if (toggle) { + toggle.textContent = state.menuCollapsed ? "Развернуть меню" : "Свернуть меню"; + toggle.setAttribute("aria-expanded", String(!state.menuCollapsed)); + } + + try { + localStorage.setItem(MENU_COLLAPSED_STORAGE_KEY, state.menuCollapsed ? "1" : "0"); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function readMenuCollapsed() { + try { + return localStorage.getItem(MENU_COLLAPSED_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +function browserTimeZone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; + } catch { + return ""; + } +} + +function isKnownTimeZone(value) { + return TIMEZONE_OPTIONS.some((option) => option.value === value); +} + +function readTimeZonePreference() { + try { + const value = localStorage.getItem(TIMEZONE_STORAGE_KEY) || "local"; + return isKnownTimeZone(value) ? value : "local"; + } catch { + return "local"; + } +} + +function saveTimeZonePreference(value) { + try { + localStorage.setItem(TIMEZONE_STORAGE_KEY, value); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function selectedTimeZoneValue() { + return state.timezone === "local" ? null : state.timezone; +} + +function selectedTimeZoneLabel() { + if (state.timezone === "local") { + const zone = browserTimeZone(); + return zone ? `локальный: ${zone}` : "локальный"; + } + const option = TIMEZONE_OPTIONS.find((item) => item.value === state.timezone); + return option?.label || state.timezone; +} + +function fillTimeZoneSelect() { + const select = byId("timezone-select"); + if (!select) return; + select.innerHTML = ""; + TIMEZONE_OPTIONS.forEach((option) => { + const element = document.createElement("option"); + element.value = option.value; + element.textContent = option.label; + select.appendChild(element); + }); +} + +function setTimeZone(value) { + const next = isKnownTimeZone(value) ? value : "local"; + state.timezone = next; + saveTimeZonePreference(next); + const select = byId("timezone-select"); + if (select && select.value !== next) { + select.value = next; + } +} + +function parseDateTimeInput(value) { + const text = String(value || "").trim(); + if (!text) return null; + const parsed = new Date(text); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.getTime(); +} + +function updateHistoryRecordingUi() { + const toggle = byId("history-record-toggle"); + if (toggle) { + toggle.textContent = state.historyRecordingEnabled ? "Пауза записи" : "Продолжить запись"; + } + setTextWithPulse( + "history-record-state", + `запись: ${state.historyRecordingEnabled ? "вкл" : "пауза"}` + ); +} + +function stopPolling() { + if (state.pollTimer) { + clearInterval(state.pollTimer); + state.pollTimer = null; + } +} + +function pollTick() { + loadAll().catch((err) => { + showToast(`Ошибка обновления: ${localizeErrorMessage(err.message)}`, "error"); + }); +} + +function startPolling() { + stopPolling(); + if (!state.autoRefreshEnabled) return; + state.pollTimer = setInterval(pollTick, state.pollIntervalMs); +} + +function updateRefreshUi() { + const button = byId("toggle-auto-refresh"); + if (button) { + button.textContent = state.autoRefreshEnabled ? "Пауза автообновления" : "Запустить автообновление"; + } + const suffix = `${Math.round(state.pollIntervalMs / 1000)}с`; + setTextWithPulse( + "refresh-state", + `автообновление: ${state.autoRefreshEnabled ? "вкл" : "выкл"} (${suffix})` + ); +} + +function setAutoRefreshEnabled(enabled) { + state.autoRefreshEnabled = Boolean(enabled); + updateRefreshUi(); + if (state.autoRefreshEnabled) { + startPolling(); + } else { + stopPolling(); + } +} + +function showToast(message, kind = "info") { + const root = byId("toast-container"); + if (!root) return; + const toast = document.createElement("div"); + toast.className = `toast toast-${kind}`; + toast.textContent = String(message || ""); + root.appendChild(toast); + requestAnimationFrame(() => toast.classList.add("toast-show")); + setTimeout(() => { + toast.classList.remove("toast-show"); + setTimeout(() => toast.remove(), 220); + }, 2600); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function statusClass(statusValue) { + const status = String(statusValue || "n/a"); + const mapping = { + ok: "io-status-ok", + error: "io-status-error", + partial: "io-status-partial", + skipped: "io-status-skipped", + disabled: "io-status-disabled", + warming_up: "io-status-warm", + "n/a": "io-status-na", + n_a: "io-status-na", + }; + return mapping[status] || "io-status-na"; +} + +function fmtDateTimeCompact(value) { + const parts = formatUpdatedTimestamp(value); + const date = parts.date.replace(/^дата:\s*/i, ""); + const time = parts.time.replace(/^время:\s*/i, ""); + return `${date} ${time}`.trim(); +} + +function findByFrequency(rows, frequencyHz) { + if (!Array.isArray(rows) || !Number.isFinite(Number(frequencyHz))) return null; + const target = Number(frequencyHz); + return ( + rows.find((row) => Math.abs(Number(row?.frequency_hz) - target) <= 1) || + rows.find((row) => Number(row?.frequency_hz) === target) || + null + ); +} + +function renderInputFlow(data) { + const root = byId("input-flow"); + if (!root) return; + const receivers = Array.isArray(data?.receivers) ? data.receivers : []; + if (receivers.length === 0) { + root.innerHTML = '
Ожидание входных измерений от ресиверов.
'; + return; + } + + root.innerHTML = receivers + .map((receiver) => { + const receiverId = escapeHtml(receiver?.receiver_id || "n/a"); + const sourceUrl = escapeHtml(receiver?.source_url || "-"); + const center = receiver?.center || {}; + const samples = Array.isArray(receiver?.samples) ? receiver.samples : []; + const perFrequency = Array.isArray(receiver?.per_frequency) ? receiver.per_frequency : []; + + const sampleRowsHtml = + samples.length === 0 + ? 'Нет сэмплов' + : samples + .map( + (sample) => ` + + ${fmt(sample?.frequency_mhz ?? hzToMhz(sample?.frequency_hz), 3)} + ${fmt(sample?.amplitude_dbm, 1)} + ${fmt(sample?.distance_m, 2)} + ` + ) + .join(""); + + const perFrequencyHtml = + perFrequency.length === 0 + ? 'нет расчётов по частотам' + : perFrequency + .map( + (row) => ` + + ${fmt(row?.frequency_mhz ?? hzToMhz(row?.frequency_hz), 3)} МГц: + R=${fmt(row?.radius_m, 2)} м, ε=${fmt(row?.residual_m, 2)} м + ` + ) + .join(""); + + return ` +
+
+

${receiverId}

+ + ${fmt(receiver?.filtered_samples_count, 0)}/${fmt(receiver?.raw_samples_count, 0)} сэмплов + +
+
+
Источник: ${sourceUrl}
+
Центр: X=${fmt(center?.x, 3)} Y=${fmt(center?.y, 3)} Z=${fmt(center?.z, 3)}
+
Радиус (все частоты): ${fmt(receiver?.radius_m_all_freq, 2)} м
+
+
+ + + + + + + + + ${sampleRowsHtml} +
Частота, МГцRSSI, дБмДистанция, м
+
+
${perFrequencyHtml}
+
`; + }) + .join(""); +} + +function renderOutputFlow(data, delivery) { + const root = byId("output-flow"); + if (!root) return; + + const servers = Array.isArray(delivery?.servers) ? delivery.servers : []; + const selectedFrequencyMhz = data?.selected_frequency_mhz ?? hzToMhz(data?.selected_frequency_hz); + const pos = data?.position || {}; + const payloadPreview = ` +
+
+

Формируемый выходной пакет

+ ${escapeHtml(localizeStatus(delivery?.status))} +
+
+
Время расчёта: ${escapeHtml(fmtDateTimeCompact(data?.timestamp_utc || delivery?.sent_at_utc))}
+
Частота: ${fmt(selectedFrequencyMhz, 3)} МГц
+
Координаты: X=${fmt(pos?.x, 3)} Y=${fmt(pos?.y, 3)} Z=${fmt(pos?.z, 3)}
+
+
+ `; + + if (servers.length === 0) { + root.innerHTML = + payloadPreview + + '
Нет настроенных выходных серверов или отправка ещё не выполнялась.
'; + return; + } + + const serversHtml = servers + .map((server) => { + const status = localizeStatus(server?.status); + const statusCls = statusClass(server?.status); + const target = server?.target || {}; + const endpoint = `${target?.ip || "-"}:${target?.port || "-"}${target?.path || ""}`; + const responseRaw = String(server?.response_body || "").trim() || "-"; + const responseShort = + responseRaw.length > 160 ? `${responseRaw.slice(0, 157)}...` : responseRaw; + return ` +
+
+

${escapeHtml(server?.name || "output")}

+ ${escapeHtml(status)} +
+
+
Назначение: ${escapeHtml(endpoint)}
+
HTTP: ${escapeHtml(server?.http_status ?? "-")}
+
Время отправки: ${escapeHtml(fmtDateTimeCompact(server?.sent_at_utc || delivery?.sent_at_utc))}
+
Ответ: ${escapeHtml(responseShort)}
+
+
`; + }) + .join(""); + + root.innerHTML = payloadPreview + serversHtml; +} + +function buildIoHistoryRow(data, delivery) { + if (!data) return null; + const selectedHz = Number(data?.selected_frequency_hz); + 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 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 radius = perFrequency?.radius_m ?? sample?.distance_m; + return `${receiverId}: ${fmt(rssi, 1)} dBm / ${fmt(radius, 2)} m`; + }); + + const outputItems = (() => { + if (servers.length === 0) { + const pos = data?.position || {}; + return [`X=${fmt(pos?.x, 3)} Y=${fmt(pos?.y, 3)} Z=${fmt(pos?.z, 3)}`]; + } + return servers.map((server) => { + const name = String(server?.name || "output"); + const status = localizeStatus(server?.status); + const code = server?.http_status ?? "-"; + return `${name}: ${status} (${code})`; + }); + })(); + + const inputSummary = inputItems.join(" | "); + 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}`; + return { + key, + timestamp, + frequencyMhz: selectedMhz, + inputItems, + outputItems, + inputSummary, + outputSummary, + statusRaw, + }; +} +function appendIoHistory(data, delivery) { + if (!state.historyRecordingEnabled) return; + const row = buildIoHistoryRow(data, delivery); + if (!row) return; + if (state.ioHistory.some((existing) => existing.key === row.key)) return; + state.ioHistory.unshift(row); + if (state.ioHistory.length > IO_HISTORY_LIMIT) { + state.ioHistory.length = IO_HISTORY_LIMIT; + } +} + +function statusIsProblem(statusRaw) { + return !["ok"].includes(String(statusRaw || "").toLowerCase()); +} + +function toPercent(part, total) { + if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) return 0; + return Math.max(0, Math.min(100, Math.round((part / total) * 100))); +} + +function renderOverviewMetrics() { + 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); + + setTextWithPulse("ov-input-online", `${inputOnline}/${inputs.length}`); + setTextWithPulse("ov-output-online", `${outputOnline}/${outputs.length}`); + setTextWithPulse("ov-history-total", total); + setTextWithPulse("ov-success-rate", `${success}%`); +} + +function renderHistoryInsights() { + const feedRoot = byId("history-feed"); + const monitorRoot = byId("history-monitor"); + + 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 (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 total = state.ioHistory.length; + const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length; + const problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length; + const success = toPercent(okCount, total); + + const healthStatus = localizeStatus(state.health?.status); + const deliveryStatus = localizeStatus( + state.result?.output_delivery?.status || state.frequencies?.output_delivery?.status + ); + + monitorRoot.innerHTML = ` +
Сервис${escapeHtml(healthStatus)}
+
Доставка${escapeHtml(deliveryStatus)}
+
Входы online${inputOnline}/${inputs.length}
+
Выходы online${outputOnline}/${outputs.length}
+
Проблемных событий${problemCount}
+
Успех доставки${success}%
+
+ `; + } +} + +function maybeNotifyStatusChanges(delivery) { + const healthNow = String(state.health?.status || "n/a").toLowerCase(); + const deliveryNow = String(delivery?.status || "n/a").toLowerCase(); + + if (!state.initialized) { + state.initialized = true; + state.lastHealthStatus = healthNow; + state.lastDeliveryStatus = deliveryNow; + return; + } + + if (healthNow !== state.lastHealthStatus) { + if (healthNow === "error") { + showToast("Сервис перешел в состояние ошибки.", "error"); + } else if (state.lastHealthStatus === "error" && healthNow === "ok") { + showToast("Сервис восстановлен.", "success"); + } + state.lastHealthStatus = healthNow; + } + + if (deliveryNow !== state.lastDeliveryStatus) { + if (deliveryNow === "error") { + showToast("Проблема с отправкой на выходной сервер.", "error"); + } else if (state.lastDeliveryStatus === "error" && deliveryNow === "ok") { + showToast("Отправка на выход восстановлена.", "success"); + } else if (deliveryNow === "partial") { + showToast("Частичная отправка: проверьте состояние выходных серверов.", "info"); + } + state.lastDeliveryStatus = deliveryNow; + } +} + +function renderHistorySummary() { + const total = state.ioHistory.length; + const okCount = state.ioHistory.filter((row) => String(row.statusRaw).toLowerCase() === "ok").length; + const problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length; + const uniqueFreqCount = new Set( + state.ioHistory + .map((row) => Number(row.frequencyMhz)) + .filter((value) => Number.isFinite(value)) + .map((value) => value.toFixed(3)) + ).size; + const lastTimestamp = state.ioHistory[0]?.timestamp || ""; + + setTextWithPulse("hist-total", total); + setTextWithPulse("hist-ok", okCount); + setTextWithPulse("hist-problem", problemCount); + setTextWithPulse("hist-freqs", uniqueFreqCount); + setTextWithPulse("hist-last", lastTimestamp ? fmtDateTimeCompact(lastTimestamp) : "н/д"); +} + +function renderHistoryPagination(totalRows, totalPages, fromIndex, toIndex) { + const pageInfo = byId("history-page-info"); + const prevBtn = byId("history-prev"); + const nextBtn = byId("history-next"); + const pageSizeSelect = byId("history-page-size"); + + if (pageSizeSelect && String(pageSizeSelect.value) !== String(state.historyPageSize)) { + pageSizeSelect.value = String(state.historyPageSize); + } + if (prevBtn) { + prevBtn.disabled = state.historyPage <= 1 || totalRows === 0; + } + if (nextBtn) { + nextBtn.disabled = state.historyPage >= totalPages || totalRows === 0; + } + if (pageInfo) { + if (totalRows === 0) { + pageInfo.textContent = "Стр. 1/1 • 0 записей"; + } else { + pageInfo.textContent = `Стр. ${state.historyPage}/${totalPages} • ${fromIndex}-${toIndex} из ${totalRows}`; + } + } +} + +function renderIoHistory() { + const tbody = byId("io-history-table")?.querySelector("tbody"); + if (!tbody) return; + updateHistoryRecordingUi(); + renderHistorySummary(); + renderHistoryInsights(); + + const filteredByStatus = state.historyFilter === "all" + ? state.ioHistory + : state.ioHistory.filter( + (row) => String(row.statusRaw || "").toLowerCase() === state.historyFilter + ); + const fromMsRaw = parseDateTimeInput(state.historyDateFrom); + const toMsRaw = parseDateTimeInput(state.historyDateTo); + const hasDateFilter = fromMsRaw !== null || toMsRaw !== null; + let fromMs = fromMsRaw; + let toMs = toMsRaw; + if (fromMs !== null && toMs !== null && fromMs > toMs) { + const temp = fromMs; + fromMs = toMs; + toMs = temp; + } + + const filtered = filteredByStatus.filter((row) => { + if (!hasDateFilter) return true; + const rowMs = Date.parse(String(row.timestamp || "")); + if (!Number.isFinite(rowMs)) return false; + if (fromMs !== null && rowMs < fromMs) return false; + if (toMs !== null && rowMs > toMs) return false; + return true; + }); + + const pageSize = Math.max(1, Number(state.historyPageSize) || 10); + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + if (state.historyPage > totalPages) { + state.historyPage = totalPages; + } + if (state.historyPage < 1) { + state.historyPage = 1; + } + + if (filtered.length === 0) { + let text = "История пока пуста."; + if (state.ioHistory.length > 0) { + if (hasDateFilter && state.historyFilter !== "all") { + text = "По статусу и диапазону времени записей нет."; + } else if (hasDateFilter) { + text = "По выбранному диапазону времени записей нет."; + } else { + text = "По выбранному статусу записей нет."; + } + } + tbody.innerHTML = `${text}`; + renderHistoryPagination(0, 1, 0, 0); + return; + } + + const start = (state.historyPage - 1) * pageSize; + const pageRows = filtered.slice(start, start + pageSize); + const fromIndex = start + 1; + const toIndex = start + pageRows.length; + + tbody.innerHTML = pageRows + .map( + (row) => ` + + ${escapeHtml(fmtDateTimeCompact(row.timestamp))} + ${escapeHtml(fmt(row.frequencyMhz, 3))} + +
+ ${(Array.isArray(row.inputItems) ? row.inputItems : String(row.inputSummary || "").split(" | ")) + .filter((item) => String(item || "").trim().length > 0) + .map((item) => `
${escapeHtml(item)}
`) + .join("")} +
+ + +
+ ${(Array.isArray(row.outputItems) ? row.outputItems : String(row.outputSummary || "").split("; ")) + .filter((item) => String(item || "").trim().length > 0) + .map((item) => `
${escapeHtml(item)}
`) + .join("")} +
+ + + + ${escapeHtml(localizeStatus(row.statusRaw))} + + + ` + ) + .join(""); + + renderHistoryPagination(filtered.length, totalPages, fromIndex, toIndex); +} + +function boolStateLabel(value, trueLabel, falseLabel) { + if (value === true) return trueLabel; + if (value === false) return falseLabel; + return "состояние неизвестно"; +} + +function buildControlCardHtml(item, target) { + const id = String(item?.id || ""); + const name = escapeHtml(item?.name || id || "n/a"); + const reachable = Boolean(item?.reachable); + const errorText = item?.error ? escapeHtml(item.error) : ""; + const stateValue = target === "input" ? item?.enabled : item?.accept_writes; + const isActive = stateValue === true; + const nextEnabled = !isActive; + const flowLabel = target === "input" ? "входной поток" : "выходной поток"; + const statusText = + target === "input" + ? boolStateLabel(stateValue, "передача активна", "передача остановлена") + : boolStateLabel(stateValue, "приём активен", "приём остановлен"); + const buttonText = + target === "input" + ? isActive + ? "Пауза входа" + : "Запустить вход" + : isActive + ? "Пауза выхода" + : "Запустить выход"; + const statusKind = + !reachable && stateValue === null ? "io-status-error" : isActive ? "io-status-ok" : "io-status-skipped"; + const reachabilityKind = reachable ? "io-status-ok" : "io-status-error"; + const reachabilityText = reachable ? "online" : "offline"; + const disabledAttr = !id ? "disabled" : ""; + + return ` +
+
+
+

${name}

+
${flowLabel}
+
+
+ ${reachabilityText} + ${escapeHtml(statusText)} +
+
+ ${errorText ? `
Ошибка: ${errorText}
` : ""} +
+ +
+
+ `; +} + +function bindControlButtons() { + document.querySelectorAll(".flow-toggle-btn").forEach((button) => { + button.addEventListener("click", async () => { + const target = button.getAttribute("data-target") || ""; + const id = button.getAttribute("data-id") || ""; + const nextEnabled = button.getAttribute("data-next-enabled") === "1"; + if (!target || !id) return; + button.disabled = true; + try { + const response = await postJson("/mock/control", { + target, + id, + enabled: nextEnabled, + }); + showToast(response.message || "Состояние потока обновлено.", "success"); + } catch (err) { + showToast(localizeErrorMessage(err.message), "error"); + } finally { + await loadAll(); + } + }); + }); +} + +function renderErrorControls() { + const root = byId("error-controls"); + if (!root) return; + const controls = state.mockControls; + if (!controls) { + root.innerHTML = '
Управление потоками недоступно.
'; + return; + } + const inputs = Array.isArray(controls?.inputs) ? controls.inputs : []; + const outputs = Array.isArray(controls?.outputs) ? controls.outputs : []; + const inputActiveCount = inputs.filter((item) => item?.enabled === true).length; + const outputActiveCount = outputs.filter((item) => item?.accept_writes === true).length; + + const inputHtml = + inputs.length === 0 + ? '
Входные источники не обнаружены.
' + : inputs.map((item) => buildControlCardHtml(item, "input")).join(""); + + const outputHtml = + outputs.length === 0 + ? '
Выходные серверы не обнаружены.
' + : outputs.map((item) => buildControlCardHtml(item, "output")).join(""); + + root.innerHTML = ` +
+
+
+

Входные Потоки

+ ${inputActiveCount}/${inputs.length} активны +
+
${inputHtml}
+
+
+
+

Выходные Потоки

+ ${outputActiveCount}/${outputs.length} активны +
+
${outputHtml}
+
+
+ `; + bindControlButtons(); } function normalizeInputFilter(filter) { @@ -352,6 +1176,8 @@ function render() { setTextWithPulse("updated-time", updated.time); setTextWithPulse("health-status", `состояние: ${localizeStatus(state.health?.status)}`); setTextWithPulse("delivery-status", `доставка: ${localizeStatus(delivery?.status)}`); + renderOverviewMetrics(); + maybeNotifyStatusChanges(delivery || {}); if (!data) { setTextWithPulse("selected-freq", "-"); @@ -359,8 +1185,10 @@ function render() { setTextWithPulse("pos-y", "-"); setTextWithPulse("pos-z", "-"); setTextWithPulse("rmse", "-"); - byId("receivers-list").textContent = "Нет данных"; - byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2); + renderInputFlow(null); + renderOutputFlow(null, delivery || {}); + renderIoHistory(); + renderErrorControls(); byId("freq-table").querySelector("tbody").innerHTML = ""; return; } @@ -372,9 +1200,11 @@ function render() { setTextWithPulse("pos-z", fmt(data.position?.z)); setTextWithPulse("rmse", fmt(data.rmse_m)); - const receivers = data.receivers || []; - byId("receivers-list").textContent = JSON.stringify(receivers, null, 2); - byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2); + renderInputFlow(data); + renderOutputFlow(data, delivery || {}); + appendIoHistory(data, delivery || {}); + renderIoHistory(); + renderErrorControls(); const rows = data.frequency_table || []; const tbody = byId("freq-table").querySelector("tbody"); @@ -394,14 +1224,16 @@ function render() { } async function loadAll() { - const [healthRes, resultRes, freqRes] = await Promise.allSettled([ + const [healthRes, resultRes, freqRes, controlsRes] = await Promise.allSettled([ getJson("/health"), getJson("/result"), getJson("/frequencies"), + getJson("/mock/controls"), ]); state.health = healthRes.status === "fulfilled" ? healthRes.value : { status: "error" }; state.result = resultRes.status === "fulfilled" ? resultRes.value : null; state.frequencies = freqRes.status === "fulfilled" ? freqRes.value : null; + state.mockControls = controlsRes.status === "fulfilled" ? controlsRes.value : null; render(); } @@ -542,6 +1374,21 @@ async function saveServers() { function bindUi() { byId("refresh-now").addEventListener("click", refreshNow); + const timezoneSelect = byId("timezone-select"); + if (timezoneSelect) { + timezoneSelect.addEventListener("change", (event) => { + setTimeZone(String(event.target.value || "local")); + render(); + showToast("Часовой пояс обновлён.", "info"); + }); + } + byId("toggle-auto-refresh").addEventListener("click", () => { + setAutoRefreshEnabled(!state.autoRefreshEnabled); + showToast( + state.autoRefreshEnabled ? "Автообновление включено." : "Автообновление остановлено.", + "info" + ); + }); byId("load-config").addEventListener("click", loadConfig); byId("save-config").addEventListener("click", saveConfig); byId("load-servers").addEventListener("click", loadConfig); @@ -568,35 +1415,122 @@ function bindUi() { }); byId("menu-toggle").addEventListener("click", () => { - const open = !byId("menu-list").classList.contains("menu-list-open"); - setMenuOpen(open); + setMenuCollapsed(!state.menuCollapsed); }); + const historyFilter = byId("history-filter"); + if (historyFilter) { + historyFilter.addEventListener("change", (event) => { + const next = String(event.target.value || "all").toLowerCase(); + state.historyFilter = next || "all"; + state.historyPage = 1; + renderIoHistory(); + }); + } + + const historyDateFrom = byId("history-date-from"); + if (historyDateFrom) { + historyDateFrom.addEventListener("change", (event) => { + state.historyDateFrom = String(event.target.value || ""); + state.historyPage = 1; + renderIoHistory(); + }); + } + + const historyDateTo = byId("history-date-to"); + if (historyDateTo) { + historyDateTo.addEventListener("change", (event) => { + state.historyDateTo = String(event.target.value || ""); + state.historyPage = 1; + renderIoHistory(); + }); + } + + const historyDateReset = byId("history-date-reset"); + if (historyDateReset) { + historyDateReset.addEventListener("click", () => { + state.historyDateFrom = ""; + state.historyDateTo = ""; + if (historyDateFrom) historyDateFrom.value = ""; + if (historyDateTo) historyDateTo.value = ""; + state.historyPage = 1; + renderIoHistory(); + showToast("Фильтр времени сброшен.", "info"); + }); + } + + const historyPageSize = byId("history-page-size"); + if (historyPageSize) { + historyPageSize.addEventListener("change", (event) => { + const nextSize = Number(event.target.value); + state.historyPageSize = Number.isFinite(nextSize) && nextSize > 0 ? nextSize : 10; + state.historyPage = 1; + renderIoHistory(); + }); + } + + const historyRecordToggle = byId("history-record-toggle"); + if (historyRecordToggle) { + historyRecordToggle.addEventListener("click", () => { + state.historyRecordingEnabled = !state.historyRecordingEnabled; + updateHistoryRecordingUi(); + showToast( + state.historyRecordingEnabled ? "Запись истории продолжена." : "Запись истории остановлена.", + "info" + ); + }); + } + + const historyPrev = byId("history-prev"); + if (historyPrev) { + historyPrev.addEventListener("click", () => { + if (state.historyPage > 1) { + state.historyPage -= 1; + renderIoHistory(); + } + }); + } + + const historyNext = byId("history-next"); + if (historyNext) { + historyNext.addEventListener("click", () => { + state.historyPage += 1; + renderIoHistory(); + }); + } + + const clearHistory = byId("clear-history"); + if (clearHistory) { + clearHistory.addEventListener("click", () => { + state.ioHistory = []; + state.historyPage = 1; + renderIoHistory(); + showToast("История очищена.", "success"); + }); + } + document.querySelectorAll(".menu-item").forEach((item) => { item.addEventListener("click", () => { setActiveSection(item.dataset.section); - setMenuOpen(false); }); }); - - document.addEventListener("click", (event) => { - const target = event.target; - if (!(target instanceof Element)) return; - if (target.closest("#menu-toggle") || target.closest("#menu-list")) { - return; - } - setMenuOpen(false); - }); } async function boot() { + fillTimeZoneSelect(); + setTimeZone(readTimeZonePreference()); bindUi(); + updateHistoryRecordingUi(); + setMenuCollapsed(readMenuCollapsed()); + updateRefreshUi(); setActiveSection(state.activeSection); await loadConfig(); await loadAll(); - setInterval(loadAll, 2000); + startPolling(); } boot().catch((err) => { setTextWithPulse("health-status", `состояние: ${localizeErrorMessage(err.message)}`); }); + + diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..002f41a --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/web/index.html b/web/index.html index 247a591..c01cfe1 100644 --- a/web/index.html +++ b/web/index.html @@ -1,27 +1,28 @@ - + Панель Триангуляции +
-
-