Revisioning frontend part

main
AlexsandrSnytkin 2 months ago
parent f327c8f0bb
commit e494215015

8
.idea/.gitignore vendored

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeveloperToolsToolWindowSettingsV1" lastSelectedContentNodeId="base64-encoder-decoder">
<developerToolsConfigurations />
</component>
</project>

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="-72e31803:19624aa96b6:-7ffa" />
</MTProjectMetadataState>
</option>
<option name="titleBarState">
<MTProjectTitleBarConfigState>
<option name="overrideColor" value="false" />
</MTProjectTitleBarConfigState>
</option>
</component>
</project>

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (PyCharmMiscProject)" project-jdk-type="Python SDK" />
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/triangulation.iml" filepath="$PROJECT_DIR$/.idea/triangulation.iml" />
</modules>
</component>
</project>

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.12 (PyCharmMiscProject)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

@ -1,4 +1,4 @@
FROM python:3.11-slim
FROM python:3.12-slim
WORKDIR /app

@ -22,7 +22,8 @@
- Поддержка нескольких выходных серверов `runtime.output_servers[]` с настройкой по имени и IP.
- Горячее применение нового конфига через `POST /config` (без ручного рестарта процесса).
- Защита write-endpoints токеном (`runtime.write_api_token`).
- Русскоязычный UI (`/ui`) с вкладками: обзор, частоты, ресиверы, доставка, серверы, JSON-конфиг.
- Русскоязычный UI (`/ui`) с вкладками: обзор, частоты, вход/выход, история, серверы, JSON-конфиг.
- В тестовом docker-профиле: управляемые mock-сбои (пауза входной передачи/выходного приема) + короткие всплывающие подсказки (toast) в UI.
- Интеграционные и юнит-тесты.
## Структура проекта
@ -112,7 +113,12 @@ python service.py --config config.json
Что доступно:
- обзор итоговой позиции,
- таблица всех частотных решений,
- просмотр сырых данных ресиверов и статуса доставки,
- вкладка `Вход/Выход`: читаемые карточки входных и выходных данных + управление mock-сбоями,
- отдельная вкладка `История`: соответствие «вход -> выход», KPI по событиям, фильтр по статусу, ручная очистка истории,
- управление mock-сбоями в тестовом режиме:
- остановка/запуск передачи с входных mock-ресиверов,
- остановка/запуск приема на mock output-sink,
- короткие toast-уведомления об ошибках и изменении состояния,
- настройка входных/выходных серверов (добавление/удаление, имена, URL, IP),
- редактирование сырого JSON-конфига,
- сохранение конфига в рантайме через API.
@ -127,6 +133,11 @@ python service.py --config config.json
- `POST /refresh` — принудительное обновление.
- `GET /config` — текущий конфиг (с редактированием секрета токена).
- `POST /config` — валидация + применение + попытка сохранения в файл.
- `GET /mock/controls` — состояние mock-входов/mock-выходов (тестовый режим).
- `POST /mock/control` — переключение mock-потока:
- `target: "input" | "output"`
- `id: "<receiver_id|output_name>"`
- `enabled: true|false`
Полные примеры запросов/ответов: [docs/API.md](./docs/API.md).

@ -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"))

@ -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()

@ -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

File diff suppressed because it is too large Load Diff

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1a67ff"/>
<stop offset="1" stop-color="#14b88f"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="52" height="52" rx="14" fill="#eef4ff"/>
<path d="M16 42c6-6 10-10 16-10s10 4 16 10" fill="none" stroke="url(#g)" stroke-width="4" stroke-linecap="round"/>
<path d="M20 28c4-4 7-6 12-6s8 2 12 6" fill="none" stroke="#1a67ff" stroke-opacity=".6" stroke-width="3.5" stroke-linecap="round"/>
<circle cx="32" cy="42" r="5.5" fill="url(#g)"/>
</svg>

After

Width:  |  Height:  |  Size: 627 B

@ -1,27 +1,28 @@
<!doctype html>
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<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" />
</head>
<body>
<div class="bg-glow bg-glow-a"></div>
<div class="bg-glow bg-glow-b"></div>
<main class="app-shell">
<aside class="side-nav card">
<p class="kicker">Триангуляция</p>
<h1 class="side-title">Пульт</h1>
<main id="app-shell" class="app-shell">
<aside id="side-nav" class="side-nav card">
<div class="nav-head">
<button id="menu-toggle" class="btn menu-toggle" type="button" aria-controls="menu-list" aria-expanded="true">Свернуть меню</button>
</div>
<div class="menu-wrap">
<button id="menu-toggle" class="btn btn-primary menu-toggle">Разделы</button>
<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="receivers">Ресиверы</button>
<button class="menu-item" data-section="delivery">Доставка</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>
</div>
@ -32,6 +33,10 @@
<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>
</aside>
@ -42,6 +47,8 @@
<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>
<span id="refresh-state" class="badge badge-meta">автообновление: вкл (2с)</span>
</div>
</header>
@ -55,6 +62,28 @@
<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>
<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>
</article>
</section>
<section id="section-frequencies" class="panel">
@ -78,17 +107,121 @@
</article>
</section>
<section id="section-receivers" class="panel">
<section id="section-io" class="panel">
<article class="card">
<h2>Ресиверы</h2>
<div id="receivers-list" class="mono small"></div>
<h2>Входные И Выходные Данные</h2>
<p class="muted">Оперативный мониторинг входящих измерений и фактической отправки на выходные серверы.</p>
<div class="io-grid">
<section class="io-block">
<h3>Входные Данные (Ресиверы)</h3>
<div id="input-flow" class="io-list"></div>
</section>
<section class="io-block">
<h3>Выходные Данные (Отправка)</h3>
<div id="output-flow" class="io-list"></div>
</section>
</div>
<h3 class="io-history-title">Управление Тестовыми Сбоями</h3>
<div id="error-controls" class="io-list"></div>
</article>
</section>
<section id="section-delivery" class="panel">
<article class="card">
<h2>Доставка На Выходы</h2>
<div id="delivery-details" class="mono small"></div>
<section id="section-history" class="panel">
<article class="card history-dashboard">
<h2>История Входов И Выходов</h2>
<p class="muted">Связка входных измерений и отправки результата для отладки, SLA и диагностики ошибок.</p>
<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>
<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>
</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>
<tr>
<th>Время</th>
<th>Частота (МГц)</th>
<th>Вход (RSSI/Радиусы)</th>
<th>Передано На Выход</th>
<th>Статус</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</article>
</section>
@ -167,5 +300,7 @@
</main>
<script src="/static/app.js"></script>
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="true"></div>
</body>
</html>

@ -41,12 +41,13 @@ body {
}
.app-shell {
width: min(1320px, 96vw);
margin: 20px auto;
width: 100%;
margin: 0;
padding: 12px 12px 18px;
display: grid;
grid-template-columns: 300px 1fr;
grid-template-columns: 1fr;
align-items: start;
gap: 16px;
gap: 14px;
position: relative;
z-index: 2;
}
@ -72,6 +73,7 @@ body {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background:
linear-gradient(
120deg,
@ -84,6 +86,11 @@ body {
animation: sheen 6.6s linear infinite;
}
.card > * {
position: relative;
z-index: 1;
}
.card:hover {
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--accent), #ffffff 76%);
@ -93,13 +100,25 @@ body {
.side-nav {
position: sticky;
top: 12px;
min-height: 0;
max-height: calc(100dvh - 24px);
overflow-y: auto;
overscroll-behavior: contain;
z-index: 5;
display: grid;
gap: 12px;
scrollbar-gutter: stable;
gap: 10px;
text-align: center;
justify-items: center;
}
.nav-head {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
}
.brand-block {
display: grid;
gap: 2px;
justify-items: center;
}
.kicker {
@ -120,6 +139,8 @@ body {
.content-area {
display: grid;
min-width: 0;
width: min(1800px, 100%);
margin: 0 auto;
}
.panel {
@ -131,6 +152,13 @@ body {
display: grid;
gap: 16px;
min-width: 0;
width: min(1500px, 100%);
margin: 0 auto;
}
.hero {
text-align: center;
justify-items: center;
}
.hero h2 {
@ -146,6 +174,10 @@ body {
margin-top: 10px;
}
.hero-actions {
justify-content: center;
}
.btn {
border: 1px solid var(--line);
background: #fff;
@ -179,43 +211,73 @@ body {
}
.menu-wrap {
display: grid;
gap: 8px;
width: 100%;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
}
.menu-toggle {
width: 100%;
white-space: nowrap;
min-width: 158px;
}
.menu-list {
display: none;
display: flex;
flex-wrap: nowrap;
align-items: stretch;
justify-content: center;
gap: 8px;
width: min(1380px, 100%);
max-width: 100%;
margin: 0 auto;
position: static;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.94);
background: rgba(255, 255, 255, 0.86);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
0 8px 24px rgba(21, 37, 73, 0.08);
padding: 6px;
max-height: min(48dvh, 440px);
overflow-y: auto;
max-height: 84px;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior: contain;
transform-origin: top center;
animation: menuIn var(--anim-mid) ease both;
transition:
max-height var(--anim-mid) ease,
opacity var(--anim-fast) ease,
transform var(--anim-mid) ease,
padding var(--anim-fast) ease,
border-color var(--anim-fast) ease;
scrollbar-width: thin;
}
.menu-list-open {
display: grid;
gap: 5px;
.side-nav.menu-collapsed .menu-list {
max-height: 0;
opacity: 0;
transform: translateY(-12px);
padding-top: 0;
padding-bottom: 0;
border-color: transparent;
pointer-events: none;
}
.menu-item {
flex: 1 1 0;
min-width: 136px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: #f9fbff;
background: #fbfcff;
color: var(--text);
border-radius: 9px;
padding: 8px 10px;
text-align: left;
padding: 8px 12px;
text-align: center;
white-space: nowrap;
cursor: pointer;
font-family: inherit;
transition:
@ -225,7 +287,7 @@ body {
}
.menu-item:hover {
transform: translateX(4px);
transform: translateY(-1px);
border-color: color-mix(in oklab, var(--accent), #ffffff 70%);
}
@ -235,36 +297,59 @@ body {
}
.side-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.timezone-picker {
display: grid;
gap: 7px;
gap: 4px;
text-align: left;
font-size: 0.78rem;
color: #3d4f70;
min-width: 240px;
}
.timezone-picker select {
border: 1px solid var(--line);
border-radius: 9px;
padding: 6px 10px;
background: #fff;
color: var(--text);
font-size: 0.84rem;
font-family: inherit;
}
.badge {
border: 1px solid var(--line);
background: rgba(236, 244, 255, 0.72);
border-radius: 999px;
padding: 6px 12px 6px 30px;
padding: 6px 12px;
line-height: 1.2;
font-size: 0.8rem;
width: fit-content;
position: relative;
overflow: hidden;
overflow: visible;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
}
.badge::after {
content: "";
position: absolute;
left: 12px;
top: 50%;
position: static;
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
background: var(--success);
transform: translateY(-50%);
opacity: 0.78;
animation: badgePulse 1.9s ease-in-out infinite;
order: -1;
}
.badge-meta {
@ -277,10 +362,565 @@ body {
}
.result-box {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
text-align: center;
width: min(960px, 100%);
margin: 0 auto;
}
/* Overview panel: force vertical flow for key info blocks. */
#section-overview .result-box {
grid-template-columns: 1fr;
width: min(560px, 100%);
gap: 8px;
}
.overview-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
#section-overview .overview-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
width: min(1200px, 100%);
margin: 0 auto;
}
.metric-tile {
border: 1px solid color-mix(in oklab, var(--line), #ffffff 32%);
border-radius: 12px;
background: linear-gradient(170deg, rgba(255, 255, 255, 0.95), rgba(236, 244, 255, 0.84));
padding: 12px 10px;
display: grid;
gap: 4px;
justify-items: center;
text-align: center;
}
.metric-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #4a5e81;
}
.metric-value {
font-size: 1.08rem;
color: #1d3258;
}
.io-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.io-block {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.52);
padding: 10px;
}
.io-list {
display: grid;
gap: 10px;
}
.io-card {
border: 1px solid var(--line);
border-radius: 11px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(247, 250, 255, 0.78));
padding: 10px;
text-align: left;
transition:
transform var(--anim-fast) ease,
box-shadow var(--anim-fast) ease,
border-color var(--anim-fast) ease;
}
.io-card:hover {
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--accent), #ffffff 62%);
box-shadow: 0 10px 22px rgba(21, 45, 92, 0.12);
}
.io-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.io-card-head h4 {
margin: 0;
font-size: 0.95rem;
}
.io-meta-grid {
display: grid;
gap: 6px;
font-size: 0.86rem;
color: #2f3f5a;
}
.io-chip-row {
margin-top: 8px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.io-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid var(--line);
padding: 4px 9px;
font-size: 0.77rem;
line-height: 1.2;
background: #f2f6ff;
white-space: nowrap;
}
.io-chip-neutral,
.io-status-na {
background: #f2f6ff;
color: #345188;
}
.io-status-ok {
background: #e7f8f0;
color: #12795f;
}
.io-status-error {
background: #ffe9e9;
color: #ae3131;
}
.io-status-partial,
.io-status-skipped {
background: #fff3dd;
color: #a36404;
}
.io-status-disabled {
background: #efeff3;
color: #656976;
}
.io-status-warm {
background: #fff4de;
color: #9b6611;
}
.io-empty {
padding: 10px;
border: 1px dashed var(--line);
border-radius: 10px;
color: var(--muted);
background: rgba(255, 255, 255, 0.55);
}
.io-mini-wrap {
margin-top: 8px;
}
.io-mini-table th,
.io-mini-table td {
font-size: 0.82rem;
padding: 7px 8px;
}
.io-history-title {
margin: 14px 0 10px;
font-size: 1rem;
letter-spacing: 0.02em;
}
.io-control-grid {
display: grid;
gap: 12px;
}
.io-control-card {
background: rgba(252, 254, 255, 0.92);
}
.io-control-actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.io-control-grid-compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.io-control-panel {
padding: 10px;
background: linear-gradient(170deg, rgba(255, 255, 255, 0.92), rgba(242, 248, 255, 0.74));
}
.io-control-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.io-control-panel-head h4 {
margin: 0;
font-size: 0.92rem;
letter-spacing: 0.02em;
}
.io-control-list {
display: grid;
gap: 8px;
}
.io-control-card-compact {
padding: 9px 10px;
}
.io-control-top {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: flex-start;
flex-wrap: wrap;
}
.io-control-name-wrap {
display: grid;
gap: 2px;
min-width: 0;
}
.io-control-name-wrap h4 {
margin: 0;
font-size: 0.9rem;
}
.io-control-sub {
font-size: 0.74rem;
color: #5a6e92;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.io-control-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.io-control-error {
margin-top: 7px;
border: 1px solid rgba(206, 70, 70, 0.35);
border-radius: 8px;
background: rgba(255, 238, 238, 0.82);
color: #9f2c2c;
padding: 6px 8px;
font-size: 0.79rem;
}
.btn-compact {
padding: 6px 10px;
font-size: 0.82rem;
}
.history-dashboard {
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.95), rgba(243, 248, 255, 0.84)),
radial-gradient(circle at 100% 0%, rgba(36, 107, 255, 0.14), transparent 55%);
}
.history-kpis {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin: 12px 0;
}
.kpi-card {
border: 1px solid color-mix(in oklab, var(--line), #ffffff 35%);
border-radius: 12px;
background: linear-gradient(170deg, rgba(255, 255, 255, 0.94), rgba(238, 245, 255, 0.9));
padding: 10px;
display: grid;
gap: 3px;
justify-items: center;
text-align: center;
animation: rise 420ms ease both;
}
.kpi-title {
color: #4b5c7a;
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.kpi-value {
font-size: 1.06rem;
line-height: 1.1;
color: #1f3358;
}
.history-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
margin: 8px 0 10px;
}
.history-toolbar label {
display: grid;
gap: 6px;
font-size: 0.84rem;
color: #3d4f70;
text-align: left;
}
.history-toolbar select {
border: 1px solid var(--line);
border-radius: 9px;
padding: 7px 10px;
background: #fff;
color: var(--text);
min-width: 180px;
}
.history-toolbar input[type="datetime-local"] {
border: 1px solid var(--line);
border-radius: 9px;
padding: 7px 10px;
background: #fff;
color: var(--text);
min-width: 210px;
font-family: inherit;
}
.history-toolbar-right {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.history-pager {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
#history-page-info {
min-width: 180px;
justify-content: center;
}
.history-insights {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.insight-panel {
border: 1px solid color-mix(in oklab, var(--line), #ffffff 30%);
border-radius: 12px;
padding: 10px;
background: linear-gradient(165deg, rgba(255, 255, 255, 0.94), rgba(242, 248, 255, 0.84));
}
.insight-panel h3 {
margin: 0 0 8px;
font-size: 0.94rem;
text-align: center;
}
.history-feed {
display: grid;
gap: 8px;
max-height: 250px;
overflow-y: auto;
padding-right: 2px;
}
.feed-item {
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.85);
padding: 8px 9px;
display: grid;
gap: 6px;
}
.feed-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.feed-time {
font-size: 0.78rem;
color: #4f607c;
}
.feed-body {
display: grid;
gap: 4px;
font-size: 0.84rem;
color: #233a62;
}
.history-monitor {
display: grid;
gap: 7px;
}
.monitor-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-bottom: 1px dashed color-mix(in oklab, var(--line), #ffffff 25%);
padding-bottom: 5px;
font-size: 0.84rem;
}
.monitor-row b {
color: #1a345e;
}
.metric-track {
margin-top: 3px;
width: 100%;
height: 9px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--line), #ffffff 26%);
background: rgba(220, 230, 249, 0.72);
overflow: hidden;
}
.metric-track > span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #31be97, #2476ff);
transition: width var(--anim-mid) ease;
}
.history-table-wrap {
max-height: min(52dvh, 560px);
overflow: auto;
}
.history-table-wrap thead th {
position: sticky;
top: 0;
z-index: 2;
backdrop-filter: blur(3px);
}
.history-table-wrap tbody tr:nth-child(odd) {
background: rgba(247, 251, 255, 0.6);
}
.history-table-wrap td:nth-child(3),
.history-table-wrap td:nth-child(4) {
text-align: left;
min-width: 290px;
}
.history-cell-list {
display: grid;
gap: 5px;
}
.history-cell-line {
padding: 4px 6px;
border-radius: 8px;
border: 1px solid color-mix(in oklab, var(--line), #ffffff 24%);
background: rgba(255, 255, 255, 0.76);
font-size: 0.82rem;
}
.history-table-wrap td:first-child {
min-width: 160px;
}
.history-table-wrap tbody tr {
animation: rowEnter var(--anim-mid) ease both;
}
.toast-container {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 50;
display: grid;
gap: 8px;
max-width: min(92vw, 360px);
}
.toast {
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10px 24px rgba(24, 38, 66, 0.14);
padding: 10px 12px;
font-size: 0.84rem;
color: #203252;
opacity: 0;
transform: translateY(10px);
transition: opacity var(--anim-fast) ease, transform var(--anim-fast) ease;
}
.toast-show {
opacity: 1;
transform: translateY(0);
}
.toast-success {
border-color: rgba(20, 163, 127, 0.4);
background: rgba(233, 250, 244, 0.98);
}
.toast-error {
border-color: rgba(208, 71, 71, 0.4);
background: rgba(255, 239, 239, 0.98);
}
.panel > .card > h2,
.servers-title,
.server-grid label,
.server-actions-row,
.editor-actions {
text-align: center;
}
.server-actions-row,
.editor-actions {
justify-content: center;
}
.muted {
color: var(--muted);
}
@ -307,7 +947,7 @@ table {
th,
td {
text-align: left;
text-align: center;
padding: 9px 10px;
border-bottom: 1px solid var(--line);
font-size: 0.9rem;
@ -372,6 +1012,7 @@ tbody tr:hover {
.server-grid label {
display: grid;
justify-items: center;
gap: 6px;
font-size: 0.88rem;
color: #34425c;
@ -385,6 +1026,7 @@ tbody tr:hover {
font-size: 0.9rem;
background: #fff;
color: var(--text);
text-align: center;
font-family: inherit;
transition: border-color var(--anim-fast) ease, box-shadow var(--anim-fast) ease;
}
@ -495,28 +1137,61 @@ tbody tr:hover {
@keyframes badgePulse {
0%,
100% {
transform: translateY(-50%) scale(1);
transform: scale(1);
opacity: 0.68;
}
50% {
transform: translateY(-50%) scale(1.4);
transform: scale(1.35);
opacity: 1;
}
}
@media (max-width: 1040px) {
.app-shell {
grid-template-columns: 1fr;
}
@media (max-width: 1180px) {
.side-nav {
position: static;
max-height: none;
overflow: visible;
gap: 10px;
}
.nav-head {
align-items: center;
flex-direction: column;
text-align: center;
}
.menu-toggle {
width: 100%;
}
.side-meta {
justify-content: center;
}
.timezone-picker {
min-width: 220px;
}
.history-kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.overview-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#section-overview .overview-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.history-insights {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.app-shell {
padding: 8px 8px 14px;
}
.server-grid {
grid-template-columns: 1fr;
}
@ -524,6 +1199,80 @@ tbody tr:hover {
.card {
padding: 14px;
}
.menu-item {
padding: 7px 10px;
font-size: 0.88rem;
}
.io-grid {
grid-template-columns: 1fr;
}
.io-control-grid-compact {
grid-template-columns: 1fr;
}
.io-control-top {
align-items: stretch;
}
.io-control-badges {
justify-content: flex-start;
}
.io-control-actions .btn {
width: 100%;
}
.history-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-metrics {
grid-template-columns: 1fr;
}
#section-overview .overview-metrics {
grid-template-columns: 1fr;
}
.timezone-picker {
width: 100%;
min-width: 0;
}
.history-toolbar {
align-items: stretch;
}
.history-toolbar label,
.history-toolbar select,
.history-toolbar input[type="datetime-local"],
.history-toolbar .btn,
.history-toolbar-right {
width: 100%;
}
.history-toolbar-right {
align-items: stretch;
justify-content: stretch;
}
.history-pager {
width: 100%;
justify-content: space-between;
}
#history-page-info {
flex: 1 1 auto;
min-width: 0;
}
.history-table-wrap td:nth-child(3),
.history-table-wrap td:nth-child(4) {
min-width: 240px;
}
}
@media (prefers-reduced-motion: reduce) {

Loading…
Cancel
Save