From d3572c600502db3dfc501fae6e214c1e0687fdac Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Feb 2026 00:02:34 +0300 Subject: [PATCH] Add system metrics snapshot --- handlers/help.py | 2 +- handlers/system.py | 13 ++++++- keyboards.py | 5 +-- main.py | 3 ++ services/metrics.py | 86 +++++++++++++++++++++++++++++++++++++++++++++ state.py | 1 + 6 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 services/metrics.py diff --git a/handlers/help.py b/handlers/help.py index 61821ca..f206ab4 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, reboot\n\n" + "⚙️ System — диски, безопасность, URL, metrics, reboot\n\n" "Inline-кнопки используются для выбора контейнеров.", reply_markup=menu_kb, parse_mode="Markdown", diff --git a/handlers/system.py b/handlers/system.py index 39a1b3f..e6941ed 100644 --- a/handlers/system.py +++ b/handlers/system.py @@ -9,7 +9,8 @@ from services.http_checks import get_url_checks, check_url from services.queue import enqueue from services.updates import list_updates, apply_updates from services.runner import run_cmd -from state import UPDATES_CACHE, REBOOT_PENDING +from state import UPDATES_CACHE, REBOOT_PENDING, METRICS_STORE +from services.metrics import summarize @dp.message(F.text == "💽 Disks") @@ -55,6 +56,16 @@ async def urls(msg: Message): asyncio.create_task(worker()) +@dp.message(F.text == "📈 Metrics") +async def metrics(msg: Message): + if not is_admin_msg(msg): + return + if METRICS_STORE is None: + await msg.answer("⚠️ Metrics not initialized", reply_markup=system_kb) + return + await msg.answer(summarize(METRICS_STORE, minutes=15), reply_markup=system_kb) + + @dp.message(F.text == "📦 Updates") async def updates_list(msg: Message): if not is_admin_msg(msg): diff --git a/keyboards.py b/keyboards.py index 2bd9c73..2dd1d17 100644 --- a/keyboards.py +++ b/keyboards.py @@ -55,8 +55,9 @@ artifacts_kb = ReplyKeyboardMarkup( system_kb = ReplyKeyboardMarkup( keyboard=[ [KeyboardButton(text="💽 Disks"), KeyboardButton(text="🔐 Security")], - [KeyboardButton(text="🌐 URLs"), KeyboardButton(text="📦 Updates")], - [KeyboardButton(text="⬆️ Upgrade"), KeyboardButton(text="🔄 Reboot")], + [KeyboardButton(text="🌐 URLs"), KeyboardButton(text="📈 Metrics")], + [KeyboardButton(text="📦 Updates"), KeyboardButton(text="⬆️ Upgrade")], + [KeyboardButton(text="🔄 Reboot")], [KeyboardButton(text="⬅️ Назад")], ], resize_keyboard=True, diff --git a/main.py b/main.py index 5748f7e..ad93b74 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ from app import bot, dp, cfg, ADMIN_ID from keyboards import menu_kb from services.docker import discover_containers, docker_watchdog from services.alerts import monitor_resources, monitor_smart +from services.metrics import MetricsStore, start_sampler from services.queue import worker as queue_worker from services.notify import notify import state @@ -36,6 +37,8 @@ async def main(): asyncio.create_task(monitor_resources(cfg, notify, bot, ADMIN_ID)) if cfg.get("alerts", {}).get("smart_enabled", True): asyncio.create_task(monitor_smart(cfg, notify, bot, ADMIN_ID)) + state.METRICS_STORE = MetricsStore() + asyncio.create_task(start_sampler(state.METRICS_STORE, interval=5)) asyncio.create_task(queue_worker()) await notify_start() await dp.start_polling(bot) diff --git a/services/metrics.py b/services/metrics.py new file mode 100644 index 0000000..ebd31c2 --- /dev/null +++ b/services/metrics.py @@ -0,0 +1,86 @@ +import asyncio +import time +from collections import deque +import psutil + + +class MetricsStore: + def __init__(self, maxlen: int = 720): + self.samples = deque(maxlen=maxlen) + self.interval = 5 + + def add(self, sample: dict): + self.samples.append(sample) + + +async def start_sampler(store: MetricsStore, interval: int = 5): + store.interval = interval + psutil.cpu_percent(interval=None) + last_net = psutil.net_io_counters() + + while True: + now = time.time() + cpu = psutil.cpu_percent(interval=None) + load1 = psutil.getloadavg()[0] + + net = psutil.net_io_counters() + rx_bytes = net.bytes_recv - last_net.bytes_recv + tx_bytes = net.bytes_sent - last_net.bytes_sent + last_net = net + + rx_rate = rx_bytes / interval + tx_rate = tx_bytes / interval + + store.add({ + "ts": now, + "cpu": cpu, + "load1": load1, + "rx_bytes": rx_bytes, + "tx_bytes": tx_bytes, + "rx_rate": rx_rate, + "tx_rate": tx_rate, + }) + + await asyncio.sleep(interval) + + +def summarize(store: MetricsStore, minutes: int = 15) -> str: + cutoff = time.time() - minutes * 60 + data = [s for s in list(store.samples) if s["ts"] >= cutoff] + if not data: + return "📈 Metrics\n\n⚠️ No data yet" + + cpu_vals = [s["cpu"] for s in data] + load_vals = [s["load1"] for s in data] + rx_rates = [s["rx_rate"] for s in data] + tx_rates = [s["tx_rate"] for s in data] + + total_rx = sum(s["rx_bytes"] for s in data) + total_tx = sum(s["tx_bytes"] for s in data) + + def avg(vals): + return sum(vals) / len(vals) if vals else 0.0 + + def fmt_rate(bps): + if bps > 1024**2: + return f"{bps / (1024**2):.2f} MiB/s" + if bps > 1024: + return f"{bps / 1024:.2f} KiB/s" + return f"{bps:.0f} B/s" + + def fmt_bytes(b): + if b > 1024**3: + return f"{b / (1024**3):.2f} GiB" + if b > 1024**2: + return f"{b / (1024**2):.2f} MiB" + if b > 1024: + return f"{b / 1024:.2f} KiB" + return f"{b} B" + + return ( + f"📈 Metrics (last {minutes}m)\n\n" + f"🧠 CPU avg: {avg(cpu_vals):.1f}% | max: {max(cpu_vals):.1f}%\n" + f"⚙️ Load avg: {avg(load_vals):.2f} | max: {max(load_vals):.2f}\n" + f"⬇️ RX avg: {fmt_rate(avg(rx_rates))} | max: {fmt_rate(max(rx_rates))} | total: {fmt_bytes(total_rx)}\n" + f"⬆️ TX avg: {fmt_rate(avg(tx_rates))} | max: {fmt_rate(max(tx_rates))} | total: {fmt_bytes(total_tx)}" + ) diff --git a/state.py b/state.py index bd5f3ea..f057b7a 100644 --- a/state.py +++ b/state.py @@ -5,3 +5,4 @@ LOG_FILTER_PENDING: Dict[int, dict] = {} UPDATES_CACHE: Dict[int, dict] = {} ARCANE_CACHE: Dict[int, dict] = {} REBOOT_PENDING: Dict[int, dict] = {} +METRICS_STORE = None