diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py
index f1dccd7f..88b52008 100644
--- a/app/database/crud/server_squad.py
+++ b/app/database/crud/server_squad.py
@@ -17,7 +17,14 @@ from sqlalchemy import (
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
+from app.database.models import (
+ PromoGroup,
+ ServerSquad,
+ SubscriptionServer,
+ Subscription,
+ SubscriptionStatus,
+ User,
+)
logger = logging.getLogger(__name__)
@@ -495,10 +502,10 @@ def _extract_country_code(original_name: str) -> Optional[str]:
async def get_server_statistics(db: AsyncSession) -> dict:
-
+
total_result = await db.execute(select(func.count(ServerSquad.id)))
total_servers = total_result.scalar()
-
+
available_result = await db.execute(
select(func.count(ServerSquad.id))
.where(ServerSquad.is_available == True)
@@ -537,6 +544,25 @@ async def get_server_statistics(db: AsyncSession) -> dict:
'total_revenue_rubles': total_revenue_kopeks / 100
}
+
+async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int:
+ """Возвращает количество активных подписок, подключенных к указанному скваду."""
+
+ result = await db.execute(
+ select(func.count(Subscription.id)).where(
+ Subscription.status.in_(
+ [
+ SubscriptionStatus.ACTIVE.value,
+ SubscriptionStatus.TRIAL.value,
+ ]
+ ),
+ cast(Subscription.connected_squads, String).like(f'%"{squad_uuid}"%'),
+ )
+ )
+
+ return result.scalar() or 0
+
+
async def add_user_to_servers(
db: AsyncSession,
server_squad_ids: List[int]
diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py
index ec5b4629..d6167a27 100644
--- a/app/handlers/admin/remnawave.py
+++ b/app/handlers/admin/remnawave.py
@@ -1,18 +1,24 @@
import logging
+import math
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
-from app.states import SquadRenameStates, SquadCreateStates
+from app.states import SquadRenameStates, SquadCreateStates, SquadMigrationStates
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
+from app.database.crud.server_squad import (
+ count_active_users_for_squad,
+ get_all_server_squads,
+ get_server_squad_by_uuid,
+)
from app.keyboards.admin import (
get_admin_remnawave_keyboard, get_sync_options_keyboard,
get_node_management_keyboard, get_confirmation_keyboard,
get_squad_management_keyboard, get_squad_edit_keyboard
)
from app.localization.texts import get_texts
-from app.services.remnawave_service import RemnaWaveService
+from app.services.remnawave_service import RemnaWaveService, RemnaWaveConfigurationError
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_bytes, format_datetime
@@ -21,6 +27,769 @@ logger = logging.getLogger(__name__)
squad_inbound_selections = {}
squad_create_data = {}
+MIGRATION_PAGE_SIZE = 8
+
+
+def _format_migration_server_label(texts, server) -> str:
+ status = (
+ texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE", "✅ Доступен")
+ if getattr(server, "is_available", True)
+ else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE", "🚫 Недоступен")
+ )
+ return texts.t(
+ "ADMIN_SQUAD_MIGRATION_SERVER_LABEL",
+ "{name} — 👥 {users} ({status})",
+ ).format(name=server.display_name, users=server.current_users, status=status)
+
+
+def _build_migration_keyboard(
+ texts,
+ squads,
+ page: int,
+ total_pages: int,
+ stage: str,
+ *,
+ exclude_uuid: str = None,
+):
+ prefix = "admin_migration_source" if stage == "source" else "admin_migration_target"
+ rows = []
+ has_items = False
+
+ button_template = texts.t(
+ "ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON",
+ "🌍 {name} — 👥 {users} ({status})",
+ )
+
+ for squad in squads:
+ if exclude_uuid and squad.squad_uuid == exclude_uuid:
+ continue
+
+ has_items = True
+ status = (
+ texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT", "✅")
+ if getattr(squad, "is_available", True)
+ else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT", "🚫")
+ )
+ rows.append(
+ [
+ types.InlineKeyboardButton(
+ text=button_template.format(
+ name=squad.display_name,
+ users=squad.current_users,
+ status=status,
+ ),
+ callback_data=f"{prefix}_{squad.squad_uuid}",
+ )
+ ]
+ )
+
+ if total_pages > 1:
+ nav_buttons = []
+ if page > 1:
+ nav_buttons.append(
+ types.InlineKeyboardButton(
+ text="⬅️",
+ callback_data=f"{prefix}_page_{page - 1}",
+ )
+ )
+ nav_buttons.append(
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_SQUAD_MIGRATION_PAGE",
+ "Стр. {page}/{pages}",
+ ).format(page=page, pages=total_pages),
+ callback_data="admin_migration_page_info",
+ )
+ )
+ if page < total_pages:
+ nav_buttons.append(
+ types.InlineKeyboardButton(
+ text="➡️",
+ callback_data=f"{prefix}_page_{page + 1}",
+ )
+ )
+ rows.append(nav_buttons)
+
+ rows.append(
+ [
+ types.InlineKeyboardButton(
+ text=texts.CANCEL,
+ callback_data="admin_migration_cancel",
+ )
+ ]
+ )
+
+ return types.InlineKeyboardMarkup(inline_keyboard=rows), has_items
+
+
+async def _fetch_migration_page(
+ db: AsyncSession,
+ page: int,
+):
+ squads, total = await get_all_server_squads(
+ db,
+ page=max(1, page),
+ limit=MIGRATION_PAGE_SIZE,
+ )
+ total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE))
+
+ if page < 1:
+ page = 1
+ if page > total_pages:
+ page = total_pages
+ squads, total = await get_all_server_squads(
+ db,
+ page=page,
+ limit=MIGRATION_PAGE_SIZE,
+ )
+ total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE))
+
+ return squads, page, total_pages
+
+
+@admin_required
+@error_handler
+async def show_squad_migration_menu(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ texts = get_texts(db_user.language)
+
+ await state.clear()
+
+ squads, page, total_pages = await _fetch_migration_page(db, page=1)
+ keyboard, has_items = _build_migration_keyboard(
+ texts,
+ squads,
+ page,
+ total_pages,
+ "source",
+ )
+
+ message = (
+ texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов")
+ + "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE",
+ "Выберите сквад, из которого нужно переехать:",
+ )
+ )
+
+ if not has_items:
+ message += (
+ "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_NO_OPTIONS",
+ "Нет доступных сквадов. Добавьте новые или отмените операцию.",
+ )
+ )
+
+ await state.set_state(SquadMigrationStates.selecting_source)
+
+ await callback.message.edit_text(
+ message,
+ reply_markup=keyboard,
+ disable_web_page_preview=True,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def paginate_migration_source(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ if await state.get_state() != SquadMigrationStates.selecting_source:
+ await callback.answer()
+ return
+
+ try:
+ page = int(callback.data.split("_page_")[-1])
+ except (ValueError, IndexError):
+ await callback.answer()
+ return
+
+ squads, page, total_pages = await _fetch_migration_page(db, page=page)
+ texts = get_texts(db_user.language)
+ keyboard, has_items = _build_migration_keyboard(
+ texts,
+ squads,
+ page,
+ total_pages,
+ "source",
+ )
+
+ message = (
+ texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов")
+ + "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE",
+ "Выберите сквад, из которого нужно переехать:",
+ )
+ )
+
+ if not has_items:
+ message += (
+ "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_NO_OPTIONS",
+ "Нет доступных сквадов. Добавьте новые или отмените операцию.",
+ )
+ )
+
+ await callback.message.edit_text(
+ message,
+ reply_markup=keyboard,
+ disable_web_page_preview=True,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def handle_migration_source_selection(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ if await state.get_state() != SquadMigrationStates.selecting_source:
+ await callback.answer()
+ return
+
+ if "_page_" in callback.data:
+ await callback.answer()
+ return
+
+ source_uuid = callback.data.replace("admin_migration_source_", "", 1)
+
+ texts = get_texts(db_user.language)
+ server = await get_server_squad_by_uuid(db, source_uuid)
+
+ if not server:
+ await callback.answer(
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND",
+ "Сквад не найден или недоступен.",
+ ),
+ show_alert=True,
+ )
+ return
+
+ await state.update_data(
+ source_uuid=server.squad_uuid,
+ source_display=_format_migration_server_label(texts, server),
+ )
+
+ squads, page, total_pages = await _fetch_migration_page(db, page=1)
+ keyboard, has_items = _build_migration_keyboard(
+ texts,
+ squads,
+ page,
+ total_pages,
+ "target",
+ exclude_uuid=server.squad_uuid,
+ )
+
+ message = (
+ texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов")
+ + "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
+ "Источник: {source}",
+ ).format(source=_format_migration_server_label(texts, server))
+ + "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
+ "Выберите сквад, в который нужно переехать:",
+ )
+ )
+
+ if not has_items:
+ message += (
+ "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
+ "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
+ )
+ )
+
+ await state.set_state(SquadMigrationStates.selecting_target)
+
+ await callback.message.edit_text(
+ message,
+ reply_markup=keyboard,
+ disable_web_page_preview=True,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def paginate_migration_target(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ if await state.get_state() != SquadMigrationStates.selecting_target:
+ await callback.answer()
+ return
+
+ try:
+ page = int(callback.data.split("_page_")[-1])
+ except (ValueError, IndexError):
+ await callback.answer()
+ return
+
+ data = await state.get_data()
+ source_uuid = data.get("source_uuid")
+ if not source_uuid:
+ await callback.answer()
+ return
+
+ texts = get_texts(db_user.language)
+
+ squads, page, total_pages = await _fetch_migration_page(db, page=page)
+ keyboard, has_items = _build_migration_keyboard(
+ texts,
+ squads,
+ page,
+ total_pages,
+ "target",
+ exclude_uuid=source_uuid,
+ )
+
+ source_display = data.get("source_display") or source_uuid
+
+ message = (
+ texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов")
+ + "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
+ "Источник: {source}",
+ ).format(source=source_display)
+ + "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
+ "Выберите сквад, в который нужно переехать:",
+ )
+ )
+
+ if not has_items:
+ message += (
+ "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
+ "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
+ )
+ )
+
+ await callback.message.edit_text(
+ message,
+ reply_markup=keyboard,
+ disable_web_page_preview=True,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def handle_migration_target_selection(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ current_state = await state.get_state()
+ if current_state != SquadMigrationStates.selecting_target:
+ await callback.answer()
+ return
+
+ if "_page_" in callback.data:
+ await callback.answer()
+ return
+
+ data = await state.get_data()
+ source_uuid = data.get("source_uuid")
+
+ if not source_uuid:
+ await callback.answer()
+ return
+
+ target_uuid = callback.data.replace("admin_migration_target_", "", 1)
+
+ texts = get_texts(db_user.language)
+
+ if target_uuid == source_uuid:
+ await callback.answer(
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_SAME_SQUAD",
+ "Нельзя выбрать тот же сквад.",
+ ),
+ show_alert=True,
+ )
+ return
+
+ target_server = await get_server_squad_by_uuid(db, target_uuid)
+ if not target_server:
+ await callback.answer(
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND",
+ "Сквад не найден или недоступен.",
+ ),
+ show_alert=True,
+ )
+ return
+
+ source_display = data.get("source_display") or source_uuid
+
+ users_to_move = await count_active_users_for_squad(db, source_uuid)
+
+ await state.update_data(
+ target_uuid=target_server.squad_uuid,
+ target_display=_format_migration_server_label(texts, target_server),
+ migration_count=users_to_move,
+ )
+
+ await state.set_state(SquadMigrationStates.confirming)
+
+ message_lines = [
+ texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов"),
+ "",
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS",
+ "Проверьте параметры переезда:",
+ ),
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE",
+ "• Из: {source}",
+ ).format(source=source_display),
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET",
+ "• В: {target}",
+ ).format(target=_format_migration_server_label(texts, target_server)),
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT",
+ "• Пользователей к переносу: {count}",
+ ).format(count=users_to_move),
+ "",
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT",
+ "Подтвердите выполнение операции.",
+ ),
+ ]
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON",
+ "✅ Подтвердить",
+ ),
+ callback_data="admin_migration_confirm",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_SQUAD_MIGRATION_CHANGE_TARGET",
+ "🔄 Изменить сервер назначения",
+ ),
+ callback_data="admin_migration_change_target",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.CANCEL,
+ callback_data="admin_migration_cancel",
+ )
+ ],
+ ]
+ )
+
+ await callback.message.edit_text(
+ "\n".join(message_lines),
+ reply_markup=keyboard,
+ disable_web_page_preview=True,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def change_migration_target(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ data = await state.get_data()
+ source_uuid = data.get("source_uuid")
+
+ if not source_uuid:
+ await callback.answer()
+ return
+
+ await state.set_state(SquadMigrationStates.selecting_target)
+
+ texts = get_texts(db_user.language)
+ squads, page, total_pages = await _fetch_migration_page(db, page=1)
+ keyboard, has_items = _build_migration_keyboard(
+ texts,
+ squads,
+ page,
+ total_pages,
+ "target",
+ exclude_uuid=source_uuid,
+ )
+
+ source_display = data.get("source_display") or source_uuid
+
+ message = (
+ texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов")
+ + "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE",
+ "Источник: {source}",
+ ).format(source=source_display)
+ + "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_SELECT_TARGET",
+ "Выберите сквад, в который нужно переехать:",
+ )
+ )
+
+ if not has_items:
+ message += (
+ "\n\n"
+ + texts.t(
+ "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY",
+ "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
+ )
+ )
+
+ await callback.message.edit_text(
+ message,
+ reply_markup=keyboard,
+ disable_web_page_preview=True,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def confirm_squad_migration(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ current_state = await state.get_state()
+ if current_state != SquadMigrationStates.confirming:
+ await callback.answer()
+ return
+
+ data = await state.get_data()
+ source_uuid = data.get("source_uuid")
+ target_uuid = data.get("target_uuid")
+
+ if not source_uuid or not target_uuid:
+ await callback.answer()
+ return
+
+ texts = get_texts(db_user.language)
+ remnawave_service = RemnaWaveService()
+
+ await callback.answer(texts.t("ADMIN_SQUAD_MIGRATION_IN_PROGRESS", "Запускаю переезд..."))
+
+ try:
+ result = await remnawave_service.migrate_squad_users(
+ db,
+ source_uuid=source_uuid,
+ target_uuid=target_uuid,
+ )
+ except RemnaWaveConfigurationError as error:
+ message = texts.t(
+ "ADMIN_SQUAD_MIGRATION_API_ERROR",
+ "❌ RemnaWave API не настроен: {error}",
+ ).format(error=str(error))
+ reply_markup = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
+ "⬅️ В Remnawave",
+ ),
+ callback_data="admin_remnawave",
+ )
+ ]
+ ]
+ )
+ await callback.message.edit_text(message, reply_markup=reply_markup)
+ await state.clear()
+ return
+
+ source_display = data.get("source_display") or source_uuid
+ target_display = data.get("target_display") or target_uuid
+
+ if not result.get("success"):
+ error_message = result.get("message") or ""
+ error_code = result.get("error") or "unexpected"
+ message = texts.t(
+ "ADMIN_SQUAD_MIGRATION_ERROR",
+ "❌ Не удалось выполнить переезд (код: {code}). {details}",
+ ).format(code=error_code, details=error_message)
+ reply_markup = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
+ "⬅️ В Remnawave",
+ ),
+ callback_data="admin_remnawave",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_SQUAD_MIGRATION_NEW_BUTTON",
+ "🔁 Новый переезд",
+ ),
+ callback_data="admin_rw_migration",
+ )
+ ],
+ ]
+ )
+ await callback.message.edit_text(message, reply_markup=reply_markup)
+ await state.clear()
+ return
+
+ message_lines = [
+ texts.t("ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE", "✅ Переезд завершен"),
+ "",
+ texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE", "• Из: {source}").format(
+ source=source_display
+ ),
+ texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET", "• В: {target}").format(
+ target=target_display
+ ),
+ "",
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_RESULT_TOTAL",
+ "Найдено подписок: {count}",
+ ).format(count=result.get("total", 0)),
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_RESULT_UPDATED",
+ "Перенесено: {count}",
+ ).format(count=result.get("updated", 0)),
+ ]
+
+ panel_updated = result.get("panel_updated", 0)
+ panel_failed = result.get("panel_failed", 0)
+
+ if panel_updated:
+ message_lines.append(
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED",
+ "Обновлено в панели: {count}",
+ ).format(count=panel_updated)
+ )
+ if panel_failed:
+ message_lines.append(
+ texts.t(
+ "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED",
+ "Не удалось обновить в панели: {count}",
+ ).format(count=panel_failed)
+ )
+
+ reply_markup = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_SQUAD_MIGRATION_NEW_BUTTON",
+ "🔁 Новый переезд",
+ ),
+ callback_data="admin_rw_migration",
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
+ "⬅️ В Remnawave",
+ ),
+ callback_data="admin_remnawave",
+ )
+ ],
+ ]
+ )
+
+ await callback.message.edit_text(
+ "\n".join(message_lines),
+ reply_markup=reply_markup,
+ disable_web_page_preview=True,
+ )
+ await state.clear()
+
+
+@admin_required
+@error_handler
+async def cancel_squad_migration(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ texts = get_texts(db_user.language)
+ await state.clear()
+
+ message = texts.t(
+ "ADMIN_SQUAD_MIGRATION_CANCELLED",
+ "❌ Переезд отменен.",
+ )
+
+ reply_markup = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_SQUAD_MIGRATION_BACK_BUTTON",
+ "⬅️ В Remnawave",
+ ),
+ callback_data="admin_remnawave",
+ )
+ ]
+ ]
+ )
+
+ await callback.message.edit_text(message, reply_markup=reply_markup)
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def handle_migration_page_info(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ texts = get_texts(db_user.language)
+ await callback.answer(
+ texts.t("ADMIN_SQUAD_MIGRATION_PAGE_HINT", "Это текущая страница."),
+ show_alert=False,
+ )
+
@admin_required
@error_handler
async def show_remnawave_menu(
@@ -1920,6 +2689,15 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes")
dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync")
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
+ dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
+ dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
+ dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_"))
+ dp.callback_query.register(paginate_migration_target, F.data.startswith("admin_migration_target_page_"))
+ dp.callback_query.register(handle_migration_target_selection, F.data.startswith("admin_migration_target_"))
+ dp.callback_query.register(change_migration_target, F.data == "admin_migration_change_target")
+ dp.callback_query.register(confirm_squad_migration, F.data == "admin_migration_confirm")
+ dp.callback_query.register(cancel_squad_migration, F.data == "admin_migration_cancel")
+ dp.callback_query.register(handle_migration_page_info, F.data == "admin_migration_page_info")
dp.callback_query.register(show_squads_management, F.data == "admin_rw_squads")
dp.callback_query.register(show_squad_details, F.data.startswith("admin_squad_manage_"))
dp.callback_query.register(manage_squad_action, F.data.startswith("squad_add_users_"))
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 076fe30e..a1d9e3a8 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -674,6 +674,12 @@ def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_rw_squads"
)
],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_REMNAWAVE_MIGRATION", "🚚 Переезд"),
+ callback_data="admin_rw_migration"
+ )
+ ],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_REMNAWAVE_TRAFFIC", "📈 Трафик"),
diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py
index 6ec15eea..84e90c6f 100644
--- a/app/services/remnawave_service.py
+++ b/app/services/remnawave_service.py
@@ -1,7 +1,7 @@
import logging
import os
import re
-from contextlib import asynccontextmanager
+from contextlib import AsyncExitStack, asynccontextmanager
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
@@ -12,7 +12,8 @@ from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
)
-from sqlalchemy import delete
+from sqlalchemy import and_, cast, delete, func, select, update, String
+from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
from app.database.crud.subscription import (
@@ -20,9 +21,16 @@ from app.database.crud.subscription import (
update_subscription_usage,
decrement_subscription_server_counts,
)
+from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.models import (
- User, SubscriptionServer, Transaction, ReferralEarning,
- PromoCodeUse, SubscriptionStatus
+ User,
+ Subscription,
+ SubscriptionServer,
+ Transaction,
+ ReferralEarning,
+ PromoCodeUse,
+ SubscriptionStatus,
+ ServerSquad,
)
logger = logging.getLogger(__name__)
@@ -481,16 +489,237 @@ class RemnaWaveService:
try:
async with self.get_api_client() as api:
result = await api.delete_internal_squad(uuid)
-
+
if result:
logger.info(f"✅ Удален сквад {uuid}")
-
+
return result
-
+
except Exception as e:
logger.error(f"Ошибка удаления сквада {uuid}: {e}")
return False
-
+
+ async def migrate_squad_users(
+ self,
+ db: AsyncSession,
+ source_uuid: str,
+ target_uuid: str,
+ ) -> Dict[str, Any]:
+ """Переносит активных подписок с одного сквада на другой."""
+
+ if source_uuid == target_uuid:
+ return {
+ "success": False,
+ "error": "same_squad",
+ "message": "Источник и назначение совпадают",
+ }
+
+ source_uuid = source_uuid.strip()
+ target_uuid = target_uuid.strip()
+
+ source_server = await get_server_squad_by_uuid(db, source_uuid)
+ target_server = await get_server_squad_by_uuid(db, target_uuid)
+
+ if not source_server or not target_server:
+ return {
+ "success": False,
+ "error": "not_found",
+ "message": "Сквады не найдены",
+ }
+
+ subscription_query = (
+ select(Subscription)
+ .options(selectinload(Subscription.user))
+ .where(
+ Subscription.status.in_(
+ [
+ SubscriptionStatus.ACTIVE.value,
+ SubscriptionStatus.TRIAL.value,
+ ]
+ ),
+ cast(Subscription.connected_squads, String).like(
+ f'%"{source_uuid}"%'
+ ),
+ )
+ )
+
+ result = await db.execute(subscription_query)
+ subscriptions = result.scalars().unique().all()
+
+ total_candidates = len(subscriptions)
+ if not subscriptions:
+ logger.info(
+ "🚚 Переезд сквада %s → %s: подходящих подписок не найдено",
+ source_uuid,
+ target_uuid,
+ )
+ return {
+ "success": True,
+ "total": 0,
+ "updated": 0,
+ "panel_updated": 0,
+ "panel_failed": 0,
+ }
+
+ exit_stack = AsyncExitStack()
+ panel_updated = 0
+ panel_failed = 0
+ updated_subscriptions = 0
+ source_decrement = 0
+ target_increment = 0
+
+ try:
+ needs_panel_update = any(
+ subscription.user and subscription.user.remnawave_uuid
+ for subscription in subscriptions
+ )
+
+ api = None
+ if needs_panel_update:
+ api = await exit_stack.enter_async_context(self.get_api_client())
+
+ for subscription in subscriptions:
+ current_squads = list(subscription.connected_squads or [])
+ if source_uuid not in current_squads:
+ continue
+
+ had_target_before = target_uuid in current_squads
+ new_squads = [
+ squad_uuid for squad_uuid in current_squads if squad_uuid != source_uuid
+ ]
+ if not had_target_before:
+ new_squads.append(target_uuid)
+
+ if subscription.user and subscription.user.remnawave_uuid:
+ if api is None:
+ panel_failed += 1
+ logger.error(
+ "❌ RemnaWave API недоступен для обновления пользователя %s",
+ subscription.user.telegram_id,
+ )
+ continue
+
+ try:
+ await api.update_user(
+ uuid=subscription.user.remnawave_uuid,
+ active_internal_squads=new_squads,
+ )
+ panel_updated += 1
+ except Exception as error:
+ panel_failed += 1
+ logger.error(
+ "❌ Ошибка обновления сквадов пользователя %s: %s",
+ subscription.user.telegram_id,
+ error,
+ )
+ continue
+
+ subscription.connected_squads = new_squads
+ subscription.updated_at = datetime.utcnow()
+
+ source_decrement += 1
+ if not had_target_before:
+ target_increment += 1
+
+ updated_subscriptions += 1
+
+ link_result = await db.execute(
+ select(SubscriptionServer)
+ .where(
+ and_(
+ SubscriptionServer.subscription_id == subscription.id,
+ SubscriptionServer.server_squad_id == source_server.id,
+ )
+ )
+ .limit(1)
+ )
+ link = link_result.scalars().first()
+
+ if link:
+ if had_target_before:
+ await db.execute(
+ delete(SubscriptionServer).where(
+ and_(
+ SubscriptionServer.subscription_id
+ == subscription.id,
+ SubscriptionServer.server_squad_id
+ == source_server.id,
+ )
+ )
+ )
+ else:
+ link.server_squad_id = target_server.id
+ elif not had_target_before:
+ db.add(
+ SubscriptionServer(
+ subscription_id=subscription.id,
+ server_squad_id=target_server.id,
+ paid_price_kopeks=0,
+ )
+ )
+
+ if updated_subscriptions:
+ if source_decrement:
+ await db.execute(
+ update(ServerSquad)
+ .where(ServerSquad.id == source_server.id)
+ .values(
+ current_users=func.greatest(
+ ServerSquad.current_users - source_decrement,
+ 0,
+ )
+ )
+ )
+ if target_increment:
+ await db.execute(
+ update(ServerSquad)
+ .where(ServerSquad.id == target_server.id)
+ .values(
+ current_users=ServerSquad.current_users + target_increment
+ )
+ )
+
+ await db.commit()
+ else:
+ await db.rollback()
+
+ logger.info(
+ "🚚 Завершен переезд сквада %s → %s: обновлено %s подписок (%s не обновлены в панели)",
+ source_uuid,
+ target_uuid,
+ updated_subscriptions,
+ panel_failed,
+ )
+
+ return {
+ "success": True,
+ "total": total_candidates,
+ "updated": updated_subscriptions,
+ "panel_updated": panel_updated,
+ "panel_failed": panel_failed,
+ "source_removed": source_decrement,
+ "target_added": target_increment,
+ }
+
+ except RemnaWaveConfigurationError:
+ await db.rollback()
+ raise
+ except Exception as error:
+ await db.rollback()
+ logger.error(
+ "❌ Ошибка переезда сквада %s → %s: %s",
+ source_uuid,
+ target_uuid,
+ error,
+ )
+ return {
+ "success": False,
+ "error": "unexpected",
+ "message": str(error),
+ }
+ finally:
+ await exit_stack.aclose()
+
async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]:
try:
stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0}
diff --git a/app/states.py b/app/states.py
index b7eb5765..0d10a629 100644
--- a/app/states.py
+++ b/app/states.py
@@ -166,6 +166,13 @@ class SquadCreateStates(StatesGroup):
class SquadRenameStates(StatesGroup):
waiting_for_new_name = State()
+
+class SquadMigrationStates(StatesGroup):
+ selecting_source = State()
+ selecting_target = State()
+ confirming = State()
+
+
class AdminSubmenuStates(StatesGroup):
in_users_submenu = State()
in_promo_submenu = State()
diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py
index 35bfc0c3..421db6d3 100644
--- a/app/webapi/routes/remnawave.py
+++ b/app/webapi/routes/remnawave.py
@@ -6,6 +6,11 @@ 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.database.crud.server_squad import (
+ count_active_users_for_squad,
+ get_server_squad_by_uuid,
+)
+
from ..dependencies import get_db_session, require_api_token
from ..schemas.remnawave import (
RemnaWaveConnectionStatus,
@@ -22,6 +27,10 @@ from ..schemas.remnawave import (
RemnaWaveSquadActionRequest,
RemnaWaveSquadCreateRequest,
RemnaWaveSquadListResponse,
+ RemnaWaveSquadMigrationPreviewResponse,
+ RemnaWaveSquadMigrationRequest,
+ RemnaWaveSquadMigrationResponse,
+ RemnaWaveSquadMigrationStats,
RemnaWaveSquadUpdateRequest,
RemnaWaveStatusResponse,
RemnaWaveSystemStatsResponse,
@@ -372,6 +381,30 @@ async def get_user_traffic(
return RemnaWaveUserTrafficResponse(telegram_id=telegram_id, **stats)
+@router.get("/squads/{squad_uuid}/migration-preview", response_model=RemnaWaveSquadMigrationPreviewResponse)
+async def preview_squad_migration(
+ squad_uuid: str,
+ _: Any = Security(require_api_token),
+ db: AsyncSession = Depends(get_db_session),
+) -> RemnaWaveSquadMigrationPreviewResponse:
+ service = _get_service()
+ _ensure_service_configured(service)
+
+ squad = await get_server_squad_by_uuid(db, squad_uuid)
+ if not squad:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден")
+
+ users_to_migrate = await count_active_users_for_squad(db, squad_uuid)
+
+ return RemnaWaveSquadMigrationPreviewResponse(
+ squad_uuid=squad.squad_uuid,
+ squad_name=squad.display_name,
+ current_users=squad.current_users or 0,
+ max_users=squad.max_users,
+ users_to_migrate=users_to_migrate,
+ )
+
+
@router.post("/sync/from-panel", response_model=RemnaWaveGenericSyncResponse)
async def sync_from_panel(
payload: RemnaWaveSyncFromPanelRequest,
@@ -454,3 +487,58 @@ async def get_sync_recommendations(
data = await service.get_sync_recommendations(db)
detail = "Рекомендации получены"
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=data)
+
+
+@router.post("/squads/migrate", response_model=RemnaWaveSquadMigrationResponse)
+async def migrate_squad(
+ payload: RemnaWaveSquadMigrationRequest,
+ _: Any = Security(require_api_token),
+ db: AsyncSession = Depends(get_db_session),
+) -> RemnaWaveSquadMigrationResponse:
+ service = _get_service()
+ _ensure_service_configured(service)
+
+ source_uuid = payload.source_uuid.strip()
+ target_uuid = payload.target_uuid.strip()
+
+ if source_uuid == target_uuid:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "Источник и назначение совпадают")
+
+ source = await get_server_squad_by_uuid(db, source_uuid)
+ if not source:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-источник не найден")
+
+ target = await get_server_squad_by_uuid(db, target_uuid)
+ if not target:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-назначение не найден")
+
+ try:
+ result = await service.migrate_squad_users(
+ db,
+ source_uuid=source.squad_uuid,
+ target_uuid=target.squad_uuid,
+ )
+ except RemnaWaveConfigurationError as exc: # pragma: no cover - зависит от окружения
+ raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
+
+ if not result.get("success"):
+ detail = result.get("message") or "Не удалось выполнить переезд"
+ return RemnaWaveSquadMigrationResponse(
+ success=False,
+ detail=detail,
+ error=result.get("error"),
+ )
+
+ stats = RemnaWaveSquadMigrationStats(
+ source_uuid=source.squad_uuid,
+ target_uuid=target.squad_uuid,
+ total=result.get("total", 0),
+ updated=result.get("updated", 0),
+ panel_updated=result.get("panel_updated", 0),
+ panel_failed=result.get("panel_failed", 0),
+ source_removed=result.get("source_removed", 0),
+ target_added=result.get("target_added", 0),
+ )
+
+ detail = result.get("message") or "Переезд выполнен"
+ return RemnaWaveSquadMigrationResponse(success=True, detail=detail, data=stats)
diff --git a/app/webapi/schemas/remnawave.py b/app/webapi/schemas/remnawave.py
index 4236d4aa..bc41a41c 100644
--- a/app/webapi/schemas/remnawave.py
+++ b/app/webapi/schemas/remnawave.py
@@ -169,3 +169,34 @@ class RemnaWaveGenericSyncResponse(BaseModel):
success: bool
detail: Optional[str] = None
data: Optional[Dict[str, Any]] = None
+
+
+class RemnaWaveSquadMigrationPreviewResponse(BaseModel):
+ squad_uuid: str
+ squad_name: str
+ current_users: int
+ max_users: Optional[int] = None
+ users_to_migrate: int
+
+
+class RemnaWaveSquadMigrationRequest(BaseModel):
+ source_uuid: str
+ target_uuid: str
+
+
+class RemnaWaveSquadMigrationStats(BaseModel):
+ source_uuid: str
+ target_uuid: str
+ total: int = 0
+ updated: int = 0
+ panel_updated: int = 0
+ panel_failed: int = 0
+ source_removed: int = 0
+ target_added: int = 0
+
+
+class RemnaWaveSquadMigrationResponse(BaseModel):
+ success: bool
+ detail: Optional[str] = None
+ error: Optional[str] = None
+ data: Optional[RemnaWaveSquadMigrationStats] = None
diff --git a/locales/en.json b/locales/en.json
index 3444c456..217035a6 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -870,7 +870,42 @@
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Manage nodes",
"ADMIN_REMNAWAVE_SYNC": "🔄 Synchronization",
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Manage squads",
+ "ADMIN_REMNAWAVE_MIGRATION": "🚚 Migration",
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Traffic",
+ "ADMIN_SQUAD_MIGRATION_TITLE": "🚚 Squad migration",
+ "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Choose the squad you want to migrate from:",
+ "ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "No squads available. Add new ones or cancel the operation.",
+ "ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Available",
+ "ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Unavailable",
+ "ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})",
+ "ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})",
+ "ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅",
+ "ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫",
+ "ADMIN_SQUAD_MIGRATION_PAGE": "Page {page}/{pages}",
+ "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Source: {source}",
+ "ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Choose the destination squad:",
+ "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "No other squads available. Cancel or create new squads.",
+ "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Squad not found or unavailable.",
+ "ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "You can't choose the same squad.",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Review the migration parameters:",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• From: {source}",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• To: {target}",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Users to migrate: {count}",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Confirm the operation.",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Confirm",
+ "ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Change destination",
+ "ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Starting migration...",
+ "ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API is not configured: {error}",
+ "ADMIN_SQUAD_MIGRATION_ERROR": "❌ Failed to migrate (code: {code}). {details}",
+ "ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 New migration",
+ "ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ Back to Remnawave",
+ "ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Migration completed",
+ "ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Subscriptions matched: {count}",
+ "ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Migrated: {count}",
+ "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Updated in panel: {count}",
+ "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Panel update failed: {count}",
+ "ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Migration cancelled.",
+ "ADMIN_SQUAD_MIGRATION_PAGE_HINT": "This is the current page.",
"ADMIN_STATS_USERS": "👥 Users",
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Subscriptions",
"ADMIN_STATS_REVENUE": "💰 Revenue",
diff --git a/locales/ru.json b/locales/ru.json
index 47242a4d..2348dd4b 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -872,7 +872,42 @@
"ADMIN_REMNAWAVE_MANAGE_NODES": "🖥️ Управление нодами",
"ADMIN_REMNAWAVE_SYNC": "🔄 Синхронизация",
"ADMIN_REMNAWAVE_MANAGE_SQUADS": "🌐 Управление сквадами",
+ "ADMIN_REMNAWAVE_MIGRATION": "🚚 Переезд",
"ADMIN_REMNAWAVE_TRAFFIC": "📈 Трафик",
+ "ADMIN_SQUAD_MIGRATION_TITLE": "🚚 Переезд сквадов",
+ "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE": "Выберите сквад, из которого нужно переехать:",
+ "ADMIN_SQUAD_MIGRATION_NO_OPTIONS": "Нет доступных сквадов. Добавьте новые или отмените операцию.",
+ "ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE": "✅ Доступен",
+ "ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE": "🚫 Недоступен",
+ "ADMIN_SQUAD_MIGRATION_SERVER_LABEL": "{name} — 👥 {users} ({status})",
+ "ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON": "🌍 {name} — 👥 {users} ({status})",
+ "ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT": "✅",
+ "ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT": "🚫",
+ "ADMIN_SQUAD_MIGRATION_PAGE": "Стр. {page}/{pages}",
+ "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE": "Источник: {source}",
+ "ADMIN_SQUAD_MIGRATION_SELECT_TARGET": "Выберите сквад, в который нужно переехать:",
+ "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY": "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.",
+ "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND": "Сквад не найден или недоступен.",
+ "ADMIN_SQUAD_MIGRATION_SAME_SQUAD": "Нельзя выбрать тот же сквад.",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS": "Проверьте параметры переезда:",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE": "• Из: {source}",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET": "• В: {target}",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT": "• Пользователей к переносу: {count}",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT": "Подтвердите выполнение операции.",
+ "ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON": "✅ Подтвердить",
+ "ADMIN_SQUAD_MIGRATION_CHANGE_TARGET": "🔄 Изменить сервер назначения",
+ "ADMIN_SQUAD_MIGRATION_IN_PROGRESS": "Запускаю переезд...",
+ "ADMIN_SQUAD_MIGRATION_API_ERROR": "❌ Remnawave API не настроен: {error}",
+ "ADMIN_SQUAD_MIGRATION_ERROR": "❌ Не удалось выполнить переезд (код: {code}). {details}",
+ "ADMIN_SQUAD_MIGRATION_NEW_BUTTON": "🔁 Новый переезд",
+ "ADMIN_SQUAD_MIGRATION_BACK_BUTTON": "⬅️ В Remnawave",
+ "ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE": "✅ Переезд завершен",
+ "ADMIN_SQUAD_MIGRATION_RESULT_TOTAL": "Найдено подписок: {count}",
+ "ADMIN_SQUAD_MIGRATION_RESULT_UPDATED": "Перенесено: {count}",
+ "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED": "Обновлено в панели: {count}",
+ "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED": "Не удалось обновить в панели: {count}",
+ "ADMIN_SQUAD_MIGRATION_CANCELLED": "❌ Переезд отменен.",
+ "ADMIN_SQUAD_MIGRATION_PAGE_HINT": "Это текущая страница.",
"ADMIN_STATS_USERS": "👥 Пользователи",
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Подписки",
"ADMIN_STATS_REVENUE": "💰 Доходы",