diff --git a/database/servers.py b/database/servers.py
index 2176fd1e..f9296d35 100644
--- a/database/servers.py
+++ b/database/servers.py
@@ -1,8 +1,8 @@
-from sqlalchemy import delete, insert, select
+from sqlalchemy import delete, insert, select, update, func
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
-from database.models import Server
+from database.models import Server, Key
from logger import logger
@@ -118,3 +118,126 @@ async def get_cluster_name_by_server(
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return row
+
+
+async def get_server_by_name(session: AsyncSession, server_name: str) -> dict | None:
+ try:
+ stmt = select(Server).where(Server.server_name == server_name)
+ result = await session.execute(stmt)
+ server = result.scalar_one_or_none()
+
+ if server:
+ return {
+ "id": server.id,
+ "cluster_name": server.cluster_name,
+ "server_name": server.server_name,
+ "api_url": server.api_url,
+ "subscription_url": server.subscription_url,
+ "inbound_id": server.inbound_id,
+ "panel_type": server.panel_type,
+ "enabled": server.enabled,
+ "max_keys": server.max_keys,
+ "tariff_group": server.tariff_group,
+ }
+ return None
+ except SQLAlchemyError as e:
+ logger.error(f"Ошибка при получении сервера {server_name}: {e}")
+ return None
+
+
+async def update_server_field(
+ session: AsyncSession, server_name: str, field: str, value: any
+) -> bool:
+ try:
+ stmt = update(Server).where(Server.server_name == server_name).values(**{field: value})
+ await session.execute(stmt)
+ await session.commit()
+ logger.info(f"✅ Поле {field} сервера {server_name} обновлено на {value}")
+ return True
+ except SQLAlchemyError as e:
+ logger.error(f"❌ Ошибка при обновлении поля {field} сервера {server_name}: {e}")
+ await session.rollback()
+ return False
+
+
+async def update_server_name_with_keys(
+ session: AsyncSession, old_name: str, new_name: str
+) -> bool:
+ try:
+ from sqlalchemy import update
+ from database.models import Key
+
+ if not await check_unique_server_name(session, new_name):
+ logger.error(f"❌ Сервер с именем {new_name} уже существует")
+ return False
+
+ stmt_server = update(Server).where(Server.server_name == old_name).values(server_name=new_name)
+ await session.execute(stmt_server)
+
+ stmt_keys = update(Key).where(Key.server_id == old_name).values(server_id=new_name)
+ await session.execute(stmt_keys)
+
+ await session.commit()
+ logger.info(f"✅ Сервер переименован с {old_name} на {new_name}")
+ return True
+ except SQLAlchemyError as e:
+ logger.error(f"❌ Ошибка при переименовании сервера {old_name}: {e}")
+ await session.rollback()
+ return False
+
+
+async def get_available_clusters(session: AsyncSession) -> list[str]:
+ try:
+ stmt = select(Server.cluster_name).distinct().order_by(Server.cluster_name)
+ result = await session.execute(stmt)
+ return [row[0] for row in result.all()]
+ except SQLAlchemyError as e:
+ logger.error(f"Ошибка при получении списка кластеров: {e}")
+ return []
+
+
+async def update_server_cluster(
+ session: AsyncSession,
+ server_name: str,
+ new_cluster: str
+) -> bool:
+ try:
+ server_data = await get_server_by_name(session, server_name)
+ if not server_data:
+ return False
+
+ old_cluster = server_data["cluster_name"]
+
+ stmt_remaining = select(func.count()).where(
+ (Server.cluster_name == old_cluster) & (Server.server_name != server_name)
+ )
+ result = await session.execute(stmt_remaining)
+ remaining_servers = result.scalar_one()
+
+ if remaining_servers == 0:
+ stmt_update_keys = update(Key).where(
+ Key.server_id == old_cluster
+ ).values(server_id=new_cluster)
+ await session.execute(stmt_update_keys)
+
+ stmt_new_cluster = select(Server.tariff_group).where(
+ Server.cluster_name == new_cluster
+ ).limit(1)
+ result = await session.execute(stmt_new_cluster)
+ new_tariff_group = result.scalar_one_or_none()
+
+ stmt_update = update(Server).where(
+ Server.server_name == server_name
+ ).values(
+ cluster_name=new_cluster,
+ tariff_group=new_tariff_group
+ )
+ await session.execute(stmt_update)
+ await session.commit()
+
+ logger.info(f"✅ Сервер {server_name} перемещен в кластер {new_cluster} с обновлением тарифной группы")
+ return True
+ except SQLAlchemyError as e:
+ logger.error(f"❌ Ошибка при обновлении кластера сервера {server_name}: {e}")
+ await session.rollback()
+ return False
diff --git a/handlers/admin/servers/keyboard.py b/handlers/admin/servers/keyboard.py
index 5af60d93..c1871286 100644
--- a/handlers/admin/servers/keyboard.py
+++ b/handlers/admin/servers/keyboard.py
@@ -1,5 +1,5 @@
from aiogram.filters.callback_data import CallbackData
-from aiogram.types import InlineKeyboardMarkup
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
@@ -37,8 +37,8 @@ def build_manage_server_kb(
)
builder.button(
- text="✏️ Сменить название",
- callback_data=AdminServerCallback(action="rename", data=server_name).pack(),
+ text="✏️ Редактировать",
+ callback_data=f"edit_server|{server_name}",
)
builder.button(
@@ -48,3 +48,107 @@ def build_manage_server_kb(
builder.adjust(1)
return builder.as_markup()
+
+
+def build_edit_server_fields_kb(server_name: str, server_data: dict) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+
+ builder.row(
+ InlineKeyboardButton(
+ text="📝 Имя сервера",
+ callback_data=f"edit_server_field|{server_name}|server_name"
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="🗂 Кластер",
+ callback_data=f"edit_server_field|{server_name}|cluster_name"
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="🌐 API URL",
+ callback_data=f"edit_server_field|{server_name}|api_url"
+ )
+ )
+
+ if server_data.get("subscription_url"):
+ builder.row(
+ InlineKeyboardButton(
+ text="📡 Subscription URL",
+ callback_data=f"edit_server_field|{server_name}|subscription_url"
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="🔑 Inbound ID",
+ callback_data=f"edit_server_field|{server_name}|inbound_id"
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="⚙️ Тип панели",
+ callback_data=f"select_panel_type|{server_name}"
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="⬅️ Назад",
+ callback_data=AdminServerCallback(action="manage", data=server_name).pack()
+ )
+ )
+
+ return builder.as_markup()
+
+
+def build_panel_type_selection_kb(server_name: str) -> InlineKeyboardMarkup:
+ return InlineKeyboardMarkup(
+ inline_keyboard=[
+ [InlineKeyboardButton(text="🌐 3x-ui", callback_data=f"set_panel_type|{server_name}|3x-ui")],
+ [InlineKeyboardButton(text="🌀 remnawave", callback_data=f"set_panel_type|{server_name}|remnawave")],
+ [InlineKeyboardButton(
+ text="⬅️ Назад",
+ callback_data=f"edit_server|{server_name}"
+ )]
+ ]
+ )
+
+
+def build_cluster_selection_kb(server_name: str, clusters: list[str]) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+
+ for cluster in clusters:
+ builder.row(
+ InlineKeyboardButton(
+ text=cluster,
+ callback_data=f"set_cluster|{server_name}|{cluster}"
+ )
+ )
+
+ builder.row(
+ InlineKeyboardButton(
+ text="⬅️ Назад",
+ callback_data=f"edit_server|{server_name}"
+ )
+ )
+
+ return builder.as_markup()
+
+
+def build_cancel_edit_kb(server_name: str) -> InlineKeyboardMarkup:
+ return InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text="❌ Отменить",
+ callback_data=f"edit_server|{server_name}"
+ )
+ ]
+ ]
+ )
+
diff --git a/handlers/admin/servers/servers_handler.py b/handlers/admin/servers/servers_handler.py
index c3fd509c..69e34ba4 100644
--- a/handlers/admin/servers/servers_handler.py
+++ b/handlers/admin/servers/servers_handler.py
@@ -8,11 +8,25 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database import get_servers
from database.models import Key, Server
+from database.servers import (
+ get_server_by_name,
+ update_server_field,
+ update_server_name_with_keys,
+ get_available_clusters,
+ update_server_cluster,
+)
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_kb
-from .keyboard import AdminServerCallback, build_manage_server_kb
+from .keyboard import (
+ AdminServerCallback,
+ build_manage_server_kb,
+ build_edit_server_fields_kb,
+ build_panel_type_selection_kb,
+ build_cluster_selection_kb,
+ build_cancel_edit_kb,
+)
router = Router()
@@ -21,6 +35,11 @@ class ServerLimitState(StatesGroup):
waiting_for_limit = State()
+class ServerEditState(StatesGroup):
+ choosing_field = State()
+ editing_value = State()
+
+
@router.callback_query(
AdminServerCallback.filter(F.action == "manage"), IsAdminFilter()
)
@@ -46,14 +65,22 @@ async def handle_server_manage(
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
+ panel_type = server.get("panel_type") or "не указан"
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys else "не задан"
text = (
f"🔧 Информация о сервере {server_name}:\n\n"
+ f"🗂 Кластер: {cluster_name}\n"
f"📡 API URL: {api_url}\n"
- f"🌐 Subscription URL: {subscription_url}\n"
+ )
+
+ if subscription_url:
+ text += f"🌐 Subscription URL: {subscription_url}\n"
+
+ text += (
f"🔑 Inbound ID: {inbound_id}\n"
+ f"⚙️ Тип панели: {panel_type}\n"
f"📈 Лимит ключей: {limit_display}"
)
@@ -353,3 +380,131 @@ async def save_server_limit(
except ValueError:
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
+
+
+@router.callback_query(F.data.startswith("edit_server|"), IsAdminFilter())
+async def start_edit_server(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ server_name = callback.data.split("|")[1]
+
+ await state.clear()
+
+ server_data = await get_server_by_name(session, server_name)
+ if not server_data:
+ await callback.message.edit_text("❌ Сервер не найден.")
+ return
+
+ await callback.message.edit_text(
+ f"✏️ Редактирование сервера: {server_name}\n\n"
+ "Выберите поле для редактирования:",
+ reply_markup=build_edit_server_fields_kb(server_name, server_data)
+ )
+
+
+@router.callback_query(F.data.startswith("edit_server_field|"), IsAdminFilter())
+async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
+ _, server_name, field = callback.data.split("|")
+
+ if field == "cluster_name":
+ clusters = await get_available_clusters(session)
+ await callback.message.edit_text(
+ f"🗂 Выберите кластер для сервера {server_name}:",
+ reply_markup=build_cluster_selection_kb(server_name, clusters)
+ )
+ return
+
+ await state.update_data(server_name=server_name, field=field)
+ await state.set_state(ServerEditState.editing_value)
+
+ field_names = {
+ "server_name": "имя сервера",
+ "api_url": "API URL",
+ "subscription_url": "Subscription URL",
+ "inbound_id": "Inbound ID",
+ }
+
+ await callback.message.edit_text(
+ f"✏️ Введите новое значение для {field_names.get(field, field)}:",
+ reply_markup=build_cancel_edit_kb(server_name)
+ )
+
+
+@router.callback_query(F.data.startswith("select_panel_type|"), IsAdminFilter())
+async def select_panel_type(callback: CallbackQuery):
+ server_name = callback.data.split("|")[1]
+
+ await callback.message.edit_text(
+ f"⚙️ Выберите тип панели для сервера {server_name}:",
+ reply_markup=build_panel_type_selection_kb(server_name)
+ )
+
+
+@router.callback_query(F.data.startswith("set_panel_type|"), IsAdminFilter())
+async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
+ _, server_name, panel_type = callback.data.split("|")
+
+ success = await update_server_field(session, server_name, "panel_type", panel_type)
+ if success:
+ await callback.message.edit_text(
+ f"✅ Тип панели сервера {server_name} изменен на {panel_type}",
+ reply_markup=InlineKeyboardBuilder().button(
+ text="⬅️ Назад к серверу",
+ callback_data=AdminServerCallback(action="manage", data=server_name).pack()
+ ).as_markup()
+ )
+ else:
+ await callback.message.edit_text("❌ Ошибка при изменении типа панели")
+
+
+@router.callback_query(F.data.startswith("set_cluster|"), IsAdminFilter())
+async def set_cluster(callback: CallbackQuery, session: AsyncSession):
+ _, server_name, new_cluster = callback.data.split("|")
+
+ success = await update_server_cluster(session, server_name, new_cluster)
+ if success:
+ await callback.message.edit_text(
+ f"✅ Кластер сервера {server_name} изменен на {new_cluster}",
+ reply_markup=InlineKeyboardBuilder().button(
+ text="⬅️ Назад к серверу",
+ callback_data=AdminServerCallback(action="manage", data=server_name).pack()
+ ).as_markup()
+ )
+ else:
+ await callback.message.edit_text("❌ Ошибка при изменении кластера")
+
+
+@router.message(ServerEditState.editing_value, IsAdminFilter())
+async def apply_field_edit(message: types.Message, state: FSMContext, session: AsyncSession):
+ data = await state.get_data()
+ server_name = data["server_name"]
+ field = data["field"]
+ value = message.text.strip()
+
+ if field == "server_name":
+ success = await update_server_name_with_keys(session, server_name, value)
+ if success:
+ server_name = value
+ else:
+ await message.answer("❌ Ошибка при изменении имени сервера. Возможно, такое имя уже существует.")
+ return
+ else:
+ success = await update_server_field(session, server_name, field, value)
+
+ if success:
+ field_names = {
+ "server_name": "имя сервера",
+ "api_url": "API URL",
+ "subscription_url": "Subscription URL",
+ "inbound_id": "Inbound ID",
+ }
+
+ await message.answer(
+ f"✅ {field_names.get(field, field).capitalize()} изменено",
+ reply_markup=InlineKeyboardBuilder().button(
+ text="⬅️ Назад к серверу",
+ callback_data=AdminServerCallback(action="manage", data=server_name).pack()
+ ).as_markup()
+ )
+ else:
+ await message.answer("❌ Ошибка при изменении поля")
+
+ await state.clear()