Refactor bot and integrate services
This commit is contained in:
1
handlers/__init__.py
Normal file
1
handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__ = []
|
||||
74
handlers/artifacts.py
Normal file
74
handlers/artifacts.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from aiogram import F
|
||||
from aiogram.types import Message
|
||||
from app import dp, ARTIFACT_STATE
|
||||
from auth import is_admin_msg
|
||||
from keyboards import artifacts_kb
|
||||
from lock_utils import acquire_lock, release_lock
|
||||
from services.artifacts import artifact_last
|
||||
from services.runner import run_cmd
|
||||
|
||||
|
||||
async def cmd_artifacts_status(msg: Message):
|
||||
p = Path(ARTIFACT_STATE)
|
||||
if not p.exists():
|
||||
await msg.answer("❌ state.json не найден", reply_markup=artifacts_kb)
|
||||
return
|
||||
|
||||
data = json.loads(p.read_text())
|
||||
lines = [f"🧉 Artifacts ({len(data)})\n"]
|
||||
for name, info in data.items():
|
||||
t = datetime.fromisoformat(info["updated_at"])
|
||||
lines.append(f"• {name} — {t:%Y-%m-%d %H:%M}")
|
||||
await msg.answer("\n".join(lines), reply_markup=artifacts_kb)
|
||||
|
||||
|
||||
async def cmd_artifacts_last(msg: Message):
|
||||
p = Path(ARTIFACT_STATE)
|
||||
if not p.exists():
|
||||
await msg.answer("❌ state.json не найден", reply_markup=artifacts_kb)
|
||||
return
|
||||
try:
|
||||
text = await asyncio.to_thread(artifact_last, ARTIFACT_STATE)
|
||||
except Exception as e:
|
||||
await msg.answer(f"❌ Last artifact failed: {type(e).__name__}: {e}", reply_markup=artifacts_kb)
|
||||
return
|
||||
await msg.answer(text, reply_markup=artifacts_kb)
|
||||
|
||||
|
||||
async def cmd_artifacts_upload(msg: Message):
|
||||
if not acquire_lock("artifacts"):
|
||||
await msg.answer("⛔ Upload уже идёт", reply_markup=artifacts_kb)
|
||||
return
|
||||
|
||||
await msg.answer("📤 Upload…", reply_markup=artifacts_kb)
|
||||
|
||||
async def worker():
|
||||
try:
|
||||
rc, out = await run_cmd(["sudo", "/usr/local/bin/backup.py", "artifact-upload"], timeout=12 * 3600)
|
||||
await msg.answer(("✅ OK\n" if rc == 0 else "❌ FAIL\n") + out, reply_markup=artifacts_kb)
|
||||
finally:
|
||||
release_lock("artifacts")
|
||||
|
||||
asyncio.create_task(worker())
|
||||
|
||||
|
||||
@dp.message(F.text == "🧉 Status")
|
||||
async def ars(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_artifacts_status(msg)
|
||||
|
||||
|
||||
@dp.message(F.text == "🧉 Last artifact")
|
||||
async def ala(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_artifacts_last(msg)
|
||||
|
||||
|
||||
@dp.message(F.text == "📤 Upload")
|
||||
async def aru(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_artifacts_upload(msg)
|
||||
163
handlers/backup.py
Normal file
163
handlers/backup.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from aiogram import F
|
||||
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from app import dp
|
||||
from auth import is_admin_msg
|
||||
from keyboards import backup_kb
|
||||
from lock_utils import acquire_lock, release_lock
|
||||
from services.backup import backup_badge, last_backup, restore_help
|
||||
from services.runner import run_cmd
|
||||
|
||||
|
||||
async def cmd_repo_stats(msg: Message):
|
||||
await msg.answer("⏳ Loading repo stats…", reply_markup=backup_kb)
|
||||
|
||||
# --- restore-size stats ---
|
||||
rc1, raw1 = await run_cmd(
|
||||
["restic", "stats", "--json"],
|
||||
use_restic_env=True,
|
||||
timeout=30
|
||||
)
|
||||
if rc1 != 0:
|
||||
await msg.answer(raw1, reply_markup=backup_kb)
|
||||
return
|
||||
|
||||
restore = json.loads(raw1)
|
||||
|
||||
# --- raw-data stats ---
|
||||
rc2, raw2 = await run_cmd(
|
||||
["restic", "stats", "--json", "--mode", "raw-data"],
|
||||
use_restic_env=True,
|
||||
timeout=30
|
||||
)
|
||||
if rc2 != 0:
|
||||
await msg.answer(raw2, reply_markup=backup_kb)
|
||||
return
|
||||
|
||||
raw = json.loads(raw2)
|
||||
|
||||
# --- snapshots count ---
|
||||
rc3, raw_snaps = await run_cmd(
|
||||
["restic", "snapshots", "--json"],
|
||||
use_restic_env=True,
|
||||
timeout=20
|
||||
)
|
||||
snaps = len(json.loads(raw_snaps)) if rc3 == 0 else "n/a"
|
||||
|
||||
msg_text = (
|
||||
"📦 **Repository stats**\n\n"
|
||||
f"🧉 Snapshots: {snaps}\n"
|
||||
f"📁 Files: {restore.get('total_file_count', 'n/a')}\n"
|
||||
f"💽 Logical size: {restore.get('total_size', 0) / (1024**3):.2f} GiB\n"
|
||||
f"🧱 Stored data: {raw.get('total_pack_size', 0) / (1024**2):.2f} MiB\n"
|
||||
)
|
||||
|
||||
await msg.answer(msg_text, reply_markup=backup_kb, parse_mode="Markdown")
|
||||
|
||||
|
||||
async def cmd_backup_status(msg: Message):
|
||||
await msg.answer("⏳ Loading snapshots…", reply_markup=backup_kb)
|
||||
|
||||
async def worker():
|
||||
rc, raw = await run_cmd(
|
||||
["restic", "snapshots", "--json"],
|
||||
use_restic_env=True,
|
||||
timeout=30
|
||||
)
|
||||
if rc != 0:
|
||||
await msg.answer(raw, reply_markup=backup_kb)
|
||||
return
|
||||
|
||||
snaps = json.loads(raw)
|
||||
if not snaps:
|
||||
await msg.answer("📦 Snapshots: none", reply_markup=backup_kb)
|
||||
return
|
||||
|
||||
snaps.sort(key=lambda s: s["time"], reverse=True)
|
||||
|
||||
# --- badge ---
|
||||
last = snaps[0]
|
||||
last_time = datetime.fromisoformat(
|
||||
last["time"].replace("Z", "+00:00")
|
||||
)
|
||||
badge = backup_badge(last_time)
|
||||
|
||||
# --- buttons ---
|
||||
rows = []
|
||||
for s in snaps[:5]:
|
||||
t = datetime.fromisoformat(
|
||||
s["time"].replace("Z", "+00:00")
|
||||
)
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"🧉 {s['short_id']} | {t:%Y-%m-%d %H:%M}",
|
||||
callback_data=f"snap:{s['short_id']}"
|
||||
)
|
||||
])
|
||||
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
await msg.answer(
|
||||
f"📦 Snapshots ({len(snaps)})\n{badge}",
|
||||
reply_markup=kb
|
||||
)
|
||||
|
||||
asyncio.create_task(worker())
|
||||
|
||||
|
||||
async def cmd_backup_now(msg: Message):
|
||||
if not acquire_lock("backup"):
|
||||
await msg.answer("⚠️ Backup уже выполняется", reply_markup=backup_kb)
|
||||
return
|
||||
|
||||
await msg.answer("▶️ Backup запущен", reply_markup=backup_kb)
|
||||
|
||||
async def worker():
|
||||
try:
|
||||
rc, out = await run_cmd(["sudo", "/usr/local/bin/backup.py", "restic-backup"], timeout=6 * 3600)
|
||||
await msg.answer(("✅ OK\n" if rc == 0 else "❌ FAIL\n") + out, reply_markup=backup_kb)
|
||||
finally:
|
||||
release_lock("backup")
|
||||
|
||||
asyncio.create_task(worker())
|
||||
|
||||
|
||||
async def cmd_last_backup(msg: Message):
|
||||
try:
|
||||
text = await asyncio.to_thread(last_backup)
|
||||
except Exception as e:
|
||||
await msg.answer(f"❌ Last backup failed: {type(e).__name__}: {e}", reply_markup=backup_kb)
|
||||
return
|
||||
await msg.answer(text, reply_markup=backup_kb)
|
||||
|
||||
|
||||
@dp.message(F.text == "📦 Status")
|
||||
async def bs(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_backup_status(msg)
|
||||
|
||||
|
||||
@dp.message(F.text == "📊 Repo stats")
|
||||
async def rs(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_repo_stats(msg)
|
||||
|
||||
|
||||
@dp.message(F.text == "📦 Last backup")
|
||||
async def lb(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_last_backup(msg)
|
||||
|
||||
|
||||
@dp.message(F.text == "▶️ Run backup")
|
||||
async def br(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_backup_now(msg)
|
||||
|
||||
|
||||
@dp.message(F.text == "🧯 Restore help")
|
||||
async def rh(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer(restore_help(), reply_markup=backup_kb)
|
||||
87
handlers/callbacks.py
Normal file
87
handlers/callbacks.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
from aiogram import F
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from app import dp, ADMIN_ID
|
||||
from services.runner import run_cmd
|
||||
from state import DOCKER_MAP
|
||||
from handlers.backup import cmd_backup_status
|
||||
|
||||
|
||||
@dp.callback_query(F.data.startswith("docker:"))
|
||||
async def docker_callback(cb: CallbackQuery):
|
||||
if cb.from_user.id != ADMIN_ID:
|
||||
return
|
||||
|
||||
_, action, alias = cb.data.split(":", 2)
|
||||
real = DOCKER_MAP[alias]
|
||||
|
||||
if action == "restart":
|
||||
await cb.answer("Restarting…")
|
||||
rc, out = await run_cmd(["sudo", "docker", "restart", real])
|
||||
|
||||
await cb.message.answer(
|
||||
f"🔄 **{alias} restarted**\n```{out}```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
elif action == "logs":
|
||||
await cb.answer("Loading logs…")
|
||||
rc, out = await run_cmd(
|
||||
["sudo", "docker", "logs", "--tail", "80", real]
|
||||
)
|
||||
|
||||
await cb.message.answer(
|
||||
f"📜 **Logs: {alias}**\n```{out}```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
|
||||
@dp.callback_query(F.data.startswith("snap:"))
|
||||
async def snapshot_details(cb: CallbackQuery):
|
||||
if cb.from_user.id != ADMIN_ID:
|
||||
return
|
||||
|
||||
snap_id = cb.data.split(":", 1)[1]
|
||||
await cb.answer("Loading snapshot…")
|
||||
|
||||
# получаем статистику snapshot
|
||||
rc, raw = await run_cmd(
|
||||
["restic", "stats", snap_id, "--json"],
|
||||
use_restic_env=True,
|
||||
timeout=20
|
||||
)
|
||||
|
||||
if rc != 0:
|
||||
await cb.message.answer(raw)
|
||||
return
|
||||
|
||||
stats = json.loads(raw)
|
||||
|
||||
msg = (
|
||||
f"🧉 **Snapshot {snap_id}**\n\n"
|
||||
f"📁 Files: {stats.get('total_file_count', 'n/a')}\n"
|
||||
f"💽 Size: {stats.get('total_size', 0) / (1024**3):.2f} GiB\n\n"
|
||||
"🧯 Restore:\n"
|
||||
f"`restic restore {snap_id} --target /restore`\n"
|
||||
)
|
||||
|
||||
back_kb = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Back to snapshots",
|
||||
callback_data="snapback"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await cb.message.answer(msg, reply_markup=back_kb, parse_mode="Markdown")
|
||||
|
||||
|
||||
@dp.callback_query(F.data == "snapback")
|
||||
async def snapshot_back(cb: CallbackQuery):
|
||||
await cb.answer()
|
||||
# просто вызываем статус снова
|
||||
fake_msg = cb.message
|
||||
await cmd_backup_status(fake_msg)
|
||||
81
handlers/docker.py
Normal file
81
handlers/docker.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from aiogram import F
|
||||
from aiogram.types import Message
|
||||
from app import dp
|
||||
from auth import is_admin_msg
|
||||
from keyboards import docker_kb, docker_inline_kb
|
||||
from services.docker import container_uptime
|
||||
from services.runner import run_cmd
|
||||
from state import DOCKER_MAP
|
||||
|
||||
|
||||
async def cmd_docker_status(msg: Message):
|
||||
try:
|
||||
if not DOCKER_MAP:
|
||||
await msg.answer(
|
||||
"⚠️ DOCKER_MAP пуст.\n"
|
||||
"Контейнеры не обнаружены.",
|
||||
reply_markup=docker_kb,
|
||||
)
|
||||
return
|
||||
|
||||
lines = ["🐳 Docker containers\n"]
|
||||
|
||||
for alias, real in DOCKER_MAP.items():
|
||||
rc, raw = await run_cmd(
|
||||
[
|
||||
"sudo", "docker", "inspect",
|
||||
"-f", "{{.State.Status}}|{{.State.StartedAt}}",
|
||||
real
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if rc != 0:
|
||||
lines.append(f"🔴 {alias}: inspect error")
|
||||
continue
|
||||
|
||||
raw = raw.strip()
|
||||
if "|" not in raw:
|
||||
lines.append(f"🟡 {alias}: invalid inspect output")
|
||||
continue
|
||||
|
||||
status, started = raw.split("|", 1)
|
||||
up = container_uptime(started)
|
||||
|
||||
icon = "🟢" if status == "running" else "🔴"
|
||||
lines.append(f"{icon} {alias}: {status} ({up})")
|
||||
|
||||
await msg.answer("\n".join(lines), reply_markup=docker_kb)
|
||||
|
||||
except Exception as e:
|
||||
# ⬅️ КРИТИЧЕСКИ ВАЖНО
|
||||
await msg.answer(
|
||||
"❌ Docker status crashed:\n"
|
||||
f"```{type(e).__name__}: {e}```",
|
||||
reply_markup=docker_kb,
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
|
||||
|
||||
@dp.message(F.text == "🔄 Restart")
|
||||
async def dr(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer(
|
||||
"🔄 Выберите контейнер для рестарта:",
|
||||
reply_markup=docker_inline_kb("restart")
|
||||
)
|
||||
|
||||
|
||||
@dp.message(F.text == "📜 Logs")
|
||||
async def dl(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer(
|
||||
"📜 Выберите контейнер для логов:",
|
||||
reply_markup=docker_inline_kb("logs")
|
||||
)
|
||||
|
||||
|
||||
@dp.message(F.text == "🐳 Status")
|
||||
async def ds(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_docker_status(msg)
|
||||
24
handlers/help.py
Normal file
24
handlers/help.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from aiogram import F
|
||||
from aiogram.types import Message
|
||||
from app import dp
|
||||
from auth import is_admin_msg
|
||||
from keyboards import menu_kb
|
||||
|
||||
|
||||
@dp.message(F.text == "ℹ️ Help")
|
||||
async def help_cmd(msg: Message):
|
||||
if not is_admin_msg(msg):
|
||||
return
|
||||
|
||||
await msg.answer(
|
||||
"ℹ️ **Help / Справка**\n\n"
|
||||
"🩺 Health — быстрый health-check сервера\n"
|
||||
"📊 Статус — общая загрузка сервера\n"
|
||||
"🐳 Docker — управление контейнерами\n"
|
||||
"📦 Backup — restic бэкапы\n"
|
||||
"🧉 Artifacts — критичные образы (Clonezilla, NAND)\n"
|
||||
"⚙️ System — диски, безопасность, reboot\n\n"
|
||||
"Inline-кнопки используются для выбора контейнеров.",
|
||||
reply_markup=menu_kb,
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
41
handlers/menu.py
Normal file
41
handlers/menu.py
Normal file
@@ -0,0 +1,41 @@
|
||||
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
|
||||
|
||||
|
||||
@dp.message(F.text == "/start")
|
||||
async def start(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer("🏠 Главное меню", reply_markup=menu_kb)
|
||||
|
||||
|
||||
@dp.message(F.text == "⬅️ Назад")
|
||||
async def back(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer("🏠 Главное меню", reply_markup=menu_kb)
|
||||
|
||||
|
||||
@dp.message(F.text == "🐳 Docker")
|
||||
async def dm(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer("🐳 Docker", reply_markup=docker_kb)
|
||||
|
||||
|
||||
@dp.message(F.text == "📦 Backup")
|
||||
async def bm(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer("📦 Backup", reply_markup=backup_kb)
|
||||
|
||||
|
||||
@dp.message(F.text == "🧉 Artifacts")
|
||||
async def am(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer("🧉 Artifacts", reply_markup=artifacts_kb)
|
||||
|
||||
|
||||
@dp.message(F.text == "⚙️ System")
|
||||
async def sm(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer("⚙️ System", reply_markup=system_kb)
|
||||
69
handlers/status.py
Normal file
69
handlers/status.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import socket
|
||||
import time
|
||||
import psutil
|
||||
from aiogram import F
|
||||
from aiogram.types import Message
|
||||
from app import dp, cfg
|
||||
from auth import is_admin_msg
|
||||
from keyboards import menu_kb
|
||||
from services.system import format_disks
|
||||
from services.health import health
|
||||
from state import DOCKER_MAP
|
||||
|
||||
|
||||
async def cmd_status(msg: Message):
|
||||
now = time.time()
|
||||
uptime_sec = int(now - psutil.boot_time())
|
||||
|
||||
days, rem = divmod(uptime_sec, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, _ = divmod(rem, 60)
|
||||
|
||||
load1 = psutil.getloadavg()[0]
|
||||
cpu_icon = "🟢"
|
||||
if load1 > 2.0:
|
||||
cpu_icon = "🔴"
|
||||
elif load1 > 1.0:
|
||||
cpu_icon = "🟡"
|
||||
|
||||
mem = psutil.virtual_memory()
|
||||
|
||||
disks = format_disks()
|
||||
|
||||
await msg.answer(
|
||||
"📊 **Server status**\n\n"
|
||||
f"🖥 **Host:** `{socket.gethostname()}`\n"
|
||||
f"⏱ **Uptime:** {days}d {hours}h {minutes}m\n"
|
||||
f"{cpu_icon} **Load (1m):** {load1:.2f}\n"
|
||||
f"🧠 **RAM:** {mem.used // (1024**3)} / {mem.total // (1024**3)} GiB ({mem.percent}%)\n\n"
|
||||
f"{disks}",
|
||||
reply_markup=menu_kb,
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
|
||||
|
||||
async def cmd_health(msg: Message):
|
||||
await msg.answer("⏳ Health-check…", reply_markup=menu_kb)
|
||||
|
||||
async def worker():
|
||||
try:
|
||||
text = await asyncio.to_thread(health, cfg, DOCKER_MAP)
|
||||
except Exception as e:
|
||||
await msg.answer(f"❌ Health failed: {type(e).__name__}: {e}", reply_markup=menu_kb)
|
||||
return
|
||||
await msg.answer(text, reply_markup=menu_kb)
|
||||
|
||||
asyncio.create_task(worker())
|
||||
|
||||
|
||||
@dp.message(F.text == "🩺 Health")
|
||||
async def h(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_health(msg)
|
||||
|
||||
|
||||
@dp.message(F.text == "📊 Статус")
|
||||
async def st(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await cmd_status(msg)
|
||||
18
handlers/system.py
Normal file
18
handlers/system.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from aiogram import F
|
||||
from aiogram.types import Message
|
||||
from app import dp
|
||||
from auth import is_admin_msg
|
||||
from keyboards import system_kb
|
||||
from system_checks import security, disks
|
||||
|
||||
|
||||
@dp.message(F.text == "💽 Disks")
|
||||
async def sd(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer(disks(), reply_markup=system_kb)
|
||||
|
||||
|
||||
@dp.message(F.text == "🔐 Security")
|
||||
async def sec(msg: Message):
|
||||
if is_admin_msg(msg):
|
||||
await msg.answer(security(), reply_markup=system_kb)
|
||||
Reference in New Issue
Block a user