eb18994b7d
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC) - Migrate 30+ datetime.now() without UTC to datetime.now(UTC) - Convert all 170 DateTime columns to DateTime(timezone=True) - Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety - Remove 70+ .replace(tzinfo=None) workarounds - Fix utcfromtimestamp → fromtimestamp(..., tz=UTC) - Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics) - Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo) - Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics) - Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring) - Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC) - Rename panel_datetime_to_naive_utc → panel_datetime_to_utc - Remove DTZ003 from ruff ignore list
3014 lines
114 KiB
Python
3014 lines
114 KiB
Python
import math
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
import structlog
|
||
from aiogram import Dispatcher, F, types
|
||
from aiogram.fsm.context import FSMContext
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.crud.server_squad import (
|
||
count_active_users_for_squad,
|
||
get_all_server_squads,
|
||
get_server_squad_by_uuid,
|
||
)
|
||
from app.database.models import User
|
||
from app.keyboards.admin import (
|
||
get_admin_remnawave_keyboard,
|
||
get_node_management_keyboard,
|
||
get_squad_edit_keyboard,
|
||
get_squad_management_keyboard,
|
||
)
|
||
from app.localization.texts import get_texts
|
||
from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService
|
||
from app.services.remnawave_sync_service import (
|
||
RemnaWaveAutoSyncStatus,
|
||
remnawave_sync_service,
|
||
)
|
||
from app.services.system_settings_service import bot_configuration_service
|
||
from app.states import (
|
||
RemnaWaveSyncStates,
|
||
SquadCreateStates,
|
||
SquadMigrationStates,
|
||
SquadRenameStates,
|
||
)
|
||
from app.utils.decorators import admin_required, error_handler
|
||
from app.utils.formatters import format_bytes, format_datetime
|
||
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
squad_inbound_selections = {}
|
||
squad_create_data = {}
|
||
|
||
MIGRATION_PAGE_SIZE = 8
|
||
|
||
|
||
def _format_duration(seconds: float) -> str:
|
||
if seconds < 1:
|
||
return 'менее 1с'
|
||
|
||
minutes, sec = divmod(int(seconds), 60)
|
||
if minutes:
|
||
if sec:
|
||
return f'{minutes} мин {sec} с'
|
||
return f'{minutes} мин'
|
||
return f'{sec} с'
|
||
|
||
|
||
def _format_user_stats(stats: dict[str, Any] | None) -> str:
|
||
if not stats:
|
||
return '—'
|
||
|
||
created = stats.get('created', 0)
|
||
updated = stats.get('updated', 0)
|
||
deleted = stats.get('deleted', stats.get('deactivated', 0))
|
||
errors = stats.get('errors', 0)
|
||
|
||
return f'• Создано: {created}\n• Обновлено: {updated}\n• Деактивировано: {deleted}\n• Ошибок: {errors}'
|
||
|
||
|
||
def _format_server_stats(stats: dict[str, Any] | None) -> str:
|
||
if not stats:
|
||
return '—'
|
||
|
||
created = stats.get('created', 0)
|
||
updated = stats.get('updated', 0)
|
||
removed = stats.get('removed', 0)
|
||
total = stats.get('total', 0)
|
||
|
||
return f'• Создано: {created}\n• Обновлено: {updated}\n• Удалено: {removed}\n• Всего в панели: {total}'
|
||
|
||
|
||
def _build_auto_sync_view(status: RemnaWaveAutoSyncStatus) -> tuple[str, types.InlineKeyboardMarkup]:
|
||
times_text = ', '.join(t.strftime('%H:%M') for t in status.times) if status.times else '—'
|
||
next_run_text = format_datetime(status.next_run) if status.next_run else '—'
|
||
|
||
if status.last_run_finished_at:
|
||
finished_text = format_datetime(status.last_run_finished_at)
|
||
started_text = format_datetime(status.last_run_started_at) if status.last_run_started_at else '—'
|
||
duration = status.last_run_finished_at - status.last_run_started_at if status.last_run_started_at else None
|
||
duration_text = f' ({_format_duration(duration.total_seconds())})' if duration else ''
|
||
reason_map = {
|
||
'manual': 'вручную',
|
||
'auto': 'по расписанию',
|
||
'immediate': 'при включении',
|
||
}
|
||
reason_text = reason_map.get(status.last_run_reason or '', '—')
|
||
result_icon = '✅' if status.last_run_success else '❌'
|
||
result_label = 'успешно' if status.last_run_success else 'с ошибками'
|
||
error_block = f'\n⚠️ Ошибка: {status.last_run_error}' if status.last_run_error else ''
|
||
last_run_text = (
|
||
f'{result_icon} {result_label}\n'
|
||
f'• Старт: {started_text}\n'
|
||
f'• Завершено: {finished_text}{duration_text}\n'
|
||
f'• Причина запуска: {reason_text}{error_block}'
|
||
)
|
||
elif status.last_run_started_at:
|
||
last_run_text = (
|
||
'⏳ Синхронизация началась, но еще не завершилась'
|
||
if status.is_running
|
||
else f'ℹ️ Последний запуск: {format_datetime(status.last_run_started_at)}'
|
||
)
|
||
else:
|
||
last_run_text = '—'
|
||
|
||
running_text = '⏳ Выполняется сейчас' if status.is_running else 'Ожидание'
|
||
toggle_text = '❌ Отключить' if status.enabled else '✅ Включить'
|
||
|
||
text = f"""🔄 <b>Автосинхронизация RemnaWave</b>
|
||
|
||
⚙️ <b>Статус:</b> {'✅ Включена' if status.enabled else '❌ Отключена'}
|
||
🕒 <b>Расписание:</b> {times_text}
|
||
📅 <b>Следующий запуск:</b> {next_run_text if status.enabled else '—'}
|
||
⏱️ <b>Состояние:</b> {running_text}
|
||
|
||
📊 <b>Последний запуск:</b>
|
||
{last_run_text}
|
||
|
||
👥 <b>Пользователи:</b>
|
||
{_format_user_stats(status.last_user_stats)}
|
||
|
||
🌐 <b>Серверы:</b>
|
||
{_format_server_stats(status.last_server_stats)}
|
||
"""
|
||
|
||
keyboard = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='🔁 Запустить сейчас',
|
||
callback_data='remnawave_auto_sync_run',
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=toggle_text,
|
||
callback_data='remnawave_auto_sync_toggle',
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='🕒 Изменить расписание',
|
||
callback_data='remnawave_auto_sync_times',
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='⬅️ Назад',
|
||
callback_data='admin_rw_sync',
|
||
)
|
||
],
|
||
]
|
||
)
|
||
|
||
return text, keyboard
|
||
|
||
|
||
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))
|
||
|
||
page = max(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', '🚚 <b>Переезд сквадов</b>')
|
||
+ '\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', '🚚 <b>Переезд сквадов</b>')
|
||
+ '\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', '🚚 <b>Переезд сквадов</b>')
|
||
+ '\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', '🚚 <b>Переезд сквадов</b>')
|
||
+ '\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', '🚚 <b>Переезд сквадов</b>'),
|
||
'',
|
||
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', '🚚 <b>Переезд сквадов</b>')
|
||
+ '\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(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
remnawave_service = RemnaWaveService()
|
||
connection_test = await remnawave_service.test_api_connection()
|
||
|
||
status = connection_test.get('status')
|
||
if status == 'connected':
|
||
status_emoji = '✅'
|
||
elif status == 'not_configured':
|
||
status_emoji = 'ℹ️'
|
||
else:
|
||
status_emoji = '❌'
|
||
|
||
api_url_display = settings.REMNAWAVE_API_URL or '—'
|
||
|
||
text = f"""
|
||
🖥️ <b>Управление Remnawave</b>
|
||
|
||
📡 <b>Соединение:</b> {status_emoji} {connection_test.get('message', 'Нет данных')}
|
||
🌐 <b>URL:</b> <code>{api_url_display}</code>
|
||
|
||
Выберите действие:
|
||
"""
|
||
|
||
await callback.message.edit_text(text, reply_markup=get_admin_remnawave_keyboard(db_user.language))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_system_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
remnawave_service = RemnaWaveService()
|
||
stats = await remnawave_service.get_system_statistics()
|
||
|
||
if 'error' in stats:
|
||
await callback.message.edit_text(
|
||
f'❌ Ошибка получения статистики: {stats["error"]}',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')]]
|
||
),
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
system = stats.get('system', {})
|
||
users_by_status = stats.get('users_by_status', {})
|
||
server_info = stats.get('server_info', {})
|
||
bandwidth = stats.get('bandwidth', {})
|
||
traffic_periods = stats.get('traffic_periods', {})
|
||
nodes_realtime = stats.get('nodes_realtime', [])
|
||
nodes_weekly = stats.get('nodes_weekly', [])
|
||
|
||
memory_total = server_info.get('memory_total', 1)
|
||
memory_used_percent = (server_info.get('memory_used', 0) / memory_total * 100) if memory_total > 0 else 0
|
||
|
||
uptime_seconds = server_info.get('uptime_seconds', 0)
|
||
uptime_days = int(uptime_seconds // 86400)
|
||
uptime_hours = int((uptime_seconds % 86400) // 3600)
|
||
uptime_str = f'{uptime_days}д {uptime_hours}ч'
|
||
|
||
users_status_text = ''
|
||
for status, count in users_by_status.items():
|
||
status_emoji = {'ACTIVE': '✅', 'DISABLED': '❌', 'LIMITED': '⚠️', 'EXPIRED': '⏰'}.get(status, '❓')
|
||
users_status_text += f' {status_emoji} {status}: {count}\n'
|
||
|
||
top_nodes_text = ''
|
||
for i, node in enumerate(nodes_weekly[:3], 1):
|
||
top_nodes_text += f' {i}. {node["name"]}: {format_bytes(node["total_bytes"])}\n'
|
||
|
||
realtime_nodes_text = ''
|
||
for node in nodes_realtime[:3]:
|
||
node_total = node.get('downloadBytes', 0) + node.get('uploadBytes', 0)
|
||
if node_total > 0:
|
||
realtime_nodes_text += f' 📡 {node.get("nodeName", "Unknown")}: {format_bytes(node_total)}\n'
|
||
|
||
def format_traffic_change(difference_str):
|
||
if not difference_str or difference_str == '0':
|
||
return ''
|
||
if difference_str.startswith('-'):
|
||
return f' (🔻 {difference_str[1:]})'
|
||
return f' (🔺 {difference_str})'
|
||
|
||
text = f"""
|
||
📊 <b>Детальная статистика Remnawave</b>
|
||
|
||
🖥️ <b>Сервер:</b>
|
||
- CPU: {server_info.get('cpu_cores', 0)} ядер ({server_info.get('cpu_physical_cores', 0)} физ.)
|
||
- RAM: {format_bytes(server_info.get('memory_used', 0))} / {format_bytes(memory_total)} ({memory_used_percent:.1f}%)
|
||
- Свободно: {format_bytes(server_info.get('memory_available', 0))}
|
||
- Uptime: {uptime_str}
|
||
|
||
👥 <b>Пользователи ({system.get('total_users', 0)} всего):</b>
|
||
- 🟢 Онлайн сейчас: {system.get('users_online', 0)}
|
||
- 📅 За сутки: {system.get('users_last_day', 0)}
|
||
- 📊 За неделю: {system.get('users_last_week', 0)}
|
||
- 💤 Никогда не заходили: {system.get('users_never_online', 0)}
|
||
|
||
<b>Статусы пользователей:</b>
|
||
{users_status_text}
|
||
|
||
🌐 <b>Ноды ({system.get('nodes_online', 0)} онлайн):</b>"""
|
||
|
||
if realtime_nodes_text:
|
||
text += f"""
|
||
<b>Реалтайм активность:</b>
|
||
{realtime_nodes_text}"""
|
||
|
||
if top_nodes_text:
|
||
text += f"""
|
||
<b>Топ нод за неделю:</b>
|
||
{top_nodes_text}"""
|
||
|
||
text += f"""
|
||
|
||
📈 <b>Общий трафик пользователей:</b> {format_bytes(system.get('total_user_traffic', 0))}
|
||
|
||
📊 <b>Трафик по периодам:</b>
|
||
- 2 дня: {format_bytes(traffic_periods.get('last_2_days', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('last_2_days', {}).get('difference', ''))}
|
||
- 7 дней: {format_bytes(traffic_periods.get('last_7_days', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('last_7_days', {}).get('difference', ''))}
|
||
- 30 дней: {format_bytes(traffic_periods.get('last_30_days', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('last_30_days', {}).get('difference', ''))}
|
||
- Месяц: {format_bytes(traffic_periods.get('current_month', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('current_month', {}).get('difference', ''))}
|
||
- Год: {format_bytes(traffic_periods.get('current_year', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('current_year', {}).get('difference', ''))}
|
||
"""
|
||
|
||
if bandwidth.get('realtime_total', 0) > 0:
|
||
text += f"""
|
||
⚡ <b>Реалтайм трафик:</b>
|
||
- Скачивание: {format_bytes(bandwidth.get('realtime_download', 0))}
|
||
- Загрузка: {format_bytes(bandwidth.get('realtime_upload', 0))}
|
||
- Итого: {format_bytes(bandwidth.get('realtime_total', 0))}
|
||
"""
|
||
|
||
text += f"""
|
||
🕒 <b>Обновлено:</b> {format_datetime(stats.get('last_updated', datetime.now(UTC)))}
|
||
"""
|
||
|
||
keyboard = [
|
||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_rw_system')],
|
||
[
|
||
types.InlineKeyboardButton(text='📈 Ноды', callback_data='admin_rw_nodes'),
|
||
types.InlineKeyboardButton(text='👥 Синхронизация', callback_data='admin_rw_sync'),
|
||
],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')],
|
||
]
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_traffic_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
remnawave_service = RemnaWaveService()
|
||
|
||
try:
|
||
async with remnawave_service.get_api_client() as api:
|
||
bandwidth_stats = await api.get_bandwidth_stats()
|
||
|
||
realtime_usage = await api.get_nodes_realtime_usage()
|
||
|
||
nodes_stats = await api.get_nodes_statistics()
|
||
|
||
except Exception as e:
|
||
await callback.message.edit_text(
|
||
f'❌ Ошибка получения статистики трафика: {e!s}',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')]]
|
||
),
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
def parse_bandwidth(bandwidth_str):
|
||
return remnawave_service._parse_bandwidth_string(bandwidth_str)
|
||
|
||
total_realtime_download = sum(node.get('downloadBytes', 0) for node in realtime_usage)
|
||
total_realtime_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage)
|
||
total_realtime = total_realtime_download + total_realtime_upload
|
||
|
||
total_download_speed = sum(node.get('downloadSpeedBps', 0) for node in realtime_usage)
|
||
total_upload_speed = sum(node.get('uploadSpeedBps', 0) for node in realtime_usage)
|
||
|
||
periods = {
|
||
'last_2_days': bandwidth_stats.get('bandwidthLastTwoDays', {}),
|
||
'last_7_days': bandwidth_stats.get('bandwidthLastSevenDays', {}),
|
||
'last_30_days': bandwidth_stats.get('bandwidthLast30Days', {}),
|
||
'current_month': bandwidth_stats.get('bandwidthCalendarMonth', {}),
|
||
'current_year': bandwidth_stats.get('bandwidthCurrentYear', {}),
|
||
}
|
||
|
||
def format_change(diff_str):
|
||
if not diff_str or diff_str == '0':
|
||
return ''
|
||
if diff_str.startswith('-'):
|
||
return f' 🔻 {diff_str[1:]}'
|
||
return f' 🔺 {diff_str}'
|
||
|
||
text = f"""
|
||
📊 <b>Статистика трафика Remnawave</b>
|
||
|
||
⚡ <b>Реалтайм данные:</b>
|
||
- Скачивание: {format_bytes(total_realtime_download)}
|
||
- Загрузка: {format_bytes(total_realtime_upload)}
|
||
- Общий трафик: {format_bytes(total_realtime)}
|
||
|
||
🚀 <b>Текущие скорости:</b>
|
||
- Скорость скачивания: {format_bytes(total_download_speed)}/с
|
||
- Скорость загрузки: {format_bytes(total_upload_speed)}/с
|
||
- Общая скорость: {format_bytes(total_download_speed + total_upload_speed)}/с
|
||
|
||
📈 <b>Статистика по периодам:</b>
|
||
|
||
<b>За 2 дня:</b>
|
||
- Текущий: {format_bytes(parse_bandwidth(periods['last_2_days'].get('current', '0')))}
|
||
- Предыдущий: {format_bytes(parse_bandwidth(periods['last_2_days'].get('previous', '0')))}
|
||
- Изменение:{format_change(periods['last_2_days'].get('difference', ''))}
|
||
|
||
<b>За 7 дней:</b>
|
||
- Текущий: {format_bytes(parse_bandwidth(periods['last_7_days'].get('current', '0')))}
|
||
- Предыдущий: {format_bytes(parse_bandwidth(periods['last_7_days'].get('previous', '0')))}
|
||
- Изменение:{format_change(periods['last_7_days'].get('difference', ''))}
|
||
|
||
<b>За 30 дней:</b>
|
||
- Текущий: {format_bytes(parse_bandwidth(periods['last_30_days'].get('current', '0')))}
|
||
- Предыдущий: {format_bytes(parse_bandwidth(periods['last_30_days'].get('previous', '0')))}
|
||
- Изменение:{format_change(periods['last_30_days'].get('difference', ''))}
|
||
|
||
<b>Текущий месяц:</b>
|
||
- Текущий: {format_bytes(parse_bandwidth(periods['current_month'].get('current', '0')))}
|
||
- Предыдущий: {format_bytes(parse_bandwidth(periods['current_month'].get('previous', '0')))}
|
||
- Изменение:{format_change(periods['current_month'].get('difference', ''))}
|
||
|
||
<b>Текущий год:</b>
|
||
- Текущий: {format_bytes(parse_bandwidth(periods['current_year'].get('current', '0')))}
|
||
- Предыдущий: {format_bytes(parse_bandwidth(periods['current_year'].get('previous', '0')))}
|
||
- Изменение:{format_change(periods['current_year'].get('difference', ''))}
|
||
"""
|
||
|
||
if realtime_usage:
|
||
text += '\n🌐 <b>Трафик по нодам (реалтайм):</b>\n'
|
||
for node in sorted(realtime_usage, key=lambda x: x.get('totalBytes', 0), reverse=True):
|
||
node_total = node.get('totalBytes', 0)
|
||
if node_total > 0:
|
||
text += f'- {node.get("nodeName", "Unknown")}: {format_bytes(node_total)}\n'
|
||
|
||
if nodes_stats.get('lastSevenDays'):
|
||
text += '\n📊 <b>Топ нод за 7 дней:</b>\n'
|
||
|
||
nodes_weekly = {}
|
||
for day_data in nodes_stats['lastSevenDays']:
|
||
node_name = day_data['nodeName']
|
||
if node_name not in nodes_weekly:
|
||
nodes_weekly[node_name] = 0
|
||
nodes_weekly[node_name] += int(day_data['totalBytes'])
|
||
|
||
sorted_nodes = sorted(nodes_weekly.items(), key=lambda x: x[1], reverse=True)
|
||
for i, (node_name, total_bytes) in enumerate(sorted_nodes[:5], 1):
|
||
text += f'{i}. {node_name}: {format_bytes(total_bytes)}\n'
|
||
|
||
text += f'\n🕒 <b>Обновлено:</b> {format_datetime(datetime.now(UTC))}'
|
||
|
||
keyboard = [
|
||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_rw_traffic')],
|
||
[
|
||
types.InlineKeyboardButton(text='📈 Ноды', callback_data='admin_rw_nodes'),
|
||
types.InlineKeyboardButton(text='📊 Система', callback_data='admin_rw_system'),
|
||
],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')],
|
||
]
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_nodes_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
remnawave_service = RemnaWaveService()
|
||
nodes = await remnawave_service.get_all_nodes()
|
||
|
||
if not nodes:
|
||
await callback.message.edit_text(
|
||
'🖥️ Ноды не найдены или ошибка подключения',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')]]
|
||
),
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
text = '🖥️ <b>Управление нодами</b>\n\n'
|
||
keyboard = []
|
||
|
||
for node in nodes:
|
||
status_emoji = '🟢' if node['is_node_online'] else '🔴'
|
||
connection_emoji = '📡' if node['is_connected'] else '📵'
|
||
|
||
text += f'{status_emoji} {connection_emoji} <b>{node["name"]}</b>\n'
|
||
text += f'🌍 {node["country_code"]} • {node["address"]}\n'
|
||
text += f'👥 Онлайн: {node["users_online"] or 0}\n\n'
|
||
|
||
keyboard.append(
|
||
[types.InlineKeyboardButton(text=f'⚙️ {node["name"]}', callback_data=f'admin_node_manage_{node["uuid"]}')]
|
||
)
|
||
|
||
keyboard.extend(
|
||
[
|
||
[types.InlineKeyboardButton(text='🔄 Перезагрузить все', callback_data='admin_restart_all_nodes')],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_node_details(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
node_uuid = callback.data.split('_')[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
node = await remnawave_service.get_node_details(node_uuid)
|
||
|
||
if not node:
|
||
await callback.answer('❌ Нода не найдена', show_alert=True)
|
||
return
|
||
|
||
status_emoji = '🟢' if node['is_node_online'] else '🔴'
|
||
xray_emoji = '✅' if node['is_xray_running'] else '❌'
|
||
|
||
status_change = format_datetime(node['last_status_change']) if node.get('last_status_change') else '—'
|
||
created_at = format_datetime(node['created_at']) if node.get('created_at') else '—'
|
||
updated_at = format_datetime(node['updated_at']) if node.get('updated_at') else '—'
|
||
notify_percent = f'{node["notify_percent"]}%' if node.get('notify_percent') is not None else '—'
|
||
cpu_info = node.get('cpu_model') or '—'
|
||
if node.get('cpu_count'):
|
||
cpu_info = f'{node["cpu_count"]}x {cpu_info}'
|
||
|
||
text = f"""
|
||
🖥️ <b>Нода: {node['name']}</b>
|
||
|
||
<b>Статус:</b>
|
||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||
- Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'}
|
||
- Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'}
|
||
- Изменение статуса: {status_change}
|
||
- Сообщение: {node.get('last_status_message') or '—'}
|
||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||
|
||
<b>Информация:</b>
|
||
- Адрес: {node['address']}
|
||
- Страна: {node['country_code']}
|
||
- Пользователей онлайн: {node['users_online']}
|
||
- CPU: {cpu_info}
|
||
- RAM: {node.get('total_ram') or '—'}
|
||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||
|
||
<b>Трафик:</b>
|
||
- Использовано: {format_bytes(node['traffic_used_bytes'])}
|
||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||
- Уведомления: {notify_percent}
|
||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||
|
||
<b>Метаданные:</b>
|
||
- Создана: {created_at}
|
||
- Обновлена: {updated_at}
|
||
"""
|
||
|
||
await callback.message.edit_text(text, reply_markup=get_node_management_keyboard(node_uuid, db_user.language))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def manage_node(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
success = await remnawave_service.manage_node(node_uuid, action)
|
||
|
||
if success:
|
||
action_text = {'enable': 'включена', 'disable': 'отключена', 'restart': 'перезагружена'}
|
||
await callback.answer(f'✅ Нода {action_text.get(action, "обработана")}')
|
||
else:
|
||
await callback.answer('❌ Ошибка выполнения действия', show_alert=True)
|
||
|
||
await show_node_details(callback, db_user, db)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
node_uuid = callback.data.split('_')[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
|
||
node = await remnawave_service.get_node_details(node_uuid)
|
||
|
||
if not node:
|
||
await callback.answer('❌ Нода не найдена', show_alert=True)
|
||
return
|
||
|
||
try:
|
||
from datetime import datetime, timedelta
|
||
|
||
end_date = datetime.now(UTC)
|
||
start_date = end_date - timedelta(days=7)
|
||
|
||
node_usage = await remnawave_service.get_node_user_usage_by_range(node_uuid, start_date, end_date)
|
||
|
||
realtime_stats = await remnawave_service.get_nodes_realtime_usage()
|
||
|
||
node_realtime = None
|
||
for stats in realtime_stats:
|
||
if stats.get('nodeUuid') == node_uuid:
|
||
node_realtime = stats
|
||
break
|
||
|
||
status_change = format_datetime(node['last_status_change']) if node.get('last_status_change') else '—'
|
||
created_at = format_datetime(node['created_at']) if node.get('created_at') else '—'
|
||
updated_at = format_datetime(node['updated_at']) if node.get('updated_at') else '—'
|
||
notify_percent = f'{node["notify_percent"]}%' if node.get('notify_percent') is not None else '—'
|
||
cpu_info = node.get('cpu_model') or '—'
|
||
if node.get('cpu_count'):
|
||
cpu_info = f'{node["cpu_count"]}x {cpu_info}'
|
||
|
||
status_emoji = '🟢' if node['is_node_online'] else '🔴'
|
||
xray_emoji = '✅' if node['is_xray_running'] else '❌'
|
||
|
||
text = f"""
|
||
📊 <b>Статистика ноды: {node['name']}</b>
|
||
|
||
<b>Статус:</b>
|
||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||
- Пользователей онлайн: {node['users_online'] or 0}
|
||
- Изменение статуса: {status_change}
|
||
- Сообщение: {node.get('last_status_message') or '—'}
|
||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||
|
||
<b>Ресурсы:</b>
|
||
- CPU: {cpu_info}
|
||
- RAM: {node.get('total_ram') or '—'}
|
||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||
|
||
<b>Трафик:</b>
|
||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||
- Уведомления: {notify_percent}
|
||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||
|
||
<b>Метаданные:</b>
|
||
- Создана: {created_at}
|
||
- Обновлена: {updated_at}
|
||
"""
|
||
|
||
if node_realtime:
|
||
text += f"""
|
||
<b>Реалтайм статистика:</b>
|
||
- Скачано: {format_bytes(node_realtime.get('downloadBytes', 0))}
|
||
- Загружено: {format_bytes(node_realtime.get('uploadBytes', 0))}
|
||
- Общий трафик: {format_bytes(node_realtime.get('totalBytes', 0))}
|
||
- Скорость скачивания: {format_bytes(node_realtime.get('downloadSpeedBps', 0))}/с
|
||
- Скорость загрузки: {format_bytes(node_realtime.get('uploadSpeedBps', 0))}/с
|
||
"""
|
||
|
||
if node_usage:
|
||
text += '\n<b>Статистика за 7 дней:</b>\n'
|
||
total_usage = 0
|
||
for usage in node_usage[-5:]:
|
||
daily_usage = usage.get('total', 0)
|
||
total_usage += daily_usage
|
||
text += f'- {usage.get("date", "N/A")}: {format_bytes(daily_usage)}\n'
|
||
|
||
text += f'\n<b>Общий трафик за 7 дней:</b> {format_bytes(total_usage)}'
|
||
else:
|
||
text += '\n<b>Статистика за 7 дней:</b> Данные недоступны'
|
||
|
||
keyboard = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data=f'node_stats_{node_uuid}')],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_node_manage_{node_uuid}')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||
await callback.answer()
|
||
|
||
except Exception as e:
|
||
logger.error('Ошибка получения статистики ноды', node_uuid=node_uuid, error=e)
|
||
|
||
text = f"""
|
||
📊 <b>Статистика ноды: {node['name']}</b>
|
||
|
||
<b>Статус:</b>
|
||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||
- Пользователей онлайн: {node['users_online'] or 0}
|
||
- Изменение статуса: {format_datetime(node.get('last_status_change')) if node.get('last_status_change') else '—'}
|
||
- Сообщение: {node.get('last_status_message') or '—'}
|
||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||
|
||
<b>Трафик:</b>
|
||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
|
||
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
|
||
- День сброса: {node.get('traffic_reset_day') or '—'}
|
||
- Уведомления: {node.get('notify_percent') or '—'}
|
||
- Множитель: {node.get('consumption_multiplier') or 1}
|
||
|
||
⚠️ <b>Детальная статистика временно недоступна</b>
|
||
Возможные причины:
|
||
• Проблемы с подключением к API
|
||
• Нода недавно добавлена
|
||
• Недостаточно данных для отображения
|
||
|
||
<b>Обновлено:</b> {format_datetime('now')}
|
||
"""
|
||
|
||
keyboard = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[types.InlineKeyboardButton(text='🔄 Попробовать снова', callback_data=f'node_stats_{node_uuid}')],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_node_manage_{node_uuid}')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_squad_details(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
squad_uuid = callback.data.split('_')[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
squad = await remnawave_service.get_squad_details(squad_uuid)
|
||
|
||
if not squad:
|
||
await callback.answer('❌ Сквад не найден', show_alert=True)
|
||
return
|
||
|
||
text = f"""
|
||
🌐 <b>Сквад: {squad['name']}</b>
|
||
|
||
<b>Информация:</b>
|
||
- UUID: <code>{squad['uuid']}</code>
|
||
- Участников: {squad['members_count']}
|
||
- Инбаундов: {squad['inbounds_count']}
|
||
|
||
<b>Инбаунды:</b>
|
||
"""
|
||
|
||
if squad.get('inbounds'):
|
||
for inbound in squad['inbounds']:
|
||
text += f'- {inbound["tag"]} ({inbound["type"]})\n'
|
||
else:
|
||
text += 'Нет активных инбаундов'
|
||
|
||
await callback.message.edit_text(text, reply_markup=get_squad_management_keyboard(squad_uuid, db_user.language))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def manage_squad_action(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
parts = callback.data.split('_')
|
||
action = parts[1]
|
||
squad_uuid = parts[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
|
||
if action == 'add_users':
|
||
success = await remnawave_service.add_all_users_to_squad(squad_uuid)
|
||
if success:
|
||
await callback.answer('✅ Задача добавления пользователей в очередь')
|
||
else:
|
||
await callback.answer('❌ Ошибка добавления пользователей', show_alert=True)
|
||
|
||
elif action == 'remove_users':
|
||
success = await remnawave_service.remove_all_users_from_squad(squad_uuid)
|
||
if success:
|
||
await callback.answer('✅ Задача удаления пользователей в очередь')
|
||
else:
|
||
await callback.answer('❌ Ошибка удаления пользователей', show_alert=True)
|
||
|
||
elif action == 'delete':
|
||
success = await remnawave_service.delete_squad(squad_uuid)
|
||
if success:
|
||
await callback.message.edit_text(
|
||
'✅ Сквад успешно удален',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')]]
|
||
),
|
||
)
|
||
else:
|
||
await callback.answer('❌ Ошибка удаления сквада', show_alert=True)
|
||
return
|
||
|
||
refreshed_callback = callback.model_copy(update={'data': f'admin_squad_manage_{squad_uuid}'}).as_(callback.bot)
|
||
|
||
await show_squad_details(refreshed_callback, db_user, db)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_squad_edit_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
squad_uuid = callback.data.split('_')[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
squad = await remnawave_service.get_squad_details(squad_uuid)
|
||
|
||
if not squad:
|
||
await callback.answer('❌ Сквад не найден', show_alert=True)
|
||
return
|
||
|
||
text = f"""
|
||
✏️ <b>Редактирование сквада: {squad['name']}</b>
|
||
|
||
<b>Текущие инбаунды:</b>
|
||
"""
|
||
|
||
if squad.get('inbounds'):
|
||
for inbound in squad['inbounds']:
|
||
text += f'✅ {inbound["tag"]} ({inbound["type"]})\n'
|
||
else:
|
||
text += 'Нет активных инбаундов\n'
|
||
|
||
text += '\n<b>Доступные действия:</b>'
|
||
|
||
await callback.message.edit_text(text, reply_markup=get_squad_edit_keyboard(squad_uuid, db_user.language))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_squad_inbounds_selection(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
squad_uuid = callback.data.split('_')[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
|
||
squad = await remnawave_service.get_squad_details(squad_uuid)
|
||
all_inbounds = await remnawave_service.get_all_inbounds()
|
||
|
||
if not squad:
|
||
await callback.answer('❌ Сквад не найден', show_alert=True)
|
||
return
|
||
|
||
if not all_inbounds:
|
||
await callback.answer('❌ Нет доступных инбаундов', show_alert=True)
|
||
return
|
||
|
||
if squad_uuid not in squad_inbound_selections:
|
||
squad_inbound_selections[squad_uuid] = {inbound['uuid'] for inbound in squad.get('inbounds', [])}
|
||
|
||
text = f"""
|
||
🔧 <b>Изменение инбаундов</b>
|
||
|
||
<b>Сквад:</b> {squad['name']}
|
||
<b>Текущих инбаундов:</b> {len(squad_inbound_selections[squad_uuid])}
|
||
|
||
<b>Доступные инбаунды:</b>
|
||
"""
|
||
|
||
keyboard = []
|
||
|
||
for i, inbound in enumerate(all_inbounds[:15]):
|
||
is_selected = inbound['uuid'] in squad_inbound_selections[squad_uuid]
|
||
emoji = '✅' if is_selected else '☐'
|
||
|
||
keyboard.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=f'{emoji} {inbound["tag"]} ({inbound["type"]})', callback_data=f'sqd_tgl_{i}_{squad_uuid[:8]}'
|
||
)
|
||
]
|
||
)
|
||
|
||
if len(all_inbounds) > 15:
|
||
text += f'\n⚠️ Показано первые 15 из {len(all_inbounds)} инбаундов'
|
||
|
||
keyboard.extend(
|
||
[
|
||
[types.InlineKeyboardButton(text='💾 Сохранить изменения', callback_data=f'sqd_save_{squad_uuid[:8]}')],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'sqd_edit_{squad_uuid[:8]}')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_squad_rename_form(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||
squad_uuid = callback.data.split('_')[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
squad = await remnawave_service.get_squad_details(squad_uuid)
|
||
|
||
if not squad:
|
||
await callback.answer('❌ Сквад не найден', show_alert=True)
|
||
return
|
||
|
||
await state.update_data(squad_uuid=squad_uuid, squad_name=squad['name'])
|
||
await state.set_state(SquadRenameStates.waiting_for_new_name)
|
||
|
||
text = f"""
|
||
✏️ <b>Переименование сквада</b>
|
||
|
||
<b>Текущее название:</b> {squad['name']}
|
||
|
||
📝 <b>Введите новое название сквада:</b>
|
||
|
||
<i>Требования к названию:</i>
|
||
• От 2 до 20 символов
|
||
• Только буквы, цифры, дефисы и подчеркивания
|
||
• Без пробелов и специальных символов
|
||
|
||
Отправьте сообщение с новым названием или нажмите "Отмена" для выхода.
|
||
"""
|
||
|
||
keyboard = [[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'cancel_rename_{squad_uuid}')]]
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def cancel_squad_rename(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||
squad_uuid = callback.data.split('_')[-1]
|
||
|
||
await state.clear()
|
||
|
||
refreshed_callback = callback.model_copy(update={'data': f'squad_edit_{squad_uuid}'}).as_(callback.bot)
|
||
|
||
await show_squad_edit_menu(refreshed_callback, db_user, db)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def process_squad_new_name(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||
data = await state.get_data()
|
||
squad_uuid = data.get('squad_uuid')
|
||
old_name = data.get('squad_name')
|
||
|
||
if not squad_uuid:
|
||
await message.answer('❌ Ошибка: сквад не найден')
|
||
await state.clear()
|
||
return
|
||
|
||
new_name = message.text.strip()
|
||
|
||
if not new_name:
|
||
await message.answer('❌ Название не может быть пустым. Попробуйте еще раз:')
|
||
return
|
||
|
||
if len(new_name) < 2 or len(new_name) > 20:
|
||
await message.answer('❌ Название должно быть от 2 до 20 символов. Попробуйте еще раз:')
|
||
return
|
||
|
||
import re
|
||
|
||
if not re.match(r'^[A-Za-z0-9_-]+$', new_name):
|
||
await message.answer(
|
||
'❌ Название может содержать только буквы, цифры, дефисы и подчеркивания. Попробуйте еще раз:'
|
||
)
|
||
return
|
||
|
||
if new_name == old_name:
|
||
await message.answer('❌ Новое название совпадает с текущим. Введите другое название:')
|
||
return
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
success = await remnawave_service.rename_squad(squad_uuid, new_name)
|
||
|
||
if success:
|
||
await message.answer(
|
||
f'✅ <b>Сквад успешно переименован!</b>\n\n'
|
||
f'<b>Старое название:</b> {old_name}\n'
|
||
f'<b>Новое название:</b> {new_name}',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='📋 Детали сквада', callback_data=f'admin_squad_manage_{squad_uuid}'
|
||
)
|
||
],
|
||
[types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')],
|
||
]
|
||
),
|
||
)
|
||
await state.clear()
|
||
else:
|
||
await message.answer(
|
||
'❌ <b>Ошибка переименования сквада</b>\n\n'
|
||
'Возможные причины:\n'
|
||
'• Сквад с таким названием уже существует\n'
|
||
'• Проблемы с подключением к API\n'
|
||
'• Недостаточно прав\n\n'
|
||
'Попробуйте другое название:',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'cancel_rename_{squad_uuid}')]
|
||
]
|
||
),
|
||
)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def toggle_squad_inbound(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
parts = callback.data.split('_')
|
||
inbound_index = int(parts[2])
|
||
short_squad_uuid = parts[3]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
squads = await remnawave_service.get_all_squads()
|
||
|
||
full_squad_uuid = None
|
||
for squad in squads:
|
||
if squad['uuid'].startswith(short_squad_uuid):
|
||
full_squad_uuid = squad['uuid']
|
||
break
|
||
|
||
if not full_squad_uuid:
|
||
await callback.answer('❌ Сквад не найден', show_alert=True)
|
||
return
|
||
|
||
all_inbounds = await remnawave_service.get_all_inbounds()
|
||
if inbound_index >= len(all_inbounds):
|
||
await callback.answer('❌ Инбаунд не найден', show_alert=True)
|
||
return
|
||
|
||
selected_inbound = all_inbounds[inbound_index]
|
||
|
||
if full_squad_uuid not in squad_inbound_selections:
|
||
squad_inbound_selections[full_squad_uuid] = set()
|
||
|
||
if selected_inbound['uuid'] in squad_inbound_selections[full_squad_uuid]:
|
||
squad_inbound_selections[full_squad_uuid].remove(selected_inbound['uuid'])
|
||
await callback.answer(f'➖ Убран: {selected_inbound["tag"]}')
|
||
else:
|
||
squad_inbound_selections[full_squad_uuid].add(selected_inbound['uuid'])
|
||
await callback.answer(f'➕ Добавлен: {selected_inbound["tag"]}')
|
||
|
||
text = f"""
|
||
🔧 <b>Изменение инбаундов</b>
|
||
|
||
<b>Сквад:</b> {squads[0]['name'] if squads else 'Неизвестно'}
|
||
<b>Выбрано инбаундов:</b> {len(squad_inbound_selections[full_squad_uuid])}
|
||
|
||
<b>Доступные инбаунды:</b>
|
||
"""
|
||
|
||
keyboard = []
|
||
for i, inbound in enumerate(all_inbounds[:15]):
|
||
is_selected = inbound['uuid'] in squad_inbound_selections[full_squad_uuid]
|
||
emoji = '✅' if is_selected else '☐'
|
||
|
||
keyboard.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=f'{emoji} {inbound["tag"]} ({inbound["type"]})',
|
||
callback_data=f'sqd_tgl_{i}_{short_squad_uuid}',
|
||
)
|
||
]
|
||
)
|
||
|
||
keyboard.extend(
|
||
[
|
||
[types.InlineKeyboardButton(text='💾 Сохранить изменения', callback_data=f'sqd_save_{short_squad_uuid}')],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'sqd_edit_{short_squad_uuid}')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def save_squad_inbounds(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
short_squad_uuid = callback.data.split('_')[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
squads = await remnawave_service.get_all_squads()
|
||
|
||
full_squad_uuid = None
|
||
squad_name = None
|
||
for squad in squads:
|
||
if squad['uuid'].startswith(short_squad_uuid):
|
||
full_squad_uuid = squad['uuid']
|
||
squad_name = squad['name']
|
||
break
|
||
|
||
if not full_squad_uuid:
|
||
await callback.answer('❌ Сквад не найден', show_alert=True)
|
||
return
|
||
|
||
selected_inbounds = squad_inbound_selections.get(full_squad_uuid, set())
|
||
|
||
try:
|
||
success = await remnawave_service.update_squad_inbounds(full_squad_uuid, list(selected_inbounds))
|
||
|
||
if success:
|
||
squad_inbound_selections.pop(full_squad_uuid, None)
|
||
|
||
await callback.message.edit_text(
|
||
f'✅ <b>Инбаунды сквада обновлены</b>\n\n'
|
||
f'<b>Сквад:</b> {squad_name}\n'
|
||
f'<b>Количество инбаундов:</b> {len(selected_inbounds)}',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='📋 Детали сквада', callback_data=f'admin_squad_manage_{full_squad_uuid}'
|
||
)
|
||
],
|
||
]
|
||
),
|
||
)
|
||
await callback.answer('✅ Изменения сохранены!')
|
||
else:
|
||
await callback.answer('❌ Ошибка сохранения изменений', show_alert=True)
|
||
|
||
except Exception as e:
|
||
logger.error('Error saving squad inbounds', error=e)
|
||
await callback.answer('❌ Ошибка при сохранении', show_alert=True)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_squad_edit_menu_short(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
short_squad_uuid = callback.data.split('_')[-1]
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
squads = await remnawave_service.get_all_squads()
|
||
|
||
full_squad_uuid = None
|
||
for squad in squads:
|
||
if squad['uuid'].startswith(short_squad_uuid):
|
||
full_squad_uuid = squad['uuid']
|
||
break
|
||
|
||
if not full_squad_uuid:
|
||
await callback.answer('❌ Сквад не найден', show_alert=True)
|
||
return
|
||
|
||
refreshed_callback = callback.model_copy(update={'data': f'squad_edit_{full_squad_uuid}'}).as_(callback.bot)
|
||
|
||
await show_squad_edit_menu(refreshed_callback, db_user, db)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def start_squad_creation(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||
await state.set_state(SquadCreateStates.waiting_for_name)
|
||
|
||
text = """
|
||
➕ <b>Создание нового сквада</b>
|
||
|
||
<b>Шаг 1 из 2: Название сквада</b>
|
||
|
||
📝 <b>Введите название для нового сквада:</b>
|
||
|
||
<i>Требования к названию:</i>
|
||
• От 2 до 20 символов
|
||
• Только буквы, цифры, дефисы и подчеркивания
|
||
• Без пробелов и специальных символов
|
||
|
||
Отправьте сообщение с названием или нажмите "Отмена" для выхода.
|
||
"""
|
||
|
||
keyboard = [[types.InlineKeyboardButton(text='❌ Отмена', callback_data='cancel_squad_create')]]
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def process_squad_name(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||
squad_name = message.text.strip()
|
||
|
||
if not squad_name:
|
||
await message.answer('❌ Название не может быть пустым. Попробуйте еще раз:')
|
||
return
|
||
|
||
if len(squad_name) < 2 or len(squad_name) > 20:
|
||
await message.answer('❌ Название должно быть от 2 до 20 символов. Попробуйте еще раз:')
|
||
return
|
||
|
||
import re
|
||
|
||
if not re.match(r'^[A-Za-z0-9_-]+$', squad_name):
|
||
await message.answer(
|
||
'❌ Название может содержать только буквы, цифры, дефисы и подчеркивания. Попробуйте еще раз:'
|
||
)
|
||
return
|
||
|
||
await state.update_data(squad_name=squad_name)
|
||
await state.set_state(SquadCreateStates.selecting_inbounds)
|
||
|
||
user_id = message.from_user.id
|
||
squad_create_data[user_id] = {'name': squad_name, 'selected_inbounds': set()}
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
all_inbounds = await remnawave_service.get_all_inbounds()
|
||
|
||
if not all_inbounds:
|
||
await message.answer(
|
||
'❌ <b>Нет доступных инбаундов</b>\n\nДля создания сквада необходимо иметь хотя бы один инбаунд.',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')]]
|
||
),
|
||
)
|
||
await state.clear()
|
||
return
|
||
|
||
text = f"""
|
||
➕ <b>Создание сквада: {squad_name}</b>
|
||
|
||
<b>Шаг 2 из 2: Выбор инбаундов</b>
|
||
|
||
<b>Выбрано инбаундов:</b> 0
|
||
|
||
<b>Доступные инбаунды:</b>
|
||
"""
|
||
|
||
keyboard = []
|
||
|
||
for i, inbound in enumerate(all_inbounds[:15]):
|
||
keyboard.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=f'☐ {inbound["tag"]} ({inbound["type"]})', callback_data=f'create_tgl_{i}'
|
||
)
|
||
]
|
||
)
|
||
|
||
if len(all_inbounds) > 15:
|
||
text += f'\n⚠️ Показано первые 15 из {len(all_inbounds)} инбаундов'
|
||
|
||
keyboard.extend(
|
||
[
|
||
[types.InlineKeyboardButton(text='✅ Создать сквад', callback_data='create_squad_finish')],
|
||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='cancel_squad_create')],
|
||
]
|
||
)
|
||
|
||
await message.answer(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def toggle_create_inbound(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||
inbound_index = int(callback.data.split('_')[-1])
|
||
user_id = callback.from_user.id
|
||
|
||
if user_id not in squad_create_data:
|
||
await callback.answer('❌ Ошибка: данные сессии не найдены', show_alert=True)
|
||
await state.clear()
|
||
return
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
all_inbounds = await remnawave_service.get_all_inbounds()
|
||
|
||
if inbound_index >= len(all_inbounds):
|
||
await callback.answer('❌ Инбаунд не найден', show_alert=True)
|
||
return
|
||
|
||
selected_inbound = all_inbounds[inbound_index]
|
||
selected_inbounds = squad_create_data[user_id]['selected_inbounds']
|
||
|
||
if selected_inbound['uuid'] in selected_inbounds:
|
||
selected_inbounds.remove(selected_inbound['uuid'])
|
||
await callback.answer(f'➖ Убран: {selected_inbound["tag"]}')
|
||
else:
|
||
selected_inbounds.add(selected_inbound['uuid'])
|
||
await callback.answer(f'➕ Добавлен: {selected_inbound["tag"]}')
|
||
|
||
squad_name = squad_create_data[user_id]['name']
|
||
|
||
text = f"""
|
||
➕ <b>Создание сквада: {squad_name}</b>
|
||
|
||
<b>Шаг 2 из 2: Выбор инбаундов</b>
|
||
|
||
<b>Выбрано инбаундов:</b> {len(selected_inbounds)}
|
||
|
||
<b>Доступные инбаунды:</b>
|
||
"""
|
||
|
||
keyboard = []
|
||
|
||
for i, inbound in enumerate(all_inbounds[:15]):
|
||
is_selected = inbound['uuid'] in selected_inbounds
|
||
emoji = '✅' if is_selected else '☐'
|
||
|
||
keyboard.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=f'{emoji} {inbound["tag"]} ({inbound["type"]})', callback_data=f'create_tgl_{i}'
|
||
)
|
||
]
|
||
)
|
||
|
||
keyboard.extend(
|
||
[
|
||
[types.InlineKeyboardButton(text='✅ Создать сквад', callback_data='create_squad_finish')],
|
||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='cancel_squad_create')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def finish_squad_creation(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||
user_id = callback.from_user.id
|
||
|
||
if user_id not in squad_create_data:
|
||
await callback.answer('❌ Ошибка: данные сессии не найдены', show_alert=True)
|
||
await state.clear()
|
||
return
|
||
|
||
squad_name = squad_create_data[user_id]['name']
|
||
selected_inbounds = list(squad_create_data[user_id]['selected_inbounds'])
|
||
|
||
if not selected_inbounds:
|
||
await callback.answer('❌ Необходимо выбрать хотя бы один инбаунд', show_alert=True)
|
||
return
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
success = await remnawave_service.create_squad(squad_name, selected_inbounds)
|
||
|
||
squad_create_data.pop(user_id, None)
|
||
await state.clear()
|
||
|
||
if success:
|
||
await callback.message.edit_text(
|
||
f'✅ <b>Сквад успешно создан!</b>\n\n'
|
||
f'<b>Название:</b> {squad_name}\n'
|
||
f'<b>Количество инбаундов:</b> {len(selected_inbounds)}\n\n'
|
||
f'Сквад готов к использованию!',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[types.InlineKeyboardButton(text='📋 Список сквадов', callback_data='admin_rw_squads')],
|
||
[types.InlineKeyboardButton(text='⬅️ К панели Remnawave', callback_data='admin_remnawave')],
|
||
]
|
||
),
|
||
)
|
||
await callback.answer('✅ Сквад создан!')
|
||
else:
|
||
await callback.message.edit_text(
|
||
f'❌ <b>Ошибка создания сквада</b>\n\n'
|
||
f'<b>Название:</b> {squad_name}\n\n'
|
||
f'Возможные причины:\n'
|
||
f'• Сквад с таким названием уже существует\n'
|
||
f'• Проблемы с подключением к API\n'
|
||
f'• Недостаточно прав\n'
|
||
f'• Некорректные инбаунды',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[types.InlineKeyboardButton(text='🔄 Попробовать снова', callback_data='admin_squad_create')],
|
||
[types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')],
|
||
]
|
||
),
|
||
)
|
||
await callback.answer('❌ Ошибка создания сквада', show_alert=True)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def cancel_squad_creation(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||
user_id = callback.from_user.id
|
||
|
||
squad_create_data.pop(user_id, None)
|
||
await state.clear()
|
||
|
||
await show_squads_management(callback, db_user, db)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def restart_all_nodes(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
remnawave_service = RemnaWaveService()
|
||
success = await remnawave_service.restart_all_nodes()
|
||
|
||
if success:
|
||
await callback.message.edit_text(
|
||
'✅ Команда перезагрузки всех нод отправлена',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ К нодам', callback_data='admin_rw_nodes')]]
|
||
),
|
||
)
|
||
else:
|
||
await callback.message.edit_text(
|
||
'❌ Ошибка перезагрузки нод',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ К нодам', callback_data='admin_rw_nodes')]]
|
||
),
|
||
)
|
||
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_sync_options(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
status = remnawave_sync_service.get_status()
|
||
times_text = ', '.join(t.strftime('%H:%M') for t in status.times) if status.times else '—'
|
||
next_run_text = format_datetime(status.next_run) if status.next_run else '—'
|
||
last_result = '—'
|
||
|
||
if status.last_run_finished_at:
|
||
result_icon = '✅' if status.last_run_success else '❌'
|
||
result_label = 'успешно' if status.last_run_success else 'с ошибками'
|
||
finished_text = format_datetime(status.last_run_finished_at)
|
||
last_result = f'{result_icon} {result_label} ({finished_text})'
|
||
elif status.last_run_started_at:
|
||
last_result = f'⏳ Запущено {format_datetime(status.last_run_started_at)}'
|
||
|
||
status_lines = [
|
||
f'⚙️ Статус: {"✅ Включена" if status.enabled else "❌ Отключена"}',
|
||
f'🕒 Расписание: {times_text}',
|
||
f'📅 Следующий запуск: {next_run_text if status.enabled else "—"}',
|
||
f'📊 Последний запуск: {last_result}',
|
||
]
|
||
|
||
text = (
|
||
'🔄 <b>Синхронизация с Remnawave</b>\n\n'
|
||
'🔄 <b>Полная синхронизация выполняет:</b>\n'
|
||
'• Создание новых пользователей из панели в боте\n'
|
||
'• Обновление данных существующих пользователей\n'
|
||
'• Деактивация подписок пользователей, отсутствующих в панели\n'
|
||
'• Сохранение балансов пользователей\n'
|
||
'• ⏱️ Время выполнения: 2-5 минут\n\n'
|
||
'⚠️ <b>Важно:</b>\n'
|
||
'• Во время синхронизации не выполняйте другие операции\n'
|
||
'• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n'
|
||
'• Рекомендуется делать полную синхронизацию ежедневно\n'
|
||
'• Баланс пользователей НЕ удаляется\n\n'
|
||
'⬆️ <b>Обратная синхронизация:</b>\n'
|
||
'• Отправляет активных пользователей из бота в панель\n'
|
||
'• Используйте при сбоях панели или для восстановления данных\n\n' + '\n'.join(status_lines)
|
||
)
|
||
|
||
keyboard = [
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='🔄 Запустить полную синхронизацию',
|
||
callback_data='sync_all_users',
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='⬆️ Синхронизация в панель',
|
||
callback_data='sync_to_panel',
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='⚙️ Настройки автосинхронизации',
|
||
callback_data='admin_rw_auto_sync',
|
||
)
|
||
],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')],
|
||
]
|
||
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_auto_sync_settings(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
await state.clear()
|
||
status = remnawave_sync_service.get_status()
|
||
text, keyboard = _build_auto_sync_view(status)
|
||
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def toggle_auto_sync_setting(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
await state.clear()
|
||
new_value = not bool(settings.REMNAWAVE_AUTO_SYNC_ENABLED)
|
||
await bot_configuration_service.set_value(
|
||
db,
|
||
'REMNAWAVE_AUTO_SYNC_ENABLED',
|
||
new_value,
|
||
)
|
||
await db.commit()
|
||
|
||
status = remnawave_sync_service.get_status()
|
||
text, keyboard = _build_auto_sync_view(status)
|
||
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer(f'Автосинхронизация {"включена" if new_value else "отключена"}')
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def prompt_auto_sync_schedule(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
status = remnawave_sync_service.get_status()
|
||
current_schedule = ', '.join(t.strftime('%H:%M') for t in status.times) if status.times else '—'
|
||
|
||
instructions = (
|
||
'🕒 <b>Настройка расписания автосинхронизации</b>\n\n'
|
||
'Укажите время запуска через запятую или с новой строки в формате HH:MM.\n'
|
||
f'Текущее расписание: <code>{current_schedule}</code>\n\n'
|
||
'Примеры: <code>03:00, 15:30</code> или <code>00:15\n06:00\n18:45</code>\n\n'
|
||
'Отправьте <b>отмена</b>, чтобы вернуться без изменений.'
|
||
)
|
||
|
||
await state.set_state(RemnaWaveSyncStates.waiting_for_schedule)
|
||
await state.update_data(
|
||
auto_sync_message_id=callback.message.message_id,
|
||
auto_sync_message_chat_id=callback.message.chat.id,
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
instructions,
|
||
parse_mode='HTML',
|
||
reply_markup=types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='❌ Отмена',
|
||
callback_data='remnawave_auto_sync_cancel',
|
||
)
|
||
]
|
||
]
|
||
),
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def cancel_auto_sync_schedule(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
await state.clear()
|
||
status = remnawave_sync_service.get_status()
|
||
text, keyboard = _build_auto_sync_view(status)
|
||
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer('Изменение расписания отменено')
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def run_auto_sync_now(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
if remnawave_sync_service.get_status().is_running:
|
||
await callback.answer('Синхронизация уже выполняется', show_alert=True)
|
||
return
|
||
|
||
await state.clear()
|
||
await callback.message.edit_text(
|
||
'🔄 Запуск автосинхронизации...\n\nПодождите, это может занять несколько минут.',
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer('Автосинхронизация запущена')
|
||
|
||
result = await remnawave_sync_service.run_sync_now(reason='manual')
|
||
status = remnawave_sync_service.get_status()
|
||
base_text, keyboard = _build_auto_sync_view(status)
|
||
|
||
if not result.get('started'):
|
||
await callback.message.edit_text(
|
||
'⚠️ <b>Синхронизация уже выполняется</b>\n\n' + base_text,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
return
|
||
|
||
if result.get('success'):
|
||
user_stats = result.get('user_stats') or {}
|
||
server_stats = result.get('server_stats') or {}
|
||
summary = (
|
||
'✅ <b>Синхронизация завершена</b>\n'
|
||
f'👥 Пользователи: создано {user_stats.get("created", 0)}, обновлено {user_stats.get("updated", 0)}, '
|
||
f'деактивировано {user_stats.get("deleted", user_stats.get("deactivated", 0))}, ошибок {user_stats.get("errors", 0)}\n'
|
||
f'🌐 Серверы: создано {server_stats.get("created", 0)}, обновлено {server_stats.get("updated", 0)}, удалено {server_stats.get("removed", 0)}\n\n'
|
||
)
|
||
final_text = summary + base_text
|
||
await callback.message.edit_text(
|
||
final_text,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
else:
|
||
error_text = result.get('error') or 'Неизвестная ошибка'
|
||
summary = f'❌ <b>Синхронизация завершилась с ошибкой</b>\nПричина: {error_text}\n\n'
|
||
await callback.message.edit_text(
|
||
summary + base_text,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def save_auto_sync_schedule(
|
||
message: types.Message,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
text = (message.text or '').strip()
|
||
data = await state.get_data()
|
||
|
||
if text.lower() in {'отмена', 'cancel'}:
|
||
await state.clear()
|
||
status = remnawave_sync_service.get_status()
|
||
view_text, keyboard = _build_auto_sync_view(status)
|
||
message_id = data.get('auto_sync_message_id')
|
||
chat_id = data.get('auto_sync_message_chat_id', message.chat.id)
|
||
if message_id:
|
||
await message.bot.edit_message_text(
|
||
view_text,
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
else:
|
||
await message.answer(
|
||
view_text,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
await message.answer('Настройка расписания отменена')
|
||
return
|
||
|
||
parsed_times = settings.parse_daily_time_list(text)
|
||
|
||
if not parsed_times:
|
||
await message.answer(
|
||
'❌ Не удалось распознать время. Используйте формат HH:MM, например 03:00 или 18:45.',
|
||
)
|
||
return
|
||
|
||
normalized_value = ', '.join(t.strftime('%H:%M') for t in parsed_times)
|
||
await bot_configuration_service.set_value(
|
||
db,
|
||
'REMNAWAVE_AUTO_SYNC_TIMES',
|
||
normalized_value,
|
||
)
|
||
await db.commit()
|
||
|
||
status = remnawave_sync_service.get_status()
|
||
view_text, keyboard = _build_auto_sync_view(status)
|
||
message_id = data.get('auto_sync_message_id')
|
||
chat_id = data.get('auto_sync_message_chat_id', message.chat.id)
|
||
|
||
if message_id:
|
||
await message.bot.edit_message_text(
|
||
view_text,
|
||
chat_id=chat_id,
|
||
message_id=message_id,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
else:
|
||
await message.answer(
|
||
view_text,
|
||
reply_markup=keyboard,
|
||
parse_mode='HTML',
|
||
)
|
||
|
||
await state.clear()
|
||
await message.answer('✅ Расписание автосинхронизации обновлено')
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def sync_all_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
"""Выполняет полную синхронизацию всех пользователей"""
|
||
|
||
progress_text = """
|
||
🔄 <b>Выполняется полная синхронизация...</b>
|
||
|
||
📋 Этапы:
|
||
• Загрузка ВСЕХ пользователей из панели Remnawave
|
||
• Создание новых пользователей в боте
|
||
• Обновление существующих пользователей
|
||
• Деактивация подписок отсутствующих пользователей
|
||
• Сохранение балансов
|
||
|
||
⏳ Пожалуйста, подождите...
|
||
"""
|
||
|
||
await callback.message.edit_text(progress_text, reply_markup=None)
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
stats = await remnawave_service.sync_users_from_panel(db, 'all')
|
||
|
||
total_operations = stats['created'] + stats['updated'] + stats.get('deleted', 0)
|
||
|
||
if stats['errors'] == 0:
|
||
status_emoji = '✅'
|
||
status_text = 'успешно завершена'
|
||
elif stats['errors'] < total_operations:
|
||
status_emoji = '⚠️'
|
||
status_text = 'завершена с предупреждениями'
|
||
else:
|
||
status_emoji = '❌'
|
||
status_text = 'завершена с ошибками'
|
||
|
||
text = f"""
|
||
{status_emoji} <b>Полная синхронизация {status_text}</b>
|
||
|
||
📊 <b>Результат:</b>
|
||
• 🆕 Создано: {stats['created']}
|
||
• 🔄 Обновлено: {stats['updated']}
|
||
• 🗑️ Деактивировано: {stats.get('deleted', 0)}
|
||
• ❌ Ошибок: {stats['errors']}
|
||
"""
|
||
|
||
if stats.get('deleted', 0) > 0:
|
||
text += """
|
||
|
||
🗑️ <b>Деактивированные подписки:</b>
|
||
Деактивированы подписки пользователей, которые
|
||
отсутствуют в панели Remnawave.
|
||
💰 Балансы пользователей сохранены.
|
||
"""
|
||
|
||
if stats['errors'] > 0:
|
||
text += """
|
||
|
||
⚠️ <b>Внимание:</b>
|
||
Некоторые операции завершились с ошибками.
|
||
Проверьте логи для получения подробной информации.
|
||
"""
|
||
|
||
text += """
|
||
|
||
💡 <b>Рекомендации:</b>
|
||
• Полная синхронизация выполнена
|
||
• Рекомендуется запускать раз в день
|
||
• Все пользователи из панели синхронизированы
|
||
"""
|
||
|
||
keyboard = []
|
||
|
||
if stats['errors'] > 0:
|
||
keyboard.append([types.InlineKeyboardButton(text='🔄 Повторить синхронизацию', callback_data='sync_all_users')])
|
||
|
||
keyboard.extend(
|
||
[
|
||
[
|
||
types.InlineKeyboardButton(text='📊 Статистика системы', callback_data='admin_rw_system'),
|
||
types.InlineKeyboardButton(text='🌐 Ноды', callback_data='admin_rw_nodes'),
|
||
],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def sync_users_to_panel(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
await callback.message.edit_text(
|
||
'⬆️ Выполняется синхронизация данных бота в панель Remnawave...\n\nЭто может занять несколько минут.',
|
||
reply_markup=None,
|
||
)
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
stats = await remnawave_service.sync_users_to_panel(db)
|
||
|
||
if stats['errors'] == 0:
|
||
status_emoji = '✅'
|
||
status_text = 'успешно завершена'
|
||
else:
|
||
status_emoji = '⚠️' if (stats['created'] + stats['updated']) > 0 else '❌'
|
||
status_text = 'завершена с предупреждениями' if status_emoji == '⚠️' else 'завершена с ошибками'
|
||
|
||
text = (
|
||
f'{status_emoji} <b>Синхронизация в панель {status_text}</b>\n\n'
|
||
'📊 <b>Результаты:</b>\n'
|
||
f'• 🆕 Создано: {stats["created"]}\n'
|
||
f'• 🔄 Обновлено: {stats["updated"]}\n'
|
||
f'• ❌ Ошибок: {stats["errors"]}'
|
||
)
|
||
|
||
keyboard = [
|
||
[types.InlineKeyboardButton(text='🔄 Повторить', callback_data='sync_to_panel')],
|
||
[types.InlineKeyboardButton(text='🔄 Полная синхронизация', callback_data='sync_all_users')],
|
||
[types.InlineKeyboardButton(text='⬅️ К синхронизации', callback_data='admin_rw_sync')],
|
||
]
|
||
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_sync_recommendations(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
await callback.message.edit_text('🔍 Анализируем состояние синхронизации...', reply_markup=None)
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
recommendations = await remnawave_service.get_sync_recommendations(db)
|
||
|
||
priority_emoji = {'low': '🟢', 'medium': '🟡', 'high': '🔴'}
|
||
|
||
text = f"""
|
||
💡 <b>Рекомендации по синхронизации</b>
|
||
|
||
{priority_emoji.get(recommendations['priority'], '🟢')} <b>Приоритет:</b> {recommendations['priority'].upper()}
|
||
⏱️ <b>Время выполнения:</b> {recommendations['estimated_time']}
|
||
|
||
<b>Рекомендуемое действие:</b>
|
||
"""
|
||
|
||
if recommendations['sync_type'] == 'all':
|
||
text += '🔄 Полная синхронизация'
|
||
elif recommendations['sync_type'] == 'update_only':
|
||
text += '📈 Обновление данных'
|
||
elif recommendations['sync_type'] == 'new_only':
|
||
text += '🆕 Синхронизация новых'
|
||
else:
|
||
text += '✅ Синхронизация не требуется'
|
||
|
||
text += '\n\n<b>Причины:</b>\n'
|
||
for reason in recommendations['reasons']:
|
||
text += f'• {reason}\n'
|
||
|
||
keyboard = []
|
||
|
||
if recommendations['should_sync'] and recommendations['sync_type'] != 'none':
|
||
keyboard.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text='✅ Выполнить рекомендацию',
|
||
callback_data=f'sync_{recommendations["sync_type"]}_users'
|
||
if recommendations['sync_type'] != 'update_only'
|
||
else 'sync_update_data',
|
||
)
|
||
]
|
||
)
|
||
|
||
keyboard.extend(
|
||
[
|
||
[types.InlineKeyboardButton(text='🔄 Другие опции', callback_data='admin_rw_sync')],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def validate_subscriptions(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
await callback.message.edit_text(
|
||
'🔍 Выполняется валидация подписок...\n\nПроверяем данные, может занять несколько минут.', reply_markup=None
|
||
)
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
stats = await remnawave_service.validate_and_fix_subscriptions(db)
|
||
|
||
if stats['errors'] == 0:
|
||
status_emoji = '✅'
|
||
status_text = 'успешно завершена'
|
||
else:
|
||
status_emoji = '⚠️'
|
||
status_text = 'завершена с ошибками'
|
||
|
||
text = f"""
|
||
{status_emoji} <b>Валидация {status_text}</b>
|
||
|
||
📊 <b>Результаты:</b>
|
||
• 🔍 Проверено подписок: {stats['checked']}
|
||
• 🔧 Исправлено подписок: {stats['fixed']}
|
||
• ⚠️ Найдено проблем: {stats['issues_found']}
|
||
• ❌ Ошибок: {stats['errors']}
|
||
"""
|
||
|
||
if stats['fixed'] > 0:
|
||
text += '\n✅ <b>Исправленные проблемы:</b>\n'
|
||
text += '• Статусы просроченных подписок\n'
|
||
text += '• Отсутствующие данные Remnawave\n'
|
||
text += '• Некорректные лимиты трафика\n'
|
||
text += '• Настройки устройств\n'
|
||
|
||
if stats['errors'] > 0:
|
||
text += '\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации.'
|
||
|
||
keyboard = [
|
||
[types.InlineKeyboardButton(text='🔄 Повторить валидацию', callback_data='sync_validate')],
|
||
[types.InlineKeyboardButton(text='🔄 Полная синхронизация', callback_data='sync_all_users')],
|
||
[types.InlineKeyboardButton(text='⬅️ К синхронизации', callback_data='admin_rw_sync')],
|
||
]
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def cleanup_subscriptions(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
await callback.message.edit_text(
|
||
'🧹 Выполняется очистка неактуальных подписок...\n\nУдаляем подписки пользователей, отсутствующих в панели.',
|
||
reply_markup=None,
|
||
)
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
stats = await remnawave_service.cleanup_orphaned_subscriptions(db)
|
||
|
||
if stats['errors'] == 0:
|
||
status_emoji = '✅'
|
||
status_text = 'успешно завершена'
|
||
else:
|
||
status_emoji = '⚠️'
|
||
status_text = 'завершена с ошибками'
|
||
|
||
text = f"""
|
||
{status_emoji} <b>Очистка {status_text}</b>
|
||
|
||
📊 <b>Результаты:</b>
|
||
• 🔍 Проверено подписок: {stats['checked']}
|
||
• 🗑️ Деактивировано: {stats['deactivated']}
|
||
• ❌ Ошибок: {stats['errors']}
|
||
"""
|
||
|
||
if stats['deactivated'] > 0:
|
||
text += '\n🗑️ <b>Деактивированные подписки:</b>\n'
|
||
text += 'Отключены подписки пользователей, которые\n'
|
||
text += 'отсутствуют в панели Remnawave.\n'
|
||
else:
|
||
text += '\n✅ Все подписки актуальны!\nНеактуальных подписок не найдено.'
|
||
|
||
if stats['errors'] > 0:
|
||
text += '\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации.'
|
||
|
||
keyboard = [
|
||
[types.InlineKeyboardButton(text='🔄 Повторить очистку', callback_data='sync_cleanup')],
|
||
[types.InlineKeyboardButton(text='🔍 Валидация', callback_data='sync_validate')],
|
||
[types.InlineKeyboardButton(text='⬅️ К синхронизации', callback_data='admin_rw_sync')],
|
||
]
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def force_cleanup_all_orphaned_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
await callback.message.edit_text(
|
||
'🗑️ Выполняется принудительная очистка всех пользователей, отсутствующих в панели...\n\n'
|
||
'⚠️ ВНИМАНИЕ: Это полностью удалит ВСЕ данные пользователей!\n'
|
||
'📊 Включая: транзакции, реферальные доходы, промокоды, серверы, балансы\n\n'
|
||
'⏳ Пожалуйста, подождите...',
|
||
reply_markup=None,
|
||
)
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
stats = await remnawave_service.cleanup_orphaned_subscriptions(db)
|
||
|
||
if stats['errors'] == 0:
|
||
status_emoji = '✅'
|
||
status_text = 'успешно завершена'
|
||
else:
|
||
status_emoji = '⚠️'
|
||
status_text = 'завершена с ошибками'
|
||
|
||
text = f"""
|
||
{status_emoji} <b>Принудительная очистка {status_text}</b>
|
||
|
||
📊 <b>Результаты:</b>
|
||
• 🔍 Проверено подписок: {stats['checked']}
|
||
• 🗑️ Полностью очищено: {stats['deactivated']}
|
||
• ❌ Ошибок: {stats['errors']}
|
||
"""
|
||
|
||
if stats['deactivated'] > 0:
|
||
text += """
|
||
|
||
🗑️ <b>Полностью очищенные данные:</b>
|
||
• Подписки сброшены к начальному состоянию
|
||
• Удалены ВСЕ транзакции пользователей
|
||
• Удалены ВСЕ реферальные доходы
|
||
• Удалены использования промокодов
|
||
• Сброшены балансы к нулю
|
||
• Удалены подключенные серверы
|
||
• Сброшены HWID устройства в Remnawave
|
||
• Очищены Remnawave UUID
|
||
"""
|
||
else:
|
||
text += '\n✅ Неактуальных подписок не найдено!\nВсе пользователи синхронизированы с панелью.'
|
||
|
||
if stats['errors'] > 0:
|
||
text += '\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации.'
|
||
|
||
keyboard = [
|
||
[types.InlineKeyboardButton(text='🔄 Повторить очистку', callback_data='force_cleanup_orphaned')],
|
||
[types.InlineKeyboardButton(text='🔄 Полная синхронизация', callback_data='sync_all_users')],
|
||
[types.InlineKeyboardButton(text='⬅️ К синхронизации', callback_data='admin_rw_sync')],
|
||
]
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def confirm_force_cleanup(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
text = """
|
||
⚠️ <b>ВНИМАНИЕ! ОПАСНАЯ ОПЕРАЦИЯ!</b>
|
||
|
||
🗑️ <b>Принудительная очистка полностью удалит:</b>
|
||
• ВСЕ транзакции пользователей отсутствующих в панели
|
||
• ВСЕ реферальные доходы и связи
|
||
• ВСЕ использования промокодов
|
||
• ВСЕ подключенные серверы подписок
|
||
• ВСЕ балансы (сброс к нулю)
|
||
• ВСЕ HWID устройства в Remnawave
|
||
• ВСЕ Remnawave UUID и ссылки
|
||
|
||
⚡ <b>Это действие НЕОБРАТИМО!</b>
|
||
|
||
Используйте только если:
|
||
• Обычная синхронизация не помогает
|
||
• Нужно полностью очистить "мусорные" данные
|
||
• После массового удаления пользователей из панели
|
||
|
||
❓ <b>Вы действительно хотите продолжить?</b>
|
||
"""
|
||
|
||
keyboard = [
|
||
[types.InlineKeyboardButton(text='🗑️ ДА, ОЧИСТИТЬ ВСЕ', callback_data='force_cleanup_orphaned')],
|
||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_rw_sync')],
|
||
]
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def sync_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
sync_type = callback.data.split('_')[-2] + '_' + callback.data.split('_')[-1]
|
||
|
||
progress_text = '🔄 Выполняется синхронизация...\n\n'
|
||
|
||
if sync_type == 'all_users':
|
||
progress_text += '📋 Тип: Полная синхронизация\n'
|
||
progress_text += '• Создание новых пользователей\n'
|
||
progress_text += '• Обновление существующих\n'
|
||
progress_text += '• Удаление неактуальных подписок\n'
|
||
elif sync_type == 'new_users':
|
||
progress_text += '📋 Тип: Только новые пользователи\n'
|
||
progress_text += '• Создание пользователей из панели\n'
|
||
elif sync_type == 'update_data':
|
||
progress_text += '📋 Тип: Обновление данных\n'
|
||
progress_text += '• Обновление информации о трафике\n'
|
||
progress_text += '• Синхронизация подписок\n'
|
||
|
||
progress_text += '\n⏳ Пожалуйста, подождите...'
|
||
|
||
await callback.message.edit_text(progress_text, reply_markup=None)
|
||
|
||
remnawave_service = RemnaWaveService()
|
||
|
||
sync_map = {'all_users': 'all', 'new_users': 'new_only', 'update_data': 'update_only'}
|
||
|
||
stats = await remnawave_service.sync_users_from_panel(db, sync_map.get(sync_type, 'all'))
|
||
|
||
total_operations = stats['created'] + stats['updated'] + stats.get('deleted', 0)
|
||
stats['created'] + stats['updated'] + stats.get('deleted', 0)
|
||
|
||
if stats['errors'] == 0:
|
||
status_emoji = '✅'
|
||
status_text = 'успешно завершена'
|
||
elif stats['errors'] < total_operations:
|
||
status_emoji = '⚠️'
|
||
status_text = 'завершена с предупреждениями'
|
||
else:
|
||
status_emoji = '❌'
|
||
status_text = 'завершена с ошибками'
|
||
|
||
text = f"""
|
||
{status_emoji} <b>Синхронизация {status_text}</b>
|
||
|
||
📊 <b>Результат:</b>
|
||
"""
|
||
|
||
if sync_type == 'all_users':
|
||
text += f'• 🆕 Создано: {stats["created"]}\n'
|
||
text += f'• 🔄 Обновлено: {stats["updated"]}\n'
|
||
if 'deleted' in stats:
|
||
text += f'• 🗑️ Удалено: {stats["deleted"]}\n'
|
||
text += f'• ❌ Ошибок: {stats["errors"]}\n'
|
||
elif sync_type == 'new_users':
|
||
text += f'• 🆕 Создано: {stats["created"]}\n'
|
||
text += f'• ❌ Ошибок: {stats["errors"]}\n'
|
||
if stats['created'] == 0 and stats['errors'] == 0:
|
||
text += '\n💡 Новых пользователей не найдено'
|
||
elif sync_type == 'update_data':
|
||
text += f'• 🔄 Обновлено: {stats["updated"]}\n'
|
||
text += f'• ❌ Ошибок: {stats["errors"]}\n'
|
||
if stats['updated'] == 0 and stats['errors'] == 0:
|
||
text += '\n💡 Все данные актуальны'
|
||
|
||
if stats['errors'] > 0:
|
||
text += '\n⚠️ <b>Внимание:</b>\n'
|
||
text += 'Некоторые операции завершились с ошибками.\n'
|
||
text += 'Проверьте логи для получения подробной информации.'
|
||
|
||
if sync_type == 'all_users' and 'deleted' in stats and stats['deleted'] > 0:
|
||
text += '\n🗑️ <b>Удаленные подписки:</b>\n'
|
||
text += 'Деактивированы подписки пользователей,\n'
|
||
text += 'которые отсутствуют в панели Remnawave.'
|
||
|
||
text += '\n\n💡 <b>Рекомендации:</b>\n'
|
||
if sync_type == 'all_users':
|
||
text += '• Полная синхронизация выполнена\n'
|
||
text += '• Рекомендуется запускать раз в день\n'
|
||
elif sync_type == 'new_users':
|
||
text += '• Синхронизация новых пользователей\n'
|
||
text += '• Используйте при массовом добавлении\n'
|
||
elif sync_type == 'update_data':
|
||
text += '• Обновление данных о трафике\n'
|
||
text += '• Запускайте для актуализации статистики\n'
|
||
|
||
keyboard = []
|
||
|
||
if stats['errors'] > 0:
|
||
keyboard.append([types.InlineKeyboardButton(text='🔄 Повторить синхронизацию', callback_data=callback.data)])
|
||
|
||
if sync_type != 'all_users':
|
||
keyboard.append([types.InlineKeyboardButton(text='🔄 Полная синхронизация', callback_data='sync_all_users')])
|
||
|
||
keyboard.extend(
|
||
[
|
||
[
|
||
types.InlineKeyboardButton(text='📊 Статистика системы', callback_data='admin_rw_system'),
|
||
types.InlineKeyboardButton(text='🌐 Ноды', callback_data='admin_rw_nodes'),
|
||
],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_squads_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
remnawave_service = RemnaWaveService()
|
||
squads = await remnawave_service.get_all_squads()
|
||
|
||
text = '🌍 <b>Управление сквадами</b>\n\n'
|
||
keyboard = []
|
||
|
||
if squads:
|
||
for squad in squads:
|
||
text += f'🔹 <b>{squad["name"]}</b>\n'
|
||
text += f'👥 Участников: {squad["members_count"]}\n'
|
||
text += f'📡 Инбаундов: {squad["inbounds_count"]}\n\n'
|
||
|
||
keyboard.append(
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=f'⚙️ {squad["name"]}', callback_data=f'admin_squad_manage_{squad["uuid"]}'
|
||
)
|
||
]
|
||
)
|
||
else:
|
||
text += 'Сквады не найдены'
|
||
|
||
keyboard.extend(
|
||
[
|
||
[types.InlineKeyboardButton(text='➕ Создать сквад', callback_data='admin_squad_create')],
|
||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')],
|
||
]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard))
|
||
await callback.answer()
|
||
|
||
|
||
def register_handlers(dp: Dispatcher):
|
||
dp.callback_query.register(show_remnawave_menu, F.data == 'admin_remnawave')
|
||
dp.callback_query.register(show_system_stats, F.data == 'admin_rw_system')
|
||
dp.callback_query.register(show_traffic_stats, F.data == 'admin_rw_traffic')
|
||
dp.callback_query.register(show_nodes_management, F.data == 'admin_rw_nodes')
|
||
dp.callback_query.register(show_node_details, F.data.startswith('admin_node_manage_'))
|
||
dp.callback_query.register(show_node_statistics, F.data.startswith('node_stats_'))
|
||
dp.callback_query.register(manage_node, F.data.startswith('node_enable_'))
|
||
dp.callback_query.register(manage_node, F.data.startswith('node_disable_'))
|
||
dp.callback_query.register(manage_node, F.data.startswith('node_restart_'))
|
||
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(show_auto_sync_settings, F.data == 'admin_rw_auto_sync')
|
||
dp.callback_query.register(toggle_auto_sync_setting, F.data == 'remnawave_auto_sync_toggle')
|
||
dp.callback_query.register(prompt_auto_sync_schedule, F.data == 'remnawave_auto_sync_times')
|
||
dp.callback_query.register(cancel_auto_sync_schedule, F.data == 'remnawave_auto_sync_cancel')
|
||
dp.callback_query.register(run_auto_sync_now, F.data == 'remnawave_auto_sync_run')
|
||
dp.callback_query.register(sync_all_users, F.data == 'sync_all_users')
|
||
dp.callback_query.register(sync_users_to_panel, F.data == 'sync_to_panel')
|
||
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_'))
|
||
dp.callback_query.register(manage_squad_action, F.data.startswith('squad_remove_users_'))
|
||
dp.callback_query.register(manage_squad_action, F.data.startswith('squad_delete_'))
|
||
dp.callback_query.register(
|
||
show_squad_edit_menu, F.data.startswith('squad_edit_') & ~F.data.startswith('squad_edit_inbounds_')
|
||
)
|
||
dp.callback_query.register(show_squad_inbounds_selection, F.data.startswith('squad_edit_inbounds_'))
|
||
dp.callback_query.register(show_squad_rename_form, F.data.startswith('squad_rename_'))
|
||
dp.callback_query.register(cancel_squad_rename, F.data.startswith('cancel_rename_'))
|
||
dp.callback_query.register(toggle_squad_inbound, F.data.startswith('sqd_tgl_'))
|
||
dp.callback_query.register(save_squad_inbounds, F.data.startswith('sqd_save_'))
|
||
dp.callback_query.register(show_squad_edit_menu_short, F.data.startswith('sqd_edit_'))
|
||
dp.callback_query.register(start_squad_creation, F.data == 'admin_squad_create')
|
||
dp.callback_query.register(cancel_squad_creation, F.data == 'cancel_squad_create')
|
||
dp.callback_query.register(toggle_create_inbound, F.data.startswith('create_tgl_'))
|
||
dp.callback_query.register(finish_squad_creation, F.data == 'create_squad_finish')
|
||
|
||
dp.message.register(process_squad_new_name, SquadRenameStates.waiting_for_new_name, F.text)
|
||
|
||
dp.message.register(process_squad_name, SquadCreateStates.waiting_for_name, F.text)
|
||
|
||
dp.message.register(
|
||
save_auto_sync_schedule,
|
||
RemnaWaveSyncStates.waiting_for_schedule,
|
||
F.text,
|
||
)
|