From 4d7577f4ec0398638b457ff294865e3f9df64843 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 12:29:43 +0300 Subject: [PATCH 1/7] fix: harden Remnawave panel timeouts --- .../bot/app/web/admin_settings_manifest.py | 36 ++++++ backend/bot/services/panel_api_service.py | 105 ++++++++++++++++-- backend/bot/utils/ttl_cache.py | 9 ++ backend/config/settings.py | 4 + docs/configuration/env-vars.md | 4 + locales/en.json | 8 ++ locales/ru.json | 8 ++ tests/test_admin_settings_manifest_i18n.py | 13 +++ tests/test_panel_api_service_logging.py | 65 +++++++++++ tests/test_performance_caches.py | 8 ++ 10 files changed, 249 insertions(+), 11 deletions(-) diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 9c86789..1ebffd0 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -101,6 +101,42 @@ SETTINGS_MANIFEST: List[SettingField] = [ "Секретный ключ API панели.", secret=True, ), + SettingField( + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API total timeout", + "Maximum total time for one Remnawave API request, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API connect timeout", + "Maximum time to get or open a Remnawave API connection, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket connect timeout", + "Maximum TCP/TLS connection time for Remnawave API, in seconds.", + optional=False, + min=1, + ), + SettingField( + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + "float", + "remnawave", + "Panel API socket read timeout", + "Maximum time to wait for response data from Remnawave API, in seconds.", + optional=False, + min=1, + ), SettingField( "PANEL_WEBHOOK_SECRET", "string", diff --git a/backend/bot/services/panel_api_service.py b/backend/bot/services/panel_api_service.py index 4ff5c43..bac4e60 100644 --- a/backend/bot/services/panel_api_service.py +++ b/backend/bot/services/panel_api_service.py @@ -22,6 +22,11 @@ class PanelApiService: _TRANSIENT_STATUS_CODES = (-1, -3) _SAFE_METHODS = frozenset({"GET", "HEAD"}) _RETRY_BACKOFF_SECONDS = 0.5 + _MIN_TIMEOUT_SECONDS = 0.1 + _DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0 + _DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0 + _DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0 def __init__(self, settings: Settings): self.settings = settings @@ -70,17 +75,46 @@ class PanelApiService: async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: - # Separate connect/read timeouts so a stuck panel does not hold a - # bot worker for the full window; total caps worst-case latency. - timeout = aiohttp.ClientTimeout( - total=15, - connect=3, - sock_connect=3, - sock_read=10, - ) - self._session = aiohttp.ClientSession(timeout=timeout) + self._session = aiohttp.ClientSession(timeout=self._client_timeout()) return self._session + @classmethod + def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float: + raw_value = getattr(settings, name, default) + try: + value = float(raw_value) + except (TypeError, ValueError): + return default + if value <= 0: + return default + return max(cls._MIN_TIMEOUT_SECONDS, value) + + def _client_timeout(self) -> aiohttp.ClientTimeout: + # Separate connect/read timeouts so a slow panel route has more room, + # while genuinely stuck requests still cannot pin a worker forever. + return aiohttp.ClientTimeout( + total=self._timeout_setting( + self.settings, + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + self._DEFAULT_TOTAL_TIMEOUT_SECONDS, + ), + connect=self._timeout_setting( + self.settings, + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_CONNECT_TIMEOUT_SECONDS, + ), + sock_connect=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS, + ), + sock_read=self._timeout_setting( + self.settings, + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", + self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS, + ), + ) + async def close_session(self): if self._session and not self._session.closed: await self._session.close() @@ -121,6 +155,15 @@ class PanelApiService: for attempt in range(max_attempts): result = await self._request_once(method, endpoint, log_full_response, **kwargs) if attempt + 1 < max_attempts and self._is_transient_error(result): + logging.warning( + "Retrying transient Panel API request method=%s endpoint=%s " + "attempt=%s/%s status_code=%s", + method.upper(), + endpoint, + attempt + 1, + max_attempts, + result.get("status_code") if isinstance(result, dict) else None, + ) await asyncio.sleep(self._RETRY_BACKOFF_SECONDS) continue return result @@ -158,8 +201,8 @@ class PanelApiService: ) except Exception: log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..." + started = time.monotonic() try: - started = time.monotonic() async with aiohttp_session.request( method.upper(), url_for_request, headers=headers, **kwargs ) as response: @@ -228,15 +271,48 @@ class PanelApiService: return {"error": True, "status_code": response_status, "details": error_details} except aiohttp.ClientConnectorError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}") return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"} + except aiohttp.ServerTimeoutError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint, + ) + logging.warning("Panel API timeout to %s: %s", url_for_request, e) + return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"} except aiohttp.ClientError as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.exception("Panel API ClientError to %s.", url_for_request) return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"} except asyncio.TimeoutError: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error(f"Panel API request to {url_for_request} timed out.") return {"error": True, "status_code": -3, "message": "Request timed out"} except Exception as e: + logging.info( + "metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error", + time.monotonic() - started, + method.upper(), + endpoint, + ) logging.error( f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True ) @@ -885,7 +961,14 @@ class PanelApiService: await self._devices_cache.invalidate_remote(f"user:{user_uuid}") async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]: - return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached) + if squads is not None: + return squads + stale_squads = self._squads_cache.get_stale("list") + if stale_squads is not None: + logging.warning("Using stale internal squads cache after panel fetch failed.") + return stale_squads + return None async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]: response_data = await self._request("GET", "/internal-squads", log_full_response=False) diff --git a/backend/bot/utils/ttl_cache.py b/backend/bot/utils/ttl_cache.py index 18408fd..d182ae7 100644 --- a/backend/bot/utils/ttl_cache.py +++ b/backend/bot/utils/ttl_cache.py @@ -29,6 +29,15 @@ class AsyncTTLCache: return None return value + def get_stale(self, key: str) -> Optional[Any]: + entry = self._data.get(key) + if entry is None: + return None + _, value = entry + if not self._is_cacheable(value): + return None + return value + @staticmethod def _is_cacheable(value: Any) -> bool: if value is None: diff --git a/backend/config/settings.py b/backend/config/settings.py index bd09406..72827f1 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -100,6 +100,10 @@ class Settings(BaseSettings): PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5) PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000) + PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25) + PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8) + PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15) ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15) ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5) ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3) diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index b7dbdf7..c3f988c 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -59,6 +59,10 @@ | `PANEL_DEVICES_CACHE_TTL_SECONDS` | TTL кеша устройств пользователя Remnawave. | | `PANEL_ALL_USERS_CACHE_TTL_SECONDS` | TTL кеша полных сканов пользователей Remnawave. | | `PANEL_ALL_USERS_PAGE_SIZE` | Размер страницы Remnawave `/users`. | +| `PANEL_API_TOTAL_TIMEOUT_SECONDS` | Общий timeout запроса к Remnawave API. | +| `PANEL_API_CONNECT_TIMEOUT_SECONDS` | Timeout получения соединения с Remnawave API. | +| `PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS` | Timeout TCP/TLS-подключения к Remnawave API. | +| `PANEL_API_SOCK_READ_TIMEOUT_SECONDS` | Timeout ожидания данных ответа Remnawave API. | | `ADMIN_PANEL_STATS_CACHE_TTL_SECONDS` | TTL статистики Remnawave в админке. | | `ADMIN_DB_STATS_CACHE_TTL_SECONDS` | TTL дорогих DB-агрегатов админки. | | `ADMIN_USERS_LIST_CACHE_TTL_SECONDS` | TTL списка пользователей админки. | diff --git a/locales/en.json b/locales/en.json index cf3ffff..360a129 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1544,6 +1544,14 @@ "admin_settings_field_panel_api_url_description": "For example, https://panel.example.com/api.", "admin_settings_field_panel_api_key_label": "Remnawave API key", "admin_settings_field_panel_api_key_description": "Secret API key for the panel.", + "admin_settings_field_panel_api_total_timeout_seconds_label": "Remnawave API total timeout", + "admin_settings_field_panel_api_total_timeout_seconds_description": "Maximum total time for one Remnawave API request, in seconds.", + "admin_settings_field_panel_api_connect_timeout_seconds_label": "Remnawave API connect timeout", + "admin_settings_field_panel_api_connect_timeout_seconds_description": "Maximum time to get or open a Remnawave API connection, in seconds.", + "admin_settings_field_panel_api_sock_connect_timeout_seconds_label": "Remnawave API TCP/TLS timeout", + "admin_settings_field_panel_api_sock_connect_timeout_seconds_description": "Maximum TCP/TLS connection time for Remnawave API, in seconds.", + "admin_settings_field_panel_api_sock_read_timeout_seconds_label": "Remnawave API read timeout", + "admin_settings_field_panel_api_sock_read_timeout_seconds_description": "Maximum time to wait for response data from Remnawave API, in seconds.", "admin_settings_field_panel_webhook_secret_label": "Remnawave webhook secret", "admin_settings_field_panel_webhook_secret_description": "Set the secret in Remnawave Panel and paste the same value here to verify incoming panel webhooks.", "admin_settings_field_user_squad_uuids_label": "Default Internal Squads", diff --git a/locales/ru.json b/locales/ru.json index 4ce0660..ab47d7f 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1544,6 +1544,14 @@ "admin_settings_field_panel_api_url_description": "Например, https://panel.example.com/api.", "admin_settings_field_panel_api_key_label": "API-ключ Remnawave", "admin_settings_field_panel_api_key_description": "Секретный ключ API панели.", + "admin_settings_field_panel_api_total_timeout_seconds_label": "Общий таймаут API Remnawave", + "admin_settings_field_panel_api_total_timeout_seconds_description": "Максимальное время одного запроса к Remnawave API, в секундах.", + "admin_settings_field_panel_api_connect_timeout_seconds_label": "Таймаут подключения API Remnawave", + "admin_settings_field_panel_api_connect_timeout_seconds_description": "Максимальное время получения или открытия соединения с Remnawave API, в секундах.", + "admin_settings_field_panel_api_sock_connect_timeout_seconds_label": "TCP/TLS-таймаут API Remnawave", + "admin_settings_field_panel_api_sock_connect_timeout_seconds_description": "Максимальное время TCP/TLS-подключения к Remnawave API, в секундах.", + "admin_settings_field_panel_api_sock_read_timeout_seconds_label": "Таймаут чтения API Remnawave", + "admin_settings_field_panel_api_sock_read_timeout_seconds_description": "Максимальное ожидание данных ответа от Remnawave API, в секундах.", "admin_settings_field_panel_webhook_secret_label": "Секрет вебхуков Remnawave", "admin_settings_field_panel_webhook_secret_description": "Задайте секрет в Remnawave Panel и вставьте то же значение здесь для проверки входящих вебхуков панели.", "admin_settings_field_user_squad_uuids_label": "Internal Squads по умолчанию", diff --git a/tests/test_admin_settings_manifest_i18n.py b/tests/test_admin_settings_manifest_i18n.py index 2144100..fb11359 100644 --- a/tests/test_admin_settings_manifest_i18n.py +++ b/tests/test_admin_settings_manifest_i18n.py @@ -236,10 +236,15 @@ def test_remnawave_settings_include_panel_webhook_metadata(): remnawave_keys = ( "PANEL_API_URL", "PANEL_API_KEY", + "PANEL_API_TOTAL_TIMEOUT_SECONDS", + "PANEL_API_CONNECT_TIMEOUT_SECONDS", + "PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS", + "PANEL_API_SOCK_READ_TIMEOUT_SECONDS", "PANEL_WEBHOOK_SECRET", "USER_SQUAD_UUIDS", "USER_EXTERNAL_SQUAD_UUID", ) + timeout_keys = remnawave_keys[2:6] assert field["webhook_path"] == "/webhook/panel" assert field["webhook_requires_base_url"] is True @@ -250,10 +255,18 @@ def test_remnawave_settings_include_panel_webhook_metadata(): assert manifest[setting_key]["section_order"] == 3 assert manifest[setting_key]["subsection"] is None + for setting_key in timeout_keys: + assert manifest[setting_key]["type"] == "float" + assert manifest[setting_key]["optional"] is False + assert manifest[setting_key]["min"] == 1 + for language in ("ru", "en"): messages = _locale(language) assert "admin_settings_section_remnawave" in messages assert field["webhook_hint_i18n_key"] in messages + for setting_key in timeout_keys: + assert manifest[setting_key]["i18n_label_key"] in messages + assert manifest[setting_key]["i18n_description_key"] in messages def test_payment_provider_admin_only_toggles_are_mutually_exclusive(): diff --git a/tests/test_panel_api_service_logging.py b/tests/test_panel_api_service_logging.py index 4691bb5..8589496 100644 --- a/tests/test_panel_api_service_logging.py +++ b/tests/test_panel_api_service_logging.py @@ -1,8 +1,11 @@ import asyncio +import time import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch +import aiohttp + from bot.services.panel_api_service import PanelApiService @@ -16,6 +19,68 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase): ) ) + async def test_client_timeout_uses_panel_settings(self): + service = PanelApiService( + SimpleNamespace( + PANEL_API_URL="https://panel.example.test/api", + PANEL_API_KEY="panel-key", + PANEL_API_TOTAL_TIMEOUT_SECONDS="30", + PANEL_API_CONNECT_TIMEOUT_SECONDS="10", + PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS="9", + PANEL_API_SOCK_READ_TIMEOUT_SECONDS="20", + ) + ) + + timeout = service._client_timeout() + + self.assertEqual(timeout.total, 30) + self.assertEqual(timeout.connect, 10) + self.assertEqual(timeout.sock_connect, 9) + self.assertEqual(timeout.sock_read, 20) + + async def test_get_request_retries_connection_timeout(self): + service = self._make_service() + request_calls = 0 + + class OkResponse: + status = 200 + headers = {"Content-Type": "application/json"} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + async def text(self): + return '{"response": {"ok": true}}' + + def fake_request(*_args, **_kwargs): + nonlocal request_calls + request_calls += 1 + if request_calls == 1: + raise aiohttp.ConnectionTimeoutError("connect took too long") + return OkResponse() + + service._get_session = AsyncMock(return_value=SimpleNamespace(request=fake_request)) + + with patch("bot.services.panel_api_service.asyncio.sleep", new=AsyncMock()): + result = await service._request("GET", "/internal-squads") + + self.assertEqual(result, {"response": {"ok": True}}) + self.assertEqual(request_calls, 2) + + async def test_get_internal_squads_uses_stale_cache_after_refresh_failure(self): + service = self._make_service() + stale_squads = [{"uuid": "squad-1", "name": "Squad 1"}] + service._squads_cache._data["list"] = (time.monotonic() - 1, stale_squads) + service._get_internal_squads_uncached = AsyncMock(return_value=None) + + squads = await service.get_internal_squads() + + self.assertEqual(squads, stale_squads) + service._get_internal_squads_uncached.assert_awaited_once() + async def test_update_user_details_does_not_log_full_response_by_default(self): service = self._make_service() service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}}) diff --git a/tests/test_performance_caches.py b/tests/test_performance_caches.py index 6093678..1d23a03 100644 --- a/tests/test_performance_caches.py +++ b/tests/test_performance_caches.py @@ -42,6 +42,14 @@ class AsyncTTLCacheSingleflightTests(unittest.IsolatedAsyncioTestCase): class AsyncTTLCacheInvalidationTests(unittest.IsolatedAsyncioTestCase): + def test_get_stale_returns_expired_cacheable_value(self): + cache = AsyncTTLCache(ttl_seconds=60) + value = {"ok": True} + cache._data["same"] = (time.monotonic() - 1, value) + + self.assertIsNone(cache.get_fresh("same")) + self.assertEqual(cache.get_stale("same"), value) + async def test_invalidate_remote_deletes_single_redis_key(self): settings = SimpleNamespace(REDIS_URL="redis://example", REDIS_KEY_PREFIX="test") cache = AsyncTTLCache(ttl_seconds=60, settings=settings, namespace="bench") From 8f009c8cafa0eb853d387858f20914c468d66d3b Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 12:33:59 +0300 Subject: [PATCH 2/7] fix: show email avatars without Telegram --- frontend/src/App.svelte | 4 ++-- frontend/src/lib/webapp/gravatar.js | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6c0eb53..c50d228 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -78,7 +78,7 @@ activationPaymentFailed, createActivationHandoff, } from "./lib/webapp/activationHandoff.js"; - import { buildGravatarUrl } from "./lib/webapp/gravatar.js"; + import { buildGravatarUrl, resolveProfileAvatarUrl } from "./lib/webapp/gravatar.js"; import { createBillingActions } from "./lib/webapp/billingActions.js"; import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js"; import { runWebappBoot } from "./lib/webapp/webappBoot.js"; @@ -494,7 +494,7 @@ $: telegramProfileName = telegramName(user); $: profileEmail = user?.email || t("wa_settings_email_not_linked"); $: profileTelegramId = user?.telegram_id ? `TG ID ${user.telegram_id}` : t("wa_tg_id_not_linked"); - $: profileAvatarUrl = user?.telegram_photo_url || emailAvatarUrl || ""; + $: profileAvatarUrl = resolveProfileAvatarUrl(user, emailAvatarUrl); $: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim(); $: userAgreementUrl = String(CFG.userAgreementUrl || "").trim(); $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); diff --git a/frontend/src/lib/webapp/gravatar.js b/frontend/src/lib/webapp/gravatar.js index bf7bfef..e89b18a 100644 --- a/frontend/src/lib/webapp/gravatar.js +++ b/frontend/src/lib/webapp/gravatar.js @@ -4,16 +4,25 @@ function bytesToHex(buffer) { async function sha256Hex(value) { const data = new TextEncoder().encode(value); - const hashBuffer = await window.crypto.subtle.digest("SHA-256", data); + const hashBuffer = await globalThis.crypto?.subtle?.digest("SHA-256", data); return bytesToHex(hashBuffer); } export async function buildGravatarUrl(emailValue) { - if (!emailValue || !window.crypto?.subtle) return ""; + const email = String(emailValue || "") + .trim() + .toLowerCase(); + if (!email || !globalThis.crypto?.subtle) return ""; try { - const hash = await sha256Hex(emailValue); - return `https://www.gravatar.com/avatar/${hash}?d=mp&s=160`; + const hash = await sha256Hex(email); + return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=160`; } catch { return ""; } } + +export function resolveProfileAvatarUrl(user, emailAvatarUrl = "") { + const telegramAvatar = String(user?.telegram_photo_url || "").trim(); + if (user?.telegram_linked && telegramAvatar) return telegramAvatar; + return String(emailAvatarUrl || "").trim(); +} From ee840a7e6d76e0ce5d953797b3fd60cf12558d75 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 12:43:57 +0300 Subject: [PATCH 3/7] fix(admin): keep mobile extend button full height --- frontend/src/styles/admin-controls.css | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frontend/src/styles/admin-controls.css b/frontend/src/styles/admin-controls.css index ae0934d..1b2f739 100644 --- a/frontend/src/styles/admin-controls.css +++ b/frontend/src/styles/admin-controls.css @@ -330,6 +330,23 @@ background: color-mix(in srgb, var(--accent) 90%, #fff); } +@media (max-width: 640px) { + .admin-extend-control .input, + .admin-extend-control .admin-btn { + height: 46px; + min-height: 46px; + } + + .admin-extend-control .input { + line-height: 46px; + } + + .admin-extend-control .admin-btn { + width: 100%; + border-width: 1px 0 0; + } +} + .admin-input-row .input, .admin-input-row .admin-btn { height: 36px; From 1ee8af338c0f7654c4df5ae57d6e0cd5f5f31f70 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 12:57:15 +0300 Subject: [PATCH 4/7] fix(admin): show trial history and log activations --- backend/bot/app/web/admin_api_impl/common.py | 7 +- backend/bot/app/web/admin_api_impl/users.py | 29 +++++ backend/bot/app/web/webapp/billing.py | 23 ++++ .../src/admin/sections/UserDetailModal.svelte | 56 ++++++++- tests/test_admin_user_reset_trial.py | 65 ++++++++++ tests/test_webapp_trial_activation.py | 117 ++++++++++++++++++ 6 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 tests/test_webapp_trial_activation.py diff --git a/backend/bot/app/web/admin_api_impl/common.py b/backend/bot/app/web/admin_api_impl/common.py index b43ac82..f469da7 100644 --- a/backend/bot/app/web/admin_api_impl/common.py +++ b/backend/bot/app/web/admin_api_impl/common.py @@ -94,6 +94,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) + provider = sub.provider + is_trial = str(provider or "").strip().lower() == "trial" + display_label = "Trial" if is_trial else sub.tariff_key return { "subscription_id": int(sub.subscription_id), "panel_user_uuid": sub.panel_user_uuid, @@ -118,8 +121,10 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: "premium_unlimited_override": premium_unlimited_override, "premium_is_limited": bool(sub.premium_is_limited), "tariff_key": sub.tariff_key, + "display_label": display_label, + "is_trial": is_trial, "auto_renew_enabled": bool(sub.auto_renew_enabled), - "provider": sub.provider, + "provider": provider, "is_throttled": bool(sub.is_throttled), } diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 31587c4..ecb8e4d 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -745,6 +745,24 @@ def _user_search_condition(query: str): return or_(*conditions) +def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]: + first_trial_sub = trial_subs[0] if trial_subs else None + latest_trial_sub = trial_subs[-1] if trial_subs else None + first_start = getattr(first_trial_sub, "start_date", None) + latest_start = getattr(latest_trial_sub, "start_date", None) + latest_end = getattr(latest_trial_sub, "end_date", None) + reset_at = getattr(user, "trial_eligibility_reset_at", None) + return { + "used": bool(trial_subs), + "count": len(trial_subs), + "first_activated_at": first_start.isoformat() if first_start else None, + "latest_activated_at": latest_start.isoformat() if latest_start else None, + "latest_end_date": latest_end.isoformat() if latest_end else None, + "active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)), + "last_reset_at": reset_at.isoformat() if reset_at else None, + } + + async def admin_user_detail_route(request: web.Request) -> web.Response: _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) @@ -764,6 +782,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: .limit(20) ) latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() + trial_subs_stmt = ( + select(Subscription) + .where( + Subscription.user_id == target_id, + sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial", + ) + .order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc()) + ) + trial_subs = (await session.execute(trial_subs_stmt)).scalars().all() total_paid = await payment_dal.get_user_total_paid(session, target_id) recent_payments_stmt = ( select(Payment) @@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response: serialized_inviter = ( _serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None ) + trial_payload = _serialize_trial_summary(user, trial_subs) return _ok( { "user": serialized_user, "active_subscription": _serialize_subscription(active_sub) if active_sub else None, "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], + "trial": trial_payload, "total_paid": float(total_paid), "recent_payments": [_serialize_payment(p) for p in recent_payments], "log_count": int(log_count or 0), diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 56189bc..6a3cf16 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -2,6 +2,7 @@ from ._runtime import * # noqa: F403,F405 from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches +from db.dal import message_log_dal def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]: @@ -395,6 +396,28 @@ async def activate_trial_route(request: web.Request) -> web.Response: except Exception: logger.exception("Failed to send WebApp trial activation notification") + try: + await message_log_dal.create_message_log_no_commit( + session, + { + "user_id": user_id, + "telegram_username": getattr(db_user, "username", None), + "telegram_first_name": getattr(db_user, "first_name", None), + "event_type": "webapp_trial_activate", + "content": ( + f"Trial activated via WebApp for user_id={user_id}; " + f"email={getattr(db_user, 'email', None) or 'N/A'}" + ), + "is_admin_event": False, + "target_user_id": user_id, + "timestamp": datetime.now(timezone.utc), + }, + ) + except Exception: + logger.exception("Failed to add WebApp trial activation audit log") + + await session.commit() + try: from db.dal import ad_dal as _ad_dal diff --git a/frontend/src/admin/sections/UserDetailModal.svelte b/frontend/src/admin/sections/UserDetailModal.svelte index f0ecc1d..35a36f2 100644 --- a/frontend/src/admin/sections/UserDetailModal.svelte +++ b/frontend/src/admin/sections/UserDetailModal.svelte @@ -53,6 +53,26 @@ return String(val ?? "—"); } + function isTrialSubscription(sub) { + return Boolean(sub?.is_trial || String(sub?.provider || "").toLowerCase() === "trial"); + } + + function subscriptionDisplayLabel(sub) { + if (!sub) return "—"; + if (isTrialSubscription(sub)) return at("user_subscription_trial", {}, "Триал"); + if (sub.display_label) return sub.display_label; + return sub.tariff_name || sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа"); + } + + function trialSummaryText(trial) { + if (!trial?.used) return at("user_trial_not_used", {}, "Не брал"); + const date = trial.latest_activated_at || trial.first_activated_at; + const base = date + ? at("user_trial_used_at", { date: fmtDate(date) }, `Брал ${fmtDate(date)}`) + : at("user_trial_used", {}, "Брал"); + return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base; + } + const usersStore = getContext("usersStore"); $: ({ @@ -404,7 +424,7 @@
  • {at("user_label_tariff", {}, "Тариф")}{openedUserDetail.active_subscription.tariff_key || "—"}{subscriptionDisplayLabel(openedUserDetail.active_subscription)}
  • @@ -507,6 +527,37 @@

    {/if} + {#if openedUserDetail?.trial} + + {/if} + {#if (openedUserDetail.subscriptions || []).length}
    @@ -521,8 +572,7 @@
    {sub.tariff_key || - at("user_history_no_tariff", {}, "Без тарифа")}{subscriptionDisplayLabel(sub)} {at( diff --git a/tests/test_admin_user_reset_trial.py b/tests/test_admin_user_reset_trial.py index 8954bc2..c24a7e2 100644 --- a/tests/test_admin_user_reset_trial.py +++ b/tests/test_admin_user_reset_trial.py @@ -1,8 +1,10 @@ import json import unittest +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, patch +from bot.app.web.admin_api_impl import common as admin_common from bot.app.web.admin_api_impl import users as admin_users @@ -73,5 +75,68 @@ class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(session.rolled_back) +class AdminUserTrialPresentationTests(unittest.TestCase): + def test_trial_subscription_serializes_display_label(self): + start_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + end_at = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc) + sub = SimpleNamespace( + subscription_id=7, + panel_user_uuid="panel-user", + panel_subscription_uuid=None, + start_date=start_at, + end_date=end_at, + duration_months=None, + is_active=False, + status_from_panel="EXPIRED", + traffic_limit_bytes=10, + traffic_used_bytes=2, + tier_baseline_bytes=0, + topup_balance_bytes=0, + premium_used_bytes=0, + premium_baseline_bytes=0, + premium_topup_balance_bytes=0, + premium_topup_used_bytes=0, + premium_bonus_bytes=0, + regular_bonus_bytes=0, + regular_unlimited_override=False, + premium_unlimited_override=False, + premium_is_limited=False, + tariff_key=None, + auto_renew_enabled=False, + provider="trial", + is_throttled=False, + ) + + payload = admin_common._serialize_subscription(sub) + + self.assertTrue(payload["is_trial"]) + self.assertEqual(payload["display_label"], "Trial") + self.assertIsNone(payload["tariff_key"]) + + def test_trial_summary_includes_usage_dates_and_reset_marker(self): + first_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc) + latest_at = datetime(2026, 2, 3, 4, 5, tzinfo=timezone.utc) + latest_end = datetime(2026, 2, 10, 4, 5, tzinfo=timezone.utc) + reset_at = datetime(2026, 3, 1, tzinfo=timezone.utc) + user = SimpleNamespace(trial_eligibility_reset_at=reset_at) + trial_subs = [ + SimpleNamespace( + start_date=first_at, + end_date=datetime(2026, 1, 9, tzinfo=timezone.utc), + ), + SimpleNamespace(start_date=latest_at, end_date=latest_end, is_active=True), + ] + + payload = admin_users._serialize_trial_summary(user, trial_subs) + + self.assertTrue(payload["used"]) + self.assertTrue(payload["active"]) + self.assertEqual(payload["count"], 2) + self.assertEqual(payload["first_activated_at"], first_at.isoformat()) + self.assertEqual(payload["latest_activated_at"], latest_at.isoformat()) + self.assertEqual(payload["latest_end_date"], latest_end.isoformat()) + self.assertEqual(payload["last_reset_at"], reset_at.isoformat()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_webapp_trial_activation.py b/tests/test_webapp_trial_activation.py new file mode 100644 index 0000000..f073a5c --- /dev/null +++ b/tests/test_webapp_trial_activation.py @@ -0,0 +1,117 @@ +import json +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, patch + +import bot.app.web.subscription_webapp # noqa: F401 +from bot.app.web.webapp import billing as billing_module + + +class _Session: + def __init__(self): + self.commit_count = 0 + self.rollback_count = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + self.commit_count += 1 + + async def rollback(self): + self.rollback_count += 1 + + +class _SessionFactory: + def __init__(self, session): + self.session = session + + def __call__(self): + return self.session + + +class WebAppTrialActivationTests(IsolatedAsyncioTestCase): + async def test_email_only_trial_activation_is_written_to_admin_logs(self): + session = _Session() + end_date = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc) + settings = SimpleNamespace( + TRIAL_ENABLED=True, + TRIAL_DURATION_DAYS=7, + TRIAL_TRAFFIC_LIMIT_GB=10, + LOG_TRIAL_ACTIVATIONS=False, + ) + db_user = SimpleNamespace( + user_id=42, + is_banned=False, + username=None, + first_name=None, + email="email-only@example.com", + ) + subscription_service = SimpleNamespace( + activate_trial_subscription=AsyncMock( + return_value={ + "activated": True, + "days": 7, + "end_date": end_date, + "traffic_gb": 10, + "subscription_url": "https://panel.example/sub", + } + ) + ) + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": _SessionFactory(session), + "subscription_service": subscription_service, + } + ) + + with ( + patch.object(billing_module, "_require_user_id", return_value=42), + patch.object( + billing_module, + "_enforce_webapp_rate_limit", + AsyncMock(return_value=None), + ), + patch.object( + billing_module.user_dal, + "get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch.object( + billing_module, + "prepare_config_links", + AsyncMock(return_value=("https://panel.example/sub", "https://connect.example")), + ), + patch.object( + billing_module.message_log_dal, + "create_message_log_no_commit", + AsyncMock(), + ) as create_log, + patch.object( + billing_module, + "invalidate_webapp_user_caches", + AsyncMock(), + ), + patch("db.dal.ad_dal.mark_trial_activated", AsyncMock()) as mark_trial_activated, + ): + response = await billing_module.activate_trial_route(request) + + payload = json.loads(response.text) + self.assertEqual(response.status, 200) + self.assertTrue(payload["activated"]) + subscription_service.activate_trial_subscription.assert_awaited_once_with(session, 42) + create_log.assert_awaited_once() + log_payload = create_log.await_args.args[1] + self.assertEqual(log_payload["user_id"], 42) + self.assertEqual(log_payload["target_user_id"], 42) + self.assertEqual(log_payload["event_type"], "webapp_trial_activate") + self.assertFalse(log_payload["is_admin_event"]) + self.assertIn("email-only@example.com", log_payload["content"]) + mark_trial_activated.assert_awaited_once_with(session, 42) + self.assertEqual(session.commit_count, 2) + self.assertEqual(session.rollback_count, 0) From 4263cb7c99b88e10432809ba0c5ecd20a258f6ed Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 14:03:14 +0300 Subject: [PATCH 5/7] fix(webapp): show expiring subscription countdown --- frontend/src/styles/webapp.css | 25 +++++++ frontend/src/webapp/screens/HomeScreen.svelte | 65 ++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index ec2bae6..24d631b 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -233,6 +233,31 @@ a { color: var(--danger); } +.status-card-warning { + border-color: var(--warning-border); + background: + linear-gradient( + 135deg, + color-mix(in srgb, var(--warning) 14%, var(--surface-sheen-soft)), + color-mix(in srgb, var(--warning) 8%, var(--surface-sheen-soft)) + ), + var(--panel); + box-shadow: + var(--shadow-soft), + 0 0 0 1px color-mix(in srgb, var(--warning) 20%, transparent), + inset 0 1px 0 var(--inset-highlight); +} + +.status-card-warning .sub-status { + color: var(--warning-text); +} + +.status-card-warning .subscription-end-line { + color: var(--warning-text); + font-variant-numeric: tabular-nums; + opacity: 1; +} + .sub-status-inactive { min-height: 0; justify-content: flex-start; diff --git a/frontend/src/webapp/screens/HomeScreen.svelte b/frontend/src/webapp/screens/HomeScreen.svelte index 429ee1f..bce6606 100644 --- a/frontend/src/webapp/screens/HomeScreen.svelte +++ b/frontend/src/webapp/screens/HomeScreen.svelte @@ -1,4 +1,5 @@