diff --git a/handlers/help.py b/handlers/help.py index f206ab4..2ad3c78 100644 --- a/handlers/help.py +++ b/handlers/help.py @@ -17,7 +17,7 @@ async def help_cmd(msg: Message): "🐳 Docker — управление контейнерами\n" "📦 Backup — restic бэкапы\n" "🧉 Artifacts — критичные образы (Clonezilla, NAND)\n" - "⚙️ System — диски, безопасность, URL, metrics, reboot\n\n" + "⚙️ System — подменю: Info / Ops / Logs\n\n" "Inline-кнопки используются для выбора контейнеров.", reply_markup=menu_kb, parse_mode="Markdown", diff --git a/handlers/menu.py b/handlers/menu.py index 5647b9c..2fd4f16 100644 --- a/handlers/menu.py +++ b/handlers/menu.py @@ -2,7 +2,16 @@ from aiogram import F from aiogram.types import Message from app import dp from auth import is_admin_msg -from keyboards import menu_kb, docker_kb, backup_kb, artifacts_kb, system_kb +from keyboards import ( + menu_kb, + docker_kb, + backup_kb, + artifacts_kb, + system_menu_kb, + system_info_kb, + system_ops_kb, + system_logs_kb, +) @dp.message(F.text == "/start") @@ -38,4 +47,28 @@ async def am(msg: Message): @dp.message(F.text == "⚙️ System") async def sm(msg: Message): if is_admin_msg(msg): - await msg.answer("⚙️ System", reply_markup=system_kb) + await msg.answer("⚙️ System", reply_markup=system_menu_kb) + + +@dp.message(F.text == "⬅️ System") +async def back_system(msg: Message): + if is_admin_msg(msg): + await msg.answer("⚙️ System", reply_markup=system_menu_kb) + + +@dp.message(F.text == "ℹ️ Info") +async def sys_info(msg: Message): + if is_admin_msg(msg): + await msg.answer("ℹ️ System info", reply_markup=system_info_kb) + + +@dp.message(F.text == "🛠 Ops") +async def sys_ops(msg: Message): + if is_admin_msg(msg): + await msg.answer("🛠 System ops", reply_markup=system_ops_kb) + + +@dp.message(F.text == "📄 Logs") +async def sys_logs(msg: Message): + if is_admin_msg(msg): + await msg.answer("📄 System logs", reply_markup=system_logs_kb) diff --git a/handlers/processes.py b/handlers/processes.py new file mode 100644 index 0000000..bd78faa --- /dev/null +++ b/handlers/processes.py @@ -0,0 +1,141 @@ +import asyncio +from aiogram import F +from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton +from app import dp, ADMIN_ID +from auth import is_admin_msg +from keyboards import system_logs_kb +from services.processes import get_top_processes, search_processes, terminate_process +from state import PROC_SEARCH_PENDING, PROC_KILL_PENDING + + +def _proc_kb() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[[ + InlineKeyboardButton(text="🔄 Refresh", callback_data="proc:refresh"), + InlineKeyboardButton(text="🔍 Search", callback_data="proc:search"), + InlineKeyboardButton(text="🛑 Kill", callback_data="proc:kill"), + ]] + ) + + +def _format_top(title: str, rows: list[dict]) -> str: + if not rows: + return f"{title}\n(no data)" + lines = ["PID CPU% MEM% NAME"] + for row in rows: + lines.append( + f"{row['pid']:<5} {row['cpu']:<5.1f} {row['mem']:<5.1f} {row['name']}" + ) + return f"{title}\n" + "\n".join(lines) + + +async def send_processes(msg: Message, edit: bool = False): + top_cpu, top_mem = await asyncio.to_thread(get_top_processes) + body = ( + "🧰 **Processes**\n\n" + "```\n" + f"{_format_top('Top CPU', top_cpu)}\n\n" + f"{_format_top('Top RAM', top_mem)}\n" + "```" + ) + if edit: + await msg.edit_text(body, reply_markup=_proc_kb(), parse_mode="Markdown") + else: + await msg.answer(body, reply_markup=_proc_kb(), parse_mode="Markdown") + + +@dp.message(F.text == "🧰 Processes") +async def proc_menu(msg: Message): + if is_admin_msg(msg): + await send_processes(msg, edit=False) + + +@dp.callback_query(F.data.startswith("proc:")) +async def proc_actions(cb: CallbackQuery): + if cb.from_user.id != ADMIN_ID: + return + await cb.answer() + action = cb.data.split(":", 1)[1] + if action == "refresh": + await send_processes(cb.message, edit=True) + return + if action == "search": + PROC_SEARCH_PENDING[cb.from_user.id] = {} + await cb.message.answer("🔍 Send search text", reply_markup=system_logs_kb) + return + if action == "kill": + PROC_KILL_PENDING[cb.from_user.id] = {} + await cb.message.answer("🛑 Send PID to terminate", reply_markup=system_logs_kb) + return + + +@dp.message(F.text, F.func(lambda msg: msg.from_user and msg.from_user.id in PROC_SEARCH_PENDING)) +async def proc_search(msg: Message): + if not is_admin_msg(msg): + return + PROC_SEARCH_PENDING.pop(msg.from_user.id, None) + query = (msg.text or "").strip() + if not query: + await msg.answer("⚠️ Empty search", reply_markup=system_logs_kb) + return + + rows = await asyncio.to_thread(search_processes, query) + if not rows: + await msg.answer("🔍 No matches", reply_markup=system_logs_kb) + return + + lines = ["PID NAME CMD"] + for row in rows: + cmd = row["cmdline"] or "-" + if len(cmd) > 80: + cmd = cmd[:80] + "…" + lines.append(f"{row['pid']:<5} {row['name']:<6} {cmd}") + + text = "🔍 **Search results**\n```\n" + "\n".join(lines) + "\n```" + await msg.answer(text, reply_markup=system_logs_kb, parse_mode="Markdown") + + +@dp.message(F.text, F.func(lambda msg: msg.from_user and msg.from_user.id in PROC_KILL_PENDING)) +async def proc_kill_pid(msg: Message): + if not is_admin_msg(msg): + return + PROC_KILL_PENDING.pop(msg.from_user.id, None) + raw = (msg.text or "").strip() + try: + pid = int(raw) + except ValueError: + await msg.answer("⚠️ Invalid PID", reply_markup=system_logs_kb) + return + + kb = InlineKeyboardMarkup( + inline_keyboard=[[ + InlineKeyboardButton(text="✅ Confirm", callback_data=f"prockill:{pid}:confirm"), + InlineKeyboardButton(text="✖ Cancel", callback_data="prockill:cancel"), + ]] + ) + await msg.answer(f"⚠️ Terminate PID `{pid}`?", reply_markup=kb, parse_mode="Markdown") + + +@dp.callback_query(F.data.startswith("prockill:")) +async def proc_kill_confirm(cb: CallbackQuery): + if cb.from_user.id != ADMIN_ID: + return + parts = cb.data.split(":") + if len(parts) < 2: + await cb.answer("Bad request") + return + if parts[1] == "cancel": + await cb.answer("Cancelled") + await cb.message.delete() + return + if len(parts) != 3 or parts[2] != "confirm": + await cb.answer("Bad request") + return + try: + pid = int(parts[1]) + except ValueError: + await cb.answer("Bad PID") + return + await cb.answer() + result = await asyncio.to_thread(terminate_process, pid) + await cb.message.answer(result, reply_markup=system_logs_kb) diff --git a/handlers/system.py b/handlers/system.py index fc28452..96fab3a 100644 --- a/handlers/system.py +++ b/handlers/system.py @@ -4,7 +4,7 @@ from aiogram import F from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton from app import dp, cfg from auth import is_admin_msg -from keyboards import system_kb +from keyboards import system_info_kb, system_ops_kb, system_logs_kb from system_checks import security, disks, hardware from services.http_checks import get_url_checks, check_url from services.queue import enqueue @@ -21,13 +21,13 @@ from services.incidents import read_recent, incidents_path @dp.message(F.text == "💽 Disks") async def sd(msg: Message): if is_admin_msg(msg): - await msg.answer(disks(), reply_markup=system_kb) + await msg.answer(disks(), reply_markup=system_info_kb) @dp.message(F.text == "🔐 Security") async def sec(msg: Message): if is_admin_msg(msg): - await msg.answer(security(), reply_markup=system_kb) + await msg.answer(security(), reply_markup=system_info_kb) @dp.message(F.text == "🌐 URLs") @@ -37,10 +37,10 @@ async def urls(msg: Message): checks = list(get_url_checks(cfg)) if not checks: - await msg.answer("⚠️ Нет URL для проверки", reply_markup=system_kb) + await msg.answer("⚠️ Нет URL для проверки", reply_markup=system_logs_kb) return - await msg.answer("⏳ Проверяю URL…", reply_markup=system_kb) + await msg.answer("⏳ Проверяю URL…", reply_markup=system_logs_kb) async def worker(): tasks = [asyncio.to_thread(check_url, url) for _, url in checks] @@ -56,7 +56,7 @@ async def urls(msg: Message): reason = err or "error" lines.append(f"🔴 {alias}: {reason} ({ms}ms)") - await msg.answer("\n".join(lines), reply_markup=system_kb) + await msg.answer("\n".join(lines), reply_markup=system_logs_kb) asyncio.create_task(worker()) @@ -66,9 +66,9 @@ async def metrics(msg: Message): if not is_admin_msg(msg): return if state.METRICS_STORE is None: - await msg.answer("⚠️ Metrics not initialized", reply_markup=system_kb) + await msg.answer("⚠️ Metrics not initialized", reply_markup=system_info_kb) return - await msg.answer(summarize(state.METRICS_STORE, minutes=15), reply_markup=system_kb) + await msg.answer(summarize(state.METRICS_STORE, minutes=15), reply_markup=system_info_kb) @dp.message(F.text == "🧾 Audit") @@ -77,9 +77,9 @@ async def audit_log(msg: Message): return text = read_audit_tail(cfg, limit=200) if text.startswith("⚠️") or text.startswith("ℹ️"): - await msg.answer(text, reply_markup=system_kb) + await msg.answer(text, reply_markup=system_logs_kb) else: - await msg.answer(text, reply_markup=system_kb, parse_mode="Markdown") + await msg.answer(text, reply_markup=system_logs_kb, parse_mode="Markdown") @dp.message(F.text == "📣 Incidents") @@ -88,7 +88,7 @@ async def incidents(msg: Message): return path = incidents_path(cfg) if not os.path.exists(path): - await msg.answer("⚠️ Incidents log not found", reply_markup=system_kb) + await msg.answer("⚠️ Incidents log not found", reply_markup=system_logs_kb) return last_24h = read_recent(cfg, hours=24, limit=500) last_7d = read_recent(cfg, hours=24 * 7, limit=1000) @@ -102,7 +102,7 @@ async def incidents(msg: Message): "Recent (24h):\n" f"```\n{body}\n```" ) - await msg.answer(text, reply_markup=system_kb, parse_mode="Markdown") + await msg.answer(text, reply_markup=system_logs_kb, parse_mode="Markdown") @dp.message(F.text == "🔒 SSL") @@ -110,7 +110,7 @@ async def ssl_certs(msg: Message): if not is_admin_msg(msg): return - await msg.answer("⏳ Checking SSL certificates…", reply_markup=system_kb) + await msg.answer("⏳ Checking SSL certificates…", reply_markup=system_logs_kb) async def worker(): try: @@ -118,7 +118,7 @@ async def ssl_certs(msg: Message): text = format_certificates(certs) except Exception as e: text = f"⚠️ NPMplus error: {e}" - await msg.answer(text, reply_markup=system_kb) + await msg.answer(text, reply_markup=system_logs_kb) asyncio.create_task(worker()) @@ -138,7 +138,7 @@ async def updates_list(msg: Message): await send_updates_page(msg, msg.from_user.id, 0, edit=False) pos = await enqueue("pkg-updates", job) - await msg.answer(f"🕓 Updates queued (#{pos})", reply_markup=system_kb) + await msg.answer(f"🕓 Updates queued (#{pos})", reply_markup=system_ops_kb) @dp.message(F.text == "⬆️ Upgrade") @@ -172,7 +172,7 @@ async def reboot_request(msg: Message): @dp.message(F.text == "🧱 Hardware") async def hw(msg: Message): if is_admin_msg(msg): - await msg.answer(hardware(), reply_markup=system_kb) + await msg.answer(hardware(), reply_markup=system_info_kb) def _updates_kb(page: int, total_pages: int) -> InlineKeyboardMarkup: @@ -191,7 +191,7 @@ def _updates_kb(page: int, total_pages: int) -> InlineKeyboardMarkup: async def send_updates_page(msg: Message, user_id: int, page: int, edit: bool): data = UPDATES_CACHE.get(user_id) if not data: - await msg.answer("⚠️ Updates cache empty", reply_markup=system_kb) + await msg.answer("⚠️ Updates cache empty", reply_markup=system_ops_kb) return lines = data["lines"] @@ -231,10 +231,10 @@ async def upgrade_confirm(cb: CallbackQuery): async def job(): text = await apply_updates() - await cb.message.answer(text, reply_markup=system_kb, parse_mode="Markdown") + await cb.message.answer(text, reply_markup=system_ops_kb, parse_mode="Markdown") pos = await enqueue("pkg-upgrade", job) - await cb.message.answer(f"🕓 Upgrade queued (#{pos})", reply_markup=system_kb) + await cb.message.answer(f"🕓 Upgrade queued (#{pos})", reply_markup=system_ops_kb) @dp.callback_query(F.data == "upgrade:cancel") @@ -249,7 +249,7 @@ async def reboot_confirm(cb: CallbackQuery): return await cb.answer() REBOOT_PENDING[cb.from_user.id] = {} - await cb.message.answer("🔐 Send reboot password", reply_markup=system_kb) + await cb.message.answer("🔐 Send reboot password", reply_markup=system_ops_kb) @dp.callback_query(F.data == "reboot:cancel") @@ -266,16 +266,16 @@ async def reboot_password(msg: Message): expected = cfg.get("security", {}).get("reboot_password") if not expected: - await msg.answer("⚠️ Reboot password not configured", reply_markup=system_kb) + await msg.answer("⚠️ Reboot password not configured", reply_markup=system_ops_kb) return if (msg.text or "").strip() != expected: - await msg.answer("❌ Wrong password", reply_markup=system_kb) + await msg.answer("❌ Wrong password", reply_markup=system_ops_kb) return async def job(): - await msg.answer("🔄 Rebooting…", reply_markup=system_kb) + await msg.answer("🔄 Rebooting…", reply_markup=system_ops_kb) await run_cmd(["sudo", "reboot"], timeout=10) pos = await enqueue("reboot", job) - await msg.answer(f"🕓 Reboot queued (#{pos})", reply_markup=system_kb) + await msg.answer(f"🕓 Reboot queued (#{pos})", reply_markup=system_ops_kb) diff --git a/keyboards.py b/keyboards.py index a6070f5..601fbcf 100644 --- a/keyboards.py +++ b/keyboards.py @@ -52,12 +52,38 @@ artifacts_kb = ReplyKeyboardMarkup( resize_keyboard=True, ) -system_kb = ReplyKeyboardMarkup( +system_menu_kb = ReplyKeyboardMarkup( keyboard=[ - [KeyboardButton(text="💽 Disks"), KeyboardButton(text="🔐 Security"), KeyboardButton(text="🧾 Audit")], - [KeyboardButton(text="🌐 URLs"), KeyboardButton(text="📈 Metrics"), KeyboardButton(text="🔒 SSL")], - [KeyboardButton(text="📦 Updates"), KeyboardButton(text="⬆️ Upgrade"), KeyboardButton(text="📣 Incidents")], - [KeyboardButton(text="🧱 Hardware"), KeyboardButton(text="🔄 Reboot"), KeyboardButton(text="⬅️ Назад")], + [KeyboardButton(text="ℹ️ Info"), KeyboardButton(text="🛠 Ops")], + [KeyboardButton(text="📄 Logs"), KeyboardButton(text="⬅️ Назад")], + ], + resize_keyboard=True, +) + +system_info_kb = ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="💽 Disks"), KeyboardButton(text="🔐 Security")], + [KeyboardButton(text="📈 Metrics"), KeyboardButton(text="🧱 Hardware")], + [KeyboardButton(text="⬅️ System")], + ], + resize_keyboard=True, +) + +system_ops_kb = ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="📦 Updates"), KeyboardButton(text="⬆️ Upgrade")], + [KeyboardButton(text="🔄 Reboot")], + [KeyboardButton(text="⬅️ System")], + ], + resize_keyboard=True, +) + +system_logs_kb = ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="🧾 Audit"), KeyboardButton(text="📣 Incidents")], + [KeyboardButton(text="🧰 Processes"), KeyboardButton(text="🔒 SSL")], + [KeyboardButton(text="🌐 URLs")], + [KeyboardButton(text="⬅️ System")], ], resize_keyboard=True, )