From 98e4be8831127ea6990a662a36b48825c29c04b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B8?= =?UTF-8?q?=D1=82=D0=B2=D0=B8=D0=BD=D0=BE=D0=B2?= Date: Sat, 25 Apr 2026 16:22:17 +0300 Subject: [PATCH] v2.5.0: use live IP counts in key cards --- admin-web/server.py | 47 +++++++++++++++++++++++++++++--- tests/test_admin_features.py | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/admin-web/server.py b/admin-web/server.py index c7ebfed..77996d7 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -851,6 +851,39 @@ def runtime_user_traffic(name: str, enabled: bool = True) -> dict[str, Any]: } +def current_user_traffic_snapshot( + name: str, + enabled: bool, + history_snapshot: dict[str, Any] | None = None, + now: int | None = None, +) -> dict[str, Any]: + """Return live counters for key cards, preserving only total bytes from history. + + History rows are minute snapshots. They are useful for charts, but stale + connection/IP values make the keys list look like users are still online. + """ + history_snapshot = history_snapshot or {} + fallback = { + "epoch": _int_value(history_snapshot.get("epoch")), + "total_octets": _int_value(history_snapshot.get("total_octets")), + "current_connections": 0, + "active_unique_ips": 0, + "recent_unique_ips": 0, + } + if not enabled: + return fallback + runtime = runtime_user_traffic(name, enabled) + if not runtime.get("ok"): + return fallback + return { + "epoch": _int_value(now if now is not None else time.time()), + "total_octets": _int_value(runtime.get("total_octets")), + "current_connections": _int_value(runtime.get("current_connections")), + "active_unique_ips": _int_value(runtime.get("active_unique_ips")), + "recent_unique_ips": _int_value(runtime.get("recent_unique_ips")), + } + + def history_limit_for_range(range_key: str) -> int: return { "15m": 180, @@ -1286,7 +1319,7 @@ class AdminHandler(BaseHTTPRequestHandler): record["secret"], record["enabled"], record.get("max_unique_ips", 0), - traffic_snapshot=latest.get(name), + traffic_snapshot=current_user_traffic_snapshot(name, record["enabled"], latest.get(name)), )) self.send_json({"ok": True, "data": items}) elif path.startswith("/api/users/") and path.endswith("/qr"): @@ -1347,7 +1380,7 @@ class AdminHandler(BaseHTTPRequestHandler): record["enabled"], record.get("max_unique_ips", 0), include_runtime=True, - traffic_snapshot=latest_user_stats().get(name), + traffic_snapshot=current_user_traffic_snapshot(name, record["enabled"], latest_user_stats().get(name)), )}) elif path == "/api/backups": self.send_json({"ok": True, "data": list_backups()}) @@ -1443,7 +1476,7 @@ class AdminHandler(BaseHTTPRequestHandler): record["secret"], record["enabled"], record.get("max_unique_ips", 0), - traffic_snapshot=latest_user_stats().get(name), + traffic_snapshot=current_user_traffic_snapshot(name, record["enabled"], latest_user_stats().get(name)), ), "restart": {"mode": "async", "requested": restart_requested}}) elif path.startswith("/api/users/") and path.endswith("/enabled"): name = urllib.parse.unquote(path[len("/api/users/"):-len("/enabled")]) @@ -1475,7 +1508,13 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_error_json(500, f"failed to save config: {exc}") return restart_requested = request_service_restart("telemt") - self.send_json({"ok": True, "data": user_payload(name, secret, enabled, records[name].get("max_unique_ips", 0)), "restart": {"mode": "async", "requested": restart_requested}}) + self.send_json({"ok": True, "data": user_payload( + name, + secret, + enabled, + records[name].get("max_unique_ips", 0), + traffic_snapshot=current_user_traffic_snapshot(name, enabled, latest_user_stats().get(name)), + ), "restart": {"mode": "async", "requested": restart_requested}}) elif path == "/api/backups": ok, result = create_backup() self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500) diff --git a/tests/test_admin_features.py b/tests/test_admin_features.py index 857b6b6..5095f7f 100644 --- a/tests/test_admin_features.py +++ b/tests/test_admin_features.py @@ -129,6 +129,59 @@ class AdminFeatureTests(unittest.TestCase): self.assertNotIn("client = 3", text) self.assertNotIn("old = 4", text) + def test_key_card_traffic_uses_live_ip_counts_not_stale_history(self): + with tempfile.TemporaryDirectory() as raw: + server = load_server(Path(raw)) + stale_history = { + "epoch": 1000, + "total_octets": 100, + "current_connections": 16, + "active_unique_ips": 8, + "recent_unique_ips": 5, + } + original = server.runtime_user_traffic + server.runtime_user_traffic = lambda name, enabled=True: { + "ok": True, + "enabled": True, + "total_octets": 200, + "current_connections": 0, + "active_unique_ips": 0, + "recent_unique_ips": 0, + } + try: + snapshot = server.current_user_traffic_snapshot("client", True, stale_history, now=2000) + finally: + server.runtime_user_traffic = original + + self.assertEqual(snapshot["epoch"], 2000) + self.assertEqual(snapshot["total_octets"], 200) + self.assertEqual(snapshot["current_connections"], 0) + self.assertEqual(snapshot["active_unique_ips"], 0) + self.assertEqual(snapshot["recent_unique_ips"], 0) + + def test_key_card_traffic_fallback_keeps_only_historical_total(self): + with tempfile.TemporaryDirectory() as raw: + server = load_server(Path(raw)) + stale_history = { + "epoch": 1000, + "total_octets": 100, + "current_connections": 16, + "active_unique_ips": 8, + "recent_unique_ips": 5, + } + original = server.runtime_user_traffic + server.runtime_user_traffic = lambda name, enabled=True: {"ok": False} + try: + snapshot = server.current_user_traffic_snapshot("client", True, stale_history, now=2000) + finally: + server.runtime_user_traffic = original + + self.assertEqual(snapshot["epoch"], 1000) + self.assertEqual(snapshot["total_octets"], 100) + self.assertEqual(snapshot["current_connections"], 0) + self.assertEqual(snapshot["active_unique_ips"], 0) + self.assertEqual(snapshot["recent_unique_ips"], 0) + def test_keys_view_uses_card_layout_without_horizontal_table(self): app_js = (ROOT / "admin-web" / "static" / "app.js").read_text(encoding="utf-8") styles = (ROOT / "admin-web" / "static" / "styles.css").read_text(encoding="utf-8")