Adaptive web interface

main
AlexsandrSnytkin 2 months ago
parent e494215015
commit 8da0a00f3a

@ -15,6 +15,7 @@
"listen_host": "0.0.0.0",
"listen_port": 8081,
"poll_interval_s": 1.0,
"mock_input_frequency_sync": false,
"write_api_token": "",
"output_servers": [
{

@ -15,6 +15,7 @@
"listen_host": "0.0.0.0",
"listen_port": 8081,
"poll_interval_s": 1.0,
"mock_input_frequency_sync": true,
"write_api_token": "",
"output_servers": [
{

@ -8,13 +8,42 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Dict, List
def _build_payload(receiver_id: str, base_rssi: float) -> Dict[str, object]:
noise_a = random.uniform(-1.2, 1.2)
noise_b = random.uniform(-1.2, 1.2)
rows: List[Dict[str, float]] = [
{"f_mhz": 433.920, "rssi": base_rssi + noise_a},
{"f_mhz": 868.100, "rssi": base_rssi - 4.0 + noise_b},
]
def _parse_frequency_list(raw_value: str) -> List[float]:
values: List[float] = []
for item in str(raw_value).split(","):
text = item.strip()
if not text:
continue
try:
value = float(text)
except ValueError as exc:
raise ValueError(f"invalid frequency value '{text}'") from exc
if value <= 0.0:
raise ValueError(f"frequency must be > 0, got {value}")
values.append(round(value, 6))
if not values:
raise ValueError("at least one frequency is required")
deduplicated: List[float] = []
seen = set()
for value in values:
key = round(value, 6)
if key in seen:
continue
seen.add(key)
deduplicated.append(value)
return deduplicated
def _build_payload(
receiver_id: str,
base_rssi: float,
frequencies_mhz: List[float],
) -> Dict[str, object]:
rows: List[Dict[str, float]] = []
for index, frequency_mhz in enumerate(frequencies_mhz):
noise = random.uniform(-1.2, 1.2)
offset = -2.2 * index
rows.append({"f_mhz": round(float(frequency_mhz), 6), "rssi": base_rssi + offset + noise})
return {
"receiver_id": receiver_id,
"timestamp_unix": time.time(),
@ -27,8 +56,12 @@ def main() -> int:
parser.add_argument("--receiver-id", required=True)
parser.add_argument("--port", type=int, default=9000)
parser.add_argument("--base-rssi", type=float, default=-62.0)
parser.add_argument("--frequencies-mhz", type=str, default="433.92,868.1")
args = parser.parse_args()
state = {"enabled": True}
state = {
"enabled": True,
"frequencies_mhz": _parse_frequency_list(args.frequencies_mhz),
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args2) -> None:
@ -39,6 +72,7 @@ def main() -> int:
payload = {
"receiver_id": args.receiver_id,
"enabled": bool(state["enabled"]),
"frequencies_mhz": list(state["frequencies_mhz"]),
"status": "ok",
}
raw = json.dumps(payload).encode("utf-8")
@ -69,7 +103,11 @@ def main() -> int:
self.wfile.write(raw)
return
payload = _build_payload(args.receiver_id, args.base_rssi)
payload = _build_payload(
receiver_id=args.receiver_id,
base_rssi=args.base_rssi,
frequencies_mhz=list(state["frequencies_mhz"]),
)
raw = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
@ -88,9 +126,18 @@ def main() -> int:
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
payload = {}
enabled = payload.get("enabled")
if not isinstance(enabled, bool):
raw = json.dumps({"status": "error", "error": "field 'enabled' must be boolean"}).encode("utf-8")
frequencies_raw = payload.get("frequencies_mhz")
has_enabled = isinstance(enabled, bool)
has_frequencies = frequencies_raw is not None
if not has_enabled and not has_frequencies:
raw = json.dumps(
{
"status": "error",
"error": "at least one of fields 'enabled' or 'frequencies_mhz' must be provided",
}
).encode("utf-8")
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
@ -98,12 +145,38 @@ def main() -> int:
self.wfile.write(raw)
return
state["enabled"] = enabled
if has_enabled:
state["enabled"] = bool(enabled)
if has_frequencies:
if not isinstance(frequencies_raw, list):
raw = json.dumps(
{"status": "error", "error": "field 'frequencies_mhz' must be an array"}
).encode("utf-8")
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
return
try:
parsed_frequencies = _parse_frequency_list(",".join(str(x) for x in frequencies_raw))
except ValueError as exc:
raw = json.dumps({"status": "error", "error": str(exc)}).encode("utf-8")
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
return
state["frequencies_mhz"] = parsed_frequencies
raw = json.dumps(
{
"status": "ok",
"receiver_id": args.receiver_id,
"enabled": bool(state["enabled"]),
"frequencies_mhz": list(state["frequencies_mhz"]),
}
).encode("utf-8")
self.send_response(200)

@ -321,10 +321,10 @@ def parse_source_payload(
if expected_receiver_id is not None and "receiver_id" in payload:
payload_receiver_id = str(payload["receiver_id"])
if payload_receiver_id != expected_receiver_id:
raise ValueError(
f"{source_label}: payload receiver_id '{payload_receiver_id}' "
f"does not match expected '{expected_receiver_id}'."
)
# Keep processing measurements even if upstream payload ID differs
# from the configured receiver_id. This allows safe UI renaming
# without breaking data collection from legacy sources.
pass
raw_items = payload.get("measurements")
if raw_items is None:
raw_items = payload.get("samples")
@ -477,6 +477,27 @@ def _output_control_urls(output_server: Dict[str, object]) -> Tuple[str, str]:
return f"{base}/control", f"{base}/status"
def _receiver_configured_frequencies_mhz(receiver: Dict[str, object]) -> List[float]:
configured_hz = receiver.get("configured_frequencies_hz")
if not isinstance(configured_hz, list):
return []
values: List[float] = []
seen = set()
for hz_value in configured_hz:
try:
mhz_value = float(hz_value) / HZ_IN_MHZ
except (TypeError, ValueError):
continue
if not math.isfinite(mhz_value) or mhz_value <= 0.0:
continue
normalized = round(mhz_value, 6)
if normalized in seen:
continue
seen.add(normalized)
values.append(normalized)
return values
def _collect_mock_controls(service: "AutoService") -> Dict[str, object]:
inputs: List[Dict[str, object]] = []
for receiver in service.receivers:
@ -488,6 +509,8 @@ def _collect_mock_controls(service: "AutoService") -> Dict[str, object]:
"source_url": source_url,
"reachable": False,
"enabled": None,
"configured_frequencies_mhz": _receiver_configured_frequencies_mhz(receiver),
"frequencies_mhz": None,
"error": "",
}
try:
@ -501,6 +524,18 @@ def _collect_mock_controls(service: "AutoService") -> Dict[str, object]:
enabled_value = payload.get("enabled")
if isinstance(enabled_value, bool):
row["enabled"] = enabled_value
frequencies_value = payload.get("frequencies_mhz")
if isinstance(frequencies_value, list):
parsed_frequencies: List[float] = []
for value in frequencies_value:
try:
numeric = float(value)
except (TypeError, ValueError):
continue
if math.isfinite(numeric) and numeric > 0.0:
parsed_frequencies.append(round(numeric, 6))
if parsed_frequencies:
row["frequencies_mhz"] = parsed_frequencies
if status_code >= 400:
row["error"] = str(payload.get("error", f"HTTP {status_code}"))
except Exception as exc:
@ -545,7 +580,8 @@ def _set_mock_control(
service: "AutoService",
target: str,
target_id: str,
enabled: bool,
enabled: Optional[bool] = None,
frequencies_mhz: Optional[List[float]] = None,
) -> Dict[str, object]:
if target == "input":
receiver = next(
@ -559,26 +595,48 @@ def _set_mock_control(
if receiver is None:
raise ValueError(f"Input receiver '{target_id}' not found.")
control_url, _ = _receiver_control_urls(str(receiver.get("source_url", "")))
control_payload: Dict[str, object] = {}
if isinstance(enabled, bool):
control_payload["enabled"] = enabled
if isinstance(frequencies_mhz, list):
control_payload["frequencies_mhz"] = list(frequencies_mhz)
if not control_payload:
raise ValueError("Input control requires 'enabled' or 'frequencies_mhz'.")
status_code, payload, request_error = _http_json_request(
control_url,
method="POST",
payload={"enabled": enabled},
payload=control_payload,
timeout_s=2.0,
)
if request_error:
raise RuntimeError(request_error)
if status_code < 200 or status_code >= 300:
raise RuntimeError(str(payload.get("error", f"HTTP {status_code}")))
action = "запущена" if enabled else "остановлена"
message_parts: List[str] = []
if isinstance(enabled, bool):
action = "запущена" if enabled else "остановлена"
message_parts.append(f"Передача входных данных '{target_id}' {action}.")
if isinstance(frequencies_mhz, list):
frequencies_text = ", ".join(
f"{value:.6f}".rstrip("0").rstrip(".")
for value in frequencies_mhz
)
message_parts.append(
f"Частоты '{target_id}' обновлены: [{frequencies_text}] МГц."
)
message = " ".join(part for part in message_parts if part).strip() or "Настройки входа обновлены."
return {
"status": "ok",
"target": "input",
"id": target_id,
"enabled": enabled,
"message": f"Передача входных данных '{target_id}' {action}.",
"frequencies_mhz": list(frequencies_mhz) if isinstance(frequencies_mhz, list) else None,
"message": message,
}
if target == "output":
if not isinstance(enabled, bool):
raise ValueError("Output control requires boolean 'enabled'.")
output_server = next(
(
row
@ -612,6 +670,28 @@ def _set_mock_control(
raise ValueError("target must be 'input' or 'output'.")
def _sync_mock_input_frequencies(service: "AutoService") -> List[str]:
errors: List[str] = []
for receiver in service.receivers:
receiver_id = str(receiver.get("receiver_id", ""))
if not receiver_id:
continue
frequencies_mhz = _receiver_configured_frequencies_mhz(receiver)
if not frequencies_mhz:
continue
try:
_set_mock_control(
service=service,
target="input",
target_id=receiver_id,
enabled=None,
frequencies_mhz=frequencies_mhz,
)
except Exception as exc:
errors.append(f"{receiver_id}: {exc}")
return errors
class AutoService:
def __init__(self, config: Dict[str, object], config_path: Optional[str] = None) -> None:
self.config = config
@ -635,6 +715,10 @@ class AutoService:
self.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0))
self.write_api_token = str(runtime_obj.get("write_api_token", "")).strip()
self.mock_input_frequency_sync_enabled = bool(
runtime_obj.get("mock_input_frequency_sync", False)
)
self.last_mock_sync_errors: List[str] = []
parsed_output_servers: List[Dict[str, object]] = []
output_servers_obj = runtime_obj.get("output_servers")
if output_servers_obj is not None:
@ -769,6 +853,10 @@ class AutoService:
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
def start(self) -> None:
if self.mock_input_frequency_sync_enabled:
self.last_mock_sync_errors = _sync_mock_input_frequencies(self)
else:
self.last_mock_sync_errors = []
self.poll_thread.start()
def stop(self) -> None:
@ -1177,10 +1265,14 @@ def _make_handler(service: AutoService):
status_code: int,
content: bytes,
content_type: str,
extra_headers: Optional[Dict[str, str]] = None,
) -> None:
self.send_response(status_code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
if isinstance(extra_headers, dict):
for key, value in extra_headers.items():
self.send_header(str(key), str(value))
self.end_headers()
self.wfile.write(content)
@ -1214,7 +1306,16 @@ def _make_handler(service: AutoService):
"application/x-javascript",
):
mime_type = f"{mime_type}; charset=utf-8"
self._write_bytes(200, file_path.read_bytes(), mime_type)
self._write_bytes(
200,
file_path.read_bytes(),
mime_type,
extra_headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
},
)
def log_message(self, format: str, *args) -> None:
return
@ -1408,6 +1509,12 @@ def _make_handler(service: AutoService):
"restart_required": False,
"applied": True,
"config_path": service_obj.config_path,
"mock_input_frequency_sync_enabled": bool(
new_service.mock_input_frequency_sync_enabled
),
"mock_input_frequency_sync_errors": list(
new_service.last_mock_sync_errors
),
},
)
return
@ -1431,6 +1538,7 @@ def _make_handler(service: AutoService):
target = str(payload.get("target", "")).strip().lower()
target_id = str(payload.get("id", "")).strip()
enabled_value = payload.get("enabled")
frequencies_value = payload.get("frequencies_mhz")
if target not in ("input", "output"):
self._write_json(
400,
@ -1440,15 +1548,57 @@ def _make_handler(service: AutoService):
if not target_id:
self._write_json(400, {"status": "error", "error": "id is required"})
return
if not isinstance(enabled_value, bool):
parsed_frequencies: Optional[List[float]] = None
if frequencies_value is not None:
if not isinstance(frequencies_value, list):
self._write_json(
400,
{"status": "error", "error": "frequencies_mhz must be array"},
)
return
parsed_frequencies = []
for index, value in enumerate(frequencies_value, start=1):
try:
numeric = float(value)
except (TypeError, ValueError):
self._write_json(
400,
{
"status": "error",
"error": f"frequencies_mhz[{index}] must be numeric",
},
)
return
if not math.isfinite(numeric) or numeric <= 0.0:
self._write_json(
400,
{
"status": "error",
"error": f"frequencies_mhz[{index}] must be > 0",
},
)
return
parsed_frequencies.append(round(numeric, 6))
if target == "output" and not isinstance(enabled_value, bool):
self._write_json(400, {"status": "error", "error": "enabled must be boolean"})
return
if target == "input" and not isinstance(enabled_value, bool) and parsed_frequencies is None:
self._write_json(
400,
{
"status": "error",
"error": "input control requires 'enabled' or 'frequencies_mhz'",
},
)
return
try:
response = _set_mock_control(
service=service_obj,
target=target,
target_id=target_id,
enabled=enabled_value,
enabled=enabled_value if isinstance(enabled_value, bool) else None,
frequencies_mhz=parsed_frequencies,
)
except Exception as exc:
self._write_json(500, {"status": "error", "error": str(exc)})

@ -166,7 +166,7 @@ def test_refresh_once_reports_row_validation_error_with_source_context(
svc.refresh_once()
def test_refresh_once_validates_receiver_id_mismatch(monkeypatch: pytest.MonkeyPatch):
def test_refresh_once_allows_receiver_id_mismatch(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
responses = {
"http://r0.local/measurements": {"receiver_id": "r0", "measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]},
@ -176,8 +176,82 @@ def test_refresh_once_validates_receiver_id_mismatch(monkeypatch: pytest.MonkeyP
_install_urlopen(monkeypatch, responses)
svc = service.AutoService(config)
with pytest.raises(RuntimeError, match="does not match expected 'r1'"):
svc.refresh_once()
svc.refresh_once()
snapshot = svc.snapshot()
payload = snapshot["payload"]
assert snapshot["last_error"] == ""
assert payload is not None
assert [row["receiver_id"] for row in payload["receivers"]] == ["r0", "r1", "r2"]
def test_set_mock_control_input_updates_frequencies(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
svc = service.AutoService(config)
captured: Dict[str, object] = {}
def _fake_http_json_request(
url: str,
method: str = "GET",
payload: Dict[str, object] | None = None,
timeout_s: float = 2.0,
):
captured["url"] = url
captured["method"] = method
captured["payload"] = payload or {}
captured["timeout_s"] = timeout_s
return 200, {"status": "ok"}, ""
monkeypatch.setattr(service, "_http_json_request", _fake_http_json_request)
response = service._set_mock_control(
service=svc,
target="input",
target_id="r0",
enabled=None,
frequencies_mhz=[915.0, 868.1],
)
assert response["status"] == "ok"
assert response["target"] == "input"
assert response["id"] == "r0"
assert response["frequencies_mhz"] == [915.0, 868.1]
assert captured["method"] == "POST"
assert captured["payload"] == {"frequencies_mhz": [915.0, 868.1]}
def test_sync_mock_input_frequencies_uses_receiver_configuration(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
for receiver in config["input"]["receivers"]: # type: ignore[index]
receiver["frequencies_mhz"] = [433.92, 915.0]
svc = service.AutoService(config)
calls: List[Dict[str, object]] = []
def _fake_set_mock_control(
service: service.AutoService,
target: str,
target_id: str,
enabled: bool | None = None,
frequencies_mhz: List[float] | None = None,
) -> Dict[str, object]:
calls.append(
{
"target": target,
"target_id": target_id,
"enabled": enabled,
"frequencies_mhz": frequencies_mhz,
}
)
return {"status": "ok"}
monkeypatch.setattr(service, "_set_mock_control", _fake_set_mock_control)
errors = service._sync_mock_input_frequencies(svc)
assert errors == []
assert len(calls) == 3
assert all(call["target"] == "input" for call in calls)
assert all(call["enabled"] is None for call in calls)
assert all(call["frequencies_mhz"] == [433.92, 915.0] for call in calls)
def test_refresh_once_raises_when_output_server_rejects_payload(
@ -268,6 +342,63 @@ def test_parse_source_payload_accepts_compact_short_keys():
assert parsed[1][1] == pytest.approx(-62.5)
def test_parse_source_payload_allows_receiver_id_mismatch_when_expected_is_set():
payload = {"receiver_id": "legacy-name", "measurements": [{"frequency_hz": 868_100_000.0, "rssi_dbm": -61.5}]}
parsed = service.parse_source_payload(
payload=payload,
source_label="source_url=test",
expected_receiver_id="new-name",
)
assert len(parsed) == 1
assert parsed[0][0] == pytest.approx(868_100_000.0)
assert parsed[0][1] == pytest.approx(-61.5)
def test_http_mock_control_accepts_input_frequency_update(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
svc = service.AutoService(config)
captured: Dict[str, object] = {}
def _fake_set_mock_control(
service: service.AutoService,
target: str,
target_id: str,
enabled: bool | None = None,
frequencies_mhz: List[float] | None = None,
) -> Dict[str, object]:
captured["target"] = target
captured["target_id"] = target_id
captured["enabled"] = enabled
captured["frequencies_mhz"] = frequencies_mhz
return {"status": "ok", "target": target, "id": target_id}
monkeypatch.setattr(service, "_set_mock_control", _fake_set_mock_control)
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
req = urllib_request.Request(
url=f"{base_url}/mock/control",
method="POST",
data=json.dumps(
{"target": "input", "id": "r0", "frequencies_mhz": [433.92, 868.1]}
).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib_request.urlopen(req) as response:
payload = json.loads(response.read().decode("utf-8"))
assert payload["status"] == "ok"
assert captured["target"] == "input"
assert captured["target_id"] == "r0"
assert captured["enabled"] is None
assert captured["frequencies_mhz"] == [433.92, 868.1]
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)
current_service = http_server.RequestHandlerClass.service_holder["current"] # type: ignore[attr-defined]
current_service.stop()
def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
svc = service.AutoService(config)

@ -17,6 +17,12 @@
selectedOutputIndex: 0,
outputDrafts: [],
menuCollapsed: false,
menuGroupCollapsed: {
monitoring: false,
io: false,
config: false,
},
dateTimeCollapsed: false,
ioHistory: [],
mockControls: null,
historyFilter: "all",
@ -32,12 +38,21 @@
lastHealthStatus: "n/a",
lastDeliveryStatus: "n/a",
timezone: "local",
uiDensity: "detailed",
};
const HZ_IN_MHZ = 1_000_000;
const MENU_COLLAPSED_STORAGE_KEY = "triangulation.menu_collapsed";
const MENU_GROUP_COLLAPSED_STORAGE_KEY = "triangulation.menu_group_collapsed";
const DATE_TIME_COLLAPSED_STORAGE_KEY = "triangulation.date_time_collapsed";
const TIMEZONE_STORAGE_KEY = "triangulation.timezone";
const UI_DENSITY_STORAGE_KEY = "triangulation.ui_density";
const AUTO_REFRESH_INTERVAL_STORAGE_KEY = "triangulation.auto_refresh_interval_ms";
const AUTO_REFRESH_MIN_MS = 1_000;
const AUTO_REFRESH_MAX_MS = 120_000;
const AUTO_REFRESH_DEFAULT_MS = 2_000;
const IO_HISTORY_LIMIT = 60;
const MENU_GROUP_KEYS = ["monitoring", "io", "config"];
const TIMEZONE_OPTIONS = [
{ value: "local", label: "Локальный (браузер)" },
{ value: "UTC", label: "UTC" },
@ -191,6 +206,10 @@ function setActiveSection(section) {
document.querySelectorAll(".menu-item").forEach((item) => {
item.classList.toggle("menu-item-active", item.dataset.section === section);
});
const group = findSectionMenuGroup(section);
if (group) {
setMenuGroupCollapsed(group, false, { persist: true });
}
}
function setMenuCollapsed(isCollapsed) {
@ -221,6 +240,106 @@ function readMenuCollapsed() {
}
}
function normalizeMenuGroupCollapsed(value) {
const result = {
monitoring: false,
io: false,
config: false,
};
if (!value || typeof value !== "object") return result;
MENU_GROUP_KEYS.forEach((groupKey) => {
result[groupKey] = Boolean(value[groupKey]);
});
return result;
}
function findSectionMenuGroup(section) {
const item = document.querySelector(`.menu-item[data-section="${String(section || "")}"]`);
const group = item?.closest(".menu-group")?.dataset?.menuGroup;
return MENU_GROUP_KEYS.includes(group) ? group : null;
}
function applyMenuGroupState(groupKey) {
if (!MENU_GROUP_KEYS.includes(groupKey)) return;
const root = document.querySelector(`.menu-group[data-menu-group="${groupKey}"]`);
if (!root) return;
const toggle = root.querySelector(".menu-group-toggle");
const collapsed = Boolean(state.menuGroupCollapsed[groupKey]);
root.classList.toggle("menu-group-collapsed", collapsed);
if (toggle) {
toggle.setAttribute("aria-expanded", String(!collapsed));
}
}
function persistMenuGroupCollapsedState() {
try {
localStorage.setItem(
MENU_GROUP_COLLAPSED_STORAGE_KEY,
JSON.stringify(normalizeMenuGroupCollapsed(state.menuGroupCollapsed))
);
} catch {
// Ignore localStorage errors in restricted environments.
}
}
function setMenuGroupCollapsed(groupKey, isCollapsed, options = {}) {
const { persist = true } = options;
if (!MENU_GROUP_KEYS.includes(groupKey)) return;
state.menuGroupCollapsed[groupKey] = Boolean(isCollapsed);
applyMenuGroupState(groupKey);
if (persist) {
persistMenuGroupCollapsedState();
}
}
function applyAllMenuGroupsCollapsed() {
MENU_GROUP_KEYS.forEach((groupKey) => applyMenuGroupState(groupKey));
}
function readMenuGroupCollapsed() {
try {
const raw = localStorage.getItem(MENU_GROUP_COLLAPSED_STORAGE_KEY);
if (!raw) {
return normalizeMenuGroupCollapsed(null);
}
const parsed = JSON.parse(raw);
return normalizeMenuGroupCollapsed(parsed);
} catch {
return normalizeMenuGroupCollapsed(null);
}
}
function setDateTimeCollapsed(isCollapsed) {
state.dateTimeCollapsed = Boolean(isCollapsed);
const sideNav = byId("side-nav");
const toggle = byId("datetime-toggle");
if (sideNav) {
sideNav.classList.toggle("date-time-collapsed", state.dateTimeCollapsed);
}
if (toggle) {
toggle.textContent = state.dateTimeCollapsed
? "Показать служебную панель"
: "Скрыть служебную панель";
toggle.setAttribute("aria-expanded", String(!state.dateTimeCollapsed));
}
try {
localStorage.setItem(DATE_TIME_COLLAPSED_STORAGE_KEY, state.dateTimeCollapsed ? "1" : "0");
} catch {
// Ignore localStorage errors in restricted environments.
}
}
function readDateTimeCollapsed() {
try {
return localStorage.getItem(DATE_TIME_COLLAPSED_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function browserTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
@ -250,6 +369,113 @@ function saveTimeZonePreference(value) {
}
}
function normalizeUiDensity(value) {
return String(value || "").toLowerCase() === "compact" ? "compact" : "detailed";
}
function saveUiDensityPreference(value) {
try {
localStorage.setItem(UI_DENSITY_STORAGE_KEY, normalizeUiDensity(value));
} catch {
// Ignore localStorage errors in restricted environments.
}
}
function readUiDensityPreference() {
try {
return normalizeUiDensity(localStorage.getItem(UI_DENSITY_STORAGE_KEY) || "detailed");
} catch {
return "detailed";
}
}
function applyUiDensity() {
const compact = state.uiDensity === "compact";
document.body.classList.toggle("ui-compact", compact);
const toggle = byId("density-toggle");
if (toggle) {
toggle.textContent = compact ? "Режим: компактный" : "Режим: детальный";
}
}
function setUiDensity(value, options = {}) {
const { persist = true } = options;
state.uiDensity = normalizeUiDensity(value);
applyUiDensity();
if (persist) {
saveUiDensityPreference(state.uiDensity);
}
}
function normalizePollIntervalMs(valueMs) {
const numeric = Number(valueMs);
if (!Number.isFinite(numeric)) return AUTO_REFRESH_DEFAULT_MS;
const rounded = Math.round(numeric);
return Math.min(AUTO_REFRESH_MAX_MS, Math.max(AUTO_REFRESH_MIN_MS, rounded));
}
function formatPollIntervalSeconds(valueMs) {
const seconds = Number(valueMs) / 1000;
if (!Number.isFinite(seconds)) return "2";
return seconds.toFixed(2).replace(/\.?0+$/, "");
}
function readPollIntervalPreference() {
try {
const raw = localStorage.getItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY);
if (!raw) return AUTO_REFRESH_DEFAULT_MS;
return normalizePollIntervalMs(Number(raw));
} catch {
return AUTO_REFRESH_DEFAULT_MS;
}
}
function savePollIntervalPreference(valueMs) {
try {
localStorage.setItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY, String(normalizePollIntervalMs(valueMs)));
} catch {
// Ignore localStorage errors in restricted environments.
}
}
function syncPollIntervalInput() {
const input = byId("auto-refresh-seconds");
if (!input) return;
input.value = formatPollIntervalSeconds(state.pollIntervalMs);
}
function setPollIntervalMs(valueMs, options = {}) {
const { persist = true, restartPolling = true } = options;
const next = normalizePollIntervalMs(valueMs);
state.pollIntervalMs = next;
if (persist) {
savePollIntervalPreference(next);
}
syncPollIntervalInput();
updateRefreshUi();
if (restartPolling && state.autoRefreshEnabled) {
startPolling();
}
}
function setPollIntervalSeconds(rawValue, options = {}) {
const { notify = true } = options;
const normalized = String(rawValue ?? "").replace(",", ".").trim();
const seconds = Number(normalized);
if (!Number.isFinite(seconds) || seconds <= 0) {
syncPollIntervalInput();
if (notify) {
showToast("Введите корректный интервал автообновления (1-120 секунд).", "error");
}
return;
}
const nextMs = normalizePollIntervalMs(seconds * 1000);
setPollIntervalMs(nextMs);
if (notify) {
showToast(`Интервал автообновления: ${formatPollIntervalSeconds(nextMs)}с.`, "success");
}
}
function selectedTimeZoneValue() {
return state.timezone === "local" ? null : state.timezone;
}
@ -328,7 +554,7 @@ function updateRefreshUi() {
if (button) {
button.textContent = state.autoRefreshEnabled ? "Пауза автообновления" : "Запустить автообновление";
}
const suffix = `${Math.round(state.pollIntervalMs / 1000)}с`;
const suffix = `${formatPollIntervalSeconds(state.pollIntervalMs)}с`;
setTextWithPulse(
"refresh-state",
`автообновление: ${state.autoRefreshEnabled ? "вкл" : "выкл"} (${suffix})`
@ -537,17 +763,27 @@ function buildIoHistoryRow(data, delivery) {
const selectedMhz = data?.selected_frequency_mhz ?? hzToMhz(selectedHz);
const receivers = Array.isArray(data?.receivers) ? data.receivers : [];
const servers = Array.isArray(delivery?.servers) ? delivery.servers : [];
const rmseMRaw = Number(data?.rmse_m);
const rmseM = Number.isFinite(rmseMRaw) ? rmseMRaw : null;
const rssiValues = [];
const inputItems = receivers.map((receiver) => {
const receiverId = String(receiver?.receiver_id || "n/a");
const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null;
const perFrequency =
findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null;
const rssi = sample?.amplitude_dbm;
const rssi = Number(sample?.amplitude_dbm);
if (Number.isFinite(rssi)) {
rssiValues.push(rssi);
}
const radius = perFrequency?.radius_m ?? sample?.distance_m;
return `${receiverId}: ${fmt(rssi, 1)} dBm / ${fmt(radius, 2)} m`;
});
const avgRssiDbm = rssiValues.length > 0
? rssiValues.reduce((acc, value) => acc + value, 0) / rssiValues.length
: null;
const outputItems = (() => {
if (servers.length === 0) {
const pos = data?.position || {};
@ -565,11 +801,13 @@ function buildIoHistoryRow(data, delivery) {
const outputSummary = outputItems.join("; ");
const statusRaw = String(delivery?.status || "n/a");
const timestamp = String(data?.timestamp_utc || delivery?.sent_at_utc || "");
const key = `${timestamp}|${fmt(selectedMhz, 3)}|${inputItems.join("||")}|${outputItems.join("||")}|${statusRaw}`;
const key = `${timestamp}|${fmt(selectedMhz, 3)}|${inputItems.join("||")}|${outputItems.join("||")}|${statusRaw}|${fmt(rmseM, 2)}|${fmt(avgRssiDbm, 1)}`;
return {
key,
timestamp,
frequencyMhz: selectedMhz,
rmseM,
avgRssiDbm,
inputItems,
outputItems,
inputSummary,
@ -597,59 +835,506 @@ function toPercent(part, total) {
return Math.max(0, Math.min(100, Math.round((part / total) * 100)));
}
function renderOverviewMetrics() {
function clamp(value, minValue, maxValue) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return minValue;
return Math.max(minValue, Math.min(maxValue, numeric));
}
function setProgressWidth(id, percent) {
const el = byId(id);
if (!el) return;
const normalized = clamp(percent, 0, 100);
el.style.width = `${normalized}%`;
}
function statusToSuccessScore(statusRaw) {
const status = String(statusRaw || "n/a").toLowerCase();
if (status === "ok") return 100;
if (status === "partial") return 70;
if (status === "warming_up") return 40;
if (status === "skipped") return 30;
if (status === "disabled") return 20;
return 0;
}
function buildTrendSeries(limit = 20) {
const rows = state.ioHistory.slice(0, Math.max(1, Number(limit) || 20)).reverse();
const rmse = rows
.map((row) => Number(row?.rmseM))
.filter((value) => Number.isFinite(value) && value >= 0);
const avgRssi = rows
.map((row) => Number(row?.avgRssiDbm))
.filter((value) => Number.isFinite(value));
const delivery = rows.map((row) => statusToSuccessScore(row?.statusRaw));
return { rows, rmse, avgRssi, delivery };
}
function formatTrendMeta(values, digits = 2, unit = "") {
if (!Array.isArray(values) || values.length === 0) return "н/д";
const finiteValues = values.filter((value) => Number.isFinite(Number(value)));
if (finiteValues.length === 0) return "н/д";
const last = Number(finiteValues[finiteValues.length - 1]);
const min = Math.min(...finiteValues);
const max = Math.max(...finiteValues);
return `посл ${fmt(last, digits)}${unit} | мин ${fmt(min, digits)}${unit} | макс ${fmt(max, digits)}${unit}`;
}
function buildSparklineSvg(values, options = {}) {
const {
width = 220,
height = 54,
padding = 4,
stroke = "#2b73f0",
area = "rgba(43, 115, 240, 0.22)",
invert = false,
} = options;
const points = Array.isArray(values)
? values.map((value) => Number(value)).filter((value) => Number.isFinite(value))
: [];
if (points.length === 0) {
return '<div class="sparkline-empty">Недостаточно данных</div>';
}
const minValue = Math.min(...points);
const maxValue = Math.max(...points);
const range = maxValue - minValue || 1;
const safeWidth = Math.max(40, Number(width) || 220);
const safeHeight = Math.max(24, Number(height) || 54);
const baseline = safeHeight - padding;
const usableWidth = Math.max(1, safeWidth - padding * 2);
const usableHeight = Math.max(1, safeHeight - padding * 2);
const normalized = points.map((value, index) => {
const x = padding + (points.length === 1 ? usableWidth / 2 : (index / (points.length - 1)) * usableWidth);
let ratio = (value - minValue) / range;
if (invert) ratio = 1 - ratio;
const y = baseline - ratio * usableHeight;
return { x, y, value };
});
const linePoints = normalized.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" ");
const first = normalized[0];
const last = normalized[normalized.length - 1];
const areaPath = `M ${first.x.toFixed(2)} ${baseline.toFixed(2)} L ${linePoints
.replaceAll(" ", " L ")} L ${last.x.toFixed(2)} ${baseline.toFixed(2)} Z`;
const y25 = (padding + usableHeight * 0.25).toFixed(2);
const y50 = (padding + usableHeight * 0.5).toFixed(2);
const y75 = (padding + usableHeight * 0.75).toFixed(2);
return `
<svg class="sparkline-svg" viewBox="0 0 ${safeWidth} ${safeHeight}" preserveAspectRatio="none" role="img" aria-label="Тренд">
<line class="sparkline-grid" x1="${padding}" y1="${y25}" x2="${safeWidth - padding}" y2="${y25}"></line>
<line class="sparkline-grid" x1="${padding}" y1="${y50}" x2="${safeWidth - padding}" y2="${y50}"></line>
<line class="sparkline-grid" x1="${padding}" y1="${y75}" x2="${safeWidth - padding}" y2="${y75}"></line>
<path class="sparkline-area" d="${areaPath}" fill="${area}"></path>
<polyline class="sparkline-line" points="${linePoints}" stroke="${stroke}"></polyline>
<circle class="sparkline-dot" cx="${last.x.toFixed(2)}" cy="${last.y.toFixed(2)}" r="2.8" fill="${stroke}"></circle>
</svg>
`;
}
function renderOverviewTrends() {
const rmseRoot = byId("ov-trend-rmse-chart");
const rssiRoot = byId("ov-trend-rssi-chart");
const deliveryRoot = byId("ov-trend-delivery-chart");
if (!rmseRoot && !rssiRoot && !deliveryRoot) return;
const series = buildTrendSeries(20);
if (rmseRoot) {
rmseRoot.innerHTML = buildSparklineSvg(series.rmse, {
stroke: "#2b73f0",
area: "rgba(43, 115, 240, 0.22)",
invert: true,
});
}
if (rssiRoot) {
rssiRoot.innerHTML = buildSparklineSvg(series.avgRssi, {
stroke: "#ff8a3d",
area: "rgba(255, 138, 61, 0.24)",
});
}
if (deliveryRoot) {
deliveryRoot.innerHTML = buildSparklineSvg(series.delivery, {
stroke: "#14a37f",
area: "rgba(20, 163, 127, 0.22)",
});
}
setTextWithPulse("ov-trend-rmse-meta", formatTrendMeta(series.rmse, 2, " м"));
setTextWithPulse("ov-trend-rssi-meta", formatTrendMeta(series.avgRssi, 1, " дБм"));
setTextWithPulse("ov-trend-delivery-meta", formatTrendMeta(series.delivery, 0, "%"));
}
function renderHistoryTrends() {
const root = byId("history-trends");
if (!root) return;
const series = buildTrendSeries(40);
const blocks = [
{
title: "RMSE, м",
meta: formatTrendMeta(series.rmse, 2, " м"),
chart: buildSparklineSvg(series.rmse, {
stroke: "#2b73f0",
area: "rgba(43, 115, 240, 0.22)",
invert: true,
}),
},
{
title: "Средний RSSI, дБм",
meta: formatTrendMeta(series.avgRssi, 1, " дБм"),
chart: buildSparklineSvg(series.avgRssi, {
stroke: "#ff8a3d",
area: "rgba(255, 138, 61, 0.24)",
}),
},
{
title: "Успех доставки, %",
meta: formatTrendMeta(series.delivery, 0, "%"),
chart: buildSparklineSvg(series.delivery, {
stroke: "#14a37f",
area: "rgba(20, 163, 127, 0.22)",
}),
},
];
root.innerHTML = blocks
.map(
(block) => `
<article class="trend-card">
<div class="trend-head">
<span>${escapeHtml(block.title)}</span>
<b>${escapeHtml(block.meta)}</b>
</div>
<div class="sparkline-wrap">${block.chart}</div>
</article>
`
)
.join("");
}
function buildMonitoringSummary() {
const data = state.result?.data || null;
const delivery = state.result?.output_delivery || state.frequencies?.output_delivery || {};
const inputs = Array.isArray(state.mockControls?.inputs) ? state.mockControls.inputs : [];
const outputs = Array.isArray(state.mockControls?.outputs) ? state.mockControls.outputs : [];
const inputOnline = inputs.filter((item) => Boolean(item?.reachable)).length;
const outputOnline = outputs.filter((item) => Boolean(item?.reachable)).length;
const total = state.ioHistory.length;
const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length;
const success = toPercent(okCount, total);
const selectedHz = Number(data?.selected_frequency_hz);
const frequencyRows = Array.isArray(data?.frequency_table) ? data.frequency_table : [];
const updatedAt = state.result?.updated_at_utc || state.frequencies?.updated_at_utc || "";
const updatedText = updatedAt ? fmtDateTimeCompact(updatedAt) : "н/д";
setTextWithPulse("ov-input-online", `${inputOnline}/${inputs.length}`);
setTextWithPulse("ov-output-online", `${outputOnline}/${outputs.length}`);
setTextWithPulse("ov-history-total", total);
setTextWithPulse("ov-success-rate", `${success}%`);
return {
data,
delivery,
inputs,
outputs,
inputOnline,
outputOnline,
inputTotal: inputs.length,
outputTotal: outputs.length,
inputOnlinePercent: toPercent(inputOnline, inputs.length),
outputOnlinePercent: toPercent(outputOnline, outputs.length),
total,
okCount,
success,
selectedHz,
frequencyRows,
receivers: Array.isArray(data?.receivers) ? data.receivers : [],
updatedText,
};
}
function renderHistoryInsights() {
const feedRoot = byId("history-feed");
const monitorRoot = byId("history-monitor");
function renderOverviewFrequencyHealth(frequencyRows, selectedHz) {
const root = byId("ov-frequency-health");
if (!root) return;
if (feedRoot) {
const feedRows = state.ioHistory.slice(0, 8);
if (feedRows.length === 0) {
feedRoot.innerHTML = '<div class="io-empty">Событий пока нет.</div>';
} else {
feedRoot.innerHTML = feedRows
.map(
(row, index) => `
<article class="feed-item row-enter" style="animation-delay:${index * 35}ms">
<div class="feed-head">
<span class="feed-time">${escapeHtml(fmtDateTimeCompact(row.timestamp))}</span>
<span class="io-chip ${statusClass(row.statusRaw)}">${escapeHtml(localizeStatus(row.statusRaw))}</span>
</div>
<div class="feed-body">
<div><b>${escapeHtml(fmt(row.frequencyMhz, 3))} МГц</b></div>
<div>${escapeHtml((row.outputItems && row.outputItems[0]) || row.outputSummary || "-")}</div>
</div>
</article>`
)
.join("");
}
if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) {
root.innerHTML = '<div class="io-empty">Расчёты по частотам пока не получены.</div>';
return;
}
if (monitorRoot) {
const inputs = Array.isArray(state.mockControls?.inputs) ? state.mockControls.inputs : [];
const outputs = Array.isArray(state.mockControls?.outputs) ? state.mockControls.outputs : [];
const inputOnline = inputs.filter((item) => Boolean(item?.reachable)).length;
const outputOnline = outputs.filter((item) => Boolean(item?.reachable)).length;
const finiteRmse = frequencyRows
.map((row) => Number(row?.rmse_m))
.filter((value) => Number.isFinite(value) && value >= 0);
const minRmse = finiteRmse.length > 0 ? Math.min(...finiteRmse) : 0;
const maxRmse = finiteRmse.length > 0 ? Math.max(...finiteRmse) : 1;
root.innerHTML = frequencyRows
.map((row) => {
const hz = Number(row?.frequency_hz);
const mhz = row?.frequency_mhz ?? hzToMhz(hz);
const rmse = Number(row?.rmse_m);
const exact = Boolean(row?.exact);
const isSelected = Number.isFinite(hz) && Math.abs(hz - Number(selectedHz)) <= 1;
let quality = 0;
if (Number.isFinite(rmse) && rmse >= 0) {
if (maxRmse <= minRmse) {
quality = 100;
} else {
quality = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100);
}
}
quality = clamp(quality, 0, 100);
return `
<article class="frequency-health-item ${isSelected ? "frequency-health-item-active" : ""}">
<div class="frequency-health-head">
<b>${escapeHtml(fmt(mhz, 3))} МГц</b>
<span class="io-chip ${exact ? "io-status-ok" : "io-status-partial"}">${exact ? "точно" : "оценка"}</span>
</div>
<div class="monitor-progress monitor-progress-accent">
<span style="width:${quality}%"></span>
</div>
<div class="frequency-health-meta">
<span>RMSE: ${escapeHtml(fmt(rmse, 2))} м</span>
<span>Качество: ${quality}%</span>
</div>
</article>
`;
})
.join("");
}
function renderOverviewSignalGrid(receivers, selectedHz) {
const root = byId("ov-signal-grid");
if (!root) return;
if (!Array.isArray(receivers) || receivers.length === 0) {
root.innerHTML = '<div class="io-empty">Сигналы ресиверов пока не получены.</div>';
return;
}
root.innerHTML = receivers
.map((receiver) => {
const receiverId = String(receiver?.receiver_id || "n/a");
const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null;
const row = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null;
const rssi = Number(sample?.amplitude_dbm);
const radius = Number(row?.radius_m ?? sample?.distance_m);
const residual = Number(row?.residual_m);
const level = Number.isFinite(rssi) ? clamp(((rssi + 120) / 70) * 100, 0, 100) : 0;
return `
<article class="signal-item">
<div class="signal-item-head">
<b>${escapeHtml(receiverId)}</b>
<span>${escapeHtml(fmt(rssi, 1))} dBm</span>
</div>
<div class="monitor-progress monitor-progress-signal">
<span style="width:${level}%"></span>
</div>
<div class="signal-item-meta">
<span>R=${escapeHtml(fmt(radius, 2))} м</span>
<span>ε=${escapeHtml(fmt(residual, 2))} м</span>
</div>
</article>
`;
})
.join("");
}
function normalizePipelineStatus(status) {
const normalized = String(status || "n/a").toLowerCase();
if (normalized === "ok") return "ok";
if (normalized === "error") return "error";
if (normalized === "partial") return "partial";
if (normalized === "warming_up") return "warm";
return "warm";
}
const total = state.ioHistory.length;
const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length;
function pipelineDotClass(status) {
const normalized = normalizePipelineStatus(status);
if (normalized === "ok") return "pipeline-dot pipeline-dot-ok";
if (normalized === "error") return "pipeline-dot pipeline-dot-error";
if (normalized === "partial") return "pipeline-dot pipeline-dot-partial";
return "pipeline-dot pipeline-dot-warm";
}
function renderOverviewPipeline(summary) {
const root = byId("ov-pipeline-stages");
if (!root) return;
const hasData = Boolean(summary.data);
const solveStatus = hasData
? Number.isFinite(Number(summary.data?.rmse_m))
? "ok"
: "partial"
: "warming_up";
const inputStatus = summary.inputTotal <= 0
? "warming_up"
: summary.inputOnline === summary.inputTotal
? "ok"
: summary.inputOnline > 0
? "partial"
: "error";
const outputStatus = summary.outputTotal <= 0
? "warming_up"
: summary.outputOnline === summary.outputTotal
? "ok"
: summary.outputOnline > 0
? "partial"
: "error";
const stages = [
{
name: "Сбор входов",
status: inputStatus,
value: `${summary.inputOnline}/${summary.inputTotal}`,
note: "доступность входных серверов",
},
{
name: "Решение",
status: solveStatus,
value: hasData ? `RMSE ${fmt(summary.data?.rmse_m, 2)} м` : "ожидание данных",
note: "оценка точности пересечения сфер",
},
{
name: "Доставка",
status: summary.delivery?.status || "warming_up",
value: `${summary.outputOnline}/${summary.outputTotal}`,
note: "доступность выходных серверов",
},
{
name: "История",
status: summary.total > 0 ? (summary.success >= 80 ? "ok" : "partial") : "warming_up",
value: `${summary.success}%`,
note: "успешность доставки по накопленной ленте",
},
];
root.innerHTML = stages
.map(
(stage) => `
<article class="pipeline-stage">
<div class="pipeline-stage-head">
<span class="pipeline-stage-label">
<span class="${pipelineDotClass(stage.status)}"></span>
<b>${escapeHtml(stage.name)}</b>
</span>
<span class="io-chip ${statusClass(stage.status)}">${escapeHtml(localizeStatus(stage.status))}</span>
</div>
<div class="monitor-progress monitor-progress-accent">
<span style="width:${clamp(stage.name === "История" ? summary.success : stage.name === "Сбор входов" ? summary.inputOnlinePercent : stage.name === "Доставка" ? summary.outputOnlinePercent : summary.success, 0, 100)}%"></span>
</div>
<div class="pipeline-stage-note">${escapeHtml(stage.value)} ${escapeHtml(stage.note)}</div>
</article>
`
)
.join("");
}
function renderOverviewTopFrequencies(frequencyRows) {
const root = byId("ov-top-frequencies");
if (!root) return;
if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) {
root.innerHTML = '<div class="io-empty">Нет данных по частотам для рейтинга.</div>';
return;
}
const rankedRows = [...frequencyRows].sort((a, b) => {
const ra = Number(a?.rmse_m);
const rb = Number(b?.rmse_m);
const aFinite = Number.isFinite(ra);
const bFinite = Number.isFinite(rb);
if (aFinite && bFinite) return ra - rb;
if (aFinite) return -1;
if (bFinite) return 1;
return 0;
});
const topRows = rankedRows.slice(0, 5);
const rmseValues = topRows
.map((row) => Number(row?.rmse_m))
.filter((value) => Number.isFinite(value) && value >= 0);
const minRmse = rmseValues.length > 0 ? Math.min(...rmseValues) : 0;
const maxRmse = rmseValues.length > 0 ? Math.max(...rmseValues) : 1;
root.innerHTML = topRows
.map((row, index) => {
const rmse = Number(row?.rmse_m);
const mhz = row?.frequency_mhz ?? hzToMhz(row?.frequency_hz);
const exact = Boolean(row?.exact);
let score = 0;
if (Number.isFinite(rmse) && rmse >= 0) {
if (maxRmse <= minRmse) {
score = 100;
} else {
score = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100);
}
}
score = clamp(score, 0, 100);
return `
<article class="top-frequency-item">
<div class="top-frequency-head">
<span class="top-frequency-title">
<span class="top-frequency-rank">${index + 1}</span>
<b>${escapeHtml(fmt(mhz, 3))} МГц</b>
</span>
<span class="io-chip ${exact ? "io-status-ok" : "io-status-partial"}">${exact ? "точно" : "оценка"}</span>
</div>
<div class="monitor-progress">
<span style="width:${score}%"></span>
</div>
<div class="top-frequency-meta">
<span>RMSE ${escapeHtml(fmt(rmse, 2))} м</span>
<span>Индекс ${score}%</span>
</div>
</article>
`;
})
.join("");
}
function renderMenuGroupBadges(summary) {
setTextWithPulse("menu-badge-monitoring", `${summary.success}%`);
setTextWithPulse("menu-badge-io", `${summary.inputOnline}/${summary.inputTotal}${summary.outputOnline}/${summary.outputTotal}`);
setTextWithPulse("menu-badge-config", `${summary.inputs.length}вх/${summary.outputs.length}вых`);
}
function renderOverviewMetrics() {
const summary = buildMonitoringSummary();
setTextWithPulse("ov-input-online", `${summary.inputOnline}/${summary.inputTotal}`);
setTextWithPulse("ov-output-online", `${summary.outputOnline}/${summary.outputTotal}`);
setTextWithPulse("ov-history-total", summary.total);
setTextWithPulse("ov-success-rate", `${summary.success}%`);
setTextWithPulse("ov-health-chip", localizeStatus(state.health?.status));
setTextWithPulse("ov-delivery-chip", localizeStatus(summary.delivery?.status));
setTextWithPulse("ov-updated-at", summary.updatedText);
setTextWithPulse("ov-input-online-bar-text", `${summary.inputOnline}/${summary.inputTotal}`);
setTextWithPulse("ov-output-online-bar-text", `${summary.outputOnline}/${summary.outputTotal}`);
setTextWithPulse("ov-delivery-bar-text", `${summary.success}%`);
setProgressWidth("ov-input-online-bar", summary.inputOnlinePercent);
setProgressWidth("ov-output-online-bar", summary.outputOnlinePercent);
setProgressWidth("ov-delivery-bar", summary.success);
renderMenuGroupBadges(summary);
renderOverviewFrequencyHealth(summary.frequencyRows, summary.selectedHz);
renderOverviewSignalGrid(summary.receivers, summary.selectedHz);
renderOverviewPipeline(summary);
renderOverviewTopFrequencies(summary.frequencyRows);
renderOverviewTrends();
}
function renderHistoryInsights() {
const monitorRoot = byId("history-monitor");
const summary = buildMonitoringSummary();
renderHistoryTrends();
if (monitorRoot) {
const problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length;
const success = toPercent(okCount, total);
const healthStatus = localizeStatus(state.health?.status);
const deliveryStatus = localizeStatus(
@ -659,11 +1344,11 @@ function renderHistoryInsights() {
monitorRoot.innerHTML = `
<div class="monitor-row"><span>Сервис</span><b>${escapeHtml(healthStatus)}</b></div>
<div class="monitor-row"><span>Доставка</span><b>${escapeHtml(deliveryStatus)}</b></div>
<div class="monitor-row"><span>Входы online</span><b>${inputOnline}/${inputs.length}</b></div>
<div class="monitor-row"><span>Выходы online</span><b>${outputOnline}/${outputs.length}</b></div>
<div class="monitor-row"><span>Входы online</span><b>${summary.inputOnline}/${summary.inputTotal}</b></div>
<div class="monitor-row"><span>Выходы online</span><b>${summary.outputOnline}/${summary.outputTotal}</b></div>
<div class="monitor-row"><span>Проблемных событий</span><b>${problemCount}</b></div>
<div class="monitor-row"><span>Успех доставки</span><b>${success}%</b></div>
<div class="metric-track"><span style="width:${success}%"></span></div>
<div class="monitor-row"><span>Успех доставки</span><b>${summary.success}%</b></div>
<div class="metric-track"><span style="width:${summary.success}%"></span></div>
`;
}
}
@ -871,6 +1556,16 @@ function buildControlCardHtml(item, target) {
const reachabilityKind = reachable ? "io-status-ok" : "io-status-error";
const reachabilityText = reachable ? "online" : "offline";
const disabledAttr = !id ? "disabled" : "";
const liveFrequencies = Array.isArray(item?.frequencies_mhz)
? item.frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ")
: "";
const configuredFrequencies = Array.isArray(item?.configured_frequencies_mhz)
? item.configured_frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ")
: "";
const frequencyText =
target === "input"
? `активные: ${liveFrequencies || "н/д"} • конфиг: ${configuredFrequencies || "н/д"}`
: "";
return `
<article class="io-card io-control-card io-control-card-compact">
@ -885,6 +1580,7 @@ function buildControlCardHtml(item, target) {
</div>
</div>
${errorText ? `<div class="io-control-error">Ошибка: ${errorText}</div>` : ""}
${frequencyText ? `<div class="io-control-sub">частоты (МГц): ${escapeHtml(frequencyText)}</div>` : ""}
<div class="io-control-actions">
<button
class="btn btn-compact flow-toggle-btn"
@ -1174,7 +1870,7 @@ function render() {
const updated = formatUpdatedTimestamp(state.result?.updated_at_utc);
setTextWithPulse("updated-date", updated.date);
setTextWithPulse("updated-time", updated.time);
setTextWithPulse("health-status", `состояние: ${localizeStatus(state.health?.status)}`);
setTextWithPulse("health-status", `сервис: ${localizeStatus(state.health?.status)}`);
setTextWithPulse("delivery-status", `доставка: ${localizeStatus(delivery?.status)}`);
renderOverviewMetrics();
maybeNotifyStatusChanges(delivery || {});
@ -1364,9 +2060,17 @@ async function saveServers() {
byId("config-editor").value = JSON.stringify(cfg, null, 2);
const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : "";
const syncErrors = Array.isArray(result.mock_input_frequency_sync_errors)
? result.mock_input_frequency_sync_errors.filter((item) => String(item || "").trim() !== "")
: [];
byId("servers-state").textContent = result.restart_required
? `серверы: сохранены, требуется перезапуск${saveSuffix}`
: `серверы: сохранены${saveSuffix}`;
if (syncErrors.length > 0) {
showToast(`Частоты тестовых входов синхронизированы не полностью: ${syncErrors.join("; ")}`, "error");
} else if (result.mock_input_frequency_sync_enabled) {
showToast("Частоты тестовых входов обновлены и сохранены.", "success");
}
} catch (err) {
byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`;
}
@ -1389,6 +2093,12 @@ function bindUi() {
"info"
);
});
const autoRefreshSecondsInput = byId("auto-refresh-seconds");
if (autoRefreshSecondsInput) {
autoRefreshSecondsInput.addEventListener("change", (event) => {
setPollIntervalSeconds(event.target.value);
});
}
byId("load-config").addEventListener("click", loadConfig);
byId("save-config").addEventListener("click", saveConfig);
byId("load-servers").addEventListener("click", loadConfig);
@ -1418,6 +2128,36 @@ function bindUi() {
setMenuCollapsed(!state.menuCollapsed);
});
document.querySelectorAll(".menu-group-toggle").forEach((toggle) => {
toggle.addEventListener("click", () => {
const group = String(toggle.dataset.menuGroupToggle || "");
if (!MENU_GROUP_KEYS.includes(group)) return;
const nextCollapsed = !Boolean(state.menuGroupCollapsed[group]);
setMenuGroupCollapsed(group, nextCollapsed, { persist: true });
});
});
const dateTimeToggle = byId("datetime-toggle");
if (dateTimeToggle) {
dateTimeToggle.addEventListener("click", () => {
setDateTimeCollapsed(!state.dateTimeCollapsed);
});
}
const densityToggle = byId("density-toggle");
if (densityToggle) {
densityToggle.addEventListener("click", () => {
const nextMode = state.uiDensity === "compact" ? "detailed" : "compact";
setUiDensity(nextMode, { persist: true });
showToast(
state.uiDensity === "compact"
? "Включен компактный режим интерфейса."
: "Включен детальный режим интерфейса.",
"info"
);
});
}
const historyFilter = byId("history-filter");
if (historyFilter) {
historyFilter.addEventListener("change", (event) => {
@ -1520,8 +2260,13 @@ async function boot() {
fillTimeZoneSelect();
setTimeZone(readTimeZonePreference());
bindUi();
state.menuGroupCollapsed = readMenuGroupCollapsed();
applyAllMenuGroupsCollapsed();
setUiDensity(readUiDensityPreference(), { persist: false });
setPollIntervalMs(readPollIntervalPreference(), { persist: false, restartPolling: false });
updateHistoryRecordingUi();
setMenuCollapsed(readMenuCollapsed());
setDateTimeCollapsed(readDateTimeCollapsed());
updateRefreshUi();
setActiveSection(state.activeSection);
await loadConfig();
@ -1530,7 +2275,7 @@ async function boot() {
}
boot().catch((err) => {
setTextWithPulse("health-status", `состояние: ${localizeErrorMessage(err.message)}`);
setTextWithPulse("health-status", `сервис: ${localizeErrorMessage(err.message)}`);
});

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Панель Триангуляции</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="/static/styles.css?v=20260312r5" />
</head>
<body>
<div class="bg-glow bg-glow-a"></div>
@ -19,71 +19,198 @@
<div class="menu-wrap">
<div id="menu-list" class="menu-list">
<button class="menu-item menu-item-active" data-section="overview">Обзор</button>
<button class="menu-item" data-section="frequencies">Частоты</button>
<button class="menu-item" data-section="io">Вход/Выход</button>
<button class="menu-item" data-section="history">История</button>
<button class="menu-item" data-section="servers">Серверы</button>
<button class="menu-item" data-section="json">Конфигурация</button>
<section class="menu-group" data-menu-group="monitoring">
<button
class="menu-group-toggle"
type="button"
data-menu-group-toggle="monitoring"
aria-controls="menu-group-monitoring"
aria-expanded="true"
>
<span class="menu-group-title">Мониторинг</span>
<span id="menu-badge-monitoring" class="menu-group-badge">н/д</span>
</button>
<div id="menu-group-monitoring" class="menu-group-body">
<button class="menu-item menu-item-active" data-section="overview" type="button">
<span class="menu-item-icon" aria-hidden="true">O</span>
<span class="menu-item-text">Обзор</span>
<span class="menu-item-note">Сводка</span>
</button>
<button class="menu-item" data-section="frequencies" type="button">
<span class="menu-item-icon" aria-hidden="true">F</span>
<span class="menu-item-text">Частоты</span>
<span class="menu-item-note">Таблица</span>
</button>
</div>
</section>
<section class="menu-group" data-menu-group="io">
<button
class="menu-group-toggle"
type="button"
data-menu-group-toggle="io"
aria-controls="menu-group-io"
aria-expanded="true"
>
<span class="menu-group-title">Потоки</span>
<span id="menu-badge-io" class="menu-group-badge">н/д</span>
</button>
<div id="menu-group-io" class="menu-group-body">
<button class="menu-item" data-section="io" type="button">
<span class="menu-item-icon" aria-hidden="true">I</span>
<span class="menu-item-text">Вход/Выход</span>
<span class="menu-item-note">Потоки</span>
</button>
<button class="menu-item" data-section="history" type="button">
<span class="menu-item-icon" aria-hidden="true">H</span>
<span class="menu-item-text">История</span>
<span class="menu-item-note">Лента</span>
</button>
</div>
</section>
<section class="menu-group" data-menu-group="config">
<button
class="menu-group-toggle"
type="button"
data-menu-group-toggle="config"
aria-controls="menu-group-config"
aria-expanded="true"
>
<span class="menu-group-title">Конфигурация</span>
<span id="menu-badge-config" class="menu-group-badge">н/д</span>
</button>
<div id="menu-group-config" class="menu-group-body">
<button class="menu-item" data-section="servers" type="button">
<span class="menu-item-icon" aria-hidden="true">S</span>
<span class="menu-item-text">Серверы</span>
<span class="menu-item-note">Настройка</span>
</button>
<button class="menu-item" data-section="json" type="button">
<span class="menu-item-icon" aria-hidden="true">{}</span>
<span class="menu-item-text">Конфигурация</span>
<span class="menu-item-note">JSON</span>
</button>
</div>
</section>
</div>
</div>
<div class="side-meta">
<span id="updated-date" class="badge badge-meta">дата: н/д</span>
<span id="updated-time" class="badge badge-meta">время: н/д</span>
<span id="health-status" class="badge">состояние: н/д</span>
<span id="delivery-status" class="badge">доставка: н/д</span>
<label class="timezone-picker">
<span>часовой пояс</span>
<select id="timezone-select"></select>
</label>
<div class="datetime-panel-controls">
<button
id="datetime-toggle"
class="btn btn-compact"
type="button"
aria-controls="meta-panel"
aria-expanded="true"
>
Скрыть служебную панель
</button>
<button id="density-toggle" class="btn btn-compact" type="button">Режим: детальный</button>
</div>
<div id="meta-panel" class="meta-panel">
<div id="date-time-panel" class="date-time-panel">
<span id="updated-date" class="meta-pill">дата: н/д</span>
<span id="updated-time" class="meta-pill">время: н/д</span>
</div>
<div class="status-panel">
<span id="health-status" class="meta-pill">сервис: н/д</span>
<span id="delivery-status" class="meta-pill">доставка: н/д</span>
</div>
<label class="timezone-picker">
<span>часовой пояс</span>
<select id="timezone-select"></select>
</label>
</div>
</div>
</aside>
<section class="content-area">
<section id="section-overview" class="panel panel-active">
<header class="hero card">
<header class="hero card overview-hero">
<h2>Панель Радиопозиционирования</h2>
<p class="muted">Мониторинг и управление расчётом 3D триангуляции.</p>
<div class="hero-actions">
<button id="refresh-now" class="btn btn-primary">Обновить</button>
<button id="toggle-auto-refresh" class="btn" type="button">Пауза автообновления</button>
<label class="refresh-interval-control" for="auto-refresh-seconds">
<span>Интервал, с</span>
<input id="auto-refresh-seconds" type="number" min="1" max="120" step="1" value="2" />
</label>
<span id="refresh-state" class="badge badge-meta">автообновление: вкл (2с)</span>
</div>
</header>
<article class="card">
<h2>Итоговая Позиция</h2>
<div class="result-box">
<div><span class="muted">Выбранная частота:</span> <b id="selected-freq">-</b></div>
<div><span class="muted">X:</span> <b id="pos-x">-</b></div>
<div><span class="muted">Y:</span> <b id="pos-y">-</b></div>
<div><span class="muted">Z:</span> <b id="pos-z">-</b></div>
<div><span class="muted">СКО (RMSE):</span> <b id="rmse">-</b></div>
</div>
</article>
<article class="card">
<h2>Оперативный Мониторинг</h2>
<div class="overview-metrics">
<div class="metric-tile">
<span class="metric-title">Входы online</span>
<b id="ov-input-online" class="metric-value">0/0</b>
</div>
<div class="metric-tile">
<span class="metric-title">Выходы online</span>
<b id="ov-output-online" class="metric-value">0/0</b>
<div class="overview-layout">
<article class="card overview-position-card">
<h2>Итоговая Позиция</h2>
<div class="result-box">
<div><span class="muted">Выбранная частота:</span> <b id="selected-freq">-</b></div>
<div><span class="muted">X:</span> <b id="pos-x">-</b></div>
<div><span class="muted">Y:</span> <b id="pos-y">-</b></div>
<div><span class="muted">Z:</span> <b id="pos-z">-</b></div>
<div><span class="muted">СКО (RMSE):</span> <b id="rmse">-</b></div>
</div>
<div class="metric-tile">
<span class="metric-title">События в истории</span>
<b id="ov-history-total" class="metric-value">0</b>
</article>
<article class="card monitor-board overview-monitor-card">
<h2>Оперативный Мониторинг</h2>
<div class="monitor-headline">
<span class="io-chip io-chip-neutral">Сервис: <b id="ov-health-chip">н/д</b></span>
<span class="io-chip io-chip-neutral">Доставка: <b id="ov-delivery-chip">н/д</b></span>
<span class="io-chip io-chip-neutral">Обновлено: <b id="ov-updated-at">н/д</b></span>
</div>
<div class="metric-tile">
<span class="metric-title">Успех доставки</span>
<b id="ov-success-rate" class="metric-value">0%</b>
<div class="monitor-grid">
<section class="monitor-panel monitor-kpi-panel">
<div class="overview-metrics">
<div class="metric-tile">
<span class="metric-title">Входы online</span>
<b id="ov-input-online" class="metric-value">0/0</b>
</div>
<div class="metric-tile">
<span class="metric-title">Выходы online</span>
<b id="ov-output-online" class="metric-value">0/0</b>
</div>
<div class="metric-tile">
<span class="metric-title">События в истории</span>
<b id="ov-history-total" class="metric-value">0</b>
</div>
<div class="metric-tile">
<span class="metric-title">Успех доставки</span>
<b id="ov-success-rate" class="metric-value">0%</b>
</div>
</div>
</section>
<section class="monitor-panel monitor-flow-panel">
<h3>Состояние Потоков</h3>
<div class="monitor-flow-row">
<span>Входные потоки</span>
<b id="ov-input-online-bar-text">0/0</b>
</div>
<div class="monitor-progress">
<span id="ov-input-online-bar" style="width:0%"></span>
</div>
<div class="monitor-flow-row">
<span>Выходные потоки</span>
<b id="ov-output-online-bar-text">0/0</b>
</div>
<div class="monitor-progress">
<span id="ov-output-online-bar" style="width:0%"></span>
</div>
<div class="monitor-flow-row">
<span>Успешная доставка</span>
<b id="ov-delivery-bar-text">0%</b>
</div>
<div class="monitor-progress monitor-progress-accent">
<span id="ov-delivery-bar" style="width:0%"></span>
</div>
</section>
</div>
</div>
</article>
</article>
</div>
</section>
<section id="section-frequencies" class="panel">
@ -105,6 +232,32 @@
</table>
</div>
</article>
<article class="card monitor-board">
<h2>Аналитика Частот</h2>
<div class="monitor-grid">
<section class="monitor-panel monitor-frequency-panel">
<h3>Профиль Частот</h3>
<div id="ov-frequency-health" class="frequency-health-list"></div>
</section>
<section class="monitor-panel monitor-topfreq-panel">
<h3>Лидеры Частот</h3>
<div id="ov-top-frequencies" class="top-frequencies"></div>
</section>
<section class="monitor-panel monitor-trends-panel">
<h3>Тренд Точности</h3>
<div class="trend-stack">
<article class="trend-card">
<div class="trend-head">
<span>RMSE, м</span>
<b id="ov-trend-rmse-meta">н/д</b>
</div>
<div id="ov-trend-rmse-chart" class="sparkline-wrap"></div>
</article>
</div>
</section>
</div>
</article>
</section>
<section id="section-io" class="panel">
@ -125,89 +278,91 @@
<h3 class="io-history-title">Управление Тестовыми Сбоями</h3>
<div id="error-controls" class="io-list"></div>
</article>
<article class="card monitor-board">
<h2>Контур Входа/Выхода</h2>
<div class="monitor-grid">
<section class="monitor-panel monitor-signal-panel">
<h3>Сигналы Ресиверов</h3>
<div id="ov-signal-grid" class="signal-grid"></div>
</section>
<section class="monitor-panel monitor-stage-panel">
<h3>Контур Обработки</h3>
<div id="ov-pipeline-stages" class="pipeline-stages"></div>
</section>
<section class="monitor-panel monitor-trends-panel">
<h3>Тренды Потоков</h3>
<div class="trend-stack">
<article class="trend-card">
<div class="trend-head">
<span>Средний RSSI, дБм</span>
<b id="ov-trend-rssi-meta">н/д</b>
</div>
<div id="ov-trend-rssi-chart" class="sparkline-wrap"></div>
</article>
<article class="trend-card">
<div class="trend-head">
<span>Успех доставки, %</span>
<b id="ov-trend-delivery-meta">н/д</b>
</div>
<div id="ov-trend-delivery-chart" class="sparkline-wrap"></div>
</article>
</div>
</section>
</div>
</article>
</section>
<section id="section-history" class="panel">
<article class="card history-dashboard">
<article class="card history-dashboard history-head-card">
<h2>История Входов И Выходов</h2>
<p class="muted">Связка входных измерений и отправки результата для отладки, SLA и диагностики ошибок.</p>
</article>
<div class="history-kpis">
<div class="kpi-card">
<span class="kpi-title">Событий</span>
<b id="hist-total" class="kpi-value">0</b>
</div>
<div class="kpi-card">
<span class="kpi-title">Успешно</span>
<b id="hist-ok" class="kpi-value">0</b>
</div>
<div class="kpi-card">
<span class="kpi-title">Проблемы</span>
<b id="hist-problem" class="kpi-value">0</b>
</div>
<div class="kpi-card">
<span class="kpi-title">Частот</span>
<b id="hist-freqs" class="kpi-value">0</b>
</div>
<div class="kpi-card">
<span class="kpi-title">Последнее событие</span>
<b id="hist-last" class="kpi-value">н/д</b>
</div>
</div>
<div class="history-toolbar">
<label>
Статус
<select id="history-filter">
<option value="all">Все</option>
<option value="ok">Ок</option>
<option value="error">Ошибка</option>
<option value="partial">Частично</option>
<option value="skipped">Пропущено</option>
<option value="disabled">Отключено</option>
<option value="warming_up">Прогрев</option>
</select>
</label>
<div class="history-toolbar-right">
<label>
От (дата и время)
<input id="history-date-from" type="datetime-local" />
</label>
<div class="history-layout">
<article class="card history-data-card">
<h2>История И Фильтры</h2>
<div class="history-toolbar">
<label>
До (дата и время)
<input id="history-date-to" type="datetime-local" />
</label>
<label>
Страница
<select id="history-page-size">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
Статус
<select id="history-filter">
<option value="all">Все</option>
<option value="ok">Ок</option>
<option value="error">Ошибка</option>
<option value="partial">Частично</option>
<option value="skipped">Пропущено</option>
<option value="disabled">Отключено</option>
<option value="warming_up">Прогрев</option>
</select>
</label>
<div class="history-pager">
<button id="history-prev" class="btn" type="button">Назад</button>
<span id="history-page-info" class="badge badge-meta">Стр. 1/1 • 0 записей</span>
<button id="history-next" class="btn" type="button">Вперёд</button>
<div class="history-toolbar-right">
<label>
От (дата и время)
<input id="history-date-from" type="datetime-local" />
</label>
<label>
До (дата и время)
<input id="history-date-to" type="datetime-local" />
</label>
<label>
Страница
<select id="history-page-size">
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</label>
<div class="history-pager">
<button id="history-prev" class="btn" type="button">Назад</button>
<span id="history-page-info" class="badge badge-meta">Стр. 1/1 • 0 записей</span>
<button id="history-next" class="btn" type="button">Вперёд</button>
</div>
<button id="history-date-reset" class="btn" type="button">Сброс времени</button>
<button id="history-record-toggle" class="btn" type="button">Пауза записи</button>
<span id="history-record-state" class="badge badge-meta">запись: вкл</span>
<button id="clear-history" class="btn" type="button">Очистить историю</button>
</div>
<button id="history-date-reset" class="btn" type="button">Сброс времени</button>
<button id="history-record-toggle" class="btn" type="button">Пауза записи</button>
<span id="history-record-state" class="badge badge-meta">запись: вкл</span>
<button id="clear-history" class="btn" type="button">Очистить историю</button>
</div>
</div>
<div class="history-insights">
<section class="insight-panel">
<h3>Лента Последних Событий</h3>
<div id="history-feed" class="history-feed"></div>
</section>
<section class="insight-panel">
<h3>Диагностика Мониторинга</h3>
<div id="history-monitor" class="history-monitor"></div>
</section>
</div>
<div class="table-wrap history-table-wrap">
<table id="io-history-table">
<thead>
@ -222,61 +377,175 @@
<tbody></tbody>
</table>
</div>
</article>
</article>
<article class="card history-monitor-card">
<h2>Мониторинг Истории</h2>
<div class="history-kpis">
<div class="kpi-card">
<span class="kpi-title">Событий</span>
<b id="hist-total" class="kpi-value">0</b>
</div>
<div class="kpi-card">
<span class="kpi-title">Успешно</span>
<b id="hist-ok" class="kpi-value">0</b>
</div>
<div class="kpi-card">
<span class="kpi-title">Проблемы</span>
<b id="hist-problem" class="kpi-value">0</b>
</div>
<div class="kpi-card">
<span class="kpi-title">Частот</span>
<b id="hist-freqs" class="kpi-value">0</b>
</div>
<div class="kpi-card">
<span class="kpi-title">Последнее событие</span>
<b id="hist-last" class="kpi-value">н/д</b>
</div>
</div>
<div class="history-insights">
<section class="insight-panel">
<h3>Диагностика Мониторинга</h3>
<div id="history-monitor" class="history-monitor"></div>
</section>
<section class="insight-panel">
<h3>Тренды Метрик</h3>
<div id="history-trends" class="history-trends"></div>
</section>
</div>
</article>
</div>
</section>
<section id="section-servers" class="panel">
<article class="card">
<article class="card servers-head-card">
<h2>Настройка Серверов</h2>
<p class="muted">Доступы входа и выхода задаются отдельно. Общий фильтр входа автоматически применяется ко всем входным серверам.</p>
</article>
<h3 class="servers-title">Доступ К Входным Серверам</h3>
<div class="server-grid">
<label>Выбранный сервер
<select id="receiver-select"></select>
</label>
<div class="server-actions-row">
<button id="add-receiver" class="btn" type="button">Добавить вход</button>
<button id="remove-receiver" class="btn" type="button">Удалить вход</button>
<span id="receiver-count" class="badge">входов: 0</span>
<div class="servers-layout servers-layout-modern">
<article class="card servers-card servers-card-modern">
<div class="server-card-head">
<h3 class="servers-title">Входные Серверы</h3>
<p class="muted">Управление ресиверами и их геометрией.</p>
</div>
<label>Имя ресивера<input id="rx-id" type="text" /></label>
<label>Адрес сервера (URL)<input id="rx-url" type="text" /></label>
<label>Частоты ресивера, МГц (через запятую)<input id="rx-frequencies" type="text" placeholder="433.92, 868.1" /></label>
<label>Координата X<input id="rx-center-x" type="number" step="0.001" /></label>
<label>Координата Y<input id="rx-center-y" type="number" step="0.001" /></label>
<label>Координата Z<input id="rx-center-z" type="number" step="0.001" /></label>
</div>
<div class="server-card-body">
<div class="selector-row">
<label class="field-control">
<span class="field-label">Активный вход</span>
<select id="receiver-select"></select>
</label>
<span id="receiver-count" class="badge badge-meta chip-counter">входов: 0</span>
</div>
<div class="action-group">
<button id="add-receiver" class="btn" type="button">Добавить вход</button>
<button id="remove-receiver" class="btn" type="button">Удалить вход</button>
</div>
<div class="field-grid field-grid-2">
<label class="field-control">
<span class="field-label">Имя ресивера</span>
<input id="rx-id" type="text" placeholder="rx_north" />
</label>
<label class="field-control">
<span class="field-label">Адрес сервера (URL)</span>
<input id="rx-url" type="text" placeholder="http://receiver-r0:9000/data" />
</label>
</div>
<label class="field-control">
<span class="field-label">Частоты ресивера, МГц</span>
<input id="rx-frequencies" type="text" placeholder="433.92, 868.1, 915.0" />
<span class="field-hint">Укажите через запятую только рабочие частоты этого входа.</span>
</label>
<div class="field-grid field-grid-3">
<label class="field-control">
<span class="field-label">Координата X</span>
<input id="rx-center-x" type="number" step="0.001" />
</label>
<label class="field-control">
<span class="field-label">Координата Y</span>
<input id="rx-center-y" type="number" step="0.001" />
</label>
<label class="field-control">
<span class="field-label">Координата Z</span>
<input id="rx-center-z" type="number" step="0.001" />
</label>
</div>
</div>
</article>
<h3 class="servers-title">Общий Фильтр Входа (Авто Для Всех)</h3>
<div class="server-grid">
<label>Фильтр включен
<select id="shared-filter-enabled">
<option value="true">да</option>
<option value="false">нет</option>
</select>
</label>
<label>Мин. частота, МГц<input id="shared-min-freq" type="number" step="0.001" min="0" /></label>
<label>Макс. частота, МГц<input id="shared-max-freq" type="number" step="0.001" min="0" /></label>
<label>Мин. RSSI, дБм<input id="shared-min-rssi" type="number" step="0.1" /></label>
<label>Макс. RSSI, дБм<input id="shared-max-rssi" type="number" step="0.1" /></label>
</div>
<article class="card servers-card servers-card-modern">
<div class="server-card-head">
<h3 class="servers-title">Общий Фильтр Входа</h3>
<p class="muted">Применяется автоматически ко всем входным серверам.</p>
</div>
<div class="server-card-body">
<label class="field-control">
<span class="field-label">Фильтр включен</span>
<select id="shared-filter-enabled">
<option value="true">да</option>
<option value="false">нет</option>
</select>
</label>
<div class="field-grid field-grid-2">
<label class="field-control">
<span class="field-label">Мин. частота, МГц</span>
<input id="shared-min-freq" type="number" step="0.001" min="0" />
</label>
<label class="field-control">
<span class="field-label">Макс. частота, МГц</span>
<input id="shared-max-freq" type="number" step="0.001" min="0" />
</label>
</div>
<div class="field-grid field-grid-2">
<label class="field-control">
<span class="field-label">Мин. RSSI, дБм</span>
<input id="shared-min-rssi" type="number" step="0.1" />
</label>
<label class="field-control">
<span class="field-label">Макс. RSSI, дБм</span>
<input id="shared-max-rssi" type="number" step="0.1" />
</label>
</div>
</div>
</article>
<h3 class="servers-title">Выходные Серверы</h3>
<div class="server-grid">
<label>Выбранный выход
<select id="output-select"></select>
</label>
<div class="server-actions-row">
<button id="add-output-server" class="btn" type="button">Добавить выход</button>
<button id="remove-output-server" class="btn" type="button">Удалить выход</button>
<span id="output-count" class="badge">выходов: 0</span>
<article class="card servers-card servers-card-modern">
<div class="server-card-head">
<h3 class="servers-title">Выходные Серверы</h3>
<p class="muted">Минимальные параметры для доставки результата.</p>
</div>
<label>Имя выхода<input id="out-name" type="text" /></label>
<label>Токен записи (API, только сессия)<input id="write-token" type="password" /></label>
<label>IP/хост выхода<input id="out-ip" type="text" /></label>
</div>
<div class="server-card-body">
<div class="selector-row">
<label class="field-control">
<span class="field-label">Активный выход</span>
<select id="output-select"></select>
</label>
<span id="output-count" class="badge badge-meta chip-counter">выходов: 0</span>
</div>
<div class="action-group">
<button id="add-output-server" class="btn" type="button">Добавить выход</button>
<button id="remove-output-server" class="btn" type="button">Удалить выход</button>
</div>
<div class="field-grid field-grid-2">
<label class="field-control">
<span class="field-label">Имя выхода</span>
<input id="out-name" type="text" placeholder="sink_main" />
</label>
<label class="field-control">
<span class="field-label">IP/хост выхода</span>
<input id="out-ip" type="text" placeholder="output-sink:8080" />
</label>
</div>
<label class="field-control">
<span class="field-label">Токен записи (API, только сессия)</span>
<input id="write-token" type="password" placeholder="необязательно" />
</label>
</div>
</article>
</div>
<article class="card servers-actions-card">
<div class="editor-actions">
<button id="load-servers" class="btn">Загрузить</button>
<button id="save-servers" class="btn btn-primary">Сохранить серверы</button>
@ -286,20 +555,54 @@
</section>
<section id="section-json" class="panel">
<article class="card">
<article class="card config-head-card">
<h2>Конфигурация</h2>
<div class="editor-actions">
<button id="load-config" class="btn">Загрузить</button>
<button id="save-config" class="btn btn-primary">Сохранить конфиг</button>
<span id="config-state" class="badge">конфиг: н/д</span>
</div>
<textarea id="config-editor" class="editor" spellcheck="false"></textarea>
</article>
<div class="config-layout config-layout-modern">
<article class="card config-editor-card config-editor-modern">
<div class="config-section-head">
<h3>Редактор JSON</h3>
<p class="muted">Точный режим настройки для прод-конфигурации.</p>
</div>
<div class="config-editor-shell">
<div class="editor-toolbar">
<span class="editor-chip">JSON</span>
<span class="editor-chip">UTF-8</span>
<span class="editor-chip">runtime + input + system</span>
</div>
<textarea id="config-editor" class="editor" spellcheck="false"></textarea>
</div>
</article>
<article class="card config-help-card config-help-modern">
<div class="config-section-head">
<h3>Памятка По Полям</h3>
<p class="muted">Краткая структура и контрольные точки перед сохранением.</p>
</div>
<div class="config-hints config-hints-grid">
<p><b>input.receivers[]</b><br />входные ресиверы: координаты, URL, частоты.</p>
<p><b>runtime.output_servers[]</b><br />список серверов, получающих координаты.</p>
<p><b>input.default_input_filter</b><br />общий фильтр частот и RSSI.</p>
<p><b>system</b><br />системные таймеры, лимиты и автообновление.</p>
</div>
<h3>Советы</h3>
<ul class="config-tips">
<li>Поддерживайте уникальные `receiver_id` для каждого входа.</li>
<li>Согласуйте диапазоны частот между входными серверами.</li>
<li>Перед сохранением проверяйте JSON на валидность.</li>
</ul>
</article>
</div>
</section>
</section>
</main>
<script src="/static/app.js"></script>
<script src="/static/app.js?v=20260312r5"></script>
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="true"></div>
</body>
</html>

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save