From 8fdc70fdc366e43658f13ccf0dd9fffb2d9dceb7 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 28 Sep 2025 06:03:11 +0300 Subject: [PATCH] Handle optional RemnaWave dependencies in web API --- app/webapi/routes/remnawave.py | 86 +++++++++++++++++++++++----------- docs/web-admin-integration.md | 18 +++++-- 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index 624648cb..35bfc0c3 100644 --- a/app/webapi/routes/remnawave.py +++ b/app/webapi/routes/remnawave.py @@ -1,13 +1,11 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, TYPE_CHECKING from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession -from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService - from ..dependencies import get_db_session, require_api_token from ..schemas.remnawave import ( RemnaWaveConnectionStatus, @@ -31,11 +29,41 @@ from ..schemas.remnawave import ( RemnaWaveUserTrafficResponse, ) +try: # pragma: no cover - импорт может не работать без optional-зависимостей + from app.services.remnawave_service import ( # type: ignore + RemnaWaveConfigurationError, + RemnaWaveService, + ) +except Exception: # pragma: no cover - при ошибке импорта скрываем функционал + RemnaWaveConfigurationError = None # type: ignore[assignment] + RemnaWaveService = None # type: ignore[assignment] + +if TYPE_CHECKING: # pragma: no cover - только для типов в IDE + from app.services.remnawave_service import RemnaWaveService as RemnaWaveServiceType +else: + RemnaWaveServiceType = Any + router = APIRouter() -def _ensure_service_configured(service: RemnaWaveService) -> None: +def _get_service() -> "RemnaWaveServiceType": + if RemnaWaveService is None: # pragma: no cover - зависимость не доступна + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="RemnaWave сервис недоступен", + ) + + return RemnaWaveService() + + +def _ensure_service_configured(service: "RemnaWaveServiceType") -> None: + if RemnaWaveService is None: # pragma: no cover - зависимость не доступна + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="RemnaWave сервис недоступен", + ) + if not service.is_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -74,7 +102,7 @@ def _parse_last_updated(value: Any) -> Optional[datetime]: async def get_remnawave_status( _: Any = Security(require_api_token), ) -> RemnaWaveStatusResponse: - service = RemnaWaveService() + service = _get_service() connection_info: Optional[RemnaWaveConnectionStatus] = None connection_result = await service.test_api_connection() @@ -93,7 +121,7 @@ async def get_remnawave_status( async def get_system_statistics( _: Any = Security(require_api_token), ) -> RemnaWaveSystemStatsResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.get_system_statistics() @@ -108,7 +136,7 @@ async def get_system_statistics( async def list_nodes( _: Any = Security(require_api_token), ) -> RemnaWaveNodeListResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) nodes = await service.get_all_nodes() @@ -120,7 +148,7 @@ async def list_nodes( async def get_nodes_realtime_usage( _: Any = Security(require_api_token), ) -> List[Dict[str, Any]]: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) return await service.get_nodes_realtime_usage() @@ -130,7 +158,7 @@ async def get_node_details( node_uuid: str, _: Any = Security(require_api_token), ) -> RemnaWaveNode: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) node = await service.get_node_details(node_uuid) @@ -144,7 +172,7 @@ async def get_node_statistics( node_uuid: str, _: Any = Security(require_api_token), ) -> RemnaWaveNodeStatisticsResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.get_node_statistics(node_uuid) @@ -171,7 +199,7 @@ async def get_node_usage_range( end: Optional[datetime] = Query(default=None), _: Any = Security(require_api_token), ) -> RemnaWaveNodeUsageResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) end_dt = end or datetime.utcnow() @@ -190,7 +218,7 @@ async def manage_node( payload: RemnaWaveNodeActionRequest, _: Any = Security(require_api_token), ) -> RemnaWaveNodeActionResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) success = await service.manage_node(node_uuid, payload.action) @@ -212,7 +240,7 @@ async def manage_node( async def restart_all_nodes( _: Any = Security(require_api_token), ) -> RemnaWaveNodeActionResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) success = await service.restart_all_nodes() @@ -224,7 +252,7 @@ async def restart_all_nodes( async def list_squads( _: Any = Security(require_api_token), ) -> RemnaWaveSquadListResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) squads = await service.get_all_squads() @@ -237,7 +265,7 @@ async def get_squad_details( squad_uuid: str, _: Any = Security(require_api_token), ) -> RemnaWaveSquad: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) squad = await service.get_squad_details(squad_uuid) @@ -251,7 +279,7 @@ async def create_squad( payload: RemnaWaveSquadCreateRequest, _: Any = Security(require_api_token), ) -> RemnaWaveOperationResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) success = await service.create_squad(payload.name, payload.inbound_uuids) @@ -265,7 +293,7 @@ async def update_squad( payload: RemnaWaveSquadUpdateRequest, _: Any = Security(require_api_token), ) -> RemnaWaveOperationResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) success = False @@ -288,7 +316,7 @@ async def squad_actions( payload: RemnaWaveSquadActionRequest, _: Any = Security(require_api_token), ) -> RemnaWaveOperationResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) action = payload.action @@ -322,7 +350,7 @@ async def squad_actions( async def list_inbounds( _: Any = Security(require_api_token), ) -> RemnaWaveInboundsResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) inbounds = await service.get_all_inbounds() @@ -334,7 +362,7 @@ async def get_user_traffic( telegram_id: int, _: Any = Security(require_api_token), ) -> RemnaWaveUserTrafficResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.get_user_traffic_stats(telegram_id) @@ -350,15 +378,17 @@ async def sync_from_panel( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) try: stats = await service.sync_users_from_panel(db, payload.mode) detail = "Синхронизация из панели выполнена" return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats) - except RemnaWaveConfigurationError as exc: - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc + except Exception as exc: # pragma: no cover - точный тип зависит от импорта + if RemnaWaveConfigurationError and isinstance(exc, RemnaWaveConfigurationError): + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc + raise @router.post("/sync/to-panel", response_model=RemnaWaveGenericSyncResponse) @@ -366,7 +396,7 @@ async def sync_to_panel( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.sync_users_to_panel(db) @@ -379,7 +409,7 @@ async def validate_and_fix_subscriptions( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.validate_and_fix_subscriptions(db) @@ -392,7 +422,7 @@ async def cleanup_orphaned_subscriptions( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.cleanup_orphaned_subscriptions(db) @@ -405,7 +435,7 @@ async def sync_subscription_statuses( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) stats = await service.sync_subscription_statuses(db) @@ -418,7 +448,7 @@ async def get_sync_recommendations( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> RemnaWaveGenericSyncResponse: - service = RemnaWaveService() + service = _get_service() _ensure_service_configured(service) data = await service.get_sync_recommendations(db) diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 31539a9b..273c677a 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -137,13 +137,25 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `GET` | `/remnawave/status` | Проверка конфигурации и доступности RemnaWave API. | | `GET` | `/remnawave/system` | Агрегированная статистика по пользователям, нодам и трафику. | | `GET` | `/remnawave/nodes` | Список нод и их текущее состояние. | +| `GET` | `/remnawave/nodes/realtime` | Текущая загрузка нод (realtime-метрики RemnaWave). | +| `GET` | `/remnawave/nodes/{uuid}` | Детальная информация по конкретной ноде. | +| `GET` | `/remnawave/nodes/{uuid}/statistics` | Агрегированная статистика и история нагрузок по ноде. | +| `GET` | `/remnawave/nodes/{uuid}/usage` | История использования ноды пользователями за выбранный период. | | `POST` | `/remnawave/nodes/{uuid}/actions` | Включение, отключение или перезапуск ноды. | -| `GET` | `/remnawave/squads` | Управление сквадами и их инбаундами. | +| `POST` | `/remnawave/nodes/restart` | Массовый перезапуск всех нод в RemnaWave. | +| `GET` | `/remnawave/squads` | Список внутренних сквадов с составом и статистикой. | +| `GET` | `/remnawave/squads/{uuid}` | Детали выбранного сквада. | +| `POST` | `/remnawave/squads` | Создание нового сквада и привязка inbounds. | +| `PATCH` | `/remnawave/squads/{uuid}` | Обновление имени или состава inbounds сквада. | +| `POST` | `/remnawave/squads/{uuid}/actions` | Массовые операции: добавить/удалить всех, переименовать, обновить inbounds, удалить. | +| `GET` | `/remnawave/inbounds` | Список доступных inbounds в панели RemnaWave. | +| `GET` | `/remnawave/users/{telegram_id}/traffic` | Использование трафика конкретного пользователя RemnaWave. | | `POST` | `/remnawave/sync/from-panel` | Синхронизация пользователей и подписок из панели в бота. | | `POST` | `/remnawave/sync/to-panel` | Обратная синхронизация данных бота в панель. | | `POST` | `/remnawave/sync/subscriptions/validate` | Проверка и восстановление подписок в RemnaWave. | - -> Остальные операции (история трафика, рекомендации по синхронизации, очистка подписок и т. д.) также доступны в этом разделе Swagger UI. +| `POST` | `/remnawave/sync/subscriptions/cleanup` | Очистка «осиротевших» подписок и пользователей в RemnaWave. | +| `POST` | `/remnawave/sync/subscriptions/statuses` | Приведение статусов подписок в боте и панели к единому виду. | +| `GET` | `/remnawave/sync/recommendations` | Рекомендации по синхронизации: что добавить, обновить или удалить. | > Все списковые эндпоинты поддерживают пагинацию (`limit`, `offset`) и фильтры, описанные в OpenAPI спецификации. Если `WEB_API_DOCS_ENABLED=true`, документация доступна по `/docs`. В ответах `/settings` поле `choices` всегда массив: пустой список означает отсутствие предопределённых значений.