Add alert tools, mutes, short status, and backup summary
This commit is contained in:
95
handlers/alerts_admin.py
Normal file
95
handlers/alerts_admin.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from aiogram import F
|
||||
from aiogram.types import Message
|
||||
from app import dp, bot, cfg, ADMIN_ID
|
||||
from auth import is_admin_msg
|
||||
from services.alert_mute import set_mute, clear_mute, list_mutes
|
||||
from services.incidents import read_recent
|
||||
from services.notify import notify
|
||||
|
||||
|
||||
HELP_TEXT = (
|
||||
"Alerts:\n"
|
||||
"/alerts test <critical|warn|info> - send test alert\n"
|
||||
"/alerts mute <category> <minutes> - mute alerts for category\n"
|
||||
"/alerts unmute <category> - unmute category\n"
|
||||
"/alerts list - show active mutes\n"
|
||||
"/alerts recent [hours] - show incidents log (default 24h)\n"
|
||||
"Categories: load, disk, smart, ssl, docker, test\n"
|
||||
)
|
||||
|
||||
|
||||
@dp.message(F.text.startswith("/alerts"))
|
||||
async def alerts_cmd(msg: Message):
|
||||
if not is_admin_msg(msg):
|
||||
return
|
||||
|
||||
parts = msg.text.split()
|
||||
if len(parts) < 2:
|
||||
await msg.answer(HELP_TEXT)
|
||||
return
|
||||
|
||||
action = parts[1].lower()
|
||||
|
||||
if action == "test":
|
||||
level = parts[2].lower() if len(parts) >= 3 else "info"
|
||||
if level not in ("critical", "warn", "info"):
|
||||
level = "info"
|
||||
key = f"test:{level}:{int(time.time())}"
|
||||
await notify(bot, msg.chat.id, f"[TEST] {level.upper()} alert", level=level, key=key, category="test")
|
||||
await msg.answer(f"Sent test alert: {level}")
|
||||
return
|
||||
|
||||
if action == "mute":
|
||||
if len(parts) < 3:
|
||||
await msg.answer("Usage: /alerts mute <category> <minutes>")
|
||||
return
|
||||
category = parts[2].lower()
|
||||
minutes = 60
|
||||
if len(parts) >= 4:
|
||||
try:
|
||||
minutes = max(1, int(parts[3]))
|
||||
except ValueError:
|
||||
minutes = 60
|
||||
until = set_mute(category, minutes * 60)
|
||||
dt = datetime.fromtimestamp(until, tz=timezone.utc).astimezone()
|
||||
await msg.answer(f"🔕 Muted {category} for {minutes}m (until {dt:%Y-%m-%d %H:%M:%S})")
|
||||
return
|
||||
|
||||
if action == "unmute":
|
||||
if len(parts) < 3:
|
||||
await msg.answer("Usage: /alerts unmute <category>")
|
||||
return
|
||||
category = parts[2].lower()
|
||||
clear_mute(category)
|
||||
await msg.answer(f"🔔 Unmuted {category}")
|
||||
return
|
||||
|
||||
if action in ("list", "mutes"):
|
||||
mutes = list_mutes()
|
||||
if not mutes:
|
||||
await msg.answer("🔔 No active mutes")
|
||||
return
|
||||
lines = ["🔕 Active mutes:"]
|
||||
for cat, secs in mutes.items():
|
||||
mins = max(0, secs) // 60
|
||||
lines.append(f"- {cat}: {mins}m left")
|
||||
await msg.answer("\n".join(lines))
|
||||
return
|
||||
|
||||
if action == "recent":
|
||||
hours = 24
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
hours = max(1, int(parts[2]))
|
||||
except ValueError:
|
||||
hours = 24
|
||||
rows = read_recent(cfg, hours, limit=50)
|
||||
if not rows:
|
||||
await msg.answer(f"No incidents in last {hours}h")
|
||||
return
|
||||
await msg.answer("🧾 Incidents:\n" + "\n".join(rows))
|
||||
return
|
||||
|
||||
await msg.answer(HELP_TEXT)
|
||||
@@ -37,6 +37,16 @@ def _sudo_cmd(cmd: list[str]) -> list[str]:
|
||||
return ["sudo", "-E"] + cmd
|
||||
|
||||
|
||||
def _format_backup_result(rc: int, out: str) -> str:
|
||||
log_hint = "log: /var/log/backup-auto.log"
|
||||
header = "✅ Backup finished" if rc == 0 else "❌ Backup failed"
|
||||
lines = out.strip().splitlines()
|
||||
body = "\n".join(lines[:20])
|
||||
if len(lines) > 20:
|
||||
body += f"\n… trimmed {len(lines) - 20} lines"
|
||||
return f"{header} (rc={rc})\n{log_hint}\n\n{body}" if body else f"{header} (rc={rc})\n{log_hint}"
|
||||
|
||||
|
||||
def _load_json(raw: str, label: str) -> tuple[bool, object | None, str]:
|
||||
if not raw or not raw.strip():
|
||||
return False, None, f"? {label} returned empty output"
|
||||
@@ -215,7 +225,7 @@ async def cmd_backup_now(msg: Message):
|
||||
use_restic_env=True,
|
||||
timeout=6 * 3600,
|
||||
)
|
||||
await msg.answer(("✅ OK\n" if rc == 0 else "❌ FAIL\n") + out, reply_markup=backup_kb)
|
||||
await msg.answer(_format_backup_result(rc, out), reply_markup=backup_kb)
|
||||
finally:
|
||||
release_lock("backup")
|
||||
|
||||
|
||||
24
handlers/config_check.py
Normal file
24
handlers/config_check.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from aiogram import F
|
||||
from aiogram.types import Message
|
||||
from app import dp, cfg
|
||||
from auth import is_admin_msg
|
||||
from services.config_check import validate_cfg
|
||||
|
||||
|
||||
@dp.message(F.text == "/config_check")
|
||||
async def config_check(msg: Message):
|
||||
if not is_admin_msg(msg):
|
||||
return
|
||||
errors, warnings = validate_cfg(cfg)
|
||||
lines = []
|
||||
if errors:
|
||||
lines.append("❌ Config errors:")
|
||||
lines += [f"- {e}" for e in errors]
|
||||
if warnings:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append("⚠️ Warnings:")
|
||||
lines += [f"- {w}" for w in warnings]
|
||||
if not lines:
|
||||
lines.append("✅ Config looks OK")
|
||||
await msg.answer("\n".join(lines))
|
||||
@@ -76,6 +76,45 @@ async def st(msg: Message):
|
||||
await cmd_status(msg)
|
||||
|
||||
|
||||
@dp.message(F.text == "/status_short")
|
||||
async def st_short(msg: Message):
|
||||
if not is_admin_msg(msg):
|
||||
return
|
||||
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, load5, load15 = psutil.getloadavg()
|
||||
mem = psutil.virtual_memory()
|
||||
disks = format_disks().splitlines()
|
||||
disk_line = disks[1] if len(disks) > 1 else "Disks: n/a"
|
||||
await msg.answer(
|
||||
"📋 **Status (short)**\n"
|
||||
f"🖥 `{socket.gethostname()}`\n"
|
||||
f"⏱ Uptime: {days}d {hours}h {minutes}m\n"
|
||||
f"⚙️ Load: {load1:.2f} {load5:.2f} {load15:.2f}\n"
|
||||
f"🧠 RAM: {mem.percent}% ({mem.used // (1024**3)} / {mem.total // (1024**3)} GiB)\n"
|
||||
f"💾 {disk_line}",
|
||||
reply_markup=menu_kb,
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
|
||||
|
||||
@dp.message(F.text == "/health_short")
|
||||
async def health_short(msg: Message):
|
||||
if not is_admin_msg(msg):
|
||||
return
|
||||
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
|
||||
lines = [ln for ln in text.splitlines() if ln.strip()]
|
||||
brief = " | ".join(lines[1:5]) if len(lines) > 1 else text
|
||||
await msg.answer(f"🩺 Health (short)\n{brief}", reply_markup=menu_kb)
|
||||
|
||||
|
||||
def _rate_str(value: float) -> str:
|
||||
if value >= 1024 * 1024:
|
||||
return f"{value / (1024 * 1024):.2f} MiB/s"
|
||||
|
||||
Reference in New Issue
Block a user