feat(admin): surface configuration problems in the admin panel
Add GET /api/admin/health powered by a config health service that detects common deployment mistakes: missing or read-only data volume, broken tariffs/locale-override/guides JSON files, payment providers enabled without credentials, webhook providers without WEBHOOK_BASE_URL, no enabled payment methods, missing or non-https mini app URL, missing Redis, partially configured SMTP, untrusted reverse proxy, invalid bot token, missing/mismatched/failing Telegram webhook and unreachable Remnawave panel. Network checks (Telegram, panel) are cached for two minutes; ?refresh=1 forces a re-check. The admin UI shows the alerts as a banner on the dashboard with per-section navigation chips and a manual re-check button, and as a filtered banner inside each affected section. Alerts are localized via admin_health_* keys with built-in Russian fallbacks.
This commit is contained in:
@@ -9,6 +9,7 @@ from bot.app.web.admin_api_impl import (
|
|||||||
backups as _backups,
|
backups as _backups,
|
||||||
broadcast as _broadcast,
|
broadcast as _broadcast,
|
||||||
common as _common,
|
common as _common,
|
||||||
|
health as _health,
|
||||||
logs as _logs,
|
logs as _logs,
|
||||||
panel as _panel,
|
panel as _panel,
|
||||||
payments as _payments,
|
payments as _payments,
|
||||||
@@ -28,6 +29,7 @@ _MODULES = (
|
|||||||
_runtime,
|
_runtime,
|
||||||
_auth,
|
_auth,
|
||||||
_common,
|
_common,
|
||||||
|
_health,
|
||||||
_stats,
|
_stats,
|
||||||
_users,
|
_users,
|
||||||
_payments,
|
_payments,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
from .auth import _require_admin_user_id
|
||||||
|
from .common import _ok
|
||||||
|
from bot.services.config_health_service import collect_config_alerts
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_health_route(request: web.Request) -> web.Response:
|
||||||
|
_require_admin_user_id(request)
|
||||||
|
refresh = str(request.query.get("refresh", "")).strip().lower() in {"1", "true", "yes"}
|
||||||
|
alerts = await collect_config_alerts(request, refresh=refresh)
|
||||||
|
return _ok(
|
||||||
|
{
|
||||||
|
"alerts": alerts,
|
||||||
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
router = app.router
|
router = app.router
|
||||||
router.add_get("/api/admin/me", admin_me_route)
|
router.add_get("/api/admin/me", admin_me_route)
|
||||||
router.add_get("/api/admin/stats", admin_stats_route)
|
router.add_get("/api/admin/stats", admin_stats_route)
|
||||||
|
router.add_get("/api/admin/health", admin_health_route)
|
||||||
|
|
||||||
router.add_get("/api/admin/users", admin_users_list_route)
|
router.add_get("/api/admin/users", admin_users_list_route)
|
||||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
"""Detect common deployment misconfigurations for the admin panel.
|
||||||
|
|
||||||
|
Each check returns :class:`ConfigAlert` items the admin UI renders as
|
||||||
|
banners on the dashboard and inside the affected sections. Local checks
|
||||||
|
(filesystem, settings flags) run on every request; network checks
|
||||||
|
(Telegram webhook, Remnawave panel) are cached for a couple of minutes so
|
||||||
|
the dashboard stays fast and external APIs are not hammered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from bot.utils.request_security import ip_in_allowlist
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
NETWORK_CHECKS_TTL_SECONDS = 120.0
|
||||||
|
NETWORK_CHECK_TIMEOUT_SECONDS = 8.0
|
||||||
|
_WEBHOOK_ERROR_RECENT_SECONDS = 3600
|
||||||
|
_WEBHOOK_PENDING_THRESHOLD = 50
|
||||||
|
|
||||||
|
SEVERITY_ERROR = "error"
|
||||||
|
SEVERITY_WARNING = "warning"
|
||||||
|
|
||||||
|
# Admin section ids the frontend routes alerts to.
|
||||||
|
SECTION_SETTINGS = "settings"
|
||||||
|
SECTION_PAYMENTS = "payments"
|
||||||
|
SECTION_BACKUPS = "backups"
|
||||||
|
SECTION_TARIFFS = "tariffs"
|
||||||
|
SECTION_APPEARANCE = "appearance"
|
||||||
|
SECTION_TRANSLATIONS = "translations"
|
||||||
|
SECTION_USERS = "users"
|
||||||
|
|
||||||
|
_DATA_DIR_SECTIONS = (
|
||||||
|
SECTION_BACKUPS,
|
||||||
|
SECTION_TARIFFS,
|
||||||
|
SECTION_APPEARANCE,
|
||||||
|
SECTION_TRANSLATIONS,
|
||||||
|
SECTION_SETTINGS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Every message key an alert can carry. Tests assert each has
|
||||||
|
# ``admin_health_<key>`` entries in both locale files.
|
||||||
|
ALL_MESSAGE_KEYS = (
|
||||||
|
"data_dir_missing",
|
||||||
|
"data_dir_not_writable",
|
||||||
|
"backups_dir_not_writable",
|
||||||
|
"tariffs_config_invalid",
|
||||||
|
"locale_overrides_invalid",
|
||||||
|
"subscription_page_config_invalid",
|
||||||
|
"provider_not_configured",
|
||||||
|
"provider_webhook_needs_base_url",
|
||||||
|
"no_payment_methods",
|
||||||
|
"mini_app_url_missing",
|
||||||
|
"mini_app_url_not_https",
|
||||||
|
"redis_not_configured",
|
||||||
|
"smtp_incomplete",
|
||||||
|
"proxy_not_trusted",
|
||||||
|
"bot_token_invalid",
|
||||||
|
"telegram_api_error",
|
||||||
|
"telegram_webhook_missing",
|
||||||
|
"telegram_webhook_mismatch",
|
||||||
|
"telegram_webhook_error",
|
||||||
|
"telegram_webhook_pending",
|
||||||
|
"panel_api_not_configured",
|
||||||
|
"panel_api_unreachable",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfigAlert:
|
||||||
|
id: str
|
||||||
|
severity: str
|
||||||
|
sections: Tuple[str, ...]
|
||||||
|
params: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
# Locale key suffix; defaults to ``id``. Per-provider alerts carry ids
|
||||||
|
# like ``provider_not_configured:wata`` but share one message key.
|
||||||
|
message_key: Optional[str] = None
|
||||||
|
|
||||||
|
def as_payload(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"severity": self.severity,
|
||||||
|
"sections": list(self.sections),
|
||||||
|
"message_key": self.message_key or self.id,
|
||||||
|
"params": dict(self.params),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Filesystem checks ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_is_writable(path: Path) -> bool:
|
||||||
|
probe = path / f".health-probe-{uuid.uuid4().hex}.tmp"
|
||||||
|
try:
|
||||||
|
probe.write_text("ok", encoding="utf-8")
|
||||||
|
probe.unlink()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
probe.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_data_path(value: str) -> Path:
|
||||||
|
path = Path(value)
|
||||||
|
return path if path.is_absolute() else APP_ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def data_dir_alerts(settings: Any, app_root: Path = APP_ROOT) -> List[ConfigAlert]:
|
||||||
|
alerts: List[ConfigAlert] = []
|
||||||
|
data_dir = app_root / "data"
|
||||||
|
if not data_dir.is_dir():
|
||||||
|
return [
|
||||||
|
ConfigAlert(
|
||||||
|
id="data_dir_missing",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=_DATA_DIR_SECTIONS,
|
||||||
|
params={"path": str(data_dir)},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not _dir_is_writable(data_dir):
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="data_dir_not_writable",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=_DATA_DIR_SECTIONS,
|
||||||
|
params={"path": str(data_dir)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
backup_dir = _resolve_data_path(str(getattr(settings, "BACKUP_DIR", "") or "data/backups"))
|
||||||
|
if backup_dir.is_dir() and not _dir_is_writable(backup_dir):
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="backups_dir_not_writable",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_BACKUPS,),
|
||||||
|
params={"path": str(backup_dir)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def config_file_alerts(settings: Any) -> List[ConfigAlert]:
|
||||||
|
alerts: List[ConfigAlert] = []
|
||||||
|
|
||||||
|
tariffs_path = _resolve_data_path(
|
||||||
|
str(getattr(settings, "TARIFFS_CONFIG_PATH", "") or "data/tariffs.json")
|
||||||
|
)
|
||||||
|
if tariffs_path.is_file():
|
||||||
|
try:
|
||||||
|
from config.tariffs_config import load_tariffs_config
|
||||||
|
|
||||||
|
load_tariffs_config(tariffs_path)
|
||||||
|
except Exception as exc:
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="tariffs_config_invalid",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=(SECTION_TARIFFS,),
|
||||||
|
params={"path": str(tariffs_path), "error": str(exc)[:300]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
locale_overrides_path = APP_ROOT / "data" / "locales-overrides.json"
|
||||||
|
if locale_overrides_path.is_file():
|
||||||
|
try:
|
||||||
|
json.loads(locale_overrides_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="locale_overrides_invalid",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_TRANSLATIONS,),
|
||||||
|
params={"path": str(locale_overrides_path), "error": str(exc)[:300]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config.subscription_guides_config import (
|
||||||
|
SubscriptionGuidesConfigError,
|
||||||
|
subscription_guides_admin_config_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subscription_guides_admin_config_json(settings)
|
||||||
|
except SubscriptionGuidesConfigError as exc:
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="subscription_page_config_invalid",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
params={"error": str(exc)[:300]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - defensive import guard
|
||||||
|
logger.exception("Subscription guides config check failed unexpectedly")
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Settings checks ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def payment_provider_alerts(settings: Any, app: Any) -> List[ConfigAlert]:
|
||||||
|
from bot.payment_providers import iter_provider_specs
|
||||||
|
|
||||||
|
alerts: List[ConfigAlert] = []
|
||||||
|
any_enabled = False
|
||||||
|
seen_services: set = set()
|
||||||
|
for spec in iter_provider_specs():
|
||||||
|
try:
|
||||||
|
enabled = spec.is_effectively_enabled(settings)
|
||||||
|
except Exception: # pragma: no cover - provider config errors
|
||||||
|
logger.exception("Provider %s enabled check failed", spec.id)
|
||||||
|
continue
|
||||||
|
if not enabled:
|
||||||
|
continue
|
||||||
|
any_enabled = True
|
||||||
|
if spec.service_key in seen_services:
|
||||||
|
continue
|
||||||
|
if spec.service_key:
|
||||||
|
seen_services.add(spec.service_key)
|
||||||
|
if not spec.is_service_configured(app):
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id=f"provider_not_configured:{spec.id}",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
params={"provider": spec.label},
|
||||||
|
message_key="provider_not_configured",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.webhook_requires_base_url and not getattr(settings, "WEBHOOK_BASE_URL", None):
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id=f"provider_webhook_needs_base_url:{spec.id}",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
params={"provider": spec.label},
|
||||||
|
message_key="provider_webhook_needs_base_url",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not any_enabled:
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="no_payment_methods",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS, SECTION_PAYMENTS),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def settings_alerts(settings: Any) -> List[ConfigAlert]:
|
||||||
|
alerts: List[ConfigAlert] = []
|
||||||
|
|
||||||
|
mini_app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||||
|
if not mini_app_url:
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="mini_app_url_missing",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif not mini_app_url.lower().startswith("https://"):
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="mini_app_url_not_https",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
params={"url": mini_app_url},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not getattr(settings, "REDIS_URL", None):
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="redis_not_configured",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
smtp_partial = any(
|
||||||
|
getattr(settings, key, None)
|
||||||
|
for key in ("SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_FROM_EMAIL")
|
||||||
|
)
|
||||||
|
if smtp_partial and not getattr(settings, "email_auth_configured", False):
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="smtp_incomplete",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
def proxy_alerts(request: Any, settings: Any) -> List[ConfigAlert]:
|
||||||
|
"""Warn when the admin request itself came through an untrusted proxy.
|
||||||
|
|
||||||
|
In that case provider webhooks with IP allowlists will see the proxy
|
||||||
|
address instead of the real sender and may reject valid callbacks.
|
||||||
|
"""
|
||||||
|
headers = getattr(request, "headers", None) or {}
|
||||||
|
forwarded = headers.get("X-Forwarded-For")
|
||||||
|
remote = getattr(request, "remote", None)
|
||||||
|
if not forwarded or not remote:
|
||||||
|
return []
|
||||||
|
if ip_in_allowlist(remote, getattr(settings, "trusted_proxies", None)):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
ConfigAlert(
|
||||||
|
id="proxy_not_trusted",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
params={"remote": str(remote)},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Network checks (cached) ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def telegram_alerts(bot: Any, settings: Any) -> List[ConfigAlert]:
|
||||||
|
if bot is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
info = await asyncio.wait_for(bot.get_webhook_info(), timeout=NETWORK_CHECK_TIMEOUT_SECONDS)
|
||||||
|
except Exception as exc:
|
||||||
|
if exc.__class__.__name__ in {"TelegramUnauthorizedError", "TelegramNotFound"}:
|
||||||
|
return [
|
||||||
|
ConfigAlert(
|
||||||
|
id="bot_token_invalid",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
ConfigAlert(
|
||||||
|
id="telegram_api_error",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
params={"error": str(exc)[:300]},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
alerts: List[ConfigAlert] = []
|
||||||
|
actual_url = str(getattr(info, "url", "") or "")
|
||||||
|
base_url = str(getattr(settings, "WEBHOOK_BASE_URL", "") or "").rstrip("/")
|
||||||
|
expected_url = (
|
||||||
|
f"{base_url}{getattr(settings, 'telegram_webhook_path', '/tg/webhook')}" if base_url else ""
|
||||||
|
)
|
||||||
|
if not actual_url:
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="telegram_webhook_missing",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif expected_url and actual_url != expected_url:
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="telegram_webhook_mismatch",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
params={"actual": actual_url, "expected": expected_url},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
last_error_date = getattr(info, "last_error_date", None)
|
||||||
|
last_error_ts: Optional[float] = None
|
||||||
|
if last_error_date is not None:
|
||||||
|
last_error_ts = (
|
||||||
|
last_error_date.timestamp()
|
||||||
|
if hasattr(last_error_date, "timestamp")
|
||||||
|
else float(last_error_date)
|
||||||
|
)
|
||||||
|
if last_error_ts and (time.time() - last_error_ts) < _WEBHOOK_ERROR_RECENT_SECONDS:
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="telegram_webhook_error",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
params={"error": str(getattr(info, "last_error_message", "") or "")[:300]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = int(getattr(info, "pending_update_count", 0) or 0)
|
||||||
|
if pending > _WEBHOOK_PENDING_THRESHOLD:
|
||||||
|
alerts.append(
|
||||||
|
ConfigAlert(
|
||||||
|
id="telegram_webhook_pending",
|
||||||
|
severity=SEVERITY_WARNING,
|
||||||
|
sections=(SECTION_SETTINGS,),
|
||||||
|
params={"count": pending},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
async def panel_alerts(panel_service: Any, settings: Any) -> List[ConfigAlert]:
|
||||||
|
if not getattr(settings, "PANEL_API_URL", None) or not getattr(settings, "PANEL_API_KEY", None):
|
||||||
|
return [
|
||||||
|
ConfigAlert(
|
||||||
|
id="panel_api_not_configured",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=(SECTION_SETTINGS, SECTION_USERS, SECTION_TARIFFS),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if panel_service is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
stats = await asyncio.wait_for(
|
||||||
|
panel_service.get_system_stats(), timeout=NETWORK_CHECK_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Panel health check failed: %s", exc)
|
||||||
|
stats = None
|
||||||
|
if stats is None:
|
||||||
|
return [
|
||||||
|
ConfigAlert(
|
||||||
|
id="panel_api_unreachable",
|
||||||
|
severity=SEVERITY_ERROR,
|
||||||
|
sections=(SECTION_SETTINGS, SECTION_USERS),
|
||||||
|
params={"url": str(getattr(settings, "PANEL_API_URL", "") or "")},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Aggregation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_network_cache: Dict[int, Tuple[float, List[ConfigAlert]]] = {}
|
||||||
|
_network_cache_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def local_alerts(request: Any, settings: Any, app: Any) -> List[ConfigAlert]:
|
||||||
|
alerts: List[ConfigAlert] = []
|
||||||
|
for collect in (
|
||||||
|
lambda: data_dir_alerts(settings),
|
||||||
|
lambda: config_file_alerts(settings),
|
||||||
|
lambda: payment_provider_alerts(settings, app),
|
||||||
|
lambda: settings_alerts(settings),
|
||||||
|
lambda: proxy_alerts(request, settings),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
alerts.extend(collect())
|
||||||
|
except Exception: # pragma: no cover - one broken check must not hide others
|
||||||
|
logger.exception("Config health check failed")
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
async def network_alerts(app: Any, settings: Any, *, refresh: bool = False) -> List[ConfigAlert]:
|
||||||
|
cache_key = id(settings)
|
||||||
|
now = time.monotonic()
|
||||||
|
if not refresh:
|
||||||
|
cached = _network_cache.get(cache_key)
|
||||||
|
if cached and (now - cached[0]) < NETWORK_CHECKS_TTL_SECONDS:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
async with _network_cache_lock:
|
||||||
|
if not refresh:
|
||||||
|
cached = _network_cache.get(cache_key)
|
||||||
|
if cached and (time.monotonic() - cached[0]) < NETWORK_CHECKS_TTL_SECONDS:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
telegram_alerts(app.get("bot"), settings),
|
||||||
|
panel_alerts(app.get("panel_service"), settings),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
alerts: List[ConfigAlert] = []
|
||||||
|
for result in results:
|
||||||
|
if isinstance(result, BaseException):
|
||||||
|
logger.exception("Network config health check failed", exc_info=result)
|
||||||
|
continue
|
||||||
|
alerts.extend(result)
|
||||||
|
_network_cache[cache_key] = (time.monotonic(), alerts)
|
||||||
|
return alerts
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_config_alerts(request: Any, *, refresh: bool = False) -> List[Dict[str, Any]]:
|
||||||
|
app = request.app
|
||||||
|
settings = app["settings"]
|
||||||
|
alerts = local_alerts(request, settings, app)
|
||||||
|
alerts.extend(await network_alerts(app, settings, refresh=refresh))
|
||||||
|
order = {SEVERITY_ERROR: 0, SEVERITY_WARNING: 1}
|
||||||
|
alerts.sort(key=lambda alert: (order.get(alert.severity, 2), alert.id))
|
||||||
|
return [alert.as_payload() for alert in alerts]
|
||||||
@@ -45,9 +45,11 @@
|
|||||||
import AppearanceSection from "./sections/AppearanceSection.svelte";
|
import AppearanceSection from "./sections/AppearanceSection.svelte";
|
||||||
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
||||||
import UsersSection from "./sections/UsersSection.svelte";
|
import UsersSection from "./sections/UsersSection.svelte";
|
||||||
|
import ConfigAlertsBanner from "./ConfigAlertsBanner.svelte";
|
||||||
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
|
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
|
||||||
import { createBackupsStore } from "../lib/admin/stores/backupsStore.js";
|
import { createBackupsStore } from "../lib/admin/stores/backupsStore.js";
|
||||||
import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
|
import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
|
||||||
|
import { createHealthStore } from "../lib/admin/stores/healthStore.js";
|
||||||
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
|
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
|
||||||
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
|
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
|
||||||
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
|
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
|
||||||
@@ -247,6 +249,7 @@
|
|||||||
const adsStore = createAdsStore({ api, onToast: flash, at });
|
const adsStore = createAdsStore({ api, onToast: flash, at });
|
||||||
const backupsStore = createBackupsStore({ api, onToast: flash, at });
|
const backupsStore = createBackupsStore({ api, onToast: flash, at });
|
||||||
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
|
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
|
||||||
|
const healthStore = createHealthStore({ api });
|
||||||
const logsStore = createLogsStore({ api, at });
|
const logsStore = createLogsStore({ api, at });
|
||||||
const paymentsStore = createPaymentsStore({ api, onToast: flash, at, routePrefix });
|
const paymentsStore = createPaymentsStore({ api, onToast: flash, at, routePrefix });
|
||||||
const promosStore = createPromosStore({ api, onToast: flash, at });
|
const promosStore = createPromosStore({ api, onToast: flash, at });
|
||||||
@@ -260,6 +263,7 @@
|
|||||||
|
|
||||||
setContext("promosStore", promosStore);
|
setContext("promosStore", promosStore);
|
||||||
setContext("adsStore", adsStore);
|
setContext("adsStore", adsStore);
|
||||||
|
setContext("healthStore", healthStore);
|
||||||
setContext("backupsStore", backupsStore);
|
setContext("backupsStore", backupsStore);
|
||||||
setContext("broadcastStore", broadcastStore);
|
setContext("broadcastStore", broadcastStore);
|
||||||
setContext("logsStore", logsStore);
|
setContext("logsStore", logsStore);
|
||||||
@@ -531,6 +535,11 @@
|
|||||||
window.addEventListener("popstate", onPopState);
|
window.addEventListener("popstate", onPopState);
|
||||||
}
|
}
|
||||||
void broadcastStore.loadCounts();
|
void broadcastStore.loadCounts();
|
||||||
|
void healthStore.loadHealth();
|
||||||
|
const healthTimer =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.setInterval(() => void healthStore.loadHealth(), 5 * 60 * 1000)
|
||||||
|
: null;
|
||||||
return () => {
|
return () => {
|
||||||
if (motionMql) motionMql.removeEventListener("change", onMotionChange);
|
if (motionMql) motionMql.removeEventListener("change", onMotionChange);
|
||||||
if (compactMql) {
|
if (compactMql) {
|
||||||
@@ -539,6 +548,7 @@
|
|||||||
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
|
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
|
||||||
}
|
}
|
||||||
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
|
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
|
||||||
|
if (healthTimer !== null) window.clearInterval(healthTimer);
|
||||||
clearAdminLanguageClickGuard();
|
clearAdminLanguageClickGuard();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -802,6 +812,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
|
<ConfigAlertsBanner {at} section={active} onNavigate={setActive} />
|
||||||
{#key active}
|
{#key active}
|
||||||
<div class="admin-section-stage" in:fade={sectionFade} out:fade={sectionFade}>
|
<div class="admin-section-stage" in:fade={sectionFade} out:fade={sectionFade}>
|
||||||
{#if active === "stats"}
|
{#if active === "stats"}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script>
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import { RefreshCw, TriangleAlert } from "$components/ui/icons.js";
|
||||||
|
import { AdminButton } from "$components/patterns/admin/index.js";
|
||||||
|
|
||||||
|
export let at = (key, _params = {}, fallback = "") => fallback || key;
|
||||||
|
export let section = "stats";
|
||||||
|
export let onNavigate = () => {};
|
||||||
|
|
||||||
|
const healthStore = getContext("healthStore");
|
||||||
|
|
||||||
|
const MESSAGE_FALLBACKS = {
|
||||||
|
data_dir_missing:
|
||||||
|
"Каталог data ({path}) не найден. Проверьте, что том data смонтирован в контейнер.",
|
||||||
|
data_dir_not_writable:
|
||||||
|
"Нет прав на запись в {path} — бэкапы, тарифы, логотипы и переводы не сохранятся.",
|
||||||
|
backups_dir_not_writable: "Каталог бэкапов {path} недоступен для записи.",
|
||||||
|
tariffs_config_invalid: "Файл тарифов {path} не читается: {error}",
|
||||||
|
locale_overrides_invalid: "Файл переводов {path} повреждён: {error}",
|
||||||
|
subscription_page_config_invalid: "Конфиг гайдов подписки не читается: {error}",
|
||||||
|
provider_not_configured:
|
||||||
|
"Провайдер {provider} включён, но не настроен — оплата через него не работает.",
|
||||||
|
provider_webhook_needs_base_url:
|
||||||
|
"Провайдеру {provider} нужен WEBHOOK_BASE_URL для приёма вебхуков, а он не задан.",
|
||||||
|
no_payment_methods: "Не включён ни один способ оплаты.",
|
||||||
|
mini_app_url_missing: "SUBSCRIPTION_MINI_APP_URL не задан — кнопка Mini App в боте не появится.",
|
||||||
|
mini_app_url_not_https: "SUBSCRIPTION_MINI_APP_URL должен начинаться с https:// (сейчас {url}).",
|
||||||
|
redis_not_configured:
|
||||||
|
"REDIS_URL не задан — состояния диалогов бота и кэш не переживут перезапуск.",
|
||||||
|
smtp_incomplete: "SMTP настроен не полностью — вход по email работать не будет.",
|
||||||
|
proxy_not_trusted:
|
||||||
|
"Запросы приходят через прокси {remote}, которого нет в TRUSTED_PROXIES — вебхуки платёжных провайдеров могут отклоняться по IP.",
|
||||||
|
bot_token_invalid: "Telegram отверг BOT_TOKEN — бот не работает.",
|
||||||
|
telegram_api_error: "Не удалось обратиться к Telegram API: {error}",
|
||||||
|
telegram_webhook_missing: "Вебхук Telegram не установлен — бот не получает обновления.",
|
||||||
|
telegram_webhook_mismatch: "Вебхук Telegram указывает на {actual}, ожидается {expected}.",
|
||||||
|
telegram_webhook_error: "Telegram сообщает об ошибке доставки вебхука: {error}",
|
||||||
|
telegram_webhook_pending: "В очереди Telegram скопилось {count} необработанных обновлений.",
|
||||||
|
panel_api_not_configured:
|
||||||
|
"PANEL_API_URL и PANEL_API_KEY не заданы — синхронизация и выдача подписок не работают.",
|
||||||
|
panel_api_unreachable: "Панель Remnawave недоступна по адресу {url}.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_FALLBACK_LABELS = {
|
||||||
|
settings: "Настройки",
|
||||||
|
payments: "Платежи",
|
||||||
|
backups: "Бэкапы",
|
||||||
|
tariffs: "Тарифы",
|
||||||
|
appearance: "Внешний вид",
|
||||||
|
translations: "Переводы",
|
||||||
|
users: "Пользователи",
|
||||||
|
};
|
||||||
|
|
||||||
|
function interpolate(template, params = {}) {
|
||||||
|
return String(template || "").replace(/\{(\w+)\}/g, (match, key) =>
|
||||||
|
params[key] !== undefined && params[key] !== null ? String(params[key]) : match
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertText(alert) {
|
||||||
|
const fallback = interpolate(MESSAGE_FALLBACKS[alert.message_key] || alert.message_key, alert.params);
|
||||||
|
return at(`health_${alert.message_key}`, alert.params || {}, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionLabel(id) {
|
||||||
|
return at(`nav_${id}`, {}, SECTION_FALLBACK_LABELS[id] || id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: alerts = $healthStore?.alerts || [];
|
||||||
|
$: healthLoading = $healthStore?.healthLoading;
|
||||||
|
$: isDashboard = section === "stats";
|
||||||
|
$: visibleAlerts = isDashboard
|
||||||
|
? alerts
|
||||||
|
: alerts.filter((alert) => (alert.sections || []).includes(section));
|
||||||
|
$: errorCount = visibleAlerts.filter((alert) => alert.severity === "error").length;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visibleAlerts.length}
|
||||||
|
<div
|
||||||
|
class="admin-config-alerts"
|
||||||
|
class:admin-config-alerts-error={errorCount > 0}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div class="admin-config-alerts-head">
|
||||||
|
<span class="admin-config-alerts-title">
|
||||||
|
<TriangleAlert size={15} />
|
||||||
|
{at("health_title", {}, "Проблемы конфигурации")}
|
||||||
|
</span>
|
||||||
|
{#if isDashboard}
|
||||||
|
<AdminButton
|
||||||
|
onclick={() => healthStore.loadHealth({ refresh: true })}
|
||||||
|
disabled={healthLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} />
|
||||||
|
{at("health_refresh", {}, "Проверить снова")}
|
||||||
|
</AdminButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ul class="admin-config-alerts-list">
|
||||||
|
{#each visibleAlerts as alert (alert.id)}
|
||||||
|
<li class="admin-config-alert admin-config-alert-{alert.severity}">
|
||||||
|
<span class="admin-config-alert-dot" aria-hidden="true"></span>
|
||||||
|
<span class="admin-config-alert-text">{alertText(alert)}</span>
|
||||||
|
{#if isDashboard && (alert.sections || []).length}
|
||||||
|
<span class="admin-config-alert-links">
|
||||||
|
{#each alert.sections as sectionId (sectionId)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-config-alert-link"
|
||||||
|
on:click={() => onNavigate(sectionId)}
|
||||||
|
>
|
||||||
|
{sectionLabel(sectionId)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
export function createHealthStore({ api }) {
|
||||||
|
const state = writable({
|
||||||
|
alerts: [],
|
||||||
|
checkedAt: null,
|
||||||
|
healthLoading: false,
|
||||||
|
healthError: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadHealth({ refresh = false } = {}) {
|
||||||
|
state.update((s) => ({ ...s, healthLoading: true, healthError: "" }));
|
||||||
|
try {
|
||||||
|
const data = await api(`/admin/health${refresh ? "?refresh=1" : ""}`);
|
||||||
|
if (!data?.ok) {
|
||||||
|
state.update((s) => ({ ...s, healthError: data?.error || "load_failed" }));
|
||||||
|
} else {
|
||||||
|
state.update((s) => ({
|
||||||
|
...s,
|
||||||
|
alerts: Array.isArray(data.alerts) ? data.alerts : [],
|
||||||
|
checkedAt: data.checked_at || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
state.update((s) => ({ ...s, healthError: e?.message || String(e) }));
|
||||||
|
} finally {
|
||||||
|
state.update((s) => ({ ...s, healthLoading: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: state.subscribe,
|
||||||
|
loadHealth,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -762,6 +762,29 @@ function demoApiResponse(path, cleanPath, options, context) {
|
|||||||
}
|
}
|
||||||
if (cleanPath === "/admin/sync") return { ok: true, status: "queued" };
|
if (cleanPath === "/admin/sync") return { ok: true, status: "queued" };
|
||||||
|
|
||||||
|
if (cleanPath === "/admin/health") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
alerts: [
|
||||||
|
{
|
||||||
|
id: "provider_not_configured:wata",
|
||||||
|
severity: "error",
|
||||||
|
sections: ["settings"],
|
||||||
|
message_key: "provider_not_configured",
|
||||||
|
params: { provider: "Wata" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mini_app_url_missing",
|
||||||
|
severity: "warning",
|
||||||
|
sections: ["settings"],
|
||||||
|
message_key: "mini_app_url_missing",
|
||||||
|
params: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (cleanPath === "/admin/payments") {
|
if (cleanPath === "/admin/payments") {
|
||||||
const page = paged(DEMO_DATASET.adminPayments || [], params, 25);
|
const page = paged(DEMO_DATASET.adminPayments || [], params, 25);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -372,6 +372,95 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-config-alerts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alerts-error {
|
||||||
|
border-color: var(--danger-border);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alerts-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alerts-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alerts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alert-dot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--warning-text);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alert-error .admin-config-alert-dot {
|
||||||
|
background: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alert-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alert-links {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alert-link {
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-config-alert-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-section-stage:has(.support-admin-layout) {
|
.admin-section-stage:has(.support-admin-layout) {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -1024,6 +1024,30 @@
|
|||||||
"admin_section_backups_subtitle": "Archives, uploads, and database/compose restore",
|
"admin_section_backups_subtitle": "Archives, uploads, and database/compose restore",
|
||||||
"admin_section_settings_title": "App Settings",
|
"admin_section_settings_title": "App Settings",
|
||||||
"admin_section_settings_subtitle": "Overrides for .env, applied instantly",
|
"admin_section_settings_subtitle": "Overrides for .env, applied instantly",
|
||||||
|
"admin_health_title": "Configuration issues",
|
||||||
|
"admin_health_refresh": "Check again",
|
||||||
|
"admin_health_data_dir_missing": "Data directory ({path}) not found. Make sure the data volume is mounted into the container.",
|
||||||
|
"admin_health_data_dir_not_writable": "No write access to {path} — backups, tariffs, logos and translations cannot be saved.",
|
||||||
|
"admin_health_backups_dir_not_writable": "Backups directory {path} is not writable.",
|
||||||
|
"admin_health_tariffs_config_invalid": "Tariffs file {path} cannot be read: {error}",
|
||||||
|
"admin_health_locale_overrides_invalid": "Translations file {path} is corrupted: {error}",
|
||||||
|
"admin_health_subscription_page_config_invalid": "Subscription guides config cannot be read: {error}",
|
||||||
|
"admin_health_provider_not_configured": "Provider {provider} is enabled but not configured — payments through it will not work.",
|
||||||
|
"admin_health_provider_webhook_needs_base_url": "Provider {provider} requires WEBHOOK_BASE_URL for webhooks, but it is not set.",
|
||||||
|
"admin_health_no_payment_methods": "No payment method is enabled.",
|
||||||
|
"admin_health_mini_app_url_missing": "SUBSCRIPTION_MINI_APP_URL is not set — the Mini App button will not appear in the bot.",
|
||||||
|
"admin_health_mini_app_url_not_https": "SUBSCRIPTION_MINI_APP_URL must start with https:// (currently {url}).",
|
||||||
|
"admin_health_redis_not_configured": "REDIS_URL is not set — bot dialog states and caches will not survive a restart.",
|
||||||
|
"admin_health_smtp_incomplete": "SMTP is partially configured — email login will not work.",
|
||||||
|
"admin_health_proxy_not_trusted": "Requests arrive through proxy {remote} which is not in TRUSTED_PROXIES — payment provider webhooks may be rejected by IP.",
|
||||||
|
"admin_health_bot_token_invalid": "Telegram rejected BOT_TOKEN — the bot is not working.",
|
||||||
|
"admin_health_telegram_api_error": "Failed to reach the Telegram API: {error}",
|
||||||
|
"admin_health_telegram_webhook_missing": "Telegram webhook is not set — the bot does not receive updates.",
|
||||||
|
"admin_health_telegram_webhook_mismatch": "Telegram webhook points to {actual}, expected {expected}.",
|
||||||
|
"admin_health_telegram_webhook_error": "Telegram reports a webhook delivery error: {error}",
|
||||||
|
"admin_health_telegram_webhook_pending": "{count} unprocessed updates are queued in Telegram.",
|
||||||
|
"admin_health_panel_api_not_configured": "PANEL_API_URL and PANEL_API_KEY are not set — sync and subscription provisioning do not work.",
|
||||||
|
"admin_health_panel_api_unreachable": "Remnawave panel is unreachable at {url}.",
|
||||||
"admin_backups_load_failed": "Failed to load backups",
|
"admin_backups_load_failed": "Failed to load backups",
|
||||||
"admin_backups_upload_done": "Archive uploaded",
|
"admin_backups_upload_done": "Archive uploaded",
|
||||||
"admin_backups_upload_failed": "Failed to upload archive",
|
"admin_backups_upload_failed": "Failed to upload archive",
|
||||||
|
|||||||
@@ -1024,6 +1024,30 @@
|
|||||||
"admin_section_backups_subtitle": "Архивы, загрузка и восстановление БД/compose",
|
"admin_section_backups_subtitle": "Архивы, загрузка и восстановление БД/compose",
|
||||||
"admin_section_settings_title": "Настройки приложения",
|
"admin_section_settings_title": "Настройки приложения",
|
||||||
"admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно",
|
"admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно",
|
||||||
|
"admin_health_title": "Проблемы конфигурации",
|
||||||
|
"admin_health_refresh": "Проверить снова",
|
||||||
|
"admin_health_data_dir_missing": "Каталог data ({path}) не найден. Проверьте, что том data смонтирован в контейнер.",
|
||||||
|
"admin_health_data_dir_not_writable": "Нет прав на запись в {path} — бэкапы, тарифы, логотипы и переводы не сохранятся.",
|
||||||
|
"admin_health_backups_dir_not_writable": "Каталог бэкапов {path} недоступен для записи.",
|
||||||
|
"admin_health_tariffs_config_invalid": "Файл тарифов {path} не читается: {error}",
|
||||||
|
"admin_health_locale_overrides_invalid": "Файл переводов {path} повреждён: {error}",
|
||||||
|
"admin_health_subscription_page_config_invalid": "Конфиг гайдов подписки не читается: {error}",
|
||||||
|
"admin_health_provider_not_configured": "Провайдер {provider} включён, но не настроен — оплата через него не работает.",
|
||||||
|
"admin_health_provider_webhook_needs_base_url": "Провайдеру {provider} нужен WEBHOOK_BASE_URL для приёма вебхуков, а он не задан.",
|
||||||
|
"admin_health_no_payment_methods": "Не включён ни один способ оплаты.",
|
||||||
|
"admin_health_mini_app_url_missing": "SUBSCRIPTION_MINI_APP_URL не задан — кнопка Mini App в боте не появится.",
|
||||||
|
"admin_health_mini_app_url_not_https": "SUBSCRIPTION_MINI_APP_URL должен начинаться с https:// (сейчас {url}).",
|
||||||
|
"admin_health_redis_not_configured": "REDIS_URL не задан — состояния диалогов бота и кэш не переживут перезапуск.",
|
||||||
|
"admin_health_smtp_incomplete": "SMTP настроен не полностью — вход по email работать не будет.",
|
||||||
|
"admin_health_proxy_not_trusted": "Запросы приходят через прокси {remote}, которого нет в TRUSTED_PROXIES — вебхуки платёжных провайдеров могут отклоняться по IP.",
|
||||||
|
"admin_health_bot_token_invalid": "Telegram отверг BOT_TOKEN — бот не работает.",
|
||||||
|
"admin_health_telegram_api_error": "Не удалось обратиться к Telegram API: {error}",
|
||||||
|
"admin_health_telegram_webhook_missing": "Вебхук Telegram не установлен — бот не получает обновления.",
|
||||||
|
"admin_health_telegram_webhook_mismatch": "Вебхук Telegram указывает на {actual}, ожидается {expected}.",
|
||||||
|
"admin_health_telegram_webhook_error": "Telegram сообщает об ошибке доставки вебхука: {error}",
|
||||||
|
"admin_health_telegram_webhook_pending": "В очереди Telegram скопилось {count} необработанных обновлений.",
|
||||||
|
"admin_health_panel_api_not_configured": "PANEL_API_URL и PANEL_API_KEY не заданы — синхронизация и выдача подписок не работают.",
|
||||||
|
"admin_health_panel_api_unreachable": "Панель Remnawave недоступна по адресу {url}.",
|
||||||
"admin_backups_load_failed": "Не удалось загрузить бэкапы",
|
"admin_backups_load_failed": "Не удалось загрузить бэкапы",
|
||||||
"admin_backups_upload_done": "Архив загружен",
|
"admin_backups_upload_done": "Архив загружен",
|
||||||
"admin_backups_upload_failed": "Не удалось загрузить архив",
|
"admin_backups_upload_failed": "Не удалось загрузить архив",
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from bot.services import config_health_service as health
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides):
|
||||||
|
base = {
|
||||||
|
"BACKUP_DIR": "data/backups",
|
||||||
|
"TARIFFS_CONFIG_PATH": "data/tariffs.json",
|
||||||
|
"SUBSCRIPTION_MINI_APP_URL": "https://shop.example.com/app",
|
||||||
|
"REDIS_URL": "redis://redis:6379/0",
|
||||||
|
"SMTP_USERNAME": None,
|
||||||
|
"SMTP_PASSWORD": None,
|
||||||
|
"SMTP_FROM_EMAIL": None,
|
||||||
|
"email_auth_configured": False,
|
||||||
|
"WEBHOOK_BASE_URL": "https://shop.example.com",
|
||||||
|
"telegram_webhook_path": "/tg/webhook",
|
||||||
|
"PANEL_API_URL": "https://panel.example.com/api",
|
||||||
|
"PANEL_API_KEY": "panel-key",
|
||||||
|
"trusted_proxies": ["127.0.0.1", "172.16.0.0/12"],
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_ids(alerts):
|
||||||
|
return [alert.id for alert in alerts]
|
||||||
|
|
||||||
|
|
||||||
|
class DataDirAlertsTests(unittest.TestCase):
|
||||||
|
def test_missing_data_dir_reported_as_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
alerts = health.data_dir_alerts(_settings(), app_root=Path(tmpdir))
|
||||||
|
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["data_dir_missing"])
|
||||||
|
self.assertEqual(alerts[0].severity, "error")
|
||||||
|
self.assertIn("backups", alerts[0].sections)
|
||||||
|
|
||||||
|
def test_writable_data_dir_produces_no_alerts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
(Path(tmpdir) / "data").mkdir()
|
||||||
|
alerts = health.data_dir_alerts(_settings(), app_root=Path(tmpdir))
|
||||||
|
|
||||||
|
self.assertEqual(alerts, [])
|
||||||
|
|
||||||
|
def test_unwritable_data_dir_reported(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
(Path(tmpdir) / "data").mkdir()
|
||||||
|
with patch.object(health, "_dir_is_writable", return_value=False):
|
||||||
|
alerts = health.data_dir_alerts(_settings(), app_root=Path(tmpdir))
|
||||||
|
|
||||||
|
self.assertIn("data_dir_not_writable", _alert_ids(alerts))
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFileAlertsTests(unittest.TestCase):
|
||||||
|
def test_invalid_tariffs_config_reported(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tariffs_path = Path(tmpdir) / "tariffs.json"
|
||||||
|
tariffs_path.write_text("{not json", encoding="utf-8")
|
||||||
|
settings = _settings(TARIFFS_CONFIG_PATH=str(tariffs_path))
|
||||||
|
with patch.object(health, "APP_ROOT", Path(tmpdir)):
|
||||||
|
alerts = health.config_file_alerts(settings)
|
||||||
|
|
||||||
|
self.assertIn("tariffs_config_invalid", _alert_ids(alerts))
|
||||||
|
|
||||||
|
def test_invalid_locale_overrides_reported(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
data_dir = Path(tmpdir) / "data"
|
||||||
|
data_dir.mkdir()
|
||||||
|
(data_dir / "locales-overrides.json").write_text("{oops", encoding="utf-8")
|
||||||
|
settings = _settings(TARIFFS_CONFIG_PATH=str(Path(tmpdir) / "absent.json"))
|
||||||
|
with patch.object(health, "APP_ROOT", Path(tmpdir)):
|
||||||
|
alerts = health.config_file_alerts(settings)
|
||||||
|
|
||||||
|
self.assertIn("locale_overrides_invalid", _alert_ids(alerts))
|
||||||
|
|
||||||
|
def test_valid_files_produce_no_alerts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
data_dir = Path(tmpdir) / "data"
|
||||||
|
data_dir.mkdir()
|
||||||
|
(data_dir / "locales-overrides.json").write_text("{}", encoding="utf-8")
|
||||||
|
settings = _settings(TARIFFS_CONFIG_PATH=str(Path(tmpdir) / "absent.json"))
|
||||||
|
with patch.object(health, "APP_ROOT", Path(tmpdir)):
|
||||||
|
alerts = health.config_file_alerts(settings)
|
||||||
|
|
||||||
|
self.assertNotIn("tariffs_config_invalid", _alert_ids(alerts))
|
||||||
|
self.assertNotIn("locale_overrides_invalid", _alert_ids(alerts))
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentProviderAlertsTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def _spec(
|
||||||
|
spec_id,
|
||||||
|
*,
|
||||||
|
enabled=True,
|
||||||
|
configured=True,
|
||||||
|
webhook_requires_base_url=False,
|
||||||
|
service_key=None,
|
||||||
|
):
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=spec_id,
|
||||||
|
label=spec_id.title(),
|
||||||
|
service_key=service_key or f"{spec_id}_service",
|
||||||
|
webhook_requires_base_url=webhook_requires_base_url,
|
||||||
|
is_effectively_enabled=lambda settings: enabled,
|
||||||
|
is_service_configured=lambda app: configured,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enabled_but_unconfigured_provider_reported(self):
|
||||||
|
specs = [self._spec("wata", configured=False)]
|
||||||
|
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
|
||||||
|
alerts = health.payment_provider_alerts(_settings(), app={})
|
||||||
|
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["provider_not_configured:wata"])
|
||||||
|
self.assertEqual(alerts[0].message_key, "provider_not_configured")
|
||||||
|
self.assertEqual(alerts[0].params["provider"], "Wata")
|
||||||
|
|
||||||
|
def test_webhook_provider_without_base_url_reported(self):
|
||||||
|
specs = [self._spec("yookassa", webhook_requires_base_url=True)]
|
||||||
|
settings = _settings(WEBHOOK_BASE_URL=None)
|
||||||
|
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
|
||||||
|
alerts = health.payment_provider_alerts(settings, app={})
|
||||||
|
|
||||||
|
self.assertIn("provider_webhook_needs_base_url:yookassa", _alert_ids(alerts))
|
||||||
|
|
||||||
|
def test_no_enabled_providers_reported_as_warning(self):
|
||||||
|
specs = [self._spec("wata", enabled=False)]
|
||||||
|
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
|
||||||
|
alerts = health.payment_provider_alerts(_settings(), app={})
|
||||||
|
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["no_payment_methods"])
|
||||||
|
self.assertEqual(alerts[0].severity, "warning")
|
||||||
|
|
||||||
|
def test_configured_enabled_provider_produces_no_alerts(self):
|
||||||
|
specs = [self._spec("wata")]
|
||||||
|
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
|
||||||
|
alerts = health.payment_provider_alerts(_settings(), app={})
|
||||||
|
|
||||||
|
self.assertEqual(alerts, [])
|
||||||
|
|
||||||
|
def test_shared_service_reported_once(self):
|
||||||
|
specs = [
|
||||||
|
self._spec("platega", configured=False, service_key="platega_service"),
|
||||||
|
self._spec("platega_crypto", configured=False, service_key="platega_service"),
|
||||||
|
]
|
||||||
|
with patch("bot.payment_providers.iter_provider_specs", return_value=specs):
|
||||||
|
alerts = health.payment_provider_alerts(_settings(), app={})
|
||||||
|
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["provider_not_configured:platega"])
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsAlertsTests(unittest.TestCase):
|
||||||
|
def test_clean_settings_produce_no_alerts(self):
|
||||||
|
self.assertEqual(health.settings_alerts(_settings()), [])
|
||||||
|
|
||||||
|
def test_missing_mini_app_url_reported(self):
|
||||||
|
alerts = health.settings_alerts(_settings(SUBSCRIPTION_MINI_APP_URL=None))
|
||||||
|
self.assertIn("mini_app_url_missing", _alert_ids(alerts))
|
||||||
|
|
||||||
|
def test_http_mini_app_url_reported_as_error(self):
|
||||||
|
alerts = health.settings_alerts(
|
||||||
|
_settings(SUBSCRIPTION_MINI_APP_URL="http://shop.example.com")
|
||||||
|
)
|
||||||
|
ids = _alert_ids(alerts)
|
||||||
|
self.assertIn("mini_app_url_not_https", ids)
|
||||||
|
self.assertEqual(alerts[ids.index("mini_app_url_not_https")].severity, "error")
|
||||||
|
|
||||||
|
def test_missing_redis_reported(self):
|
||||||
|
alerts = health.settings_alerts(_settings(REDIS_URL=None))
|
||||||
|
self.assertIn("redis_not_configured", _alert_ids(alerts))
|
||||||
|
|
||||||
|
def test_partial_smtp_reported(self):
|
||||||
|
alerts = health.settings_alerts(_settings(SMTP_USERNAME="mailer"))
|
||||||
|
self.assertIn("smtp_incomplete", _alert_ids(alerts))
|
||||||
|
|
||||||
|
def test_complete_smtp_not_reported(self):
|
||||||
|
alerts = health.settings_alerts(
|
||||||
|
_settings(SMTP_USERNAME="mailer", email_auth_configured=True)
|
||||||
|
)
|
||||||
|
self.assertNotIn("smtp_incomplete", _alert_ids(alerts))
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyAlertsTests(unittest.TestCase):
|
||||||
|
def test_untrusted_proxy_reported(self):
|
||||||
|
request = SimpleNamespace(
|
||||||
|
remote="203.0.113.50",
|
||||||
|
headers={"X-Forwarded-For": "198.51.100.7"},
|
||||||
|
)
|
||||||
|
alerts = health.proxy_alerts(request, _settings())
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["proxy_not_trusted"])
|
||||||
|
|
||||||
|
def test_trusted_proxy_not_reported(self):
|
||||||
|
request = SimpleNamespace(
|
||||||
|
remote="172.18.0.5",
|
||||||
|
headers={"X-Forwarded-For": "198.51.100.7"},
|
||||||
|
)
|
||||||
|
self.assertEqual(health.proxy_alerts(request, _settings()), [])
|
||||||
|
|
||||||
|
def test_direct_request_not_reported(self):
|
||||||
|
request = SimpleNamespace(remote="203.0.113.50", headers={})
|
||||||
|
self.assertEqual(health.proxy_alerts(request, _settings()), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramAlertsTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
@staticmethod
|
||||||
|
def _webhook_info(**overrides):
|
||||||
|
base = {
|
||||||
|
"url": "https://shop.example.com/tg/webhook",
|
||||||
|
"last_error_date": None,
|
||||||
|
"last_error_message": None,
|
||||||
|
"pending_update_count": 0,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return SimpleNamespace(**base)
|
||||||
|
|
||||||
|
async def test_healthy_webhook_produces_no_alerts(self):
|
||||||
|
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=self._webhook_info()))
|
||||||
|
self.assertEqual(await health.telegram_alerts(bot, _settings()), [])
|
||||||
|
|
||||||
|
async def test_missing_webhook_reported_as_error(self):
|
||||||
|
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=self._webhook_info(url="")))
|
||||||
|
alerts = await health.telegram_alerts(bot, _settings())
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["telegram_webhook_missing"])
|
||||||
|
self.assertEqual(alerts[0].severity, "error")
|
||||||
|
|
||||||
|
async def test_webhook_mismatch_reported(self):
|
||||||
|
bot = SimpleNamespace(
|
||||||
|
get_webhook_info=AsyncMock(
|
||||||
|
return_value=self._webhook_info(url="https://other.example.com/tg/webhook")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
alerts = await health.telegram_alerts(bot, _settings())
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["telegram_webhook_mismatch"])
|
||||||
|
|
||||||
|
async def test_recent_delivery_error_reported(self):
|
||||||
|
info = self._webhook_info(
|
||||||
|
last_error_date=datetime.now(timezone.utc),
|
||||||
|
last_error_message="SSL error",
|
||||||
|
)
|
||||||
|
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info))
|
||||||
|
alerts = await health.telegram_alerts(bot, _settings())
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["telegram_webhook_error"])
|
||||||
|
self.assertEqual(alerts[0].params["error"], "SSL error")
|
||||||
|
|
||||||
|
async def test_stale_delivery_error_not_reported(self):
|
||||||
|
info = self._webhook_info(last_error_date=time.time() - 7200)
|
||||||
|
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info))
|
||||||
|
self.assertEqual(await health.telegram_alerts(bot, _settings()), [])
|
||||||
|
|
||||||
|
async def test_pending_updates_reported(self):
|
||||||
|
info = self._webhook_info(pending_update_count=500)
|
||||||
|
bot = SimpleNamespace(get_webhook_info=AsyncMock(return_value=info))
|
||||||
|
alerts = await health.telegram_alerts(bot, _settings())
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["telegram_webhook_pending"])
|
||||||
|
|
||||||
|
async def test_unauthorized_token_reported_as_error(self):
|
||||||
|
class TelegramUnauthorizedError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
bot = SimpleNamespace(
|
||||||
|
get_webhook_info=AsyncMock(side_effect=TelegramUnauthorizedError("401"))
|
||||||
|
)
|
||||||
|
alerts = await health.telegram_alerts(bot, _settings())
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["bot_token_invalid"])
|
||||||
|
|
||||||
|
async def test_generic_api_error_reported_as_warning(self):
|
||||||
|
bot = SimpleNamespace(get_webhook_info=AsyncMock(side_effect=OSError("boom")))
|
||||||
|
alerts = await health.telegram_alerts(bot, _settings())
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["telegram_api_error"])
|
||||||
|
self.assertEqual(alerts[0].severity, "warning")
|
||||||
|
|
||||||
|
|
||||||
|
class PanelAlertsTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_unconfigured_panel_reported(self):
|
||||||
|
settings = _settings(PANEL_API_URL=None, PANEL_API_KEY=None)
|
||||||
|
alerts = await health.panel_alerts(None, settings)
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["panel_api_not_configured"])
|
||||||
|
|
||||||
|
async def test_unreachable_panel_reported(self):
|
||||||
|
panel_service = SimpleNamespace(get_system_stats=AsyncMock(return_value=None))
|
||||||
|
alerts = await health.panel_alerts(panel_service, _settings())
|
||||||
|
self.assertEqual(_alert_ids(alerts), ["panel_api_unreachable"])
|
||||||
|
|
||||||
|
async def test_healthy_panel_produces_no_alerts(self):
|
||||||
|
panel_service = SimpleNamespace(get_system_stats=AsyncMock(return_value={"cpu": 1}))
|
||||||
|
self.assertEqual(await health.panel_alerts(panel_service, _settings()), [])
|
||||||
|
|
||||||
|
|
||||||
|
class CollectAlertsTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_collect_sorts_errors_first_and_serializes(self):
|
||||||
|
settings = _settings()
|
||||||
|
request = SimpleNamespace(app={"settings": settings}, headers={}, remote="127.0.0.1")
|
||||||
|
warning = health.ConfigAlert(id="warn_alert", severity="warning", sections=("settings",))
|
||||||
|
error = health.ConfigAlert(id="error_alert", severity="error", sections=("backups",))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(health, "local_alerts", return_value=[warning, error]),
|
||||||
|
patch.object(health, "network_alerts", AsyncMock(return_value=[])),
|
||||||
|
):
|
||||||
|
payload = await health.collect_config_alerts(request)
|
||||||
|
|
||||||
|
self.assertEqual([item["id"] for item in payload], ["error_alert", "warn_alert"])
|
||||||
|
self.assertEqual(payload[0]["message_key"], "error_alert")
|
||||||
|
self.assertEqual(payload[0]["sections"], ["backups"])
|
||||||
|
|
||||||
|
async def test_network_alerts_cached_between_calls(self):
|
||||||
|
settings = _settings()
|
||||||
|
app = {"settings": settings, "bot": None, "panel_service": None}
|
||||||
|
health._network_cache.clear()
|
||||||
|
|
||||||
|
with patch.object(health, "panel_alerts", AsyncMock(return_value=[])) as panel_mock:
|
||||||
|
await health.network_alerts(app, settings)
|
||||||
|
await health.network_alerts(app, settings)
|
||||||
|
self.assertEqual(panel_mock.await_count, 1)
|
||||||
|
await health.network_alerts(app, settings, refresh=True)
|
||||||
|
self.assertEqual(panel_mock.await_count, 2)
|
||||||
|
health._network_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class HealthLocaleKeysTests(unittest.TestCase):
|
||||||
|
def test_every_message_key_has_locale_entries(self):
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
for language in ("ru", "en"):
|
||||||
|
messages = json.loads(
|
||||||
|
(root / "locales" / f"{language}.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
for suffix in ("title", "refresh", *health.ALL_MESSAGE_KEYS):
|
||||||
|
self.assertIn(
|
||||||
|
f"admin_health_{suffix}",
|
||||||
|
messages,
|
||||||
|
f"locales/{language}.json is missing admin_health_{suffix}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_alert_ids_used_by_checks_are_known_message_keys(self):
|
||||||
|
known = set(health.ALL_MESSAGE_KEYS)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
local = health.data_dir_alerts(_settings(), app_root=Path(tmpdir))
|
||||||
|
local += health.settings_alerts(_settings(SUBSCRIPTION_MINI_APP_URL=None, REDIS_URL=None))
|
||||||
|
for alert in local:
|
||||||
|
self.assertIn(alert.message_key or alert.id, known)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user