From 8da0a00f3a5ac6deb033b33d71a4d063f590fc3a Mon Sep 17 00:00:00 2001 From: AlexsandrSnytkin Date: Thu, 12 Mar 2026 21:09:17 +0700 Subject: [PATCH] Adaptive web interface --- __pycache__/service.cpython-311.pyc | Bin 72603 -> 79346 bytes ...e_integration.cpython-311-pytest-8.2.2.pyc | Bin 69961 -> 89330 bytes .../test_service_integration.cpython-311.pyc | Bin 0 -> 46503 bytes config.template.json | 1 + .../__pycache__/mock_receiver.cpython-311.pyc | Bin 0 -> 11571 bytes docker/config.docker.test.json | 1 + docker/mock_receiver.py | 97 +- service.py | 172 +- test_service_integration.py | 137 +- web/app.js | 839 ++++++++- web/index.html | 633 +++++-- web/styles.css | 1644 +++++++++++++++-- 12 files changed, 3085 insertions(+), 439 deletions(-) create mode 100644 __pycache__/test_service_integration.cpython-311.pyc create mode 100644 docker/__pycache__/mock_receiver.cpython-311.pyc diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc index 90f5c1776b0fe750fcde3b3023a9c88111603aed..a3053a09f301e2dc33a3a83e41e14849f824e782 100644 GIT binary patch delta 12799 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 S3*-d$quX$`wtH?eS^eLbzAStI delta 7722 zcmd5>d3apKk$?T>zI4oJG`cmqHIjAtkSw1v7+?6r2HWy69;?UFSkg#-GqP-Fw32NZ z>;U;}isd=MN+8)d0fY|*$FK*14c8_iNW_3LiIcF95MUF_1PB`+fVz zK5Kqm-CccES5;TH-+xbg|9NfV_iQ$^gx_Pk9tzBRPdb+9V)kPtf9UmcTferkh;QRb zm=<#%cVJq=xARm?OL-H|#ni(&&%?Bg`*{JTe9$me4^t=A-BbqDX{3$c1S-^CYU@eC@(a)pQZ zGAysv$Jmh3QPbo8N+IJ@4l0F`Xq#izU9HU*a22%98X5{^DQbY`%v#BF7xQ zmv6>&F5kzuU^>q~pKooIB^&Z74q5Uy;BOur+nHrNz_`3t@_Z$_PJV~wl-xLL^Q|Gj z651RJ?hUtY4l4m)u(_+v7Y=j;H%I4b(`wjsSU;oEc5g)9U&mQQ+AS&5;GP*XoM~ah z_(UWr!tR&29MM3FJxi@&EFFI4N`h6jMs+%4sqpY06JceoDV{OkFp+UVur`mGp{6Ch zLKBgCv_boIy_yE(lMI)Qzxa26g&CaxeThqsXhYZYn<1sHNMjka!koIBTwk1{EuzPn zCcyE!B9;hG)#c7j;>i&f(GS{B>TneN#lId?#K;}3VpPR>-K^sMq(H{Z#q*sj6$KmZ zbIo#h`u4VU__$l~H~9m*{falh-6i`nOWeG}A94pf!tQooxT(eM-_z-D3j48A*%0;5 zG%(9->_M4Da2Isfe^aiL!8L2ivWuzi5m&*e#GFf69Eawtx;8sfy>L`>wKiULS)*~; zKadbcB~7w@)F?T#qRq2D$^>g;p~Z-!Xe5dK`J@(RH>PVUdSn=AEP_bmE-RP0?u5Qa zXOhAOSkaOJwl)(p!MC=y!^4~GFj#4Y@AqdWDY}RyqT|*%*cHmj1{GVXSORR>Qsqd% zqOq?~QnGriVGXoBkO940j9D8cF7;Ud&Kfkv({9!+?a^$Mx>NdlDn~k83$0dCdKb-YVmDv^D7bf+O|@I8`+eI1V7!DRhUb(;HA=-&DYfA?+kmpl|a}Z zQb_7b0a$%*uZ8-Ft6xZQQMpj7RBCS^sGzbw0v%=VMk+oZPI&W;7`Q5}W404u1{=nx?>6e|z*feh2HF zihVzmeM9Dr_41GE%?opN&uZ)oGjz{p=#YM{=7!8AHu*)HdC4^0i!S?;O5KZ3Wz%8wUA*4sz+A92#GVPjHiBfpNI>-pc?DX8m6Cwtv3RJI*XcU zaabajK1X=MM@kY;gVyGB=71G#8UM9F(*IuwWSJ011_~ro2qaYqB=hBsH9ZR7~${q6k27VJ2k7Vz+yn z+YRO}w|gEIxl!{&=MX;blSZwQG2y7|6+_yPA?>2W1-X0Dp{Z+U^2N%A5vyy&kv`(g zdB8A~QVIimcCRX_977mItlTxjnTS81Bnv$#AK!faR!Xi&-Xg z9{1glu@^B?%9~zZoFa@^kUjtTeqW%Cz01qU?u>($+xzJ|jsS)G-(tteZf;9)T zz;Ym$r9t_DHO}-e>cX|nv^*o;05?CPk4Rp}_-J9zIE5yY*)O3%InbceVD(E0T4Z!W z-oXk@7SG1va`6=0b+7_f9?Z|CofRK8n4r0PHzWT6f!1uea0KhIUrDh|iV zzJ(XU#(obgf`k35U_-wh-s;a{CBP2lvK+`el!c?% z$q{>a20VEM=K=IifCqybiZzBFMOw4#|8L`kC1njwIo4&cu$VXU{!Bno%&` z!=!l>(mZTM*Ve(A?>ikO<0X9F;QaWq;qsA`3@%|@(XcY64|TNRsU0&YUBPgm-H#Ev z_p7_vQh4pFow8CI<#+C7>>JTv-&Mz0Ni_9qO|ssLW~^+3-lP4wq3)PRen*Dw$NsI_Pc3&4G*d&vSj4c-BMToF({rD{y(8%H zDD!aI$^zJQPf71$;un5u2~jr_oTKXHMBPP{NT4Swg86Vz)Vq})ag?~e(oKQX+pSM zY-XmIDirc|Y~Lv&G-WG|vI!xS0zW+Gf^Xkjq;DbTcRc#iz1vuH(|s#+7R<+Eh?x?+ zG%_73PFNFVWe+vI533$x%3fH1qS&HAWejCwNuLC#PW;K;MI7ItITLTp7zlZNZFp&j zX?J!6g32NIr-5RokN*3>IV;;5J^pZnu^YiT*r*q4-wo~Ca-nDNb9O8AJo-A@0Jol+ z%Qga>nx_}iUIMS5@{pyUo}nM0p-N!R>2k|)A|D|5Iw+?D`umAI0dJi4TH++-2jJ#2 z8F~_x^6hBrnH(m+tbsevZe;;@_iU+lpFG74pO&r!|6{kwuW8_49;b>i~EIOaXkVy42A(R^nIjnqk?5bPqh4Dh|0X$w*QFoLHD z#hCW^6sI`3=)R@Y5*UeLD{Oqcj2(~mKYo-kOVs%Tty%tsCYt&5&zMuJs*YG^64Vpi z4gdMf0r^c$^uWKW8unetcz%Elz!T5=@#x5TVZCdZI_(pj2r*52Am}mS_;q4nyBl=_x1WU#7Q=?nq|YbmzPZwh9zSBQ`G(p0r?AMJ8?cv)EiD`bA}&eae5gfy>cx1;+OK> z4D!!!WG7+K&zD$M6UQ}uY=C!uUSBRkgBGmvSmMEf2v{cDc|3k;ffX0Z76js~c{CR? z)2otFVw6px3ZV*Xi6ZB&3<-)FCZZglxKP8^!n+rWQbph)yvdu?^euwlF(`VqjGcki zua>inXynxww9Cj?VtO=@kUy-vKwLTkp*UhFp)=ZUUmz^J3wooy#PAW08zKvJyA?Oh zp&RawWwM>ohho2Hb0)M+#6Gdau}cv5lN~C1F?JIJTxAnFDs=J?{Nz`aECg46^^aK# zXrzCp-ct!~!0bcv7!5-go$N({FTrOQ+cq8~=KF~62vM|_m^Bo}Czqynf4HTCD?g=j zp@Kr3XDHiEFiIe{kIt(2dK##Vpc$U|b;`_*vThAN6#O^IZY|U0;WNP`(1Yza!>j>* z^P981JF)M-$E%QEYGhn$l>Hi>c38rjfTrJi66+qpU;Jy-@^r4l2PYlR5HD?do+)11 z3_Oe5vE0bBc?zZ`p2MA(nt9G)=`h%^0xb0{oWQ|kmJo4PQ8(4 zhzqX)roJ&l{tJV^8$os;I`YPwENcrcb)R9o->3K$_dE2wvj|P|Gm^q`c>T?JY%ip~ z^(ea?o_%XXR?6X9zbnrcJ2L6sM^7LQdICvfuzG10TzM)9N`C(}^(R`Ergj#x1RI8M z($4Xl4*d4HGRxz0!J1IvNMW$702=Eb4n&=w7_wFY49niej zW7dzaX`sDHhn?v;YN;ez@L1@%wV4_j}#V7+N* z{147tUal8rR}Av|x5+=!MjPM%P}fTiEN1TZDdV6ZdyVu0>)I}ziuC`iP zsanNYuG)v$QhWovaeOe0&*g|Mk`PH8tepI;(UYW}wz99YBpRPpsXiOaGgRX*;PZHR|gbI?H zjl|8Wl{|GtA{(&BcZ&o|=Pbro3)IgNSsuGf%}HWQl7(K0GnDjhoky>BC$Z^T+7dL>Y;45(vdwOJ%aOBr_6UU1cpN^)|h%ClMFlu_A~oOv;~dc<8ml3O&AUq0fl7&YnK zsh1^$4+xGfIlAQI53N$#eHkqK{Hz?dl(7-DKbIZuO)Fi^q#;(Yx>Pe%!VrfOO4ig$ z!?mTien~5> zQha@>a+&5~Ro*A5|C!(ef)5F*g?b2WA!i*|8R3CEl$}Pf2mzPzDe>Q=Hs&$6`4gn~ zljD9@?ayO3FUU8Wibti%kft{!;G@bUNJ=#=!r^91Z$e&zB%rF+&C~MV$GFGYIg4Gvv=ZDMKS#57Ni9;wB zsgTDU_cEeX*rxCT!rusroQ~2*aJ7vbZ*#3aFU29ob(@sbvmj>ndUte%yA;3ItKdyk zQdZI|b3do?A0`kXMppTheTWdQ&H diff --git a/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc b/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc index 2de923d4d46a94cffbc426ae4ea0ab9383142293..d16edb26f56400528d62a4ef90fc7198120f1841 100644 GIT binary patch delta 19708 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 Wa8q>IwZj5XT39bW<8_GtA^sOTlfBpg delta 7160 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 bKQQ^7MRlKC?AZP9x6lm4U)>^%D)0?5r( diff --git a/__pycache__/test_service_integration.cpython-311.pyc b/__pycache__/test_service_integration.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73ec649763202f593cb340fda969bb8a5742275f GIT binary patch literal 46503 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 Dict[str, object]: - noise_a = random.uniform(-1.2, 1.2) - noise_b = random.uniform(-1.2, 1.2) - rows: List[Dict[str, float]] = [ - {"f_mhz": 433.920, "rssi": base_rssi + noise_a}, - {"f_mhz": 868.100, "rssi": base_rssi - 4.0 + noise_b}, - ] +def _parse_frequency_list(raw_value: str) -> List[float]: + values: List[float] = [] + for item in str(raw_value).split(","): + text = item.strip() + if not text: + continue + try: + value = float(text) + except ValueError as exc: + raise ValueError(f"invalid frequency value '{text}'") from exc + if value <= 0.0: + raise ValueError(f"frequency must be > 0, got {value}") + values.append(round(value, 6)) + if not values: + raise ValueError("at least one frequency is required") + deduplicated: List[float] = [] + seen = set() + for value in values: + key = round(value, 6) + if key in seen: + continue + seen.add(key) + deduplicated.append(value) + return deduplicated + + +def _build_payload( + receiver_id: str, + base_rssi: float, + frequencies_mhz: List[float], +) -> Dict[str, object]: + rows: List[Dict[str, float]] = [] + for index, frequency_mhz in enumerate(frequencies_mhz): + noise = random.uniform(-1.2, 1.2) + offset = -2.2 * index + rows.append({"f_mhz": round(float(frequency_mhz), 6), "rssi": base_rssi + offset + noise}) return { "receiver_id": receiver_id, "timestamp_unix": time.time(), @@ -27,8 +56,12 @@ def main() -> int: parser.add_argument("--receiver-id", required=True) parser.add_argument("--port", type=int, default=9000) parser.add_argument("--base-rssi", type=float, default=-62.0) + parser.add_argument("--frequencies-mhz", type=str, default="433.92,868.1") args = parser.parse_args() - state = {"enabled": True} + state = { + "enabled": True, + "frequencies_mhz": _parse_frequency_list(args.frequencies_mhz), + } class Handler(BaseHTTPRequestHandler): def log_message(self, format: str, *args2) -> None: @@ -39,6 +72,7 @@ def main() -> int: payload = { "receiver_id": args.receiver_id, "enabled": bool(state["enabled"]), + "frequencies_mhz": list(state["frequencies_mhz"]), "status": "ok", } raw = json.dumps(payload).encode("utf-8") @@ -69,7 +103,11 @@ def main() -> int: self.wfile.write(raw) return - payload = _build_payload(args.receiver_id, args.base_rssi) + payload = _build_payload( + receiver_id=args.receiver_id, + base_rssi=args.base_rssi, + frequencies_mhz=list(state["frequencies_mhz"]), + ) raw = json.dumps(payload).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") @@ -88,9 +126,18 @@ def main() -> int: payload = json.loads(body.decode("utf-8")) except json.JSONDecodeError: payload = {} + enabled = payload.get("enabled") - if not isinstance(enabled, bool): - raw = json.dumps({"status": "error", "error": "field 'enabled' must be boolean"}).encode("utf-8") + frequencies_raw = payload.get("frequencies_mhz") + has_enabled = isinstance(enabled, bool) + has_frequencies = frequencies_raw is not None + if not has_enabled and not has_frequencies: + raw = json.dumps( + { + "status": "error", + "error": "at least one of fields 'enabled' or 'frequencies_mhz' must be provided", + } + ).encode("utf-8") self.send_response(400) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(raw))) @@ -98,12 +145,38 @@ def main() -> int: self.wfile.write(raw) return - state["enabled"] = enabled + if has_enabled: + state["enabled"] = bool(enabled) + + if has_frequencies: + if not isinstance(frequencies_raw, list): + raw = json.dumps( + {"status": "error", "error": "field 'frequencies_mhz' must be an array"} + ).encode("utf-8") + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + try: + parsed_frequencies = _parse_frequency_list(",".join(str(x) for x in frequencies_raw)) + except ValueError as exc: + raw = json.dumps({"status": "error", "error": str(exc)}).encode("utf-8") + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + state["frequencies_mhz"] = parsed_frequencies + raw = json.dumps( { "status": "ok", "receiver_id": args.receiver_id, "enabled": bool(state["enabled"]), + "frequencies_mhz": list(state["frequencies_mhz"]), } ).encode("utf-8") self.send_response(200) diff --git a/service.py b/service.py index a392c30..3da9ed3 100644 --- a/service.py +++ b/service.py @@ -321,10 +321,10 @@ def parse_source_payload( if expected_receiver_id is not None and "receiver_id" in payload: payload_receiver_id = str(payload["receiver_id"]) if payload_receiver_id != expected_receiver_id: - raise ValueError( - f"{source_label}: payload receiver_id '{payload_receiver_id}' " - f"does not match expected '{expected_receiver_id}'." - ) + # Keep processing measurements even if upstream payload ID differs + # from the configured receiver_id. This allows safe UI renaming + # without breaking data collection from legacy sources. + pass raw_items = payload.get("measurements") if raw_items is None: raw_items = payload.get("samples") @@ -477,6 +477,27 @@ def _output_control_urls(output_server: Dict[str, object]) -> Tuple[str, str]: return f"{base}/control", f"{base}/status" +def _receiver_configured_frequencies_mhz(receiver: Dict[str, object]) -> List[float]: + configured_hz = receiver.get("configured_frequencies_hz") + if not isinstance(configured_hz, list): + return [] + values: List[float] = [] + seen = set() + for hz_value in configured_hz: + try: + mhz_value = float(hz_value) / HZ_IN_MHZ + except (TypeError, ValueError): + continue + if not math.isfinite(mhz_value) or mhz_value <= 0.0: + continue + normalized = round(mhz_value, 6) + if normalized in seen: + continue + seen.add(normalized) + values.append(normalized) + return values + + def _collect_mock_controls(service: "AutoService") -> Dict[str, object]: inputs: List[Dict[str, object]] = [] for receiver in service.receivers: @@ -488,6 +509,8 @@ def _collect_mock_controls(service: "AutoService") -> Dict[str, object]: "source_url": source_url, "reachable": False, "enabled": None, + "configured_frequencies_mhz": _receiver_configured_frequencies_mhz(receiver), + "frequencies_mhz": None, "error": "", } try: @@ -501,6 +524,18 @@ def _collect_mock_controls(service: "AutoService") -> Dict[str, object]: enabled_value = payload.get("enabled") if isinstance(enabled_value, bool): row["enabled"] = enabled_value + frequencies_value = payload.get("frequencies_mhz") + if isinstance(frequencies_value, list): + parsed_frequencies: List[float] = [] + for value in frequencies_value: + try: + numeric = float(value) + except (TypeError, ValueError): + continue + if math.isfinite(numeric) and numeric > 0.0: + parsed_frequencies.append(round(numeric, 6)) + if parsed_frequencies: + row["frequencies_mhz"] = parsed_frequencies if status_code >= 400: row["error"] = str(payload.get("error", f"HTTP {status_code}")) except Exception as exc: @@ -545,7 +580,8 @@ def _set_mock_control( service: "AutoService", target: str, target_id: str, - enabled: bool, + enabled: Optional[bool] = None, + frequencies_mhz: Optional[List[float]] = None, ) -> Dict[str, object]: if target == "input": receiver = next( @@ -559,26 +595,48 @@ def _set_mock_control( if receiver is None: raise ValueError(f"Input receiver '{target_id}' not found.") control_url, _ = _receiver_control_urls(str(receiver.get("source_url", ""))) + control_payload: Dict[str, object] = {} + if isinstance(enabled, bool): + control_payload["enabled"] = enabled + if isinstance(frequencies_mhz, list): + control_payload["frequencies_mhz"] = list(frequencies_mhz) + if not control_payload: + raise ValueError("Input control requires 'enabled' or 'frequencies_mhz'.") status_code, payload, request_error = _http_json_request( control_url, method="POST", - payload={"enabled": enabled}, + payload=control_payload, timeout_s=2.0, ) if request_error: raise RuntimeError(request_error) if status_code < 200 or status_code >= 300: raise RuntimeError(str(payload.get("error", f"HTTP {status_code}"))) - action = "запущена" if enabled else "остановлена" + message_parts: List[str] = [] + if isinstance(enabled, bool): + action = "запущена" if enabled else "остановлена" + message_parts.append(f"Передача входных данных '{target_id}' {action}.") + if isinstance(frequencies_mhz, list): + frequencies_text = ", ".join( + f"{value:.6f}".rstrip("0").rstrip(".") + for value in frequencies_mhz + ) + message_parts.append( + f"Частоты '{target_id}' обновлены: [{frequencies_text}] МГц." + ) + message = " ".join(part for part in message_parts if part).strip() or "Настройки входа обновлены." return { "status": "ok", "target": "input", "id": target_id, "enabled": enabled, - "message": f"Передача входных данных '{target_id}' {action}.", + "frequencies_mhz": list(frequencies_mhz) if isinstance(frequencies_mhz, list) else None, + "message": message, } if target == "output": + if not isinstance(enabled, bool): + raise ValueError("Output control requires boolean 'enabled'.") output_server = next( ( row @@ -612,6 +670,28 @@ def _set_mock_control( raise ValueError("target must be 'input' or 'output'.") +def _sync_mock_input_frequencies(service: "AutoService") -> List[str]: + errors: List[str] = [] + for receiver in service.receivers: + receiver_id = str(receiver.get("receiver_id", "")) + if not receiver_id: + continue + frequencies_mhz = _receiver_configured_frequencies_mhz(receiver) + if not frequencies_mhz: + continue + try: + _set_mock_control( + service=service, + target="input", + target_id=receiver_id, + enabled=None, + frequencies_mhz=frequencies_mhz, + ) + except Exception as exc: + errors.append(f"{receiver_id}: {exc}") + return errors + + class AutoService: def __init__(self, config: Dict[str, object], config_path: Optional[str] = None) -> None: self.config = config @@ -635,6 +715,10 @@ class AutoService: self.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0)) self.write_api_token = str(runtime_obj.get("write_api_token", "")).strip() + self.mock_input_frequency_sync_enabled = bool( + runtime_obj.get("mock_input_frequency_sync", False) + ) + self.last_mock_sync_errors: List[str] = [] parsed_output_servers: List[Dict[str, object]] = [] output_servers_obj = runtime_obj.get("output_servers") if output_servers_obj is not None: @@ -769,6 +853,10 @@ class AutoService: self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True) def start(self) -> None: + if self.mock_input_frequency_sync_enabled: + self.last_mock_sync_errors = _sync_mock_input_frequencies(self) + else: + self.last_mock_sync_errors = [] self.poll_thread.start() def stop(self) -> None: @@ -1177,10 +1265,14 @@ def _make_handler(service: AutoService): status_code: int, content: bytes, content_type: str, + extra_headers: Optional[Dict[str, str]] = None, ) -> None: self.send_response(status_code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(content))) + if isinstance(extra_headers, dict): + for key, value in extra_headers.items(): + self.send_header(str(key), str(value)) self.end_headers() self.wfile.write(content) @@ -1214,7 +1306,16 @@ def _make_handler(service: AutoService): "application/x-javascript", ): mime_type = f"{mime_type}; charset=utf-8" - self._write_bytes(200, file_path.read_bytes(), mime_type) + self._write_bytes( + 200, + file_path.read_bytes(), + mime_type, + extra_headers={ + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0", + }, + ) def log_message(self, format: str, *args) -> None: return @@ -1408,6 +1509,12 @@ def _make_handler(service: AutoService): "restart_required": False, "applied": True, "config_path": service_obj.config_path, + "mock_input_frequency_sync_enabled": bool( + new_service.mock_input_frequency_sync_enabled + ), + "mock_input_frequency_sync_errors": list( + new_service.last_mock_sync_errors + ), }, ) return @@ -1431,6 +1538,7 @@ def _make_handler(service: AutoService): target = str(payload.get("target", "")).strip().lower() target_id = str(payload.get("id", "")).strip() enabled_value = payload.get("enabled") + frequencies_value = payload.get("frequencies_mhz") if target not in ("input", "output"): self._write_json( 400, @@ -1440,15 +1548,57 @@ def _make_handler(service: AutoService): if not target_id: self._write_json(400, {"status": "error", "error": "id is required"}) return - if not isinstance(enabled_value, bool): + parsed_frequencies: Optional[List[float]] = None + if frequencies_value is not None: + if not isinstance(frequencies_value, list): + self._write_json( + 400, + {"status": "error", "error": "frequencies_mhz must be array"}, + ) + return + parsed_frequencies = [] + for index, value in enumerate(frequencies_value, start=1): + try: + numeric = float(value) + except (TypeError, ValueError): + self._write_json( + 400, + { + "status": "error", + "error": f"frequencies_mhz[{index}] must be numeric", + }, + ) + return + if not math.isfinite(numeric) or numeric <= 0.0: + self._write_json( + 400, + { + "status": "error", + "error": f"frequencies_mhz[{index}] must be > 0", + }, + ) + return + parsed_frequencies.append(round(numeric, 6)) + + if target == "output" and not isinstance(enabled_value, bool): self._write_json(400, {"status": "error", "error": "enabled must be boolean"}) return + if target == "input" and not isinstance(enabled_value, bool) and parsed_frequencies is None: + self._write_json( + 400, + { + "status": "error", + "error": "input control requires 'enabled' or 'frequencies_mhz'", + }, + ) + return try: response = _set_mock_control( service=service_obj, target=target, target_id=target_id, - enabled=enabled_value, + enabled=enabled_value if isinstance(enabled_value, bool) else None, + frequencies_mhz=parsed_frequencies, ) except Exception as exc: self._write_json(500, {"status": "error", "error": str(exc)}) diff --git a/test_service_integration.py b/test_service_integration.py index c0a65b7..b625a36 100644 --- a/test_service_integration.py +++ b/test_service_integration.py @@ -166,7 +166,7 @@ def test_refresh_once_reports_row_validation_error_with_source_context( svc.refresh_once() -def test_refresh_once_validates_receiver_id_mismatch(monkeypatch: pytest.MonkeyPatch): +def test_refresh_once_allows_receiver_id_mismatch(monkeypatch: pytest.MonkeyPatch): config = _base_config() responses = { "http://r0.local/measurements": {"receiver_id": "r0", "measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, @@ -176,8 +176,82 @@ def test_refresh_once_validates_receiver_id_mismatch(monkeypatch: pytest.MonkeyP _install_urlopen(monkeypatch, responses) svc = service.AutoService(config) - with pytest.raises(RuntimeError, match="does not match expected 'r1'"): - svc.refresh_once() + svc.refresh_once() + snapshot = svc.snapshot() + payload = snapshot["payload"] + assert snapshot["last_error"] == "" + assert payload is not None + assert [row["receiver_id"] for row in payload["receivers"]] == ["r0", "r1", "r2"] + + +def test_set_mock_control_input_updates_frequencies(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + svc = service.AutoService(config) + captured: Dict[str, object] = {} + + def _fake_http_json_request( + url: str, + method: str = "GET", + payload: Dict[str, object] | None = None, + timeout_s: float = 2.0, + ): + captured["url"] = url + captured["method"] = method + captured["payload"] = payload or {} + captured["timeout_s"] = timeout_s + return 200, {"status": "ok"}, "" + + monkeypatch.setattr(service, "_http_json_request", _fake_http_json_request) + + response = service._set_mock_control( + service=svc, + target="input", + target_id="r0", + enabled=None, + frequencies_mhz=[915.0, 868.1], + ) + + assert response["status"] == "ok" + assert response["target"] == "input" + assert response["id"] == "r0" + assert response["frequencies_mhz"] == [915.0, 868.1] + assert captured["method"] == "POST" + assert captured["payload"] == {"frequencies_mhz": [915.0, 868.1]} + + +def test_sync_mock_input_frequencies_uses_receiver_configuration(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + for receiver in config["input"]["receivers"]: # type: ignore[index] + receiver["frequencies_mhz"] = [433.92, 915.0] + svc = service.AutoService(config) + calls: List[Dict[str, object]] = [] + + def _fake_set_mock_control( + service: service.AutoService, + target: str, + target_id: str, + enabled: bool | None = None, + frequencies_mhz: List[float] | None = None, + ) -> Dict[str, object]: + calls.append( + { + "target": target, + "target_id": target_id, + "enabled": enabled, + "frequencies_mhz": frequencies_mhz, + } + ) + return {"status": "ok"} + + monkeypatch.setattr(service, "_set_mock_control", _fake_set_mock_control) + + errors = service._sync_mock_input_frequencies(svc) + + assert errors == [] + assert len(calls) == 3 + assert all(call["target"] == "input" for call in calls) + assert all(call["enabled"] is None for call in calls) + assert all(call["frequencies_mhz"] == [433.92, 915.0] for call in calls) def test_refresh_once_raises_when_output_server_rejects_payload( @@ -268,6 +342,63 @@ def test_parse_source_payload_accepts_compact_short_keys(): assert parsed[1][1] == pytest.approx(-62.5) +def test_parse_source_payload_allows_receiver_id_mismatch_when_expected_is_set(): + payload = {"receiver_id": "legacy-name", "measurements": [{"frequency_hz": 868_100_000.0, "rssi_dbm": -61.5}]} + parsed = service.parse_source_payload( + payload=payload, + source_label="source_url=test", + expected_receiver_id="new-name", + ) + assert len(parsed) == 1 + assert parsed[0][0] == pytest.approx(868_100_000.0) + assert parsed[0][1] == pytest.approx(-61.5) + + +def test_http_mock_control_accepts_input_frequency_update(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + svc = service.AutoService(config) + captured: Dict[str, object] = {} + + def _fake_set_mock_control( + service: service.AutoService, + target: str, + target_id: str, + enabled: bool | None = None, + frequencies_mhz: List[float] | None = None, + ) -> Dict[str, object]: + captured["target"] = target + captured["target_id"] = target_id + captured["enabled"] = enabled + captured["frequencies_mhz"] = frequencies_mhz + return {"status": "ok", "target": target, "id": target_id} + + monkeypatch.setattr(service, "_set_mock_control", _fake_set_mock_control) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + req = urllib_request.Request( + url=f"{base_url}/mock/control", + method="POST", + data=json.dumps( + {"target": "input", "id": "r0", "frequencies_mhz": [433.92, 868.1]} + ).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib_request.urlopen(req) as response: + payload = json.loads(response.read().decode("utf-8")) + assert payload["status"] == "ok" + assert captured["target"] == "input" + assert captured["target_id"] == "r0" + assert captured["enabled"] is None + assert captured["frequencies_mhz"] == [433.92, 868.1] + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + current_service = http_server.RequestHandlerClass.service_holder["current"] # type: ignore[attr-defined] + current_service.stop() + + def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch): config = _base_config() svc = service.AutoService(config) diff --git a/web/app.js b/web/app.js index ac17eaa..430b4d0 100644 --- a/web/app.js +++ b/web/app.js @@ -17,6 +17,12 @@ selectedOutputIndex: 0, outputDrafts: [], menuCollapsed: false, + menuGroupCollapsed: { + monitoring: false, + io: false, + config: false, + }, + dateTimeCollapsed: false, ioHistory: [], mockControls: null, historyFilter: "all", @@ -32,12 +38,21 @@ lastHealthStatus: "n/a", lastDeliveryStatus: "n/a", timezone: "local", + uiDensity: "detailed", }; const HZ_IN_MHZ = 1_000_000; const MENU_COLLAPSED_STORAGE_KEY = "triangulation.menu_collapsed"; +const MENU_GROUP_COLLAPSED_STORAGE_KEY = "triangulation.menu_group_collapsed"; +const DATE_TIME_COLLAPSED_STORAGE_KEY = "triangulation.date_time_collapsed"; const TIMEZONE_STORAGE_KEY = "triangulation.timezone"; +const UI_DENSITY_STORAGE_KEY = "triangulation.ui_density"; +const AUTO_REFRESH_INTERVAL_STORAGE_KEY = "triangulation.auto_refresh_interval_ms"; +const AUTO_REFRESH_MIN_MS = 1_000; +const AUTO_REFRESH_MAX_MS = 120_000; +const AUTO_REFRESH_DEFAULT_MS = 2_000; const IO_HISTORY_LIMIT = 60; +const MENU_GROUP_KEYS = ["monitoring", "io", "config"]; const TIMEZONE_OPTIONS = [ { value: "local", label: "Локальный (браузер)" }, { value: "UTC", label: "UTC" }, @@ -191,6 +206,10 @@ function setActiveSection(section) { document.querySelectorAll(".menu-item").forEach((item) => { item.classList.toggle("menu-item-active", item.dataset.section === section); }); + const group = findSectionMenuGroup(section); + if (group) { + setMenuGroupCollapsed(group, false, { persist: true }); + } } function setMenuCollapsed(isCollapsed) { @@ -221,6 +240,106 @@ function readMenuCollapsed() { } } +function normalizeMenuGroupCollapsed(value) { + const result = { + monitoring: false, + io: false, + config: false, + }; + + if (!value || typeof value !== "object") return result; + MENU_GROUP_KEYS.forEach((groupKey) => { + result[groupKey] = Boolean(value[groupKey]); + }); + return result; +} + +function findSectionMenuGroup(section) { + const item = document.querySelector(`.menu-item[data-section="${String(section || "")}"]`); + const group = item?.closest(".menu-group")?.dataset?.menuGroup; + return MENU_GROUP_KEYS.includes(group) ? group : null; +} + +function applyMenuGroupState(groupKey) { + if (!MENU_GROUP_KEYS.includes(groupKey)) return; + const root = document.querySelector(`.menu-group[data-menu-group="${groupKey}"]`); + if (!root) return; + const toggle = root.querySelector(".menu-group-toggle"); + const collapsed = Boolean(state.menuGroupCollapsed[groupKey]); + root.classList.toggle("menu-group-collapsed", collapsed); + if (toggle) { + toggle.setAttribute("aria-expanded", String(!collapsed)); + } +} + +function persistMenuGroupCollapsedState() { + try { + localStorage.setItem( + MENU_GROUP_COLLAPSED_STORAGE_KEY, + JSON.stringify(normalizeMenuGroupCollapsed(state.menuGroupCollapsed)) + ); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function setMenuGroupCollapsed(groupKey, isCollapsed, options = {}) { + const { persist = true } = options; + if (!MENU_GROUP_KEYS.includes(groupKey)) return; + state.menuGroupCollapsed[groupKey] = Boolean(isCollapsed); + applyMenuGroupState(groupKey); + if (persist) { + persistMenuGroupCollapsedState(); + } +} + +function applyAllMenuGroupsCollapsed() { + MENU_GROUP_KEYS.forEach((groupKey) => applyMenuGroupState(groupKey)); +} + +function readMenuGroupCollapsed() { + try { + const raw = localStorage.getItem(MENU_GROUP_COLLAPSED_STORAGE_KEY); + if (!raw) { + return normalizeMenuGroupCollapsed(null); + } + const parsed = JSON.parse(raw); + return normalizeMenuGroupCollapsed(parsed); + } catch { + return normalizeMenuGroupCollapsed(null); + } +} + +function setDateTimeCollapsed(isCollapsed) { + state.dateTimeCollapsed = Boolean(isCollapsed); + const sideNav = byId("side-nav"); + const toggle = byId("datetime-toggle"); + + if (sideNav) { + sideNav.classList.toggle("date-time-collapsed", state.dateTimeCollapsed); + } + if (toggle) { + toggle.textContent = state.dateTimeCollapsed + ? "Показать служебную панель" + : "Скрыть служебную панель"; + toggle.setAttribute("aria-expanded", String(!state.dateTimeCollapsed)); + } + + try { + localStorage.setItem(DATE_TIME_COLLAPSED_STORAGE_KEY, state.dateTimeCollapsed ? "1" : "0"); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function readDateTimeCollapsed() { + try { + return localStorage.getItem(DATE_TIME_COLLAPSED_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + function browserTimeZone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; @@ -250,6 +369,113 @@ function saveTimeZonePreference(value) { } } +function normalizeUiDensity(value) { + return String(value || "").toLowerCase() === "compact" ? "compact" : "detailed"; +} + +function saveUiDensityPreference(value) { + try { + localStorage.setItem(UI_DENSITY_STORAGE_KEY, normalizeUiDensity(value)); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function readUiDensityPreference() { + try { + return normalizeUiDensity(localStorage.getItem(UI_DENSITY_STORAGE_KEY) || "detailed"); + } catch { + return "detailed"; + } +} + +function applyUiDensity() { + const compact = state.uiDensity === "compact"; + document.body.classList.toggle("ui-compact", compact); + const toggle = byId("density-toggle"); + if (toggle) { + toggle.textContent = compact ? "Режим: компактный" : "Режим: детальный"; + } +} + +function setUiDensity(value, options = {}) { + const { persist = true } = options; + state.uiDensity = normalizeUiDensity(value); + applyUiDensity(); + if (persist) { + saveUiDensityPreference(state.uiDensity); + } +} + +function normalizePollIntervalMs(valueMs) { + const numeric = Number(valueMs); + if (!Number.isFinite(numeric)) return AUTO_REFRESH_DEFAULT_MS; + const rounded = Math.round(numeric); + return Math.min(AUTO_REFRESH_MAX_MS, Math.max(AUTO_REFRESH_MIN_MS, rounded)); +} + +function formatPollIntervalSeconds(valueMs) { + const seconds = Number(valueMs) / 1000; + if (!Number.isFinite(seconds)) return "2"; + return seconds.toFixed(2).replace(/\.?0+$/, ""); +} + +function readPollIntervalPreference() { + try { + const raw = localStorage.getItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY); + if (!raw) return AUTO_REFRESH_DEFAULT_MS; + return normalizePollIntervalMs(Number(raw)); + } catch { + return AUTO_REFRESH_DEFAULT_MS; + } +} + +function savePollIntervalPreference(valueMs) { + try { + localStorage.setItem(AUTO_REFRESH_INTERVAL_STORAGE_KEY, String(normalizePollIntervalMs(valueMs))); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function syncPollIntervalInput() { + const input = byId("auto-refresh-seconds"); + if (!input) return; + input.value = formatPollIntervalSeconds(state.pollIntervalMs); +} + +function setPollIntervalMs(valueMs, options = {}) { + const { persist = true, restartPolling = true } = options; + const next = normalizePollIntervalMs(valueMs); + state.pollIntervalMs = next; + if (persist) { + savePollIntervalPreference(next); + } + syncPollIntervalInput(); + updateRefreshUi(); + if (restartPolling && state.autoRefreshEnabled) { + startPolling(); + } +} + +function setPollIntervalSeconds(rawValue, options = {}) { + const { notify = true } = options; + const normalized = String(rawValue ?? "").replace(",", ".").trim(); + const seconds = Number(normalized); + if (!Number.isFinite(seconds) || seconds <= 0) { + syncPollIntervalInput(); + if (notify) { + showToast("Введите корректный интервал автообновления (1-120 секунд).", "error"); + } + return; + } + const nextMs = normalizePollIntervalMs(seconds * 1000); + setPollIntervalMs(nextMs); + if (notify) { + showToast(`Интервал автообновления: ${formatPollIntervalSeconds(nextMs)}с.`, "success"); + } +} + function selectedTimeZoneValue() { return state.timezone === "local" ? null : state.timezone; } @@ -328,7 +554,7 @@ function updateRefreshUi() { if (button) { button.textContent = state.autoRefreshEnabled ? "Пауза автообновления" : "Запустить автообновление"; } - const suffix = `${Math.round(state.pollIntervalMs / 1000)}с`; + const suffix = `${formatPollIntervalSeconds(state.pollIntervalMs)}с`; setTextWithPulse( "refresh-state", `автообновление: ${state.autoRefreshEnabled ? "вкл" : "выкл"} (${suffix})` @@ -537,17 +763,27 @@ function buildIoHistoryRow(data, delivery) { const selectedMhz = data?.selected_frequency_mhz ?? hzToMhz(selectedHz); const receivers = Array.isArray(data?.receivers) ? data.receivers : []; const servers = Array.isArray(delivery?.servers) ? delivery.servers : []; + const rmseMRaw = Number(data?.rmse_m); + const rmseM = Number.isFinite(rmseMRaw) ? rmseMRaw : null; + const rssiValues = []; const inputItems = receivers.map((receiver) => { const receiverId = String(receiver?.receiver_id || "n/a"); const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null; const perFrequency = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null; - const rssi = sample?.amplitude_dbm; + const rssi = Number(sample?.amplitude_dbm); + if (Number.isFinite(rssi)) { + rssiValues.push(rssi); + } const radius = perFrequency?.radius_m ?? sample?.distance_m; return `${receiverId}: ${fmt(rssi, 1)} dBm / ${fmt(radius, 2)} m`; }); + const avgRssiDbm = rssiValues.length > 0 + ? rssiValues.reduce((acc, value) => acc + value, 0) / rssiValues.length + : null; + const outputItems = (() => { if (servers.length === 0) { const pos = data?.position || {}; @@ -565,11 +801,13 @@ function buildIoHistoryRow(data, delivery) { const outputSummary = outputItems.join("; "); const statusRaw = String(delivery?.status || "n/a"); const timestamp = String(data?.timestamp_utc || delivery?.sent_at_utc || ""); - const key = `${timestamp}|${fmt(selectedMhz, 3)}|${inputItems.join("||")}|${outputItems.join("||")}|${statusRaw}`; + const key = `${timestamp}|${fmt(selectedMhz, 3)}|${inputItems.join("||")}|${outputItems.join("||")}|${statusRaw}|${fmt(rmseM, 2)}|${fmt(avgRssiDbm, 1)}`; return { key, timestamp, frequencyMhz: selectedMhz, + rmseM, + avgRssiDbm, inputItems, outputItems, inputSummary, @@ -597,59 +835,506 @@ function toPercent(part, total) { return Math.max(0, Math.min(100, Math.round((part / total) * 100))); } -function renderOverviewMetrics() { +function clamp(value, minValue, maxValue) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return minValue; + return Math.max(minValue, Math.min(maxValue, numeric)); +} + +function setProgressWidth(id, percent) { + const el = byId(id); + if (!el) return; + const normalized = clamp(percent, 0, 100); + el.style.width = `${normalized}%`; +} + +function statusToSuccessScore(statusRaw) { + const status = String(statusRaw || "n/a").toLowerCase(); + if (status === "ok") return 100; + if (status === "partial") return 70; + if (status === "warming_up") return 40; + if (status === "skipped") return 30; + if (status === "disabled") return 20; + return 0; +} + +function buildTrendSeries(limit = 20) { + const rows = state.ioHistory.slice(0, Math.max(1, Number(limit) || 20)).reverse(); + const rmse = rows + .map((row) => Number(row?.rmseM)) + .filter((value) => Number.isFinite(value) && value >= 0); + const avgRssi = rows + .map((row) => Number(row?.avgRssiDbm)) + .filter((value) => Number.isFinite(value)); + const delivery = rows.map((row) => statusToSuccessScore(row?.statusRaw)); + return { rows, rmse, avgRssi, delivery }; +} + +function formatTrendMeta(values, digits = 2, unit = "") { + if (!Array.isArray(values) || values.length === 0) return "н/д"; + const finiteValues = values.filter((value) => Number.isFinite(Number(value))); + if (finiteValues.length === 0) return "н/д"; + const last = Number(finiteValues[finiteValues.length - 1]); + const min = Math.min(...finiteValues); + const max = Math.max(...finiteValues); + return `посл ${fmt(last, digits)}${unit} | мин ${fmt(min, digits)}${unit} | макс ${fmt(max, digits)}${unit}`; +} + +function buildSparklineSvg(values, options = {}) { + const { + width = 220, + height = 54, + padding = 4, + stroke = "#2b73f0", + area = "rgba(43, 115, 240, 0.22)", + invert = false, + } = options; + + const points = Array.isArray(values) + ? values.map((value) => Number(value)).filter((value) => Number.isFinite(value)) + : []; + if (points.length === 0) { + return '
Недостаточно данных
'; + } + + const minValue = Math.min(...points); + const maxValue = Math.max(...points); + const range = maxValue - minValue || 1; + const safeWidth = Math.max(40, Number(width) || 220); + const safeHeight = Math.max(24, Number(height) || 54); + const baseline = safeHeight - padding; + const usableWidth = Math.max(1, safeWidth - padding * 2); + const usableHeight = Math.max(1, safeHeight - padding * 2); + + const normalized = points.map((value, index) => { + const x = padding + (points.length === 1 ? usableWidth / 2 : (index / (points.length - 1)) * usableWidth); + let ratio = (value - minValue) / range; + if (invert) ratio = 1 - ratio; + const y = baseline - ratio * usableHeight; + return { x, y, value }; + }); + + const linePoints = normalized.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" "); + const first = normalized[0]; + const last = normalized[normalized.length - 1]; + const areaPath = `M ${first.x.toFixed(2)} ${baseline.toFixed(2)} L ${linePoints + .replaceAll(" ", " L ")} L ${last.x.toFixed(2)} ${baseline.toFixed(2)} Z`; + const y25 = (padding + usableHeight * 0.25).toFixed(2); + const y50 = (padding + usableHeight * 0.5).toFixed(2); + const y75 = (padding + usableHeight * 0.75).toFixed(2); + + return ` + + + + + + + + + `; +} + +function renderOverviewTrends() { + const rmseRoot = byId("ov-trend-rmse-chart"); + const rssiRoot = byId("ov-trend-rssi-chart"); + const deliveryRoot = byId("ov-trend-delivery-chart"); + if (!rmseRoot && !rssiRoot && !deliveryRoot) return; + + const series = buildTrendSeries(20); + + if (rmseRoot) { + rmseRoot.innerHTML = buildSparklineSvg(series.rmse, { + stroke: "#2b73f0", + area: "rgba(43, 115, 240, 0.22)", + invert: true, + }); + } + if (rssiRoot) { + rssiRoot.innerHTML = buildSparklineSvg(series.avgRssi, { + stroke: "#ff8a3d", + area: "rgba(255, 138, 61, 0.24)", + }); + } + if (deliveryRoot) { + deliveryRoot.innerHTML = buildSparklineSvg(series.delivery, { + stroke: "#14a37f", + area: "rgba(20, 163, 127, 0.22)", + }); + } + + setTextWithPulse("ov-trend-rmse-meta", formatTrendMeta(series.rmse, 2, " м")); + setTextWithPulse("ov-trend-rssi-meta", formatTrendMeta(series.avgRssi, 1, " дБм")); + setTextWithPulse("ov-trend-delivery-meta", formatTrendMeta(series.delivery, 0, "%")); +} + +function renderHistoryTrends() { + const root = byId("history-trends"); + if (!root) return; + + const series = buildTrendSeries(40); + const blocks = [ + { + title: "RMSE, м", + meta: formatTrendMeta(series.rmse, 2, " м"), + chart: buildSparklineSvg(series.rmse, { + stroke: "#2b73f0", + area: "rgba(43, 115, 240, 0.22)", + invert: true, + }), + }, + { + title: "Средний RSSI, дБм", + meta: formatTrendMeta(series.avgRssi, 1, " дБм"), + chart: buildSparklineSvg(series.avgRssi, { + stroke: "#ff8a3d", + area: "rgba(255, 138, 61, 0.24)", + }), + }, + { + title: "Успех доставки, %", + meta: formatTrendMeta(series.delivery, 0, "%"), + chart: buildSparklineSvg(series.delivery, { + stroke: "#14a37f", + area: "rgba(20, 163, 127, 0.22)", + }), + }, + ]; + + root.innerHTML = blocks + .map( + (block) => ` +
+
+ ${escapeHtml(block.title)} + ${escapeHtml(block.meta)} +
+
${block.chart}
+
+ ` + ) + .join(""); +} + +function buildMonitoringSummary() { + const data = state.result?.data || null; + const delivery = state.result?.output_delivery || state.frequencies?.output_delivery || {}; const inputs = Array.isArray(state.mockControls?.inputs) ? state.mockControls.inputs : []; const outputs = Array.isArray(state.mockControls?.outputs) ? state.mockControls.outputs : []; const inputOnline = inputs.filter((item) => Boolean(item?.reachable)).length; const outputOnline = outputs.filter((item) => Boolean(item?.reachable)).length; - const total = state.ioHistory.length; const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length; const success = toPercent(okCount, total); + const selectedHz = Number(data?.selected_frequency_hz); + const frequencyRows = Array.isArray(data?.frequency_table) ? data.frequency_table : []; + const updatedAt = state.result?.updated_at_utc || state.frequencies?.updated_at_utc || ""; + const updatedText = updatedAt ? fmtDateTimeCompact(updatedAt) : "н/д"; - setTextWithPulse("ov-input-online", `${inputOnline}/${inputs.length}`); - setTextWithPulse("ov-output-online", `${outputOnline}/${outputs.length}`); - setTextWithPulse("ov-history-total", total); - setTextWithPulse("ov-success-rate", `${success}%`); + return { + data, + delivery, + inputs, + outputs, + inputOnline, + outputOnline, + inputTotal: inputs.length, + outputTotal: outputs.length, + inputOnlinePercent: toPercent(inputOnline, inputs.length), + outputOnlinePercent: toPercent(outputOnline, outputs.length), + total, + okCount, + success, + selectedHz, + frequencyRows, + receivers: Array.isArray(data?.receivers) ? data.receivers : [], + updatedText, + }; } -function renderHistoryInsights() { - const feedRoot = byId("history-feed"); - const monitorRoot = byId("history-monitor"); +function renderOverviewFrequencyHealth(frequencyRows, selectedHz) { + const root = byId("ov-frequency-health"); + if (!root) return; - if (feedRoot) { - const feedRows = state.ioHistory.slice(0, 8); - if (feedRows.length === 0) { - feedRoot.innerHTML = '
Событий пока нет.
'; - } else { - feedRoot.innerHTML = feedRows - .map( - (row, index) => ` -
-
- ${escapeHtml(fmtDateTimeCompact(row.timestamp))} - ${escapeHtml(localizeStatus(row.statusRaw))} -
-
-
${escapeHtml(fmt(row.frequencyMhz, 3))} МГц
-
${escapeHtml((row.outputItems && row.outputItems[0]) || row.outputSummary || "-")}
-
-
` - ) - .join(""); - } + if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) { + root.innerHTML = '
Расчёты по частотам пока не получены.
'; + return; } - if (monitorRoot) { - const inputs = Array.isArray(state.mockControls?.inputs) ? state.mockControls.inputs : []; - const outputs = Array.isArray(state.mockControls?.outputs) ? state.mockControls.outputs : []; - const inputOnline = inputs.filter((item) => Boolean(item?.reachable)).length; - const outputOnline = outputs.filter((item) => Boolean(item?.reachable)).length; + const finiteRmse = frequencyRows + .map((row) => Number(row?.rmse_m)) + .filter((value) => Number.isFinite(value) && value >= 0); + const minRmse = finiteRmse.length > 0 ? Math.min(...finiteRmse) : 0; + const maxRmse = finiteRmse.length > 0 ? Math.max(...finiteRmse) : 1; + + root.innerHTML = frequencyRows + .map((row) => { + const hz = Number(row?.frequency_hz); + const mhz = row?.frequency_mhz ?? hzToMhz(hz); + const rmse = Number(row?.rmse_m); + const exact = Boolean(row?.exact); + const isSelected = Number.isFinite(hz) && Math.abs(hz - Number(selectedHz)) <= 1; + + let quality = 0; + if (Number.isFinite(rmse) && rmse >= 0) { + if (maxRmse <= minRmse) { + quality = 100; + } else { + quality = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100); + } + } + quality = clamp(quality, 0, 100); + + return ` +
+
+ ${escapeHtml(fmt(mhz, 3))} МГц + ${exact ? "точно" : "оценка"} +
+
+ +
+
+ RMSE: ${escapeHtml(fmt(rmse, 2))} м + Качество: ${quality}% +
+
+ `; + }) + .join(""); +} + +function renderOverviewSignalGrid(receivers, selectedHz) { + const root = byId("ov-signal-grid"); + if (!root) return; + if (!Array.isArray(receivers) || receivers.length === 0) { + root.innerHTML = '
Сигналы ресиверов пока не получены.
'; + return; + } + + root.innerHTML = receivers + .map((receiver) => { + const receiverId = String(receiver?.receiver_id || "n/a"); + const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null; + const row = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null; + + const rssi = Number(sample?.amplitude_dbm); + const radius = Number(row?.radius_m ?? sample?.distance_m); + const residual = Number(row?.residual_m); + const level = Number.isFinite(rssi) ? clamp(((rssi + 120) / 70) * 100, 0, 100) : 0; + + return ` +
+
+ ${escapeHtml(receiverId)} + ${escapeHtml(fmt(rssi, 1))} dBm +
+
+ +
+
+ R=${escapeHtml(fmt(radius, 2))} м + ε=${escapeHtml(fmt(residual, 2))} м +
+
+ `; + }) + .join(""); +} + +function normalizePipelineStatus(status) { + const normalized = String(status || "n/a").toLowerCase(); + if (normalized === "ok") return "ok"; + if (normalized === "error") return "error"; + if (normalized === "partial") return "partial"; + if (normalized === "warming_up") return "warm"; + return "warm"; +} - const total = state.ioHistory.length; - const okCount = state.ioHistory.filter((row) => String(row.statusRaw || "").toLowerCase() === "ok").length; +function pipelineDotClass(status) { + const normalized = normalizePipelineStatus(status); + if (normalized === "ok") return "pipeline-dot pipeline-dot-ok"; + if (normalized === "error") return "pipeline-dot pipeline-dot-error"; + if (normalized === "partial") return "pipeline-dot pipeline-dot-partial"; + return "pipeline-dot pipeline-dot-warm"; +} + +function renderOverviewPipeline(summary) { + const root = byId("ov-pipeline-stages"); + if (!root) return; + + const hasData = Boolean(summary.data); + const solveStatus = hasData + ? Number.isFinite(Number(summary.data?.rmse_m)) + ? "ok" + : "partial" + : "warming_up"; + + const inputStatus = summary.inputTotal <= 0 + ? "warming_up" + : summary.inputOnline === summary.inputTotal + ? "ok" + : summary.inputOnline > 0 + ? "partial" + : "error"; + + const outputStatus = summary.outputTotal <= 0 + ? "warming_up" + : summary.outputOnline === summary.outputTotal + ? "ok" + : summary.outputOnline > 0 + ? "partial" + : "error"; + + const stages = [ + { + name: "Сбор входов", + status: inputStatus, + value: `${summary.inputOnline}/${summary.inputTotal}`, + note: "доступность входных серверов", + }, + { + name: "Решение", + status: solveStatus, + value: hasData ? `RMSE ${fmt(summary.data?.rmse_m, 2)} м` : "ожидание данных", + note: "оценка точности пересечения сфер", + }, + { + name: "Доставка", + status: summary.delivery?.status || "warming_up", + value: `${summary.outputOnline}/${summary.outputTotal}`, + note: "доступность выходных серверов", + }, + { + name: "История", + status: summary.total > 0 ? (summary.success >= 80 ? "ok" : "partial") : "warming_up", + value: `${summary.success}%`, + note: "успешность доставки по накопленной ленте", + }, + ]; + + root.innerHTML = stages + .map( + (stage) => ` +
+
+ + + ${escapeHtml(stage.name)} + + ${escapeHtml(localizeStatus(stage.status))} +
+
+ +
+
${escapeHtml(stage.value)} • ${escapeHtml(stage.note)}
+
+ ` + ) + .join(""); +} + +function renderOverviewTopFrequencies(frequencyRows) { + const root = byId("ov-top-frequencies"); + if (!root) return; + + if (!Array.isArray(frequencyRows) || frequencyRows.length === 0) { + root.innerHTML = '
Нет данных по частотам для рейтинга.
'; + return; + } + + const rankedRows = [...frequencyRows].sort((a, b) => { + const ra = Number(a?.rmse_m); + const rb = Number(b?.rmse_m); + const aFinite = Number.isFinite(ra); + const bFinite = Number.isFinite(rb); + if (aFinite && bFinite) return ra - rb; + if (aFinite) return -1; + if (bFinite) return 1; + return 0; + }); + + const topRows = rankedRows.slice(0, 5); + const rmseValues = topRows + .map((row) => Number(row?.rmse_m)) + .filter((value) => Number.isFinite(value) && value >= 0); + const minRmse = rmseValues.length > 0 ? Math.min(...rmseValues) : 0; + const maxRmse = rmseValues.length > 0 ? Math.max(...rmseValues) : 1; + + root.innerHTML = topRows + .map((row, index) => { + const rmse = Number(row?.rmse_m); + const mhz = row?.frequency_mhz ?? hzToMhz(row?.frequency_hz); + const exact = Boolean(row?.exact); + let score = 0; + if (Number.isFinite(rmse) && rmse >= 0) { + if (maxRmse <= minRmse) { + score = 100; + } else { + score = Math.round(((maxRmse - rmse) / (maxRmse - minRmse)) * 100); + } + } + score = clamp(score, 0, 100); + + return ` +
+
+ + ${index + 1} + ${escapeHtml(fmt(mhz, 3))} МГц + + ${exact ? "точно" : "оценка"} +
+
+ +
+
+ RMSE ${escapeHtml(fmt(rmse, 2))} м + Индекс ${score}% +
+
+ `; + }) + .join(""); +} + +function renderMenuGroupBadges(summary) { + setTextWithPulse("menu-badge-monitoring", `${summary.success}%`); + setTextWithPulse("menu-badge-io", `${summary.inputOnline}/${summary.inputTotal} • ${summary.outputOnline}/${summary.outputTotal}`); + setTextWithPulse("menu-badge-config", `${summary.inputs.length}вх/${summary.outputs.length}вых`); +} + +function renderOverviewMetrics() { + const summary = buildMonitoringSummary(); + + setTextWithPulse("ov-input-online", `${summary.inputOnline}/${summary.inputTotal}`); + setTextWithPulse("ov-output-online", `${summary.outputOnline}/${summary.outputTotal}`); + setTextWithPulse("ov-history-total", summary.total); + setTextWithPulse("ov-success-rate", `${summary.success}%`); + setTextWithPulse("ov-health-chip", localizeStatus(state.health?.status)); + setTextWithPulse("ov-delivery-chip", localizeStatus(summary.delivery?.status)); + setTextWithPulse("ov-updated-at", summary.updatedText); + setTextWithPulse("ov-input-online-bar-text", `${summary.inputOnline}/${summary.inputTotal}`); + setTextWithPulse("ov-output-online-bar-text", `${summary.outputOnline}/${summary.outputTotal}`); + setTextWithPulse("ov-delivery-bar-text", `${summary.success}%`); + setProgressWidth("ov-input-online-bar", summary.inputOnlinePercent); + setProgressWidth("ov-output-online-bar", summary.outputOnlinePercent); + setProgressWidth("ov-delivery-bar", summary.success); + renderMenuGroupBadges(summary); + renderOverviewFrequencyHealth(summary.frequencyRows, summary.selectedHz); + renderOverviewSignalGrid(summary.receivers, summary.selectedHz); + renderOverviewPipeline(summary); + renderOverviewTopFrequencies(summary.frequencyRows); + renderOverviewTrends(); +} + +function renderHistoryInsights() { + const monitorRoot = byId("history-monitor"); + const summary = buildMonitoringSummary(); + renderHistoryTrends(); + + if (monitorRoot) { const problemCount = state.ioHistory.filter((row) => statusIsProblem(row.statusRaw)).length; - const success = toPercent(okCount, total); const healthStatus = localizeStatus(state.health?.status); const deliveryStatus = localizeStatus( @@ -659,11 +1344,11 @@ function renderHistoryInsights() { monitorRoot.innerHTML = `
Сервис${escapeHtml(healthStatus)}
Доставка${escapeHtml(deliveryStatus)}
-
Входы online${inputOnline}/${inputs.length}
-
Выходы online${outputOnline}/${outputs.length}
+
Входы online${summary.inputOnline}/${summary.inputTotal}
+
Выходы online${summary.outputOnline}/${summary.outputTotal}
Проблемных событий${problemCount}
-
Успех доставки${success}%
-
+
Успех доставки${summary.success}%
+
`; } } @@ -871,6 +1556,16 @@ function buildControlCardHtml(item, target) { const reachabilityKind = reachable ? "io-status-ok" : "io-status-error"; const reachabilityText = reachable ? "online" : "offline"; const disabledAttr = !id ? "disabled" : ""; + const liveFrequencies = Array.isArray(item?.frequencies_mhz) + ? item.frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ") + : ""; + const configuredFrequencies = Array.isArray(item?.configured_frequencies_mhz) + ? item.configured_frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ") + : ""; + const frequencyText = + target === "input" + ? `активные: ${liveFrequencies || "н/д"} • конфиг: ${configuredFrequencies || "н/д"}` + : ""; return `
@@ -885,6 +1580,7 @@ function buildControlCardHtml(item, target) { ${errorText ? `
Ошибка: ${errorText}
` : ""} + ${frequencyText ? `
частоты (МГц): ${escapeHtml(frequencyText)}
` : ""}
- - - - - + + + + +
- дата: н/д - время: н/д - состояние: н/д - доставка: н/д - +
+ + +
+
+
+ дата: н/д + время: н/д +
+
+ сервис: н/д + доставка: н/д +
+ +
-
+

Панель Радиопозиционирования

Мониторинг и управление расчётом 3D триангуляции.

+ автообновление: вкл (2с)
-
-

Итоговая Позиция

-
-
Выбранная частота: -
-
X: -
-
Y: -
-
Z: -
-
СКО (RMSE): -
-
-
- -
-

Оперативный Мониторинг

-
-
- Входы online - 0/0 +
+
+

Итоговая Позиция

+
+
Выбранная частота: -
+
X: -
+
Y: -
+
Z: -
+
СКО (RMSE): -
-
- Выходы online - 0/0 -
-
- События в истории - 0 +
+ +
+

Оперативный Мониторинг

+
+ Сервис: н/д + Доставка: н/д + Обновлено: н/д
-
- Успех доставки - 0% + +
+
+
+
+ Входы online + 0/0 +
+
+ Выходы online + 0/0 +
+
+ События в истории + 0 +
+
+ Успех доставки + 0% +
+
+
+ +
+

Состояние Потоков

+
+ Входные потоки + 0/0 +
+
+ +
+
+ Выходные потоки + 0/0 +
+
+ +
+
+ Успешная доставка + 0% +
+
+ +
+
-
-
+
+
- +

Таблица По Частотам

@@ -105,6 +232,32 @@
+ +
+

Аналитика Частот

+
+
+

Профиль Частот

+
+
+
+

Лидеры Частот

+
+
+ +
+
@@ -125,89 +278,91 @@

Управление Тестовыми Сбоями

+ +
+

Контур Входа/Выхода

+
+
+

Сигналы Ресиверов

+
+
+
+

Контур Обработки

+
+
+ +
+
-
+

История Входов И Выходов

Связка входных измерений и отправки результата для отладки, SLA и диагностики ошибок.

+
-
-
- Событий - 0 -
-
- Успешно - 0 -
-
- Проблемы - 0 -
-
- Частот - 0 -
-
- Последнее событие - н/д -
-
- -
- -
- +
+
+

История И Фильтры

+
- -
- - Стр. 1/1 • 0 записей - +
+ + + +
+ + Стр. 1/1 • 0 записей + +
+ + + запись: вкл +
- - - запись: вкл -
-
- -
-
-

Лента Последних Событий

-
-
-
-

Диагностика Мониторинга

-
-
-
-
@@ -222,61 +377,175 @@
-
+
+ +
+

Мониторинг Истории

+
+
+ Событий + 0 +
+
+ Успешно + 0 +
+
+ Проблемы + 0 +
+
+ Частот + 0 +
+
+ Последнее событие + н/д +
+
+ +
+
+

Диагностика Мониторинга

+
+
+
+

Тренды Метрик

+ +
+
+
+
-
+

Настройка Серверов

Доступы входа и выхода задаются отдельно. Общий фильтр входа автоматически применяется ко всем входным серверам.

+
-

Доступ К Входным Серверам

-
- -
- - - входов: 0 +
+
+
+

Входные Серверы

+

Управление ресиверами и их геометрией.

- - - - - - -
+
+
+ + входов: 0 +
+
+ + +
+
+ + +
+ +
+ + + +
+
+
-

Общий Фильтр Входа (Авто Для Всех)

-
- - - - - -
+
+
+

Общий Фильтр Входа

+

Применяется автоматически ко всем входным серверам.

+
+
+ +
+ + +
+
+ + +
+
+
-

Выходные Серверы

-
- -
- - - выходов: 0 +
+
+

Выходные Серверы

+

Минимальные параметры для доставки результата.

- - - -
+
+
+ + выходов: 0 +
+
+ + +
+
+ + +
+ +
+ +
+
@@ -286,20 +555,54 @@
-
+

Конфигурация

конфиг: н/д
-
+ +
+
+
+

Редактор JSON

+

Точный режим настройки для прод-конфигурации.

+
+
+
+ JSON + UTF-8 + runtime + input + system +
+ +
+
+
+
+

Памятка По Полям

+

Краткая структура и контрольные точки перед сохранением.

+
+
+

input.receivers[]
входные ресиверы: координаты, URL, частоты.

+

runtime.output_servers[]
список серверов, получающих координаты.

+

input.default_input_filter
общий фильтр частот и RSSI.

+

system
системные таймеры, лимиты и автообновление.

+
+

Советы

+
    +
  • Поддерживайте уникальные `receiver_id` для каждого входа.
  • +
  • Согласуйте диапазоны частот между входными серверами.
  • +
  • Перед сохранением проверяйте JSON на валидность.
  • +
+
+
- +
diff --git a/web/styles.css b/web/styles.css index d559ef4..f929208 100644 --- a/web/styles.css +++ b/web/styles.css @@ -24,6 +24,14 @@ box-sizing: border-box; } +img, +svg, +video, +canvas { + max-width: 100%; + height: auto; +} + html, body { min-height: 100%; @@ -43,11 +51,11 @@ body { .app-shell { width: 100%; margin: 0; - padding: 12px 12px 18px; + padding: clamp(8px, 1.1vw, 14px) clamp(8px, 1.2vw, 16px) clamp(12px, 1.6vw, 20px); display: grid; grid-template-columns: 1fr; align-items: start; - gap: 14px; + gap: clamp(10px, 1.2vw, 16px); position: relative; z-index: 2; } @@ -61,6 +69,7 @@ body { box-shadow: var(--shadow); position: relative; overflow: hidden; + min-width: 0; animation: rise 460ms ease both; transition: transform var(--anim-fast) ease, @@ -139,10 +148,16 @@ body { .content-area { display: grid; min-width: 0; - width: min(1800px, 100%); + width: min(1900px, 100%); margin: 0 auto; } +.content-area > .panel, +.panel > article, +.panel > div { + min-width: 0; +} + .panel { display: none; animation: fadeSlide var(--anim-mid) ease; @@ -150,9 +165,9 @@ body { .panel-active { display: grid; - gap: 16px; + gap: clamp(10px, 1.2vw, 16px); min-width: 0; - width: min(1500px, 100%); + width: 100%; margin: 0 auto; } @@ -166,12 +181,34 @@ body { font-size: clamp(1.3rem, 1rem + 1vw, 1.8rem); } +.overview-hero { + background: + radial-gradient(circle at 8% 0%, rgba(36, 107, 255, 0.18), transparent 40%), + linear-gradient(165deg, var(--card-strong), var(--card)); +} + +.overview-layout { + display: grid; + grid-template-columns: minmax(300px, 420px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.overview-position-card { + min-height: 100%; +} + +.overview-monitor-card { + min-height: 100%; +} + .hero-actions, .editor-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; + width: 100%; } .hero-actions { @@ -187,6 +224,10 @@ body { cursor: pointer; font-family: inherit; font-weight: 600; + max-width: 100%; + text-align: center; + white-space: normal; + overflow-wrap: anywhere; transition: transform var(--anim-fast) ease, background-color var(--anim-fast) ease, @@ -224,26 +265,25 @@ body { } .menu-list { - display: flex; - flex-wrap: nowrap; - align-items: stretch; - justify-content: center; - gap: 8px; - width: min(1380px, 100%); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + align-items: start; + gap: 10px; + width: min(1780px, 100%); max-width: 100%; margin: 0 auto; position: static; border: 1px solid var(--line); - border-radius: 12px; - background: rgba(255, 255, 255, 0.86); + border-radius: 14px; + background: + radial-gradient(130% 180% at 0% 0%, rgba(234, 242, 255, 0.62), transparent 52%), + linear-gradient(170deg, rgba(255, 255, 255, 0.94), rgba(245, 250, 255, 0.8)); 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: 84px; - overflow-x: auto; - overflow-y: hidden; - overscroll-behavior: contain; + 0 10px 28px rgba(21, 37, 73, 0.1); + padding: 8px; + max-height: none; + overflow: visible; transform-origin: top center; animation: menuIn var(--anim-mid) ease both; transition: @@ -265,51 +305,288 @@ body { pointer-events: none; } +.menu-group { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 24%); + border-radius: 12px; + background: linear-gradient(175deg, rgba(255, 255, 255, 0.94), rgba(243, 249, 255, 0.82)); + padding: 8px; + display: grid; + gap: 8px; + min-width: 0; +} + +.menu-group-toggle { + width: 100%; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 18%); + background: linear-gradient(165deg, rgba(235, 243, 255, 0.88), rgba(255, 255, 255, 0.94)); + border-radius: 10px; + padding: 7px 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + font-family: inherit; + color: #1d3e74; + transition: + transform var(--anim-fast) ease, + box-shadow var(--anim-fast) ease, + border-color var(--anim-fast) ease; +} + +.menu-group-toggle:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent), #ffffff 64%); + box-shadow: 0 8px 18px rgba(26, 63, 126, 0.12); +} + +.menu-group-title { + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 800; +} + +.menu-group-badge { + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 16%); + background: rgba(255, 255, 255, 0.94); + color: #2a4f88; + padding: 3px 8px; + font-size: 0.72rem; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.menu-group-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 8px; + max-height: 300px; + opacity: 1; + transform: translateY(0); + transition: + max-height var(--anim-mid) ease, + opacity var(--anim-fast) ease, + transform var(--anim-fast) ease; +} + +.menu-group.menu-group-collapsed .menu-group-body { + max-height: 0; + opacity: 0; + transform: translateY(-8px); + overflow: hidden; + pointer-events: none; +} + .menu-item { - flex: 1 1 0; - min-width: 136px; - display: inline-flex; + min-width: 0; + min-height: 68px; + display: grid; + grid-template-rows: auto auto auto; + align-content: center; + justify-items: center; + gap: 4px; align-items: center; justify-content: center; - border: 1px solid transparent; - background: #fbfcff; - color: var(--text); - border-radius: 9px; - padding: 8px 12px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 24%); + background: + linear-gradient(178deg, rgba(255, 255, 255, 0.96), rgba(242, 248, 255, 0.86)); + color: #23395f; + border-radius: 11px; + padding: 8px 12px 10px; text-align: center; - white-space: nowrap; + white-space: normal; + line-height: 1.05; + font-size: 0.83rem; + letter-spacing: 0.02em; + font-weight: 700; cursor: pointer; font-family: inherit; + position: relative; + overflow: hidden; transition: transform var(--anim-fast) ease, border-color var(--anim-fast) ease, - background-color var(--anim-fast) ease; + background-color var(--anim-fast) ease, + box-shadow var(--anim-fast) ease, + color var(--anim-fast) ease; +} + +.menu-item-icon { + width: 24px; + height: 24px; + min-width: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 10%); + background: linear-gradient(155deg, #f6f9ff, #e7efff); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.74), + 0 4px 10px rgba(21, 52, 105, 0.12); + color: #2a4f88; + font-size: 0.72rem; + font-weight: 800; + transition: transform var(--anim-fast) ease, box-shadow var(--anim-fast) ease, background var(--anim-fast) ease; +} + +.menu-item-text { + font-size: 0.83rem; + font-weight: 800; + letter-spacing: 0.01em; + color: #1f3860; +} + +.menu-item-note { + font-size: 0.69rem; + text-transform: uppercase; + letter-spacing: 0.09em; + color: #5f7398; + font-weight: 700; +} + +.menu-group.menu-group-collapsed .menu-group-toggle { + border-color: color-mix(in oklab, var(--line), #ffffff 28%); +} + +.menu-item::after { + content: ""; + position: absolute; + left: 14px; + right: 14px; + bottom: 5px; + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(36, 107, 255, 0.1), rgba(36, 107, 255, 0.62), rgba(36, 107, 255, 0.1)); + transform: scaleX(0.28); + opacity: 0; + transition: + transform var(--anim-fast) ease, + opacity var(--anim-fast) ease; } .menu-item:hover { - transform: translateY(-1px); - border-color: color-mix(in oklab, var(--accent), #ffffff 70%); + transform: translateY(-2px); + border-color: color-mix(in oklab, var(--accent), #ffffff 64%); + box-shadow: 0 10px 20px rgba(30, 65, 122, 0.14); + color: #1c3f74; +} + +.menu-item:hover .menu-item-icon { + transform: translateY(-1px) scale(1.03); +} + +.menu-item:hover::after { + transform: scaleX(1); + opacity: 1; } .menu-item-active { - background: linear-gradient(90deg, var(--accent-soft), #f4f7ff); - border-color: color-mix(in oklab, var(--accent), #ffffff 64%); + background: + radial-gradient(120% 150% at 10% -20%, rgba(36, 107, 255, 0.28), transparent 50%), + linear-gradient(158deg, #edf4ff, #ffffff 70%); + border-color: color-mix(in oklab, var(--accent), #ffffff 44%); + color: #133d7b; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.84), + 0 12px 24px rgba(19, 76, 178, 0.16); +} + +.menu-item-active .menu-item-icon { + background: linear-gradient(155deg, #2e6ff1, #1d56cd); + border-color: color-mix(in oklab, #1d56cd, #ffffff 25%); + color: #fff; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.34), + 0 7px 14px rgba(23, 71, 171, 0.28); +} + +.menu-item-active .menu-item-text { + color: #14386b; +} + +.menu-item-active .menu-item-note { + color: #2a5ea8; +} + +.menu-item-active::after { + transform: scaleX(1); + opacity: 1; } .side-meta { + width: 100%; + display: grid; + gap: 8px; + justify-items: center; + min-width: 0; +} + +.datetime-panel-controls { + width: 100%; + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 8px; +} + +.meta-panel { + width: 100%; + display: grid; + gap: 8px; + justify-items: center; + max-height: 240px; + opacity: 1; + transform: translateY(0); + transition: + max-height var(--anim-mid) ease, + opacity var(--anim-fast) ease, + transform var(--anim-fast) ease, + margin var(--anim-fast) ease; +} + +.side-nav.date-time-collapsed .meta-panel { + max-height: 0; + opacity: 0; + transform: translateY(-8px); + overflow: hidden; + margin: 0; + pointer-events: none; +} + +.date-time-panel, +.status-panel { + width: 100%; display: flex; flex-wrap: wrap; gap: 8px; + align-items: center; justify-content: center; } +.meta-pill { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 22%); + background: rgba(247, 251, 255, 0.92); + border-radius: 999px; + padding: 6px 11px; + font-size: 0.78rem; + line-height: 1.2; + color: #30486f; + white-space: normal; + text-align: center; + overflow-wrap: anywhere; +} + .timezone-picker { display: grid; gap: 4px; - text-align: left; + text-align: center; font-size: 0.78rem; color: #3d4f70; - min-width: 240px; + min-width: 220px; } .timezone-picker select { @@ -322,6 +599,26 @@ body { font-family: inherit; } +.refresh-interval-control { + display: grid; + gap: 4px; + text-align: left; + font-size: 0.78rem; + color: #3d4f70; + min-width: 140px; +} + +.refresh-interval-control input { + border: 1px solid var(--line); + border-radius: 9px; + padding: 6px 10px; + background: #fff; + color: var(--text); + font-size: 0.84rem; + font-family: inherit; + width: 100%; +} + .badge { border: 1px solid var(--line); background: rgba(236, 244, 255, 0.72); @@ -336,7 +633,8 @@ body { align-items: center; justify-content: center; gap: 8px; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } .badge::after { @@ -412,43 +710,388 @@ body { color: #1d3258; } -.io-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; +.monitor-board { + gap: 12px; } -.io-block { - border: 1px solid var(--line); - border-radius: 12px; - background: rgba(255, 255, 255, 0.52); - padding: 10px; +.monitor-headline { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; } -.io-list { +.monitor-headline .io-chip b { + font-weight: 800; +} + +.monitor-grid { display: grid; - gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; } -.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)); +.monitor-panel { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 30%); + border-radius: 12px; + background: linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(240, 247, 255, 0.82)); padding: 10px; - text-align: left; - transition: - transform var(--anim-fast) ease, - box-shadow var(--anim-fast) ease, - border-color var(--anim-fast) ease; + display: grid; + gap: 8px; + min-height: 152px; } -.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); +.monitor-panel h3 { + margin: 0; + text-align: center; + font-size: 0.92rem; } -.io-card-head { +.monitor-kpi-panel { + grid-column: 1 / -1; +} + +.monitor-flow-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 0.84rem; + color: #324766; +} + +.monitor-flow-row b { + color: #14335f; +} + +.monitor-progress { + width: 100%; + height: 9px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 26%); + background: rgba(221, 232, 250, 0.82); + overflow: hidden; +} + +.monitor-progress > span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #2bb48d, #2b73f0); + transition: width var(--anim-mid) ease; +} + +.monitor-progress-accent > span { + background: linear-gradient(90deg, #ff9d49, #2b73f0); +} + +.monitor-progress-signal > span { + background: linear-gradient(90deg, #ff7b5a, #ffc44d, #2bb48d); +} + +.frequency-health-list { + display: grid; + gap: 8px; + max-height: clamp(220px, 44dvh, 520px); + overflow-y: auto; + padding-right: 2px; +} + +.frequency-health-item { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 30%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.86); + padding: 8px; + display: grid; + gap: 6px; +} + +.frequency-health-item-active { + border-color: color-mix(in oklab, var(--accent), #ffffff 44%); + box-shadow: 0 0 0 1px rgba(36, 107, 255, 0.14) inset; +} + +.frequency-health-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.frequency-health-head b { + font-size: 0.9rem; +} + +.frequency-health-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 0.78rem; + color: #435876; +} + +.signal-grid { + display: grid; + gap: 8px; + max-height: clamp(220px, 44dvh, 520px); + overflow-y: auto; + padding-right: 2px; +} + +.signal-item { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 30%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.86); + padding: 8px; + display: grid; + gap: 6px; +} + +.signal-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.82rem; + color: #22395f; +} + +.signal-item-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.76rem; + color: #4a5f7f; +} + +.pipeline-stages { + display: grid; + gap: 8px; +} + +.pipeline-stage { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.9); + padding: 8px; + display: grid; + gap: 6px; +} + +.pipeline-stage-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.82rem; + color: #27406a; +} + +.pipeline-stage-label { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.pipeline-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #95a6c7; + box-shadow: 0 0 0 2px rgba(149, 166, 199, 0.22); +} + +.pipeline-dot-ok { + background: #14a37f; + box-shadow: 0 0 0 2px rgba(20, 163, 127, 0.2); +} + +.pipeline-dot-partial { + background: #ff9d49; + box-shadow: 0 0 0 2px rgba(255, 157, 73, 0.2); +} + +.pipeline-dot-error { + background: #d95c5c; + box-shadow: 0 0 0 2px rgba(217, 92, 92, 0.2); +} + +.pipeline-dot-warm { + background: #8a7ce5; + box-shadow: 0 0 0 2px rgba(138, 124, 229, 0.2); +} + +.pipeline-stage-note { + font-size: 0.74rem; + color: #4d6286; +} + +.top-frequencies { + display: grid; + gap: 8px; + max-height: clamp(220px, 44dvh, 520px); + overflow-y: auto; + padding-right: 2px; +} + +.top-frequency-item { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.9); + padding: 8px; + display: grid; + gap: 6px; +} + +.top-frequency-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.top-frequency-rank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 14%); + background: linear-gradient(160deg, #f2f7ff, #dfeaff); + color: #21457f; + font-size: 0.74rem; + font-weight: 800; +} + +.top-frequency-title { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 0.82rem; + color: #28436e; +} + +.top-frequency-meta { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 0.76rem; + color: #4a5f82; +} + +.trend-stack, +.history-trends { + display: grid; + gap: 8px; +} + +.trend-card { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.9); + padding: 8px; + display: grid; + gap: 6px; +} + +.trend-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.8rem; + color: #2b436e; +} + +.trend-head b { + color: #123c78; + font-size: 0.82rem; +} + +.sparkline-wrap { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 22%); + border-radius: 9px; + background: linear-gradient(180deg, rgba(245, 249, 255, 0.94), rgba(255, 255, 255, 0.94)); + padding: 4px; + min-height: 64px; + display: grid; + align-items: center; +} + +.sparkline-svg { + width: 100%; + height: 54px; + display: block; +} + +.sparkline-grid { + stroke: rgba(59, 85, 131, 0.2); + stroke-width: 1; +} + +.sparkline-area { + opacity: 0.28; +} + +.sparkline-line { + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.sparkline-dot { + stroke: #fff; + stroke-width: 1.5; +} + +.sparkline-empty { + text-align: center; + color: #607295; + font-size: 0.78rem; +} + +.io-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 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; + overflow-wrap: anywhere; +} + +.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; @@ -479,13 +1122,17 @@ body { .io-chip { display: inline-flex; align-items: center; + justify-content: 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; + white-space: normal; + overflow-wrap: anywhere; + max-width: 100%; + text-align: center; } .io-chip-neutral, @@ -646,9 +1293,35 @@ body { radial-gradient(circle at 100% 0%, rgba(36, 107, 255, 0.14), transparent 55%); } +.history-head-card { + text-align: center; +} + +.history-layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 1fr); + gap: 12px; +} + +.history-data-card, +.history-monitor-card { + background: + linear-gradient(170deg, rgba(255, 255, 255, 0.97), rgba(241, 247, 255, 0.88)); +} + +.history-data-card > h2, +.history-monitor-card > h2 { + margin: 0 0 10px; + text-align: center; +} + +.history-data-card .history-toolbar { + margin-bottom: 10px; +} + .history-kpis { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 12px 0; } @@ -684,7 +1357,8 @@ body { align-items: flex-end; gap: 10px; flex-wrap: wrap; - margin: 8px 0 10px; + margin: 0; + min-width: 0; } .history-toolbar label { @@ -693,6 +1367,8 @@ body { font-size: 0.84rem; color: #3d4f70; text-align: left; + min-width: 0; + flex: 1 1 180px; } .history-toolbar select { @@ -701,7 +1377,8 @@ body { padding: 7px 10px; background: #fff; color: var(--text); - min-width: 180px; + min-width: 0; + width: 100%; } .history-toolbar input[type="datetime-local"] { @@ -710,7 +1387,8 @@ body { padding: 7px 10px; background: #fff; color: var(--text); - min-width: 210px; + min-width: 0; + width: 100%; font-family: inherit; } @@ -720,6 +1398,8 @@ body { gap: 10px; flex-wrap: wrap; justify-content: flex-end; + min-width: 0; + flex: 999 1 700px; } .history-pager { @@ -736,9 +1416,9 @@ body { .history-insights { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 10px; - margin-bottom: 10px; + margin-top: 10px; } .insight-panel { @@ -754,42 +1434,6 @@ body { 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; @@ -828,8 +1472,19 @@ body { } .history-table-wrap { - max-height: min(52dvh, 560px); + max-height: min(74dvh, 920px); overflow: auto; + scrollbar-gutter: stable; +} + +#io-history-table { + table-layout: fixed; +} + +#io-history-table th, +#io-history-table td { + overflow-wrap: anywhere; + word-break: break-word; } .history-table-wrap thead th { @@ -846,7 +1501,7 @@ body { .history-table-wrap td:nth-child(3), .history-table-wrap td:nth-child(4) { text-align: left; - min-width: 290px; + min-width: 180px; } .history-cell-list { @@ -860,6 +1515,7 @@ body { border: 1px solid color-mix(in oklab, var(--line), #ffffff 24%); background: rgba(255, 255, 255, 0.76); font-size: 0.82rem; + overflow-wrap: anywhere; } .history-table-wrap td:first-child { @@ -903,140 +1559,479 @@ body { background: rgba(233, 250, 244, 0.98); } -.toast-error { - border-color: rgba(208, 71, 71, 0.4); - background: rgba(255, 239, 239, 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); +} + +.small { + font-size: 0.86rem; +} + +.mono { + font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; +} + +.table-wrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.56); + scrollbar-gutter: stable; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: center; + padding: 9px 10px; + border-bottom: 1px solid var(--line); + font-size: 0.9rem; +} + +thead th { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #4a5a74; + background: rgba(245, 249, 255, 0.9); +} + +tbody tr { + transition: background-color var(--anim-fast) ease; +} + +tbody tr:hover { + background: rgba(230, 239, 255, 0.58); +} + +.row-enter { + opacity: 0; + transform: translateY(6px); + animation: rowEnter var(--anim-mid) ease forwards; +} + +.value-updated { + animation: valuePulse 640ms ease; +} + +.editor { + width: 100%; + min-height: clamp(230px, 42dvh, 560px); + border: 1px solid var(--line); + border-radius: 12px; + padding: 11px; + background: rgba(250, 253, 255, 0.88); + font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; + font-size: 0.85rem; + margin-top: 10px; +} + +.server-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + min-width: 0; +} + +.servers-head-card { + text-align: center; +} + +.servers-layout { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; +} + +.servers-card { + min-height: 100%; + background: + radial-gradient(circle at 0% 0%, rgba(36, 107, 255, 0.14), transparent 42%), + linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(241, 248, 255, 0.86)); +} + +.servers-actions-card { + background: + linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(245, 250, 255, 0.9)); +} + +.servers-title { + margin: 16px 0 10px; + font-size: 0.98rem; + letter-spacing: 0.02em; +} + +.server-actions-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.server-grid label { + display: grid; + justify-items: center; + gap: 6px; + font-size: 0.88rem; + color: #34425c; + min-width: 0; +} + +.server-grid input, +.server-grid select { + border: 1px solid var(--line); + border-radius: 9px; + padding: 8px 10px; + 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; + width: 100%; + min-width: 0; +} + +.server-grid input:focus, +.server-grid select:focus, +.editor:focus { + outline: none; + border-color: color-mix(in oklab, var(--accent), #ffffff 50%); + box-shadow: 0 0 0 3px rgba(36, 107, 255, 0.14); +} + +.config-head-card { + text-align: center; +} + +.config-layout { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); + gap: 12px; + align-items: start; +} + +.config-editor-card { + background: + linear-gradient(165deg, rgba(255, 255, 255, 0.97), rgba(244, 249, 255, 0.9)); +} + +.config-editor-card h3 { + margin: 0 0 8px; + text-align: center; +} + +.config-help-card { + background: + radial-gradient(circle at 100% 0%, rgba(255, 164, 81, 0.18), transparent 42%), + linear-gradient(165deg, rgba(255, 255, 255, 0.97), rgba(250, 246, 239, 0.9)); + text-align: left; +} + +.config-help-card h3 { + margin: 0 0 8px; + text-align: center; +} + +.config-hints { + display: grid; + gap: 8px; +} + +.config-hints p { + margin: 0; + padding: 8px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 26%); + border-radius: 10px; + background: rgba(255, 255, 255, 0.72); + font-size: 0.84rem; + color: #31486f; +} + +.config-tips { + margin: 0; + padding-left: 20px; + display: grid; + gap: 6px; + color: #31486f; + font-size: 0.84rem; +} + +.servers-layout-modern { + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + align-items: stretch; +} + +.servers-card-modern { + padding: 0; + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; +} + +.server-card-head { + display: grid; + gap: 4px; + padding: 14px 14px 12px; + border-bottom: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + background: + radial-gradient(circle at 0% 0%, rgba(36, 107, 255, 0.16), transparent 54%), + linear-gradient(170deg, rgba(255, 255, 255, 0.98), rgba(242, 248, 255, 0.9)); +} + +.server-card-head .servers-title { + margin: 0; +} + +.server-card-head .muted { + margin: 0; + font-size: 0.8rem; +} + +.server-card-body { + padding: 12px 14px 14px; + display: grid; + gap: 10px; + align-content: start; +} + +.selector-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: end; +} + +.chip-counter { + height: fit-content; +} + +.action-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-start; +} + +.field-grid { + display: grid; + gap: 10px; +} + +.field-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.field-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-control { + display: grid; + gap: 6px; + border: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + border-radius: 12px; + padding: 9px 10px; + background: linear-gradient(170deg, rgba(255, 255, 255, 0.92), rgba(243, 248, 255, 0.84)); + transition: + border-color var(--anim-fast) ease, + box-shadow var(--anim-fast) ease, + transform var(--anim-fast) ease; +} + +.field-control:focus-within { + border-color: color-mix(in oklab, var(--accent), #ffffff 44%); + box-shadow: 0 0 0 3px rgba(36, 107, 255, 0.14); + transform: translateY(-1px); +} + +.field-label { + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #49608a; + font-weight: 700; +} + +.field-control input, +.field-control select { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 30%); + border-radius: 9px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.96); + color: var(--text); + font-size: 0.88rem; + font-family: inherit; + width: 100%; + min-width: 0; +} + +.field-control input:focus, +.field-control select:focus { + outline: none; + border-color: color-mix(in oklab, var(--accent), #ffffff 50%); +} + +.field-hint { + font-size: 0.76rem; + color: #556b90; +} + +.config-layout-modern { + grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.95fr); + align-items: stretch; +} + +.config-editor-modern, +.config-help-modern { + padding: 0; + overflow: hidden; + display: grid; + grid-template-rows: auto 1fr; +} + +.config-section-head { + padding: 14px 14px 12px; + border-bottom: 1px solid color-mix(in oklab, var(--line), #ffffff 28%); + background: + radial-gradient(circle at 100% 0%, rgba(36, 107, 255, 0.14), transparent 58%), + linear-gradient(168deg, rgba(255, 255, 255, 0.97), rgba(243, 249, 255, 0.88)); } -.panel > .card > h2, -.servers-title, -.server-grid label, -.server-actions-row, -.editor-actions { +.config-section-head h3 { + margin: 0; text-align: center; } -.server-actions-row, -.editor-actions { - justify-content: center; +.config-section-head .muted { + margin: 4px 0 0; + text-align: center; + font-size: 0.8rem; } -.muted { - color: var(--muted); +.config-editor-shell { + padding: 12px 14px 14px; + display: grid; + gap: 10px; } -.small { - font-size: 0.86rem; +.editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 7px; } -.mono { - font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; +.editor-chip { + border: 1px solid color-mix(in oklab, var(--line), #ffffff 24%); + border-radius: 999px; + padding: 4px 9px; + font-size: 0.74rem; + letter-spacing: 0.03em; + color: #335078; + background: rgba(242, 248, 255, 0.9); } -.table-wrap { - overflow-x: auto; - border: 1px solid var(--line); - border-radius: 12px; - background: rgba(255, 255, 255, 0.56); +.config-editor-modern .editor { + margin-top: 0; + min-height: clamp(250px, 52dvh, 760px); + resize: vertical; } -table { - width: 100%; - border-collapse: collapse; +.config-help-modern { + align-content: start; } -th, -td { - text-align: center; - padding: 9px 10px; - border-bottom: 1px solid var(--line); - font-size: 0.9rem; +.config-help-modern .config-hints-grid { + grid-template-columns: 1fr; + padding: 12px 14px 0; } -thead th { +.config-help-modern .config-hints-grid p { font-size: 0.82rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #4a5a74; - background: rgba(245, 249, 255, 0.9); } -tbody tr { - transition: background-color var(--anim-fast) ease; +.config-help-modern h3 { + margin: 10px 14px 8px; } -tbody tr:hover { - background: rgba(230, 239, 255, 0.58); +.config-help-modern .config-tips { + margin: 0 14px 14px; } -.row-enter { - opacity: 0; - transform: translateY(6px); - animation: rowEnter var(--anim-mid) ease forwards; +body.ui-compact .panel-active { + gap: 12px; } -.value-updated { - animation: valuePulse 640ms ease; +body.ui-compact .card { + padding: 12px; + border-radius: 14px; } -.editor { - width: 100%; - min-height: 340px; - border: 1px solid var(--line); - border-radius: 12px; - padding: 11px; - background: rgba(250, 253, 255, 0.88); - font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; - font-size: 0.85rem; - margin-top: 10px; +body.ui-compact .hero-actions, +body.ui-compact .editor-actions { + gap: 8px; } -.server-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px 14px; +body.ui-compact .menu-item { + min-height: 58px; + padding: 6px 8px 8px; } -.servers-title { - margin: 16px 0 10px; - font-size: 0.98rem; - letter-spacing: 0.02em; +body.ui-compact .menu-item-text { + font-size: 0.75rem; } -.server-actions-row { - display: flex; - flex-wrap: wrap; +body.ui-compact .menu-item-note { + font-size: 0.62rem; +} + +body.ui-compact .monitor-grid, +body.ui-compact .history-insights, +body.ui-compact .servers-layout, +body.ui-compact .config-layout, +body.ui-compact .history-layout { gap: 8px; - align-items: center; } -.server-grid label { - display: grid; - justify-items: center; - gap: 6px; - font-size: 0.88rem; - color: #34425c; +body.ui-compact .metric-title { + font-size: 0.68rem; } -.server-grid input, -.server-grid select { - border: 1px solid var(--line); - border-radius: 9px; - padding: 8px 10px; - 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; +body.ui-compact .metric-value, +body.ui-compact .kpi-value { + font-size: 0.96rem; } -.server-grid input:focus, -.server-grid select:focus, -.editor:focus { - outline: none; - border-color: color-mix(in oklab, var(--accent), #ffffff 50%); - box-shadow: 0 0 0 3px rgba(36, 107, 255, 0.14); +body.ui-compact .history-table-wrap { + max-height: min(58dvh, 700px); +} + +body.ui-compact th, +body.ui-compact td { + padding: 7px 8px; + font-size: 0.84rem; } .bg-glow { @@ -1162,8 +2157,19 @@ tbody tr:hover { width: 100%; } + .menu-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + max-height: none; + overflow: visible; + } + + .menu-group-body { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .side-meta { - justify-content: center; + justify-items: center; } .timezone-picker { @@ -1174,6 +2180,30 @@ tbody tr:hover { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .overview-layout { + grid-template-columns: 1fr; + } + + .history-layout { + grid-template-columns: 1fr; + } + + .servers-layout { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .servers-layout-modern { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .config-layout { + grid-template-columns: 1fr; + } + + .config-layout-modern { + grid-template-columns: 1fr; + } + .overview-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1187,6 +2217,42 @@ tbody tr:hover { } } +@media (max-width: 980px) { + .menu-list { + grid-template-columns: 1fr; + max-height: min(60dvh, 620px); + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + } + + .monitor-grid { + grid-template-columns: 1fr; + } + + .monitor-kpi-panel { + grid-column: auto; + } + + .history-toolbar-right { + flex: 1 1 100%; + justify-content: stretch; + } + + .history-toolbar-right > * { + flex: 1 1 220px; + } + + .selector-row { + grid-template-columns: 1fr; + align-items: stretch; + } + + .field-grid-3 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + @media (max-width: 760px) { .app-shell { padding: 8px 8px 14px; @@ -1196,19 +2262,75 @@ tbody tr:hover { grid-template-columns: 1fr; } + .servers-layout { + grid-template-columns: 1fr; + } + + .servers-layout-modern { + grid-template-columns: 1fr; + } + .card { padding: 14px; } .menu-item { - padding: 7px 10px; - font-size: 0.88rem; + min-height: 62px; + padding: 7px 9px 9px; + gap: 3px; + } + + .menu-list { + gap: 7px; + padding: 6px; + max-height: 62dvh; + overflow-y: auto; + } + + .menu-group { + padding: 6px; + } + + .menu-group-body { + grid-template-columns: 1fr; + } + + .menu-item-icon { + width: 22px; + height: 22px; + min-width: 22px; + font-size: 0.66rem; + } + + .menu-item[data-section="json"] .menu-item-icon { + font-size: 0.61rem; + } + + .menu-item-text { + font-size: 0.75rem; + } + + .menu-item-note { + font-size: 0.64rem; } .io-grid { grid-template-columns: 1fr; } + .field-grid-2, + .field-grid-3 { + grid-template-columns: 1fr; + } + + .action-group { + justify-content: stretch; + } + + .action-group .btn { + width: 100%; + } + .io-control-grid-compact { grid-template-columns: 1fr; } @@ -1229,6 +2351,36 @@ tbody tr:hover { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .history-layout { + grid-template-columns: 1fr; + } + + .monitor-headline { + justify-content: stretch; + } + + .monitor-headline .io-chip { + width: 100%; + justify-content: space-between; + } + + .frequency-health-meta, + .signal-item-meta, + .top-frequency-meta { + flex-direction: column; + align-items: flex-start; + } + + .trend-head { + flex-direction: column; + align-items: flex-start; + } + + .pipeline-stage-head { + flex-direction: column; + align-items: flex-start; + } + .overview-metrics { grid-template-columns: 1fr; } @@ -1242,6 +2394,29 @@ tbody tr:hover { min-width: 0; } + .meta-panel, + .date-time-panel, + .status-panel { + width: 100%; + } + + .config-layout { + grid-template-columns: 1fr; + } + + .config-layout-modern { + grid-template-columns: 1fr; + } + + .config-tips { + padding-left: 18px; + } + + .refresh-interval-control { + width: 100%; + min-width: 0; + } + .history-toolbar { align-items: stretch; } @@ -1254,6 +2429,10 @@ tbody tr:hover { width: 100%; } + .history-toolbar-right > * { + flex: 1 1 100%; + } + .history-toolbar-right { align-items: stretch; justify-content: stretch; @@ -1271,7 +2450,70 @@ tbody tr:hover { .history-table-wrap td:nth-child(3), .history-table-wrap td:nth-child(4) { - min-width: 240px; + min-width: 140px; + } + + .btn, + .hero-actions .btn, + .editor-actions .btn { + width: 100%; + } + + .hero-actions .badge, + .editor-actions .badge { + width: 100%; + } +} + +@media (max-width: 480px) { + .card { + border-radius: 14px; + padding: 12px; + } + + .hero h2 { + font-size: clamp(1.05rem, 4.8vw, 1.3rem); + } + + .kpi-value, + .metric-value { + font-size: 0.98rem; + } + + .menu-item { + min-height: 56px; + } + + .menu-item-text { + font-size: 0.72rem; + } + + .menu-item-note { + font-size: 0.6rem; + } + + .toast-container { + right: 8px; + left: 8px; + max-width: none; + } +} + +@media (max-height: 880px) { + .history-table-wrap { + max-height: min(64dvh, 760px); + } + + .frequency-health-list, + .signal-grid, + .top-frequencies { + max-height: min(34dvh, 360px); + } + + .menu-list { + max-height: min(34dvh, 360px); + overflow-y: auto; + overflow-x: hidden; } }