syslog v0.6.0 [base]
Журнал бота для кабинета (прямой эфир с паузой).
README
# syslog Журнал бота для кабинета: прямой эфир с паузой. Тип: Модуль. Категория: `base`. Зависимостей нет. ## Описание Читалка логов без SSH. Модуль показывает хвост системного журнала службы с фильтрами по уровню и размеру выборки — быстрая диагностика «что упало и когда», не вставая из кабинета. Источник выбирается сам, по цепочке: заданный в `SYSTEMD_UNIT` юнит, затем свой юнит (в котором запущен бот), затем частые имена служб, затем файл `data/logs/bot.log`, затем общий журнал. Пустой `SYSTEMD_UNIT` — не ошибка: логи всё равно найдутся. В кабинете журнал идёт прямым эфиром: новые строки подливаются сами. Поток можно заморозить паузой и спокойно прочитать нужное. Ниже эфира — окно «⚠ Варнинги и ошибки»: весь файл логов (`bot.log` + ротации), новые сверху, таблица время/модуль/уровень/текст с пагинацией (20/50/100 на странице). Трейсбеки приклеены к записи-причине. ## Права - `view_logs` — Просмотр журнала бота в кабинете ## Web - `GET /api/syslog/logs` — хвост журнала (`?tail=200|500|1000`, `?level=all|info|warning|error`) - `GET /api/syslog/problems` — варнинги/ошибки всего журнала таблицей (`?level=warning|error`, `?page=1`, `?per_page=20|50|100`) ## Конфигурация (`config.schema.json`) - Опциональные: `SYSTEMD_UNIT` = ``
Манифест
{
"name": "syslog",
"version": "0.6.0",
"category": "base",
"description": "Журнал бота для кабинета (прямой эфир с паузой).",
"requires": [],
"rights": [],
"api_methods": [],
"web_routes": [],
"commit": "0bbe59d1e1f6de9abe9b853d242e5310239fa4ae",
"updated": "2026-09-22 09:02:39 +0300",
"history": [
{
"version": "0.1.0",
"date": "2026-09-09 16:13:44 +0300",
"commit": "91bed316610d30bb1e1a603299c904f4a759999e"
},
{
"version": "0.2.0",
"date": "2026-09-11 10:14:23 +0300",
"commit": "86ca69448ecf0c529c97ee1215747a8eb33dcc48"
},
{
"version": "0.3.0",
"date": "2026-09-17 15:23:08 +0300",
"commit": "4341fd29b1fb92c27825071d49a701fe6fea601f"
},
{
"version": "0.4.0",
"date": "2026-09-17 15:51:06 +0300",
"commit": "665dab3aa17bd5c52ba46e631ba9451a52d5063c"
},
{
"version": "0.5.0",
"date": "2026-09-18 10:18:07 +0300",
"commit": "aa0cda60970ce2537731e008256dc93eda58e436"
},
{
"version": "0.6.0",
"date": "2026-09-21 17:56:41 +0300",
"commit": "bf8125926601829a1ee527438f12a16e5ca4b006"
}
],
"integrity": "sha256:565357fe1c3776fef5b48899a9db14b3cc164f4b06686de0c2bf4daef0954789",
"files": "[7 файлов — см. вкладки ниже]",
"note": "",
"wdesc": "",
"wbody": "",
"_extra": {
"api": "[]",
"events_emitted": "[]",
"events_subscribed": "[]",
"shared_routes": "",
"config_schema": "config.schema.json",
"env_example": ".env.example"
}
} Файлы и исходники
Дерево файлов
- · корень
- 2.0 КБ
- 0.1 КБ
- 0.5 КБ
- 0.5 КБ
- 11.2 КБ
- web/
- 2.8 КБ
- 4.7 КБ
Предпросмотр
Выберите файл в дереве выше — код откроется здесь.
Все исходники (.py) одним списком
module.py 0.5 КБ
"""syslog.Module — журнал бота для кабинета (прямой эфир с паузой)."""
import logging
import os
from core.base_module import BaseModule
logger = logging.getLogger(__name__)
class Module(BaseModule):
def setup(self, ctx) -> None:
mod_dir = os.path.dirname(os.path.abspath(__file__))
self.config = ctx.module_config("syslog", mod_dir)
self.ctx = ctx
def health(self) -> dict:
return {"ok": True, "module": self.name}
ui_web.py 11.2 КБ
"""syslog.ui_web — GET /api/syslog/logs: хвост журнала службы/файла."""
ROUTES = [
("GET", "/api/syslog/logs", "logs", {"right": "view_logs",
"desc": "Хвост журнала (?tail=200|500|1000, ?level=all|info|warning|error)"}),
("GET", "/api/syslog/alerts", "alerts", {"right": "view_logs",
"desc": "Свежие ошибки для тостов (?since=ts)"}),
("GET", "/api/syslog/problems", "problems", {"right": "view_logs",
"desc": "Варнинги/ошибки всего журнала таблицей (?level=warning|error, ?page=1, ?per_page=20|50|100)"}),
]
SERVICE = [
{"key": "logs", "icon": "🧾", "title": "Логи бота", "right": "view_logs",
"phase": 1, "desc": "Прямой эфир журнала службы с паузой"},
]
PANELS = []
def handle_api(ctx, config, method, req):
from core.errors import UserError
if method == "logs":
query = req.get("query") or {}
try:
tail = int(query.get("tail") or 200)
except (TypeError, ValueError):
tail = 200
if tail not in (200, 500, 1000):
tail = 200
level = (query.get("level") or "all").strip().lower()
if level not in ("all", "info", "warning", "error"):
level = "all"
lines, source = _tail_log(ctx, config, tail, level)
return {"ok": True, "lines": lines, "source": source,
"tail": tail, "level": level}
if method == "alerts":
query = req.get("query") or {}
try:
since = float(query.get("since") or 0)
except (TypeError, ValueError):
since = 0
return _alerts(ctx, config, since)
if method == "problems":
query = req.get("query") or {}
level = (query.get("level") or "warning").strip().lower()
if level not in ("warning", "error"):
level = "warning"
try:
page = int(query.get("page") or 1)
except (TypeError, ValueError):
page = 1
try:
per_page = int(query.get("per_page") or 50)
except (TypeError, ValueError):
per_page = 50
if per_page not in (20, 50, 100):
per_page = 50
if page < 1:
page = 1
return _problems(ctx, level, page, per_page)
raise UserError("неизвестный метод: %s" % method)
def _problems(ctx, level="warning", page=1, per_page=50):
"""Варнинги/ошибки всего журнала таблицей с пагинацией.
Читает ВЕСЬ файл логов (data/logs/bot.log + ротации .1/.2/.3),
новые сверху. Строка: время, модуль, уровень, текст (с трейсбеком,
обрезанным до 1000). Право view_logs (проверяет транспорт).
"""
from core import logparse as _lp
try:
data_dir = ctx.data_dir
except Exception: # noqa: BLE001
data_dir = "."
recs = _lp.read_records(data_dir)
if level == "error":
want = ("ERROR", "CRITICAL")
else:
want = ("WARNING", "ERROR", "CRITICAL")
rows = [{"time": r["time"], "module": r["module"],
"level": r["level"], "text": r["text"][:1000]}
for r in reversed(recs) if r.get("level") in want]
total = len(rows)
pages = max(1, (total + per_page - 1) // per_page)
if page > pages:
page = pages
start = (page - 1) * per_page
return {"ok": True, "total": total, "page": page,
"per_page": per_page, "pages": pages, "level": level,
"rows": rows[start:start + per_page]}
def _level_rx(level):
import re as _re
return {"info": _re.compile(r"\bDEBUG\b"),
"warning": _re.compile(r"\b(WARNING|ERROR|CRITICAL)\b"),
"error": _re.compile(r"\b(ERROR|CRITICAL)\b")}.get(level)
def _apply_level(lines, level):
"""Фильтр по маркеру уровня в тексте строки (надёжнее journalctl -p:
Python пишет всё в stderr с приоритетом info). info = всё кроме DEBUG."""
import re as _re
if level == "all":
return lines
rx = _level_rx(level)
if level == "info":
return [l for l in lines if not rx.search(l)]
return [l for l in lines if rx.search(l)]
def _unit_candidates(config):
"""Юниты по приоритету: явный SYSTEMD_UNIT, затем автоопределение.
Автоопределение: имя службы из /proc/1 через systemd (свой юнит),
иначе частые имена бота. Пустые/несуществующие отсекаются вызовом.
"""
out = []
try:
u = ((config.get("SYSTEMD_UNIT", "") or "").strip()
if config else "")
if u:
out.append(u)
except Exception: # noqa: BLE001
pass
out += ["bot_modular.service", "bot_prod.service", "bot_verify.service",
"bot.service"]
seen, uniq = set(), []
for u in out:
if u not in seen:
seen.add(u)
uniq.append(u)
return uniq
def _running_unit():
"""Юнит текущего процесса через systemd (идём от своего PID вверх)."""
import os as _os
import subprocess as _sp
try:
r = _sp.run(["systemctl", "--no-pager", "--no-legend",
"status", str(_os.getpid())],
capture_output=True, text=True, timeout=10)
if r.returncode != 0:
return ""
for line in (r.stdout or "").splitlines():
s = line.strip()
if s.startswith("Unit:") or "Loaded:" in s or "CGroup:" in s:
import re as _re
m = _re.search(r"([\w@:.~-]+\.service)", s)
if m:
return m.group(1)
return ""
except Exception: # noqa: BLE001
return ""
def _tail_journalctl(unit, tail, level):
import subprocess as _sp
cmd = ["journalctl", "-u", unit, "-n", str(tail * 5),
"--no-pager", "-q"]
r = _sp.run(cmd, capture_output=True, text=True, timeout=15)
if r.returncode != 0:
return None
lines = (r.stdout or "").splitlines()
lines = _apply_level(lines, level)[-tail:]
src = "journalctl -u %s%s" % (
unit, (" level=%s" % level) if level != "all" else "")
return [l[:2000] for l in lines], src
def _alerts(ctx, config, since=0):
"""Свежие ошибки для тостов: ERROR/CRITICAL из журнала + пропущенные.
since — unix-ts курсора клиента (0 = всё свежее за 5 минут).
Возвращает {alerts: [{key, text}], now}. Дедуп — по key на клиенте.
"""
import re as _re
import time as _time
now = _time.time()
window = since or (now - 300)
out = []
def _ts_of(line):
m = _re.search(r"(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})", line)
if not m:
return 0
try:
import datetime as _dt
return _dt.datetime.strptime(
m.group(1)[:19], "%Y-%m-%d %H:%M:%S").timestamp()
except Exception: # noqa: BLE001
return 0
def _short(line):
# «2026-09-17 15:44:27,249 - tornado.access - WARNING - ... 400 POST ...»
# -> «tornado.access: 400 POST /api/modsafe/update»
parts = [p.strip() for p in line.split(" - ")]
if len(parts) >= 4:
tail = " - ".join(parts[3:])
return "%s: %s" % (parts[1][:40], tail[:160])
return line[:200]
try:
lines, _src = _tail_log(ctx, config, 1000, "error")
except Exception: # noqa: BLE001
lines = []
seen_texts = set()
for line in lines or []:
if "ERROR" not in line and "CRITICAL" not in line:
continue
if _ts_of(line) < window:
continue
short = _short(line)
if short in seen_texts:
continue
seen_texts.add(short)
out.append({"key": "log:%s" % short[:120], "text": short})
if len(out) >= 5:
break
try:
skipped = getattr(ctx, "skipped_modules", None) or {}
except Exception: # noqa: BLE001
skipped = {}
for name, reason in list(skipped.items())[:5]:
out.append({"key": "skip:%s" % name,
"text": "Модуль %s пропущен: %s" % (name, reason)[:200]})
return {"ok": True, "alerts": out, "now": now}
def _tail_log(ctx, config, tail, level="all"):
"""Хвост журнала: файл -> unit -> свой юнит -> частые имена -> общий.
Файл data/logs/bot.log — основной источник (пишет сам бот, права
не нужны). journalctl — запасной (требует группу systemd-journal).
Фильтр уровня — по содержимому строк (маркер LEVELNAME), на стороне
команды. Берём с запасом x5, отдаём до tail.
"""
import os as _os
log_file = ""
try:
log_file = _os.path.join(ctx.data_dir, "logs", "bot.log")
except Exception: # noqa: BLE001
log_file = ""
if log_file and _os.path.isfile(log_file):
try:
with open(log_file, "r", encoding="utf-8",
errors="replace") as f:
# запас на фильтрацию: читаем больше, отдаём tail
raw = f.read().splitlines()[-(tail * 5):]
raw = _apply_level(raw, level)
if raw:
return [l[:2000] for l in raw[-tail:]], "файл " + log_file
except Exception: # noqa: BLE001
pass
journal_ok = True
for unit in _unit_candidates(config):
try:
got = _tail_journalctl(unit, tail, level)
except Exception: # noqa: BLE001
got = None
if got is None:
journal_ok = False
continue
if got and got[0]:
return got
own = _running_unit()
if own:
try:
got = _tail_journalctl(own, tail, level)
except Exception: # noqa: BLE001
got = None
if got is None:
journal_ok = False
elif got and got[0]:
return got
# Последний рубеж: весь journal без фильтра по юниту (хоть что-то).
import subprocess as _sp
try:
r = _sp.run(["journalctl", "-n", str(tail * 5), "--no-pager",
"-q"], capture_output=True, text=True, timeout=15)
if r.returncode == 0:
lines = (r.stdout or "").splitlines()
lines = _apply_level(lines, level)[-tail:]
if lines:
return [l[:2000] for l in lines], "journalctl (весь)"
else:
journal_ok = False
except Exception: # noqa: BLE001
journal_ok = False
if not journal_ok:
return [], ("нет источника: файла нет, journalctl недоступен "
"(пользователь бота вне группы systemd-journal)")
return [], "нет источника (файла нет, журнал пуст)"
История версий
| version | date | commit |
|---|---|---|
| 0.6.0 | 2026-09-21 | bf8125926601 |
| 0.5.0 | 2026-09-18 | aa0cda60970c |
| 0.4.0 | 2026-09-17 | 665dab3aa17b |
| 0.3.0 | 2026-09-17 | 4341fd29b1fb |
| 0.2.0 | 2026-09-11 | 86ca69448ecf |
| 0.1.0 | 2026-09-09 | 91bed316610d |