Версия 1.1 | 12 сентября 2026
Расширение бота @avk_vps_monitoring_bot тремя отчётами по запросу: занятое дисковое пространство в разрезе каталогов, RAM в разрезе процессов и контейнеров, загрузка CPU в разрезе процессов и контейнеров. Базовая версия бота (сводка, алерты, long polling, systemd) — в документе «Netdata: алерты и уведомления в Telegram»; здесь — только новые отчёты.
Клавиатура под каждым отчётом стала двухрядной:
[ 🔄 Обновить ] [ 🛡 Алерты ]
[ 💾 Диск ] [ 🧠 RAM ] [ ⚡ CPU ]
[ 🧹 Очистить чат ]
Меню команд (кнопка «/» рядом с полем ввода):
| Команда | Описание в меню |
|---|---|
| /status | Сводка состояния |
| /disk | 💾 Диск в разрезе |
| /ram | 🧠 RAM по процессам |
| /cpu | ⚡ CPU по процессам |
| /alerts | Активные алерты |
| /clear | 🧹 Очистить историю чата |
| /ping | Проверить, что бот жив |
du обходит весь корневой раздел. На время подсчёта бот отвечает на нажатие кнопки статусом «Собираю данные, до минуты…», чтобы интерфейс не выглядел зависшим.
Команда /clear (или кнопка «🧹 Очистить чат») удаляет накопившиеся отчёты: у Telegram-ботов нет API «очистить чат целиком», поэтому бот идёт по номерам сообщений назад и удаляет каждое методом deleteMessage. В конце присылает подтверждение «🧹 Очищено сообщений: N» с клавиатурой — чат остаётся чистым.
df -h / — итог по разделу;du -x -d2 -BM / | sort -rn | head -12 — топ каталогов до глубины 2 (флаг -x не даёт выйти за границы файловой системы, /proc и /tmp-монтирования не считаются);docker system df — сколько занято образами, контейнерами, томами и кэшем сборки, и сколько из этого можно освободить (колонка RECLAIMABLE).
💾 /: занято 29G из 38G (77%), свободно 8.8G
Топ каталогов (du, глубина 2):
15.3G /opt
11.6G /var
11.0G /opt/backups
9.1G /var/lib
4.2G /opt/projects
2.3G /var/log
2.1G /usr
975M /usr/lib
383M /usr/bin
311M /usr/src
285M /usr/share
Docker:
Images 13 13 8.316GB 0B (0%)
Containers 16 16 18.82MB 0B (0%)
Local Volumes 4 1 100.8MB 100.8MB (100%)
Build Cache 27 0 919.4MB 919.4MB
На этом снимке сразу видны главные пожиратели: /opt/backups — 11 GB (бэкапы с retention 7 дней), образы Docker 8.3 GB и кэш сборки 0.9 GB. Отчёт превращает вопрос «куда делось место» в один тап.
system.ram — итог по памяти (used/free/cached/buffers в MiB);ps aux --sort=-rss | head -8 — топ процессов по RSS. Бот работает на хосте, поэтому видит процессы всех контейнеров «изнутри»: mysqld, xray, php-fpm и т.д.;docker stats --no-stream — память по контейнерам (сортировка по убыванию).
🧠 RAM: занято 2.5G из 3.8G (64%) + кэш 0.9G
Топ процессов (по RSS):
694M mysqld
155M /usr/sbin/netdata -u netdata -D -s /ho
132M node server/server.js
102M node --abort_on_uncaught_exception --m
101M /usr/libexec/netdata/plugins.d/go.d.pl
101M /usr/bin/dockerd -H fd:// --containerd
75M /usr/lib/systemd/systemd-journald
66M /app/x-ui
Контейнеры (по памяти):
692.6MiB mylife-common-db
295.7MiB netdata
127MiB uptime-kuma
126MiB nginx-proxy-manager
98.7MiB 3x-ui
78.23MiB mylife-avk27-php-fpm
Обратите внимание: процесс mysqld (694 MB) и контейнер mylife-common-db (692 MiB) — одно и то же, просто в двух разрезах. Так и задумано: процессы дают точность до бинаря, контейнеры — привязку к проекту.
system.cpu и system.load — общая загрузка и load average с числом ядер;ps aux --sort=-%cpu | head -8 — топ процессов (сам ps из списка исключён, иначе показывал бы себя на первом месте);docker stats --no-stream — CPU по контейнерам.
⚡ CPU: 34% · load 1.03/0.54/1.49 (2 яд.)
Топ процессов (по CPU):
5.9% /usr/bin/containerd
3.8% /usr/libexec/netdata/plugins.d/apps.pl
2.6% /usr/sbin/netdata -u netdata -D -s /ho
1.8% mysqld
0.7% /usr/libexec/netdata/plugins.d/go.d.pl
0.6% node server/server.js
0.5% /usr/bin/dockerd -H fd:// --containerd
Контейнеры (по CPU):
53.37% mylife-avk27-cron
13.99% netdata
3.26% mylife-common-db
0.40% uptime-kuma
0.09% 3x-ui
0.04% dozzle
def sh(cmd, timeout=300): # shell-команды с таймаутом
return subprocess.run(cmd, shell=True, capture_output=True,
text=True, timeout=timeout).stdout
def send(text, buttons=False, mono=False):
params = {"chat_id": ADMIN_CHAT, "text": text}
if mono: # отчёты-таблицы — моноширинно
params["text"] = "<pre>" + html_mod.escape(text) + "</pre>"
params["parse_mode"] = "HTML"
if buttons:
params["reply_markup"] = BUTTONS
tg("sendMessage", **params)
def fmt_m(mb): # 12345 -> "12.1G", 543 -> "543M"
mb = float(mb)
return f"{mb/1024:.1f}G" if mb >= 1024 else f"{int(mb)}M"
def to_mb(s): # "102.2MiB" -> 102.2
mult = {"TiB": 1048576, "GiB": 1024, "MiB": 1, "KiB": 1/1024, "B": 1/1048576}
for suf in ("TiB", "GiB", "MiB", "KiB"):
if s.endswith(suf):
return float(s[:-len(suf)]) * mult[suf]
return float(s.rstrip("B")) / 1048576
def docker_stats(): # [{"name","mem","mem_mb","cpu"}]
out = sh("docker stats --no-stream --format "
"'{{.Name}};{{.MemUsage}};{{.CPUPerc}}'", timeout=90)
rows = []
for line in out.strip().splitlines():
parts = line.split(";")
if len(parts) != 3:
continue
mem_str = parts[1].split("/")[0].strip() # "102.2MiB"
rows.append({"name": parts[0], "mem": mem_str,
"mem_mb": to_mb(mem_str),
"cpu": float(parts[2].strip().rstrip("%"))})
return rows
def disk_report():
f = sh("df -h / | tail -1").split()
lines = [f"💾 {f[5]}: занято {f[2]} из {f[1]} ({f[4]}), свободно {f[3]}",
"", "Топ каталогов (du, глубина 2):"]
for row in sh("du -x -d2 -BM / 2>/dev/null | sort -rn | head -12").splitlines():
size, path = row.rstrip().split("\t")
if path == "/": # общий итог уже в заголовке
continue
lines.append(f"{fmt_m(size[:-1]):>8} {path}")
lines += ["", "Docker:"]
lines += sh("docker system df | tail -n +2").strip().splitlines()
return "\n".join(lines)
def ram_report():
m = nd("allmetrics?format=json")
ram = m['system.ram']['dimensions']
tot = sum(d['value'] for k, d in ram.items()
if k in ('free', 'used', 'cached', 'buffers'))
used, cached = ram['used']['value'], ram['cached']['value']
lines = [f"🧠 RAM: занято {used/1024:.1f}G из {tot/1024:.1f}G ({used*100/tot:.0f}%)"
f" + кэш {cached/1024:.1f}G",
"", "Топ процессов (по RSS):"]
for row in sh("ps aux --sort=-rss --no-headers | head -8").splitlines():
p = row.split(None, 10)
if len(p) >= 11:
lines.append(f"{int(p[5])/1024:7.0f}M {p[10][:38]}")
lines += ["", "Контейнеры (по памяти):"]
for r in sorted(docker_stats(), key=lambda r: r["mem_mb"], reverse=True)[:6]:
lines.append(f"{r['mem']:>10} {r['name']}")
return "\n".join(lines)
def cpu_report():
m = nd("allmetrics?format=json")
ld = m['system.load']['dimensions']
cpu = 100 - m['system.cpu']['dimensions']['idle']['value']
cores = os.cpu_count()
lines = [f"⚡ CPU: {cpu:.0f}% · load "
f"{ld['load1']['value']:.2f}/{ld['load5']['value']:.2f}/"
f"{ld['load15']['value']:.2f} ({cores} яд.)",
"", "Топ процессов (по CPU):"]
for row in sh("ps aux --sort=-%cpu --no-headers | head -8").splitlines():
p = row.split(None, 10)
if len(p) >= 11 and not p[10].startswith("ps aux"): # не показываем себя
lines.append(f"{float(p[2]):6.1f}% {p[10][:38]}")
lines += ["", "Контейнеры (по CPU):"]
for r in sorted(docker_stats(), key=lambda r: r["cpu"], reverse=True)[:6]:
lines.append(f"{r['cpu']:6.2f}% {r['name']}")
return "\n".join(lines)
BUTTONS = {"inline_keyboard": [
[{"text": "🔄 Обновить", "callback_data": "status"},
{"text": "🛡 Алерты", "callback_data": "alerts"}],
[{"text": "💾 Диск", "callback_data": "disk"},
{"text": "🧠 RAM", "callback_data": "ram"},
{"text": "⚡ CPU", "callback_data": "cpu"}],
[{"text": "🧹 Очистить чат", "callback_data": "clear"}],
]}
def clear_history(up_to_including):
"""Удаляет недавнюю историю чата (что разрешает Telegram API)."""
deleted, fails, mid = 0, 0, up_to_including
floor = max(0, up_to_including - 400) # окно сканирования
while mid > floor and fails < 15: # 15 ошибок подряд = дошли до старья
try:
tg("deleteMessage", chat_id=ADMIN_CHAT, message_id=mid)
deleted += 1
fails = 0
except Exception:
fails += 1
mid -= 1
return deleted
def handle(kind): # единая точка: команды и кнопки
try:
if kind == "status": send(report(), buttons=True)
elif kind == "alerts": send(alerts_text(), buttons=True)
elif kind == "disk": send(disk_report(), mono=True)
elif kind == "ram": send(ram_report(), mono=True)
elif kind == "cpu": send(cpu_report(), mono=True)
elif kind == "ping": send("pong")
except Exception as e:
try:
send(f"⚠️ Ошибка отчёта {kind}: {e}")
except Exception:
pass
В главном цикле нажатие кнопки и команду из меню сводятся к одному вызову handle(); перед тяжёлым отчётом /disk бот сразу отвечает answerCallbackQuery со статусом «Собираю данные…». Очистка истории — особый случай: ей нужен номер сообщения, с которого начинать (сообщение с кнопкой или сама команда), поэтому она обрабатывается в цикле напрямую, минуя handle(). Меню команд регистрируется через setMyCommands (семь команд из таблицы выше).
# Все отчёты в консоль, без отправки в Telegram (du считается по-настоящему)
python3 /opt/scripts/vps-status-bot.py --selftest
# Один отчёт в Telegram и выйти (проверка доставки)
set -a; . /etc/vps-report.env; set +a
python3 /opt/scripts/vps-status-bot.py --test
# Применить изменения скрипта
systemctl restart vps-status-bot
# Логи бота
journalctl -u vps-status-bot -f
🎉 Готово. Отчёты развёрнуты на сервере 12.09.2026; документ обновляйте при изменении состава метрик.