"`
+ - `enabled: true|false`
Полные примеры запросов/ответов: [docs/API.md](./docs/API.md).
diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc
index cd031ed..90f5c17 100644
Binary files a/__pycache__/service.cpython-311.pyc and b/__pycache__/service.cpython-311.pyc differ
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)} сэмплов
+
+
+
+
+
+
+
+ | Частота, МГц |
+ RSSI, дБм |
+ Дистанция, м |
+
+
+ ${sampleRowsHtml}
+
+
+ ${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))}
+
+
+
+ `;
+
+ 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)}
+
+
+ `;
+ })
+ .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 @@
-
+
Панель Триангуляции
+
-
-