user_accaunt_restrictions

main
AlexsandrSnytkin 1 month ago
parent 8da0a00f3a
commit 1d0ac78bbd

@ -0,0 +1,6 @@
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=admin
TRIANGULATION_ADMIN_USERNAME=admin_ui
TRIANGULATION_ADMIN_PASSWORD=admin123
TRIANGULATION_VIEWER_USERNAME=viewer
TRIANGULATION_VIEWER_PASSWORD=viewer123

@ -0,0 +1,6 @@
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=admin
TRIANGULATION_ADMIN_USERNAME=admin_ui
TRIANGULATION_ADMIN_PASSWORD=admin123
TRIANGULATION_VIEWER_USERNAME=viewer
TRIANGULATION_VIEWER_PASSWORD=viewer123

@ -28,6 +28,23 @@
"ip": ""
}
},
"auth": {
"enabled": false,
"provider": "keycloak",
"session_ttl_s": 43200,
"cookie_name": "triangulation_session",
"keycloak": {
"base_url": "http://keycloak:8080",
"realm": "triangulation",
"client_id": "triangulation-ui",
"client_secret": "",
"admin_client_id": "triangulation-admin",
"admin_client_secret": "",
"user_role": "triangulation_user",
"admin_role": "triangulation_admin",
"admin_console_url": "http://127.0.0.1:38083/admin/"
}
},
"input": {
"mode": "http_sources",
"aggregation": "median",

@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

@ -1,7 +1,6 @@
services:
triangulation-test:
build: .
container_name: triangulation-test
command: ["python", "service.py", "--config", "docker/config.docker.test.json"]
ports:
- "127.0.0.1:38081:8081"
@ -10,11 +9,26 @@ services:
- receiver-r1
- receiver-r2
- output-sink
- keycloak
profiles: ["test"]
keycloak:
build:
context: .
dockerfile: docker/keycloak/Dockerfile
environment:
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
TRIANGULATION_ADMIN_USERNAME: ${TRIANGULATION_ADMIN_USERNAME:-admin_ui}
TRIANGULATION_ADMIN_PASSWORD: ${TRIANGULATION_ADMIN_PASSWORD:-admin123}
TRIANGULATION_VIEWER_USERNAME: ${TRIANGULATION_VIEWER_USERNAME:-viewer}
TRIANGULATION_VIEWER_PASSWORD: ${TRIANGULATION_VIEWER_PASSWORD:-viewer123}
ports:
- "127.0.0.1:38083:8080"
profiles: ["test"]
receiver-r0:
build: .
container_name: receiver-r0
command: ["python", "docker/mock_receiver.py", "--receiver-id", "r0", "--port", "9000", "--base-rssi", "-61.0"]
expose:
- "9000"
@ -22,7 +36,6 @@ services:
receiver-r1:
build: .
container_name: receiver-r1
command: ["python", "docker/mock_receiver.py", "--receiver-id", "r1", "--port", "9000", "--base-rssi", "-64.0"]
expose:
- "9000"
@ -30,7 +43,6 @@ services:
receiver-r2:
build: .
container_name: receiver-r2
command: ["python", "docker/mock_receiver.py", "--receiver-id", "r2", "--port", "9000", "--base-rssi", "-63.0"]
expose:
- "9000"
@ -38,7 +50,6 @@ services:
output-sink:
build: .
container_name: output-sink
command: ["python", "docker/mock_output_sink.py", "--port", "8080"]
expose:
- "8080"
@ -46,7 +57,6 @@ services:
triangulation-prod:
build: .
container_name: triangulation-prod
command: ["python", "service.py", "--config", "/app/config.json"]
ports:
- "127.0.0.1:38082:8081"

@ -28,6 +28,23 @@
"ip": "output-sink"
}
},
"auth": {
"enabled": true,
"provider": "keycloak",
"session_ttl_s": 43200,
"cookie_name": "triangulation_session",
"keycloak": {
"base_url": "http://keycloak:8080",
"realm": "triangulation",
"client_id": "triangulation-ui",
"client_secret": "triangulation-ui-secret",
"admin_client_id": "triangulation-admin",
"admin_client_secret": "triangulation-admin-secret",
"user_role": "triangulation_user",
"admin_role": "triangulation_admin",
"admin_console_url": "http://127.0.0.1:38083/admin/"
}
},
"input": {
"mode": "http_sources",
"aggregation": "median",

@ -0,0 +1,12 @@
FROM keycloak/keycloak:22.0.1
COPY docker/keycloak/triangulation-realm.template.json /opt/keycloak/templates/triangulation-realm.template.json
COPY docker/keycloak/entrypoint.sh /opt/keycloak/bin/triangulation-entrypoint.sh
USER root
RUN chmod +x /opt/keycloak/bin/triangulation-entrypoint.sh \
&& mkdir -p /opt/keycloak/templates /opt/keycloak/data/import \
&& chown -R keycloak:root /opt/keycloak/templates /opt/keycloak/data/import
USER keycloak
ENTRYPOINT ["/opt/keycloak/bin/triangulation-entrypoint.sh"]

@ -0,0 +1,23 @@
#!/bin/bash
set -euo pipefail
template="/opt/keycloak/templates/triangulation-realm.template.json"
target="/opt/keycloak/data/import/triangulation-realm.json"
escape_sed() {
printf '%s' "$1" | sed -e 's/[\/&|]/\\&/g'
}
admin_username_escaped="$(escape_sed "${TRIANGULATION_ADMIN_USERNAME:-admin_ui}")"
admin_password_escaped="$(escape_sed "${TRIANGULATION_ADMIN_PASSWORD:-admin123}")"
viewer_username_escaped="$(escape_sed "${TRIANGULATION_VIEWER_USERNAME:-viewer}")"
viewer_password_escaped="$(escape_sed "${TRIANGULATION_VIEWER_PASSWORD:-viewer123}")"
sed \
-e "s|__TRIANGULATION_ADMIN_USERNAME__|${admin_username_escaped}|g" \
-e "s|__TRIANGULATION_ADMIN_PASSWORD__|${admin_password_escaped}|g" \
-e "s|__TRIANGULATION_VIEWER_USERNAME__|${viewer_username_escaped}|g" \
-e "s|__TRIANGULATION_VIEWER_PASSWORD__|${viewer_password_escaped}|g" \
"$template" > "$target"
exec /opt/keycloak/bin/kc.sh start-dev --import-realm

@ -0,0 +1,95 @@
{
"realm": "triangulation",
"enabled": true,
"displayName": "Triangulation",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"rememberMe": true,
"roles": {
"realm": [
{
"name": "triangulation_admin",
"description": "Administrator access to the triangulation console"
},
{
"name": "triangulation_user",
"description": "Read-only access to triangulation results"
}
]
},
"clients": [
{
"clientId": "triangulation-ui",
"name": "Triangulation UI",
"enabled": true,
"publicClient": false,
"secret": "triangulation-ui-secret",
"directAccessGrantsEnabled": true,
"standardFlowEnabled": false,
"serviceAccountsEnabled": false,
"protocol": "openid-connect"
},
{
"clientId": "triangulation-admin",
"name": "Triangulation Admin Service",
"enabled": true,
"publicClient": false,
"secret": "triangulation-admin-secret",
"directAccessGrantsEnabled": false,
"standardFlowEnabled": false,
"serviceAccountsEnabled": true,
"protocol": "openid-connect"
}
],
"users": [
{
"username": "__TRIANGULATION_ADMIN_USERNAME__",
"enabled": true,
"emailVerified": true,
"firstName": "System",
"lastName": "Admin",
"credentials": [
{
"type": "password",
"value": "__TRIANGULATION_ADMIN_PASSWORD__",
"temporary": false
}
],
"realmRoles": [
"triangulation_admin"
]
},
{
"username": "__TRIANGULATION_VIEWER_USERNAME__",
"enabled": true,
"emailVerified": true,
"firstName": "Read",
"lastName": "Only",
"credentials": [
{
"type": "password",
"value": "__TRIANGULATION_VIEWER_PASSWORD__",
"temporary": false
}
],
"realmRoles": [
"triangulation_user"
]
},
{
"username": "service-account-triangulation-admin",
"enabled": true,
"serviceAccountClientId": "triangulation-admin",
"clientRoles": {
"realm-management": [
"manage-users",
"query-users",
"view-users",
"view-realm"
]
}
}
]
}

File diff suppressed because it is too large Load Diff

@ -1,5 +1,6 @@
import json
import threading
import base64
from typing import Any, Dict, List
from urllib import error, request as urllib_request
@ -23,6 +24,21 @@ class _FakeResponse:
return None
def _jwt_for_role(role_name: str, username: str = "viewer") -> str:
def _seg(payload: Dict[str, object]) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
header = _seg({"alg": "none", "typ": "JWT"})
payload = _seg(
{
"preferred_username": username,
"realm_access": {"roles": [role_name]},
}
)
return f"{header}.{payload}.signature"
def _base_config() -> Dict[str, object]:
return {
"model": {
@ -69,6 +85,28 @@ def _base_config() -> Dict[str, object]:
}
def _auth_config() -> Dict[str, object]:
config = _base_config()
config["auth"] = {
"enabled": True,
"provider": "keycloak",
"session_ttl_s": 3600,
"cookie_name": "triangulation_session",
"keycloak": {
"base_url": "http://keycloak.local",
"realm": "triangulation",
"client_id": "triangulation-ui",
"client_secret": "ui-secret",
"admin_client_id": "triangulation-admin",
"admin_client_secret": "admin-secret",
"user_role": "triangulation_user",
"admin_role": "triangulation_admin",
"admin_console_url": "http://keycloak.local/admin/",
},
}
return config
def _install_urlopen(monkeypatch: pytest.MonkeyPatch, responses: Dict[str, object]) -> None:
def _fake_urlopen(req: Any, timeout: float = 0.0):
url = getattr(req, "full_url", str(req))
@ -866,3 +904,227 @@ def test_receiver_configured_frequencies_limit_trilateration(monkeypatch: pytest
assert payload is not None
assert len(payload["frequency_table"]) == 1
assert payload["frequency_table"][0]["frequency_hz"] == pytest.approx(915e6)
def test_http_keycloak_login_creates_user_session(monkeypatch: pytest.MonkeyPatch):
config = _auth_config()
svc = service.AutoService(config)
svc.latest_payload = {
"selected_frequency_hz": 915e6,
"selected_frequency_mhz": 915.0,
"position": {"x": 1.0, "y": 2.0, "z": 3.0},
"rmse_m": 0.12,
"frequency_table": [],
}
svc.updated_at_utc = "2026-03-16T12:00:00+00:00"
svc.last_error = ""
def _fake_http_json_request(url: str, method: str = "GET", payload=None, form=None, headers=None, timeout_s: float = 2.0):
assert method == "POST"
assert form is not None
if form.get("username") == "viewer" and form.get("password") == "viewer123":
return 200, {"access_token": _jwt_for_role("triangulation_user", "viewer")}, ""
return 401, {"error": "invalid_grant"}, ""
monkeypatch.setattr(service, "_http_json_request", _fake_http_json_request)
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
login_req = urllib_request.Request(
url=f"{base_url}/auth/login",
method="POST",
data=json.dumps({"username": "viewer", "password": "viewer123"}).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib_request.urlopen(login_req) as response:
payload = json.loads(response.read().decode("utf-8"))
cookie = response.headers.get("Set-Cookie", "")
assert payload["auth"]["authenticated"] is True
assert payload["auth"]["role"] == "user"
assert "triangulation_session=" in cookie
session_req = urllib_request.Request(
url=f"{base_url}/auth/session",
headers={"Cookie": cookie.split(";", 1)[0]},
)
with urllib_request.urlopen(session_req) as response:
session_payload = json.loads(response.read().decode("utf-8"))
assert session_payload["auth"]["authenticated"] is True
assert session_payload["auth"]["visible_sections"] == [
"overview",
"frequencies",
"io",
"history",
]
result_req = urllib_request.Request(
url=f"{base_url}/result",
headers={"Cookie": cookie.split(";", 1)[0]},
)
with urllib_request.urlopen(result_req) as response:
result_payload = json.loads(response.read().decode("utf-8"))
assert result_payload["status"] == "ok"
assert result_payload["data"]["position"]["x"] == pytest.approx(1.0)
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)
def test_http_keycloak_user_can_view_status_but_not_admin_config(monkeypatch: pytest.MonkeyPatch):
config = _auth_config()
svc = service.AutoService(config)
monkeypatch.setattr(
service,
"_http_json_request",
lambda *args, **kwargs: (200, {"access_token": _jwt_for_role("triangulation_user", "viewer")}, ""),
)
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
login_req = urllib_request.Request(
url=f"{base_url}/auth/login",
method="POST",
data=json.dumps({"username": "viewer", "password": "viewer123"}).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib_request.urlopen(login_req) as response:
cookie = response.headers.get("Set-Cookie", "")
config_req = urllib_request.Request(
url=f"{base_url}/config",
headers={"Cookie": cookie.split(";", 1)[0]},
)
with pytest.raises(error.HTTPError) as exc_info:
urllib_request.urlopen(config_req)
assert exc_info.value.code == 403
controls_req = urllib_request.Request(
url=f"{base_url}/mock/controls",
headers={"Cookie": cookie.split(";", 1)[0]},
)
with urllib_request.urlopen(controls_req) as response:
controls_payload = json.loads(response.read().decode("utf-8"))
assert "inputs" in controls_payload
assert "outputs" in controls_payload
users_req = urllib_request.Request(
url=f"{base_url}/users",
headers={"Cookie": cookie.split(";", 1)[0]},
)
with pytest.raises(error.HTTPError) as exc_info:
urllib_request.urlopen(users_req)
assert exc_info.value.code == 403
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)
def test_http_keycloak_admin_can_open_redacted_config(monkeypatch: pytest.MonkeyPatch):
config = _auth_config()
svc = service.AutoService(config)
monkeypatch.setattr(
service,
"_http_json_request",
lambda *args, **kwargs: (200, {"access_token": _jwt_for_role("triangulation_admin", "admin_ui")}, ""),
)
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
login_req = urllib_request.Request(
url=f"{base_url}/auth/login",
method="POST",
data=json.dumps({"username": "admin_ui", "password": "admin123"}).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib_request.urlopen(login_req) as response:
cookie = response.headers.get("Set-Cookie", "")
config_req = urllib_request.Request(
url=f"{base_url}/config",
headers={"Cookie": cookie.split(";", 1)[0]},
)
with urllib_request.urlopen(config_req) as response:
payload = json.loads(response.read().decode("utf-8"))
assert payload["config"]["auth"]["keycloak"]["client_secret"] == ""
assert payload["config"]["auth"]["keycloak"]["admin_client_secret"] == ""
assert payload["config"]["auth"]["keycloak"]["client_secret_set"] is True
assert payload["config"]["auth"]["keycloak"]["admin_client_secret_set"] is True
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)
def test_http_keycloak_admin_can_list_users(monkeypatch: pytest.MonkeyPatch):
config = _auth_config()
svc = service.AutoService(config)
monkeypatch.setattr(
service,
"_http_json_request",
lambda *args, **kwargs: (200, {"access_token": _jwt_for_role("triangulation_admin", "admin_ui")}, ""),
)
monkeypatch.setattr(service.AutoService, "get_admin_access_token", lambda self: "admin-token")
def _fake_keycloak_admin_request(
svc: service.AutoService,
path: str,
method: str = "GET",
payload=None,
json_body=None,
timeout_s: float = 5.0,
):
if path == "/users?max=200":
return 200, [
{
"id": "u1",
"username": "admin_ui",
"enabled": True,
"firstName": "System",
"lastName": "Admin",
},
{
"id": "u2",
"username": "viewer",
"enabled": True,
"firstName": "Read",
"lastName": "Only",
},
], ""
if path == "/users/u1/role-mappings/realm":
return 200, [{"name": "triangulation_admin"}], ""
if path == "/users/u2/role-mappings/realm":
return 200, [{"name": "triangulation_user"}], ""
raise AssertionError(f"Unexpected path: {path}")
monkeypatch.setattr(service, "_keycloak_admin_request", _fake_keycloak_admin_request)
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
login_req = urllib_request.Request(
url=f"{base_url}/auth/login",
method="POST",
data=json.dumps({"username": "admin_ui", "password": "admin123"}).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib_request.urlopen(login_req) as response:
cookie = response.headers.get("Set-Cookie", "")
users_req = urllib_request.Request(
url=f"{base_url}/users",
headers={"Cookie": cookie.split(";", 1)[0]},
)
with urllib_request.urlopen(users_req) as response:
payload = json.loads(response.read().decode("utf-8"))
assert payload["status"] == "ok"
assert [row["username"] for row in payload["users"]] == ["admin_ui", "viewer"]
assert payload["users"][0]["role"] == "admin"
assert payload["users"][1]["role"] == "user"
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)

@ -3,6 +3,8 @@
frequencies: null,
health: null,
config: null,
auth: null,
users: [],
writeToken: "",
activeSection: "overview",
selectedReceiverIndex: 0,
@ -39,6 +41,7 @@
lastDeliveryStatus: "n/a",
timezone: "local",
uiDensity: "detailed",
uiTheme: "aurora",
};
const HZ_IN_MHZ = 1_000_000;
@ -47,12 +50,14 @@ 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 UI_THEME_STORAGE_KEY = "triangulation.ui_theme";
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 UI_THEME_OPTIONS = ["aurora", "signal", "slate"];
const TIMEZONE_OPTIONS = [
{ value: "local", label: "Локальный (браузер)" },
{ value: "UTC", label: "UTC" },
@ -108,6 +113,15 @@ function localizeStatus(value) {
return mapping[status] || status;
}
function localizeThemeName(value) {
const mapping = {
aurora: "свет",
signal: "сигнал",
slate: "графит",
};
return mapping[String(value || "").toLowerCase()] || "свет";
}
function localizeErrorMessage(message) {
const text = String(message || "неизвестная ошибка");
const known = {
@ -115,6 +129,10 @@ function localizeErrorMessage(message) {
"at least 1 output server is required": "необходим минимум 1 выходной сервер",
Unauthorized: "доступ запрещён (проверьте токен)",
"unauthorized: missing or invalid API token": "доступ запрещён: отсутствует или неверный API-токен",
"authentication required": "требуется вход в систему",
"admin role required": "требуются права администратора",
"username and password are required": "нужны логин и пароль",
"user does not have required Keycloak role": "у пользователя нет нужной роли Keycloak",
warming_up: "прогрев",
not_found: "не найдено",
"no data yet": "данные пока не получены",
@ -198,6 +216,57 @@ function authHeaders() {
};
}
function authEnabled() {
return Boolean(state.auth?.enabled);
}
function isAuthenticated() {
return Boolean(state.auth?.authenticated) || !authEnabled();
}
function isAdmin() {
return String(state.auth?.role || "") === "admin" || !authEnabled();
}
function visibleSections() {
if (!authEnabled()) {
return ["overview", "frequencies", "io", "history", "servers", "json"];
}
const sections = Array.isArray(state.auth?.visible_sections) ? state.auth.visible_sections : [];
return sections.length > 0 ? sections : [];
}
function applyServerReadOnlyMode() {
const readOnly = !isAdmin();
[
"rx-id",
"rx-url",
"rx-frequencies",
"rx-center-x",
"rx-center-y",
"rx-center-z",
"shared-filter-enabled",
"shared-min-freq",
"shared-max-freq",
"shared-min-rssi",
"shared-max-rssi",
"out-name",
"out-ip",
"write-token",
].forEach((id) => {
const el = byId(id);
if (!el) return;
el.disabled = readOnly;
if ("readOnly" in el && el.tagName === "INPUT") {
el.readOnly = readOnly;
}
});
const stateBadge = byId("servers-state");
if (stateBadge && readOnly) {
stateBadge.textContent = "узлы: только просмотр";
}
}
function setActiveSection(section) {
state.activeSection = section;
document.querySelectorAll(".panel").forEach((panel) => {
@ -212,6 +281,102 @@ function setActiveSection(section) {
}
}
function ensureValidActiveSection() {
const allowed = visibleSections();
if (allowed.includes(state.activeSection)) return;
state.activeSection = allowed[0] || "overview";
}
function applySectionAccess() {
const allowed = new Set(visibleSections());
document.querySelectorAll(".menu-item").forEach((item) => {
const section = String(item.dataset.section || "");
item.hidden = !allowed.has(section);
});
document.querySelectorAll(".panel").forEach((panel) => {
const section = panel.id.replace("section-", "");
panel.hidden = !allowed.has(section);
});
const usersCard = byId("users-card");
if (usersCard) {
usersCard.hidden = !isAdmin();
}
document.querySelectorAll(".menu-group").forEach((group) => {
const hasVisibleItems = Array.from(group.querySelectorAll(".menu-item")).some((item) => !item.hidden);
group.hidden = !hasVisibleItems;
});
const configGroup = document.querySelector('.menu-group[data-menu-group="config"]');
if (configGroup && !isAdmin()) {
configGroup.hidden = true;
}
ensureValidActiveSection();
setActiveSection(state.activeSection);
}
function renderAuthUi() {
const overlay = byId("auth-overlay");
const userChip = byId("auth-user-chip");
const roleChip = byId("auth-role-chip");
const logoutButton = byId("logout-button");
const loginState = byId("login-state");
const shell = byId("app-shell");
const body = document.body;
if (body) {
body.classList.remove("role-admin", "role-user", "role-guest");
if (isAdmin()) {
body.classList.add("role-admin");
} else if (isAuthenticated()) {
body.classList.add("role-user");
} else {
body.classList.add("role-guest");
}
}
if (userChip) {
userChip.textContent = `пользователь: ${state.auth?.username || "гость"}`;
}
if (roleChip) {
roleChip.textContent = `роль: ${state.auth?.role || "-"}`;
}
if (logoutButton) {
logoutButton.hidden = !isAuthenticated() || !authEnabled();
}
if (overlay) {
const shouldShow = authEnabled() && !isAuthenticated();
overlay.classList.toggle("auth-overlay-hidden", !shouldShow);
}
if (shell) {
shell.classList.toggle("app-shell-locked", authEnabled() && !isAuthenticated());
}
[
"load-config",
"save-config",
"load-servers",
"save-servers",
"add-receiver",
"remove-receiver",
"add-output-server",
"remove-output-server",
].forEach((id) => {
const el = byId(id);
if (el) el.hidden = !isAdmin();
});
const ioAdminControls = byId("io-admin-controls");
if (ioAdminControls) {
ioAdminControls.hidden = !isAdmin();
}
const historyAdminActions = byId("history-admin-actions");
if (historyAdminActions) {
historyAdminActions.hidden = !isAdmin();
}
if (loginState && authEnabled() && !isAuthenticated()) {
loginState.textContent = "авторизация: требуется вход";
}
applySectionAccess();
applyServerReadOnlyMode();
}
function setMenuCollapsed(isCollapsed) {
state.menuCollapsed = Boolean(isCollapsed);
const sideNav = byId("side-nav");
@ -320,8 +485,8 @@ function setDateTimeCollapsed(isCollapsed) {
}
if (toggle) {
toggle.textContent = state.dateTimeCollapsed
? "Показать служебную панель"
: "Скрыть служебную панель";
? "Показать панель"
: "Скрыть панель";
toggle.setAttribute("aria-expanded", String(!state.dateTimeCollapsed));
}
@ -373,6 +538,27 @@ function normalizeUiDensity(value) {
return String(value || "").toLowerCase() === "compact" ? "compact" : "detailed";
}
function normalizeUiTheme(value) {
const next = String(value || "").toLowerCase();
return UI_THEME_OPTIONS.includes(next) ? next : "aurora";
}
function saveUiThemePreference(value) {
try {
localStorage.setItem(UI_THEME_STORAGE_KEY, normalizeUiTheme(value));
} catch {
// Ignore localStorage errors in restricted environments.
}
}
function readUiThemePreference() {
try {
return normalizeUiTheme(localStorage.getItem(UI_THEME_STORAGE_KEY) || "aurora");
} catch {
return "aurora";
}
}
function saveUiDensityPreference(value) {
try {
localStorage.setItem(UI_DENSITY_STORAGE_KEY, normalizeUiDensity(value));
@ -394,10 +580,20 @@ function applyUiDensity() {
document.body.classList.toggle("ui-compact", compact);
const toggle = byId("density-toggle");
if (toggle) {
toggle.textContent = compact ? "Режим: компактный" : "Режим: детальный";
toggle.textContent = compact ? "Вид: компактный" : "Вид: детальный";
}
}
function applyUiTheme() {
const nextTheme = normalizeUiTheme(state.uiTheme);
document.body.dataset.theme = nextTheme;
document.querySelectorAll(".theme-chip").forEach((button) => {
const isActive = normalizeUiTheme(button.dataset.themeValue) === nextTheme;
button.classList.toggle("theme-chip-active", isActive);
button.setAttribute("aria-pressed", String(isActive));
});
}
function setUiDensity(value, options = {}) {
const { persist = true } = options;
state.uiDensity = normalizeUiDensity(value);
@ -407,6 +603,15 @@ function setUiDensity(value, options = {}) {
}
}
function setUiTheme(value, options = {}) {
const { persist = true } = options;
state.uiTheme = normalizeUiTheme(value);
applyUiTheme();
if (persist) {
saveUiThemePreference(state.uiTheme);
}
}
function normalizePollIntervalMs(valueMs) {
const numeric = Number(valueMs);
if (!Number.isFinite(numeric)) return AUTO_REFRESH_DEFAULT_MS;
@ -631,13 +836,13 @@ function renderInputFlow(data) {
if (!root) return;
const receivers = Array.isArray(data?.receivers) ? data.receivers : [];
if (receivers.length === 0) {
root.innerHTML = '<div class="io-empty">Ожидание входных измерений от ресиверов.</div>';
root.innerHTML = '<div class="io-empty">Ожидание данных от приемников.</div>';
return;
}
root.innerHTML = receivers
.map((receiver) => {
const receiverId = escapeHtml(receiver?.receiver_id || "n/a");
const receiverId = escapeHtml(receiver?.receiver_id || "н/д");
const sourceUrl = escapeHtml(receiver?.source_url || "-");
const center = receiver?.center || {};
const samples = Array.isArray(receiver?.samples) ? receiver.samples : [];
@ -741,7 +946,7 @@ function renderOutputFlow(data, delivery) {
return `
<article class="io-card">
<header class="io-card-head">
<h4>${escapeHtml(server?.name || "output")}</h4>
<h4>${escapeHtml(server?.name || "выход")}</h4>
<span class="io-chip ${statusCls}">${escapeHtml(status)}</span>
</header>
<div class="io-meta-grid">
@ -768,7 +973,7 @@ function buildIoHistoryRow(data, delivery) {
const rssiValues = [];
const inputItems = receivers.map((receiver) => {
const receiverId = String(receiver?.receiver_id || "n/a");
const receiverId = String(receiver?.receiver_id || "н/д");
const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null;
const perFrequency =
findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null;
@ -790,7 +995,7 @@ function buildIoHistoryRow(data, delivery) {
return [`X=${fmt(pos?.x, 3)} Y=${fmt(pos?.y, 3)} Z=${fmt(pos?.z, 3)}`];
}
return servers.map((server) => {
const name = String(server?.name || "output");
const name = String(server?.name || "выход");
const status = localizeStatus(server?.status);
const code = server?.http_status ?? "-";
return `${name}: ${status} (${code})`;
@ -1108,13 +1313,13 @@ 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>';
root.innerHTML = '<div class="io-empty">Сигналы от приемников еще не получены.</div>';
return;
}
root.innerHTML = receivers
.map((receiver) => {
const receiverId = String(receiver?.receiver_id || "n/a");
const receiverId = String(receiver?.receiver_id || "н/д");
const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null;
const row = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null;
@ -1188,28 +1393,28 @@ function renderOverviewPipeline(summary) {
const stages = [
{
name: "Сбор входов",
name: "Прием",
status: inputStatus,
value: `${summary.inputOnline}/${summary.inputTotal}`,
note: "доступность входных серверов",
note: "доступность приемников",
},
{
name: "Решение",
status: solveStatus,
value: hasData ? `RMSE ${fmt(summary.data?.rmse_m, 2)} м` : "ожидание данных",
note: "оценка точности пересечения сфер",
note: "расчет координат",
},
{
name: "Доставка",
name: "Отправка",
status: summary.delivery?.status || "warming_up",
value: `${summary.outputOnline}/${summary.outputTotal}`,
note: "доступность выходных серверов",
note: "доступность серверов отправки",
},
{
name: "История",
name: "Журнал",
status: summary.total > 0 ? (summary.success >= 80 ? "ok" : "partial") : "warming_up",
value: `${summary.success}%`,
note: "успешность доставки по накопленной ленте",
note: "успех по накопленным событиям",
},
];
@ -1225,7 +1430,7 @@ function renderOverviewPipeline(summary) {
<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>
<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>
@ -1343,9 +1548,9 @@ 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>${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>${escapeHtml(deliveryStatus)}</b></div>
<div class="monitor-row"><span>Входы онлайн</span><b>${summary.inputOnline}/${summary.inputTotal}</b></div>
<div class="monitor-row"><span>Выходы онлайн</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>${summary.success}%</b></div>
<div class="metric-track"><span style="width:${summary.success}%"></span></div>
@ -1470,7 +1675,7 @@ function renderIoHistory() {
}
if (filtered.length === 0) {
let text = "История пока пуста.";
let text = "Журнал пока пуст.";
if (state.ioHistory.length > 0) {
if (hasDateFilter && state.historyFilter !== "all") {
text = "По статусу и диапазону времени записей нет.";
@ -1532,7 +1737,7 @@ function boolStateLabel(value, trueLabel, falseLabel) {
function buildControlCardHtml(item, target) {
const id = String(item?.id || "");
const name = escapeHtml(item?.name || id || "n/a");
const name = escapeHtml(item?.name || id || "н/д");
const reachable = Boolean(item?.reachable);
const errorText = item?.error ? escapeHtml(item.error) : "";
const stateValue = target === "input" ? item?.enabled : item?.accept_writes;
@ -1554,7 +1759,7 @@ function buildControlCardHtml(item, target) {
const statusKind =
!reachable && stateValue === null ? "io-status-error" : isActive ? "io-status-ok" : "io-status-skipped";
const reachabilityKind = reachable ? "io-status-ok" : "io-status-error";
const reachabilityText = reachable ? "online" : "offline";
const reachabilityText = reachable ? "онлайн" : "офлайн";
const disabledAttr = !id ? "disabled" : "";
const liveFrequencies = Array.isArray(item?.frequencies_mhz)
? item.frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ")
@ -1634,26 +1839,26 @@ function renderErrorControls() {
const inputHtml =
inputs.length === 0
? '<div class="io-empty">Входные источники не обнаружены.</div>'
? '<div class="io-empty">Приемники не найдены.</div>'
: inputs.map((item) => buildControlCardHtml(item, "input")).join("");
const outputHtml =
outputs.length === 0
? '<div class="io-empty">Выходные серверы не обнаружены.</div>'
? '<div class="io-empty">Серверы отправки не найдены.</div>'
: outputs.map((item) => buildControlCardHtml(item, "output")).join("");
root.innerHTML = `
<div class="io-control-grid io-control-grid-compact">
<section class="io-block io-control-panel">
<div class="io-control-panel-head">
<h4>Входные Потоки</h4>
<h4>Прием</h4>
<span class="io-chip io-chip-neutral">${inputActiveCount}/${inputs.length} активны</span>
</div>
<div class="io-control-list">${inputHtml}</div>
</section>
<section class="io-block io-control-panel">
<div class="io-control-panel-head">
<h4>Выходные Потоки</h4>
<h4>Отправка</h4>
<span class="io-chip io-chip-neutral">${outputActiveCount}/${outputs.length} активны</span>
</div>
<div class="io-control-list">${outputHtml}</div>
@ -1761,7 +1966,7 @@ function addReceiverDraft() {
function removeReceiverDraft() {
if (state.receiverDrafts.length <= 3) {
byId("servers-state").textContent = "серверы: необходимо минимум 3 входа";
byId("servers-state").textContent = "узлы: нужно минимум 3 приемника";
return;
}
state.receiverDrafts.splice(state.selectedReceiverIndex, 1);
@ -1833,7 +2038,7 @@ function addOutputDraft() {
function removeOutputDraft() {
if (state.outputDrafts.length <= 1) {
byId("servers-state").textContent = "серверы: необходим минимум 1 выход";
byId("servers-state").textContent = "узлы: нужен минимум 1 сервер отправки";
return;
}
state.outputDrafts.splice(state.selectedOutputIndex, 1);
@ -1846,6 +2051,10 @@ async function getJson(url) {
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401 && authEnabled() && url !== "/auth/session") {
state.auth = { ...(state.auth || {}), authenticated: false, username: "", role: "", visible_sections: [] };
renderAuthUi();
}
throw new Error(data.error || data.status || `HTTP ${res.status}`);
}
return data;
@ -1859,11 +2068,141 @@ async function postJson(url, payload) {
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401 && authEnabled() && url !== "/auth/login") {
state.auth = { ...(state.auth || {}), authenticated: false, username: "", role: "", visible_sections: [] };
renderAuthUi();
}
throw new Error(data.error || data.status || `HTTP ${res.status}`);
}
return data;
}
function clearUserForm() {
const ids = [
"user-id",
"user-username",
"user-first-name",
"user-last-name",
"user-password",
];
ids.forEach((id) => {
const input = byId(id);
if (input) input.value = "";
});
const userRole = byId("user-role");
if (userRole) userRole.value = "user";
const userEnabled = byId("user-enabled");
if (userEnabled) userEnabled.value = "true";
}
function fillUserForm(user) {
byId("user-id").value = user?.id || "";
byId("user-username").value = user?.username || "";
byId("user-first-name").value = user?.first_name || "";
byId("user-last-name").value = user?.last_name || "";
byId("user-role").value = user?.role || "user";
byId("user-enabled").value = String(Boolean(user?.enabled));
byId("user-password").value = "";
}
function renderUsers() {
const tbody = byId("users-table")?.querySelector("tbody");
if (!tbody) return;
tbody.innerHTML = (state.users || [])
.map(
(user) => `
<tr class="user-row" data-user-id="${escapeHtml(user.id || "")}">
<td>${escapeHtml(user.username || "")}</td>
<td>${escapeHtml(user.role || "user")}</td>
<td>${user.enabled ? "включён" : "отключён"}</td>
<td>${escapeHtml([user.first_name, user.last_name].filter(Boolean).join(" ") || "-")}</td>
</tr>`
)
.join("");
tbody.querySelectorAll("tr").forEach((row) => {
row.addEventListener("click", () => {
const userId = row.dataset.userId;
const user = state.users.find((item) => String(item.id) === String(userId));
if (user) fillUserForm(user);
});
});
}
async function loadUsers() {
if (!isAdmin()) return;
try {
const payload = await getJson("/users");
state.users = Array.isArray(payload.users) ? payload.users : [];
renderUsers();
byId("users-state").textContent = `пользователи: ${state.users.length}`;
} catch (err) {
byId("users-state").textContent = `пользователи: ${localizeErrorMessage(err.message)}`;
}
}
async function submitUserAction(action) {
if (!isAdmin()) return;
const payload = {
action,
user_id: byId("user-id").value.trim(),
username: byId("user-username").value.trim(),
first_name: byId("user-first-name").value.trim(),
last_name: byId("user-last-name").value.trim(),
role: byId("user-role").value,
enabled: byId("user-enabled").value === "true",
password: byId("user-password").value,
};
try {
const response = await postJson("/users", payload);
state.users = Array.isArray(response.users) ? response.users : state.users;
renderUsers();
byId("users-state").textContent = "пользователи: сохранено";
if (action !== "update") clearUserForm();
} catch (err) {
byId("users-state").textContent = `пользователи: ${localizeErrorMessage(err.message)}`;
}
}
async function loadAuthSession() {
const payload = await getJson("/auth/session");
state.auth = payload.auth || null;
renderAuthUi();
}
async function login() {
const username = byId("login-username").value.trim();
const password = byId("login-password").value;
try {
const payload = await postJson("/auth/login", { username, password });
state.auth = payload.auth || null;
byId("login-password").value = "";
byId("login-state").textContent = "авторизация: выполнена";
renderAuthUi();
await loadAll();
if (isAdmin()) {
await loadConfig();
await loadUsers();
}
} catch (err) {
byId("login-state").textContent = `авторизация: ${localizeErrorMessage(err.message)}`;
}
}
async function logout() {
try {
await postJson("/auth/logout", {});
} catch {
// Ignore local logout errors and still reset the client state.
}
state.auth = authEnabled()
? { ...(state.auth || {}), authenticated: false, username: "", role: "", visible_sections: [] }
: null;
state.config = null;
state.users = [];
renderUsers();
renderAuthUi();
}
function render() {
const data = state.result?.data;
const delivery = state.result?.output_delivery || state.frequencies?.output_delivery;
@ -1920,39 +2259,47 @@ function render() {
}
async function loadAll() {
const [healthRes, resultRes, freqRes, controlsRes] = await Promise.allSettled([
getJson("/health"),
getJson("/result"),
getJson("/frequencies"),
getJson("/mock/controls"),
]);
state.health = healthRes.status === "fulfilled" ? healthRes.value : { status: "error" };
state.result = resultRes.status === "fulfilled" ? resultRes.value : null;
state.frequencies = freqRes.status === "fulfilled" ? freqRes.value : null;
state.mockControls = controlsRes.status === "fulfilled" ? controlsRes.value : null;
const requests = [getJson("/health")];
if (isAuthenticated()) {
requests.push(getJson("/result"));
requests.push(getJson("/frequencies"));
requests.push(getJson("/mock/controls"));
}
const results = await Promise.allSettled(requests);
state.health = results[0]?.status === "fulfilled" ? results[0].value : { status: "error" };
state.result = results[1]?.status === "fulfilled" ? results[1].value : null;
state.frequencies = results[2]?.status === "fulfilled" ? results[2].value : null;
state.mockControls = results[3]?.status === "fulfilled" ? results[3].value : null;
render();
}
async function refreshNow() {
if (!isAdmin()) {
await loadAll();
return;
}
await postJson("/refresh", {});
await loadAll();
}
async function loadConfig() {
if (!isAdmin()) return;
try {
const config = await getJson("/config");
state.config = config.config || null;
byId("config-editor").value = JSON.stringify(config.config, null, 2);
fillServerForm();
byId("config-state").textContent = "конфиг: загружен";
byId("servers-state").textContent = "серверы: загружены";
byId("servers-state").textContent = "узлы: загружены";
applyServerReadOnlyMode();
} catch (err) {
byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`;
byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`;
byId("servers-state").textContent = `узлы: ${localizeErrorMessage(err.message)}`;
}
}
async function saveConfig() {
if (!isAdmin()) return;
const raw = byId("config-editor").value.trim();
try {
const parsed = JSON.parse(raw);
@ -2005,6 +2352,7 @@ function fillServerForm() {
}
async function saveServers() {
if (!isAdmin()) return;
try {
if (!state.config) {
await loadConfig();
@ -2064,19 +2412,36 @@ async function saveServers() {
? result.mock_input_frequency_sync_errors.filter((item) => String(item || "").trim() !== "")
: [];
byId("servers-state").textContent = result.restart_required
? `серверы: сохранены, требуется перезапуск${saveSuffix}`
: `серверы: сохранены${saveSuffix}`;
? `узлы: сохранены, требуется перезапуск${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)}`;
byId("servers-state").textContent = `узлы: ${localizeErrorMessage(err.message)}`;
}
}
function bindUi() {
const loginSubmit = byId("login-submit");
if (loginSubmit) {
loginSubmit.addEventListener("click", login);
}
const loginPassword = byId("login-password");
if (loginPassword) {
loginPassword.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
login();
}
});
}
const logoutButton = byId("logout-button");
if (logoutButton) {
logoutButton.addEventListener("click", logout);
}
byId("refresh-now").addEventListener("click", refreshNow);
const timezoneSelect = byId("timezone-select");
if (timezoneSelect) {
@ -2103,6 +2468,20 @@ function bindUi() {
byId("save-config").addEventListener("click", saveConfig);
byId("load-servers").addEventListener("click", loadConfig);
byId("save-servers").addEventListener("click", saveServers);
const loadUsersButton = byId("load-users");
if (loadUsersButton) loadUsersButton.addEventListener("click", loadUsers);
const createUserButton = byId("create-user");
if (createUserButton) createUserButton.addEventListener("click", () => submitUserAction("create"));
const updateUserButton = byId("update-user");
if (updateUserButton) updateUserButton.addEventListener("click", () => submitUserAction("update"));
const resetPasswordButton = byId("reset-user-password");
if (resetPasswordButton) {
resetPasswordButton.addEventListener("click", () => submitUserAction("set_password"));
}
const deleteUserButton = byId("delete-user");
if (deleteUserButton) deleteUserButton.addEventListener("click", () => submitUserAction("delete"));
const clearUserFormButton = byId("clear-user-form");
if (clearUserFormButton) clearUserFormButton.addEventListener("click", clearUserForm);
byId("add-receiver").addEventListener("click", addReceiverDraft);
byId("remove-receiver").addEventListener("click", removeReceiverDraft);
@ -2124,9 +2503,12 @@ function bindUi() {
state.writeToken = event.target.value;
});
byId("menu-toggle").addEventListener("click", () => {
const menuToggle = byId("menu-toggle");
if (menuToggle) {
menuToggle.addEventListener("click", () => {
setMenuCollapsed(!state.menuCollapsed);
});
}
document.querySelectorAll(".menu-group-toggle").forEach((toggle) => {
toggle.addEventListener("click", () => {
@ -2158,6 +2540,15 @@ function bindUi() {
});
}
document.querySelectorAll(".theme-chip").forEach((button) => {
button.addEventListener("click", () => {
const nextTheme = normalizeUiTheme(button.dataset.themeValue);
if (nextTheme === state.uiTheme) return;
setUiTheme(nextTheme, { persist: true });
showToast(`Тема интерфейса: ${localizeThemeName(nextTheme)}.`, "info");
});
});
const historyFilter = byId("history-filter");
if (historyFilter) {
historyFilter.addEventListener("change", (event) => {
@ -2263,13 +2654,20 @@ async function boot() {
state.menuGroupCollapsed = readMenuGroupCollapsed();
applyAllMenuGroupsCollapsed();
setUiDensity(readUiDensityPreference(), { persist: false });
setUiTheme(readUiThemePreference(), { persist: false });
setPollIntervalMs(readPollIntervalPreference(), { persist: false, restartPolling: false });
updateHistoryRecordingUi();
setMenuCollapsed(readMenuCollapsed());
setDateTimeCollapsed(readDateTimeCollapsed());
updateRefreshUi();
await loadAuthSession();
setActiveSection(state.activeSection);
if (isAdmin()) {
await loadConfig();
}
if (isAdmin()) {
await loadUsers();
}
await loadAll();
startPolling();
}

@ -3,18 +3,42 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Панель Триангуляции</title>
<title>Радиотрекинг</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="stylesheet" href="/static/styles.css?v=20260312r5" />
<link rel="stylesheet" href="/static/styles.css?v=20260319r2" />
</head>
<body>
<div class="bg-glow bg-glow-a"></div>
<div class="bg-glow bg-glow-b"></div>
<div id="auth-overlay" class="auth-overlay auth-overlay-hidden">
<div class="auth-dialog card">
<h2>Вход в систему</h2>
<p class="muted">Авторизация выполняется через Keycloak.</p>
<label class="field-control">
<span class="field-label">Логин</span>
<input id="login-username" type="text" autocomplete="username" />
</label>
<label class="field-control">
<span class="field-label">Пароль</span>
<input id="login-password" type="password" autocomplete="current-password" />
</label>
<div class="editor-actions">
<button id="login-submit" class="btn btn-primary" type="button">Войти</button>
<span id="login-state" class="badge">авторизация: ожидание</span>
</div>
</div>
</div>
<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 class="auth-summary">
<span id="auth-user-chip" class="badge badge-meta">пользователь: гость</span>
<span id="auth-role-chip" class="badge badge-meta">роль: -</span>
<button id="logout-button" class="btn btn-compact" type="button">Выйти</button>
</div>
</div>
<div class="menu-wrap">
@ -27,14 +51,14 @@
aria-controls="menu-group-monitoring"
aria-expanded="true"
>
<span class="menu-group-title">Мониторинг</span>
<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>
<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>
@ -52,19 +76,19 @@
aria-controls="menu-group-io"
aria-expanded="true"
>
<span class="menu-group-title">Потоки</span>
<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>
<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>
<span class="menu-item-note">Журнал</span>
</button>
</div>
</section>
@ -77,19 +101,19 @@
aria-controls="menu-group-config"
aria-expanded="true"
>
<span class="menu-group-title">Конфигурация</span>
<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>
<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>
<span class="menu-item-text">Конфиг</span>
<span class="menu-item-note">Файл</span>
</button>
</div>
</section>
@ -105,9 +129,9 @@
aria-controls="meta-panel"
aria-expanded="true"
>
Скрыть служебную панель
Скрыть панель
</button>
<button id="density-toggle" class="btn btn-compact" type="button">Режим: детальный</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">
@ -129,8 +153,8 @@
<section class="content-area">
<section id="section-overview" class="panel panel-active">
<header class="hero card overview-hero">
<h2>Панель Радиопозиционирования</h2>
<p class="muted">Мониторинг и управление расчётом 3D триангуляции.</p>
<h2>Радиотрекинг</h2>
<p class="muted">Координаты по RSSI и частотам.</p>
<div class="hero-actions">
<button id="refresh-now" class="btn btn-primary">Обновить</button>
<button id="toggle-auto-refresh" class="btn" type="button">Пауза автообновления</button>
@ -144,18 +168,18 @@
<div class="overview-layout">
<article class="card overview-position-card">
<h2>Итоговая Позиция</h2>
<h2>Координаты</h2>
<div class="result-box">
<div><span class="muted">Выбранная частота:</span> <b id="selected-freq">-</b></div>
<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><span class="muted">Ошибка:</span> <b id="rmse">-</b></div>
</div>
</article>
<article class="card monitor-board overview-monitor-card">
<h2>Оперативный Мониторинг</h2>
<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>
@ -166,26 +190,26 @@
<section class="monitor-panel monitor-kpi-panel">
<div class="overview-metrics">
<div class="metric-tile">
<span class="metric-title">Входы online</span>
<span class="metric-title">Входы онлайн</span>
<b id="ov-input-online" class="metric-value">0/0</b>
</div>
<div class="metric-tile">
<span class="metric-title">Выходы online</span>
<span class="metric-title">Выходы онлайн</span>
<b id="ov-output-online" class="metric-value">0/0</b>
</div>
<div class="metric-tile">
<span class="metric-title">События в истории</span>
<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>
<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>
<h3>Потоки</h3>
<div class="monitor-flow-row">
<span>Входные потоки</span>
<b id="ov-input-online-bar-text">0/0</b>
@ -201,7 +225,7 @@
<span id="ov-output-online-bar" style="width:0%"></span>
</div>
<div class="monitor-flow-row">
<span>Успешная доставка</span>
<span>Успех отправки</span>
<b id="ov-delivery-bar-text">0%</b>
</div>
<div class="monitor-progress monitor-progress-accent">
@ -215,7 +239,7 @@
<section id="section-frequencies" class="panel">
<article class="card">
<h2>Таблица По Частотам</h2>
<h2>Решения по частотам</h2>
<div class="table-wrap">
<table id="freq-table">
<thead>
@ -234,18 +258,18 @@
</article>
<article class="card monitor-board">
<h2>Аналитика Частот</h2>
<h2>Аналитика</h2>
<div class="monitor-grid">
<section class="monitor-panel monitor-frequency-panel">
<h3>Профиль Частот</h3>
<h3>Диапазон</h3>
<div id="ov-frequency-health" class="frequency-health-list"></div>
</section>
<section class="monitor-panel monitor-topfreq-panel">
<h3>Лидеры Частот</h3>
<h3>Лучшие частоты</h3>
<div id="ov-top-frequencies" class="top-frequencies"></div>
</section>
<section class="monitor-panel monitor-trends-panel">
<h3>Тренд Точности</h3>
<h3>Точность</h3>
<div class="trend-stack">
<article class="trend-card">
<div class="trend-head">
@ -262,36 +286,38 @@
<section id="section-io" class="panel">
<article class="card">
<h2>Входные И Выходные Данные</h2>
<p class="muted">Оперативный мониторинг входящих измерений и фактической отправки на выходные серверы.</p>
<h2>Сигнал и отправка</h2>
<p class="muted">Приём данных и отправка координат.</p>
<div class="io-grid">
<section class="io-block">
<h3>Входные Данные (Ресиверы)</h3>
<h3>Приёмники</h3>
<div id="input-flow" class="io-list"></div>
</section>
<section class="io-block">
<h3>Выходные Данные (Отправка)</h3>
<h3>Отправка</h3>
<div id="output-flow" class="io-list"></div>
</section>
</div>
<h3 class="io-history-title">Управление Тестовыми Сбоями</h3>
<div id="io-admin-controls">
<h3 class="io-history-title">Тестовые сбои</h3>
<div id="error-controls" class="io-list"></div>
</div>
</article>
<article class="card monitor-board">
<h2>Контур Входа/Выхода</h2>
<h2>Обработка</h2>
<div class="monitor-grid">
<section class="monitor-panel monitor-signal-panel">
<h3>Сигналы Ресиверов</h3>
<h3>Сигналы</h3>
<div id="ov-signal-grid" class="signal-grid"></div>
</section>
<section class="monitor-panel monitor-stage-panel">
<h3>Контур Обработки</h3>
<h3>Этапы</h3>
<div id="ov-pipeline-stages" class="pipeline-stages"></div>
</section>
<section class="monitor-panel monitor-trends-panel">
<h3>Тренды Потоков</h3>
<h3>Тренды</h3>
<div class="trend-stack">
<article class="trend-card">
<div class="trend-head">
@ -302,7 +328,7 @@
</article>
<article class="trend-card">
<div class="trend-head">
<span>Успех доставки, %</span>
<span>Успех отправки, %</span>
<b id="ov-trend-delivery-meta">н/д</b>
</div>
<div id="ov-trend-delivery-chart" class="sparkline-wrap"></div>
@ -315,13 +341,13 @@
<section id="section-history" class="panel">
<article class="card history-dashboard history-head-card">
<h2>История Входов И Выходов</h2>
<p class="muted">Связка входных измерений и отправки результата для отладки, SLA и диагностики ошибок.</p>
<h2>Журнал обмена</h2>
<p class="muted">Приём, расчёт и отправка результатов.</p>
</article>
<div class="history-layout">
<article class="card history-data-card">
<h2>История И Фильтры</h2>
<h2>Журнал</h2>
<div class="history-toolbar">
<label>
Статус
@ -337,11 +363,11 @@
</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>
@ -358,19 +384,21 @@
<button id="history-next" class="btn" type="button">Вперёд</button>
</div>
<button id="history-date-reset" class="btn" type="button">Сброс времени</button>
<div id="history-admin-actions" class="history-admin-actions">
<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>
<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>
<th>Выход</th>
<th>Статус</th>
</tr>
</thead>
@ -380,7 +408,7 @@
</article>
<article class="card history-monitor-card">
<h2>Мониторинг Истории</h2>
<h2>Сводка</h2>
<div class="history-kpis">
<div class="kpi-card">
<span class="kpi-title">Событий</span>
@ -399,18 +427,18 @@
<b id="hist-freqs" class="kpi-value">0</b>
</div>
<div class="kpi-card">
<span class="kpi-title">Последнее событие</span>
<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>
<h3>Диагностика</h3>
<div id="history-monitor" class="history-monitor"></div>
</section>
<section class="insight-panel">
<h3>Тренды Метрик</h3>
<h3>Тренды</h3>
<div id="history-trends" class="history-trends"></div>
</section>
</div>
@ -420,15 +448,15 @@
<section id="section-servers" class="panel">
<article class="card servers-head-card">
<h2>Настройка Серверов</h2>
<p class="muted">Доступы входа и выхода задаются отдельно. Общий фильтр входа автоматически применяется ко всем входным серверам.</p>
<h2>Узлы системы</h2>
<p class="muted">Приёмники, фильтр и адреса отправки.</p>
</article>
<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>
<h3 class="servers-title">Приёмники</h3>
<p class="muted">Адреса, частоты и координаты.</p>
</div>
<div class="server-card-body">
<div class="selector-row">
@ -444,30 +472,30 @@
</div>
<div class="field-grid field-grid-2">
<label class="field-control">
<span class="field-label">Имя ресивера</span>
<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>
<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>
<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>
<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>
<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>
<span class="field-label">Z</span>
<input id="rx-center-z" type="number" step="0.001" />
</label>
</div>
@ -476,7 +504,7 @@
<article class="card servers-card servers-card-modern">
<div class="server-card-head">
<h3 class="servers-title">Общий Фильтр Входа</h3>
<h3 class="servers-title">Общий фильтр</h3>
<p class="muted">Применяется автоматически ко всем входным серверам.</p>
</div>
<div class="server-card-body">
@ -512,8 +540,8 @@
<article class="card servers-card servers-card-modern">
<div class="server-card-head">
<h3 class="servers-title">Выходные Серверы</h3>
<p class="muted">Минимальные параметры для доставки результата.</p>
<h3 class="servers-title">Серверы отправки</h3>
<p class="muted">Адреса для передачи координат.</p>
</div>
<div class="server-card-body">
<div class="selector-row">
@ -529,16 +557,16 @@
</div>
<div class="field-grid field-grid-2">
<label class="field-control">
<span class="field-label">Имя выхода</span>
<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>
<span class="field-label">Адрес</span>
<input id="out-ip" type="text" placeholder="output-sink:8080" />
</label>
</div>
<label class="field-control">
<span class="field-label">Токен записи (API, только сессия)</span>
<span class="field-label">API-токен</span>
<input id="write-token" type="password" placeholder="необязательно" />
</label>
</div>
@ -548,18 +576,89 @@
<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>
<span id="servers-state" class="badge">серверы: н/д</span>
<button id="save-servers" class="btn btn-primary">Сохранить узлы</button>
<span id="servers-state" class="badge">узлы: н/д</span>
</div>
</article>
<article id="users-card" class="card servers-actions-card">
<div class="server-card-head">
<h3 class="servers-title">Пользователи</h3>
<p class="muted">Управление учётными записями и ролями Keycloak.</p>
</div>
<div class="users-layout">
<div class="table-wrap users-table-wrap">
<table id="users-table">
<thead>
<tr>
<th>Логин</th>
<th>Роль</th>
<th>Состояние</th>
<th>Имя</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="users-form">
<label class="field-control">
<span class="field-label">ID пользователя</span>
<input id="user-id" type="text" placeholder="заполняется автоматически" />
</label>
<label class="field-control">
<span class="field-label">Логин</span>
<input id="user-username" type="text" placeholder="operator_1" />
</label>
<div class="field-grid field-grid-2">
<label class="field-control">
<span class="field-label">Имя</span>
<input id="user-first-name" type="text" />
</label>
<label class="field-control">
<span class="field-label">Фамилия</span>
<input id="user-last-name" type="text" />
</label>
</div>
<div class="field-grid field-grid-2">
<label class="field-control">
<span class="field-label">Роль</span>
<select id="user-role">
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</label>
<label class="field-control">
<span class="field-label">Включён</span>
<select id="user-enabled">
<option value="true">Да</option>
<option value="false">Нет</option>
</select>
</label>
</div>
<label class="field-control">
<span class="field-label">Пароль</span>
<input id="user-password" type="password" placeholder="для создания или смены" />
</label>
<div class="editor-actions">
<button id="load-users" class="btn" type="button">Обновить список</button>
<button id="create-user" class="btn btn-primary" type="button">Создать</button>
<button id="update-user" class="btn" type="button">Сохранить</button>
<button id="reset-user-password" class="btn" type="button">Сменить пароль</button>
<button id="delete-user" class="btn" type="button">Удалить</button>
<button id="clear-user-form" class="btn" type="button">Очистить</button>
</div>
<span id="users-state" class="badge">пользователи: н/д</span>
</div>
</div>
</article>
</section>
<section id="section-json" class="panel">
<article class="card config-head-card">
<h2>Конфигурация</h2>
<h2>Конфиг</h2>
<div class="editor-actions">
<button id="load-config" class="btn">Загрузить</button>
<button id="save-config" class="btn btn-primary">Сохранить конфиг</button>
<button id="save-config" class="btn btn-primary">Сохранить файл</button>
<span id="config-state" class="badge">конфиг: н/д</span>
</div>
</article>
@ -567,8 +666,8 @@
<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>
<h3>JSON</h3>
<p class="muted">Полный файл настроек.</p>
</div>
<div class="config-editor-shell">
<div class="editor-toolbar">
@ -581,16 +680,16 @@
</article>
<article class="card config-help-card config-help-modern">
<div class="config-section-head">
<h3>Памятка По Полям</h3>
<p class="muted">Краткая структура и контрольные точки перед сохранением.</p>
<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.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>
<h3>Подсказки</h3>
<ul class="config-tips">
<li>Поддерживайте уникальные `receiver_id` для каждого входа.</li>
<li>Согласуйте диапазоны частот между входными серверами.</li>
@ -602,7 +701,7 @@
</section>
</main>
<script src="/static/app.js?v=20260312r5"></script>
<script src="/static/app.js?v=20260319r2"></script>
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="true"></div>
</body>
</html>

@ -1,4 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap");
:root {
--bg-main: #f5f7fb;
@ -48,6 +48,87 @@ body {
overflow-x: hidden;
}
body.role-user .menu-group[data-menu-group="config"],
body.role-guest .menu-group[data-menu-group="config"] {
display: none !important;
}
.auth-overlay {
position: fixed;
inset: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(18, 28, 45, 0.34);
backdrop-filter: blur(10px);
}
.auth-overlay-hidden {
display: none;
}
.auth-dialog {
width: min(460px, 100%);
display: grid;
gap: 12px;
}
.auth-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
margin-left: auto;
}
.app-shell-locked {
pointer-events: none;
user-select: none;
}
.users-layout {
display: grid;
grid-template-columns: minmax(280px, 1.2fr) minmax(320px, 1fr);
gap: 14px;
align-items: start;
}
.users-form {
display: grid;
gap: 12px;
}
.users-table-wrap {
max-height: 420px;
}
#users-table tbody tr {
cursor: pointer;
}
#users-table tbody tr:hover {
background: rgba(36, 107, 255, 0.08);
}
#user-id {
background: rgba(36, 107, 255, 0.04);
}
@media (max-width: 1100px) {
.auth-summary {
width: 100%;
justify-content: flex-start;
margin-left: 0;
}
.users-layout {
grid-template-columns: 1fr;
}
}
.app-shell {
width: 100%;
margin: 0;
@ -112,14 +193,14 @@ body {
z-index: 5;
display: grid;
gap: 10px;
text-align: center;
justify-items: center;
text-align: left;
justify-items: stretch;
}
.nav-head {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 10px;
width: 100%;
}
@ -127,7 +208,7 @@ body {
.brand-block {
display: grid;
gap: 2px;
justify-items: center;
justify-items: start;
}
.kicker {
@ -172,8 +253,8 @@ body {
}
.hero {
text-align: center;
justify-items: center;
text-align: left;
justify-items: stretch;
}
.hero h2 {
@ -212,7 +293,7 @@ body {
}
.hero-actions {
justify-content: center;
justify-content: flex-start;
}
.btn {
@ -521,14 +602,14 @@ body {
width: 100%;
display: grid;
gap: 8px;
justify-items: center;
justify-items: stretch;
min-width: 0;
}
.datetime-panel-controls {
width: 100%;
display: flex;
justify-content: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 8px;
}
@ -537,7 +618,7 @@ body {
width: 100%;
display: grid;
gap: 8px;
justify-items: center;
justify-items: stretch;
max-height: 240px;
opacity: 1;
transform: translateY(0);
@ -564,7 +645,7 @@ body {
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: center;
justify-content: flex-start;
}
.meta-pill {
@ -576,14 +657,14 @@ body {
line-height: 1.2;
color: #30486f;
white-space: normal;
text-align: center;
text-align: left;
overflow-wrap: anywhere;
}
.timezone-picker {
display: grid;
gap: 4px;
text-align: center;
text-align: left;
font-size: 0.78rem;
color: #3d4f70;
min-width: 220px;
@ -663,15 +744,15 @@ body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
text-align: center;
width: min(960px, 100%);
margin: 0 auto;
text-align: left;
width: 100%;
margin: 0;
}
/* Overview panel: force vertical flow for key info blocks. */
#section-overview .result-box {
grid-template-columns: 1fr;
width: min(560px, 100%);
width: 100%;
gap: 8px;
}
@ -718,7 +799,7 @@ body {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
justify-content: flex-start;
}
.monitor-headline .io-chip b {
@ -743,7 +824,7 @@ body {
.monitor-panel h3 {
margin: 0;
text-align: center;
text-align: left;
font-size: 0.92rem;
}
@ -1294,7 +1375,7 @@ body {
}
.history-head-card {
text-align: center;
text-align: left;
}
.history-layout {
@ -1312,7 +1393,7 @@ body {
.history-data-card > h2,
.history-monitor-card > h2 {
margin: 0 0 10px;
text-align: center;
text-align: left;
}
.history-data-card .history-toolbar {
@ -1402,6 +1483,13 @@ body {
flex: 999 1 700px;
}
.history-admin-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.history-pager {
display: flex;
align-items: center;
@ -1431,7 +1519,7 @@ body {
.insight-panel h3 {
margin: 0 0 8px;
font-size: 0.94rem;
text-align: center;
text-align: left;
}
.history-monitor {
@ -1569,12 +1657,12 @@ body {
.server-grid label,
.server-actions-row,
.editor-actions {
text-align: center;
text-align: left;
}
.server-actions-row,
.editor-actions {
justify-content: center;
justify-content: flex-start;
}
.muted {
@ -1656,7 +1744,7 @@ tbody tr:hover {
}
.servers-head-card {
text-align: center;
text-align: left;
}
.servers-layout {
@ -1692,7 +1780,7 @@ tbody tr:hover {
.server-grid label {
display: grid;
justify-items: center;
justify-items: stretch;
gap: 6px;
font-size: 0.88rem;
color: #34425c;
@ -1707,7 +1795,7 @@ tbody tr:hover {
font-size: 0.9rem;
background: #fff;
color: var(--text);
text-align: center;
text-align: left;
font-family: inherit;
transition: border-color var(--anim-fast) ease, box-shadow var(--anim-fast) ease;
width: 100%;
@ -1723,7 +1811,7 @@ tbody tr:hover {
}
.config-head-card {
text-align: center;
text-align: left;
}
.config-layout {
@ -1740,7 +1828,7 @@ tbody tr:hover {
.config-editor-card h3 {
margin: 0 0 8px;
text-align: center;
text-align: left;
}
.config-help-card {
@ -1752,7 +1840,7 @@ tbody tr:hover {
.config-help-card h3 {
margin: 0 0 8px;
text-align: center;
text-align: left;
}
.config-hints {
@ -1808,6 +1896,7 @@ tbody tr:hover {
.server-card-head .muted {
margin: 0;
font-size: 0.8rem;
text-align: left;
}
.server-card-body {
@ -1922,12 +2011,12 @@ tbody tr:hover {
.config-section-head h3 {
margin: 0;
text-align: center;
text-align: left;
}
.config-section-head .muted {
margin: 4px 0 0;
text-align: center;
text-align: left;
font-size: 0.8rem;
}
@ -2148,9 +2237,9 @@ body.ui-compact td {
}
.nav-head {
align-items: center;
align-items: stretch;
flex-direction: column;
text-align: center;
text-align: left;
}
.menu-toggle {
@ -2169,7 +2258,7 @@ body.ui-compact td {
}
.side-meta {
justify-items: center;
justify-items: stretch;
}
.timezone-picker {
@ -2356,7 +2445,7 @@ body.ui-compact td {
}
.monitor-headline {
justify-content: stretch;
justify-content: flex-start;
}
.monitor-headline .io-chip {

Loading…
Cancel
Save