Merge pull request #530 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components-1yj53d

Add Remnawave component management API
This commit is contained in:
Egor
2025-09-28 04:53:36 +03:00
committed by GitHub
6 changed files with 471 additions and 5 deletions

View File

@@ -477,6 +477,47 @@ class RemnaWaveAPI:
return response['response']['eventSent']
async def list_components(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/components')
components = response.get('response')
if isinstance(components, dict):
if 'components' in components:
components = components['components']
elif 'items' in components:
components = components['items']
if not components:
return []
if isinstance(components, list):
return components
return [components]
async def get_component(self, component_id: str) -> Dict[str, Any]:
response = await self._make_request('GET', f'/api/components/{component_id}')
component = response.get('response')
if isinstance(component, dict):
return component
return {}
async def perform_component_action(
self,
component_id: str,
action: str,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
endpoint = f'/api/components/{component_id}/actions/{action}'
response = await self._make_request('POST', endpoint, data=payload)
return response.get('response') or {}
async def update_component(self, component_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
response = await self._make_request('PATCH', f'/api/components/{component_id}', payload)
return response.get('response') or {}
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
return self._parse_subscription_info(response['response'])

View File

@@ -254,7 +254,7 @@ class RemnaWaveService:
try:
if not bandwidth_str or bandwidth_str == '0 B' or bandwidth_str == '0':
return 0
bandwidth_str = bandwidth_str.replace(' ', '').upper()
units = {
@@ -291,9 +291,74 @@ class RemnaWaveService:
except Exception as e:
logger.error(f"Ошибка парсинга строки трафика '{bandwidth_str}': {e}")
return 0
def _normalize_component(self, component: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(component, dict):
return {}
metadata = dict(component)
component_uuid = (
metadata.get("uuid")
or metadata.get("id")
or metadata.get("slug")
or metadata.get("name")
)
installed_version = (
metadata.get("installedVersion")
or metadata.get("installed_version")
or metadata.get("version")
)
latest_version = (
metadata.get("latestVersion")
or metadata.get("latest_version")
or metadata.get("availableVersion")
)
status = metadata.get("status") or metadata.get("state")
is_installed = metadata.get("isInstalled")
if is_installed is None:
for key in ("installed", "is_installed"):
if key in metadata:
is_installed = metadata[key]
break
is_enabled = metadata.get("isEnabled")
if is_enabled is None:
for key in ("enabled", "is_enabled"):
if key in metadata:
is_enabled = metadata[key]
break
tags = metadata.get("tags") or metadata.get("labels") or metadata.get("keywords")
if isinstance(tags, str):
tags = [tags]
elif isinstance(tags, list):
tags = [str(tag) for tag in tags]
else:
tags = []
return {
"uuid": component_uuid,
"name": metadata.get("name") or metadata.get("title") or component_uuid,
"slug": metadata.get("slug") or metadata.get("id"),
"status": status,
"installed_version": installed_version,
"latest_version": latest_version,
"category": metadata.get("category") or metadata.get("type"),
"description": metadata.get("description") or metadata.get("details"),
"is_installed": is_installed,
"is_enabled": is_enabled,
"installed_at": metadata.get("installedAt") or metadata.get("installed_at"),
"updated_at": metadata.get("updatedAt") or metadata.get("updated_at"),
"tags": tags,
"metadata": metadata,
}
async def get_all_nodes(self) -> List[Dict[str, Any]]:
try:
async with self.get_api_client() as api:
nodes = await api.get_all_nodes()
@@ -337,7 +402,7 @@ class RemnaWaveService:
try:
async with self.get_api_client() as api:
node = await api.get_node_by_uuid(node_uuid)
if not node:
return None
@@ -358,7 +423,163 @@ class RemnaWaveService:
except Exception as e:
logger.error(f"Ошибка получения информации о ноде {node_uuid}: {e}")
return None
async def list_components(self) -> List[Dict[str, Any]]:
try:
async with self.get_api_client() as api:
raw_components = await api.list_components()
normalized = [
self._normalize_component(component)
for component in raw_components
if isinstance(component, dict)
]
logger.info(f"✅ Получено {len(normalized)} компонентов из Remnawave")
return normalized
except RemnaWaveConfigurationError:
raise
except RemnaWaveAPIError:
raise
except Exception as error:
logger.error(f"Ошибка получения списка компонентов Remnawave: {error}")
raise RemnaWaveAPIError(
"Не удалось получить список компонентов",
response_data={"details": str(error)},
)
async def get_component_details(self, component_id: str) -> Optional[Dict[str, Any]]:
try:
async with self.get_api_client() as api:
raw_component = await api.get_component(component_id)
if not raw_component:
return None
return self._normalize_component(raw_component)
except RemnaWaveConfigurationError:
raise
except RemnaWaveAPIError as error:
if error.status_code == 404:
logger.info(f"Компонент Remnawave {component_id} не найден")
return None
raise
except Exception as err:
logger.error(f"Ошибка получения компонента Remnawave {component_id}: {err}")
raise RemnaWaveAPIError(
"Не удалось получить данные компонента",
response_data={"details": str(err)},
)
async def perform_component_action(
self,
component_id: str,
action: str,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
if not action or not re.fullmatch(r"[A-Za-z0-9_-]+", action):
raise ValueError("Недопустимое действие для компонента")
try:
async with self.get_api_client() as api:
raw_result = await api.perform_component_action(component_id, action, payload)
except RemnaWaveConfigurationError:
raise
except RemnaWaveAPIError:
raise
except Exception as err:
logger.error(
f"Ошибка выполнения действия '{action}' для компонента {component_id}: {err}"
)
raise RemnaWaveAPIError(
"Не удалось выполнить действие с компонентом",
response_data={"details": str(err)},
)
details: Dict[str, Any]
if isinstance(raw_result, dict):
details = raw_result
elif raw_result is None:
details = {}
else:
details = {"response": raw_result}
component_data = details.get("component") or details.get("data") or details.get("response")
normalized_component = (
self._normalize_component(component_data)
if isinstance(component_data, dict)
else None
)
success = details.get("success") if isinstance(details.get("success"), bool) else None
if success is None:
success = True
message = details.get("message") if isinstance(details.get("message"), str) else None
if not message:
message = f"Действие '{action}' выполнено"
return {
"success": bool(success),
"message": message,
"details": details,
"component": normalized_component,
}
async def update_component_settings(
self, component_id: str, payload: Dict[str, Any]
) -> Dict[str, Any]:
if not payload:
raise ValueError("Данные для обновления компонента не могут быть пустыми")
try:
async with self.get_api_client() as api:
raw_result = await api.update_component(component_id, payload)
except RemnaWaveConfigurationError:
raise
except RemnaWaveAPIError:
raise
except Exception as err:
logger.error(f"Ошибка обновления компонента Remnawave {component_id}: {err}")
raise RemnaWaveAPIError(
"Не удалось обновить компонент",
response_data={"details": str(err)},
)
details: Dict[str, Any]
if isinstance(raw_result, dict):
details = raw_result
elif raw_result is None:
details = {}
else:
details = {"response": raw_result}
component_data = details.get("component") or details.get("response")
normalized_component = (
self._normalize_component(component_data)
if isinstance(component_data, dict)
else None
)
success = details.get("success") if isinstance(details.get("success"), bool) else None
if success is None:
success = True
message = details.get("message") if isinstance(details.get("message"), str) else None
if not message:
message = "Настройки компонента обновлены"
return {
"success": bool(success),
"message": message,
"details": details,
"component": normalized_component,
}
async def manage_node(self, node_uuid: str, action: str) -> bool:
try:
async with self.get_api_client() as api:

View File

@@ -10,6 +10,7 @@ from .routes import (
config,
health,
promo_groups,
remnawave,
stats,
subscriptions,
tickets,
@@ -51,6 +52,7 @@ def create_web_api_app() -> FastAPI:
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
return app

View File

@@ -0,0 +1,152 @@
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, HTTPException, Security, status
from app.external.remnawave_api import RemnaWaveAPIError
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
from ..dependencies import require_api_token
from ..schemas.remnawave import (
ComponentActionRequest,
ComponentActionResponse,
ComponentInfo,
ComponentListResponse,
ComponentUpdateRequest,
)
router = APIRouter()
def _map_api_error(error: RemnaWaveAPIError) -> HTTPException:
status_code = error.status_code or status.HTTP_502_BAD_GATEWAY
if status_code < 400 or status_code >= 600:
status_code = status.HTTP_502_BAD_GATEWAY
return HTTPException(status_code=status_code, detail=error.message)
@router.get("/components", response_model=ComponentListResponse)
async def list_components(
_: object = Security(require_api_token),
) -> ComponentListResponse:
service = RemnaWaveService()
try:
components = await service.list_components()
except RemnaWaveConfigurationError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)
) from error
except RemnaWaveAPIError as error:
raise _map_api_error(error)
items = [ComponentInfo(**component) for component in components]
return ComponentListResponse(items=items, total=len(items))
@router.get("/components/{component_id}", response_model=ComponentInfo)
async def get_component(
component_id: str,
_: object = Security(require_api_token),
) -> ComponentInfo:
service = RemnaWaveService()
try:
component = await service.get_component_details(component_id)
except RemnaWaveConfigurationError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)
) from error
except RemnaWaveAPIError as error:
raise _map_api_error(error)
if not component:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Компонент не найден")
return ComponentInfo(**component)
@router.post(
"/components/{component_id}/actions/{action}",
response_model=ComponentActionResponse,
)
async def perform_component_action(
component_id: str,
action: str,
request: Optional[ComponentActionRequest] = None,
_: object = Security(require_api_token),
) -> ComponentActionResponse:
service = RemnaWaveService()
payload = request.payload if request else None
try:
result = await service.perform_component_action(component_id, action, payload)
except RemnaWaveConfigurationError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)
) from error
except RemnaWaveAPIError as error:
raise _map_api_error(error)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
component_data = result.get("component")
component = ComponentInfo(**component_data) if isinstance(component_data, dict) else None
details = result.get("details")
if details is not None and not isinstance(details, dict):
details = {"response": details}
return ComponentActionResponse(
success=bool(result.get("success", True)),
message=result.get("message"),
component=component,
details=details,
)
@router.patch(
"/components/{component_id}",
response_model=ComponentActionResponse,
)
async def update_component(
component_id: str,
request: ComponentUpdateRequest,
_: object = Security(require_api_token),
) -> ComponentActionResponse:
service = RemnaWaveService()
if not request.payload:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Поле payload не может быть пустым",
)
try:
result = await service.update_component_settings(component_id, request.payload)
except RemnaWaveConfigurationError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)
) from error
except RemnaWaveAPIError as error:
raise _map_api_error(error)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
component_data = result.get("component")
component = ComponentInfo(**component_data) if isinstance(component_data, dict) else None
details = result.get("details")
if details is not None and not isinstance(details, dict):
details = {"response": details}
return ComponentActionResponse(
success=bool(result.get("success", True)),
message=result.get("message"),
component=component,
details=details,
)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
class ComponentInfo(BaseModel):
"""Normalized representation of a Remnawave component."""
model_config = ConfigDict(extra="allow", populate_by_name=True)
uuid: Optional[str] = None
name: Optional[str] = None
slug: Optional[str] = None
status: Optional[str] = None
installed_version: Optional[str] = Field(default=None, alias="installedVersion")
latest_version: Optional[str] = Field(default=None, alias="latestVersion")
category: Optional[str] = None
description: Optional[str] = None
is_installed: Optional[bool] = Field(default=None, alias="isInstalled")
is_enabled: Optional[bool] = Field(default=None, alias="isEnabled")
installed_at: Optional[str] = Field(default=None, alias="installedAt")
updated_at: Optional[str] = Field(default=None, alias="updatedAt")
tags: Optional[List[str]] = None
metadata: Optional[Dict[str, Any]] = None
class ComponentListResponse(BaseModel):
items: List[ComponentInfo]
total: int
class ComponentActionRequest(BaseModel):
payload: Optional[Dict[str, Any]] = None
class ComponentUpdateRequest(BaseModel):
payload: Dict[str, Any] = Field(default_factory=dict)
class ComponentActionResponse(BaseModel):
success: bool
message: Optional[str] = None
component: Optional[ComponentInfo] = None
details: Optional[Dict[str, Any]] = None

View File

@@ -126,6 +126,10 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
| `POST` | `/promo-groups` | Создать промо-группу.
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
| `GET` | `/remnawave/components` | Список компонентов Remnawave.
| `GET` | `/remnawave/components/{id}` | Детали конкретного компонента.
| `POST` | `/remnawave/components/{id}/actions/{action}` | Выполнить действие над компонентом (install, update, restart и т.д.).
| `PATCH` | `/remnawave/components/{id}` | Обновить настройки компонента.
| `GET` | `/tokens` | Управление токенами доступа.
> Все списковые эндпоинты поддерживают пагинацию (`limit`, `offset`) и фильтры, описанные в OpenAPI спецификации. Если `WEB_API_DOCS_ENABLED=true`, документация доступна по `/docs`. В ответах `/settings` поле `choices` всегда массив: пустой список означает отсутствие предопределённых значений.