feat(logs): раздел «Логи» — хвост и свой grep (#16)
CI / ruff · mypy · pytest (pull_request) Successful in 9m40s
CI / ruff · mypy · pytest (pull_request) Successful in 9m40s
- app/logs/grep.py: подмножество grep (шаблон, -i -v -E -c -C N), разбор shlex без исполнения, BRE → regex, таймаут на строку и бюджет на поиск, лимиты 200 символов / 500 строк вывода - app/logs/journal.py: каждый поиск в log_queries (L-6) - бот: выбор контейнера, хвост 50/200 строк или 5 мин/1 ч/24 ч, свой grep по 24 ч, длинный результат — файлом .log (L-5) - логи Docker: не больше 50 000 строк и 5 МБ, сохраняются новейшие - fix: кнопки меню и /menu, /help больше не проглатываются шагами ввода (имя файла, содержимое, сообщение коммита, grep) — фильтр free_input Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from app.bot.menu import Nav, NavAction
|
||||
from app.bot.menu import Nav, NavAction, free_input
|
||||
from app.db.models import ChangeOp, User
|
||||
from app.repo.drafts import DraftFull, DraftService, DraftView
|
||||
from app.repo.git import Entry, GitError, UploadsRepo
|
||||
@@ -520,7 +520,7 @@ def build_router(
|
||||
|
||||
# ---- text input --------------------------------------------------------------------------------------------
|
||||
|
||||
@router.message(Flow.file_name, F.text)
|
||||
@router.message(Flow.file_name, F.text, free_input(texts))
|
||||
async def got_file_name(message: Message, state: FSMContext, member: User) -> None:
|
||||
data = await state.get_data()
|
||||
directory = data.get("commit_path", "")
|
||||
@@ -541,7 +541,7 @@ def build_router(
|
||||
await state.update_data(commit_target=path, commit_op=ChangeOp.ADD.value)
|
||||
await message.answer(t.ask_content.format(path=path), reply_markup=to_dir_keyboard(texts))
|
||||
|
||||
@router.message(Flow.dir_name, F.text)
|
||||
@router.message(Flow.dir_name, F.text, free_input(texts))
|
||||
async def got_dir_name(message: Message, state: FSMContext, member: User) -> None:
|
||||
data = await state.get_data()
|
||||
directory = data.get("commit_path", "")
|
||||
@@ -562,7 +562,7 @@ def build_router(
|
||||
text, kb = await dir_screen(state, member.tg_id, path, 0)
|
||||
await message.answer(text, reply_markup=kb)
|
||||
|
||||
@router.message(Flow.message, F.text)
|
||||
@router.message(Flow.message, F.text, free_input(texts))
|
||||
async def got_message(message: Message, state: FSMContext, member: User) -> None:
|
||||
try:
|
||||
text = check_message(message.text or "")
|
||||
@@ -579,7 +579,7 @@ def build_router(
|
||||
screen, kb = await draft_screen(state, member.tg_id)
|
||||
await message.answer(screen, reply_markup=kb)
|
||||
|
||||
@router.message(Flow.content, F.text | F.document)
|
||||
@router.message(Flow.content, F.text | F.document, free_input(texts))
|
||||
async def got_content(message: Message, state: FSMContext, member: User, bot: Bot) -> None:
|
||||
data = await state.get_data()
|
||||
target: str = data["commit_target"]
|
||||
|
||||
@@ -16,7 +16,6 @@ def build_router(texts: Texts, access: AccessService) -> Router:
|
||||
router = Router(name="common")
|
||||
sections = section_by_button(texts)
|
||||
section_text = {
|
||||
Section.LOGS: texts.section.logs,
|
||||
Section.ALERTS: texts.section.alerts,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""«📜 Логи»: pick a container, then its tail or your own grep (SPEC L-1…L-3, L-5, L-6). The agent is #18."""
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from enum import StrEnum
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.filters.callback_data import CallbackData
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from app.bot.menu import Nav, NavAction, free_input
|
||||
from app.db.models import LogMode, User, utcnow
|
||||
from app.logs.grep import GrepError, GrepErrorCode, GrepResult, parse_grep, run, to_command
|
||||
from app.logs.journal import LogJournal
|
||||
from app.runtime.docker import Container, DockerClient, DockerError
|
||||
from app.texts import LogsTexts, Texts
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TAILS = (50, 200) # lines (L-2)
|
||||
PERIODS = (5, 60, 24 * 60) # minutes (L-2)
|
||||
GREP_WINDOW = timedelta(hours=24)
|
||||
MAX_MESSAGE = 3500 # leave room under Telegram's 4096 for the header and HTML markup
|
||||
MAX_FILE = 5 * 1024 * 1024 # L-5
|
||||
|
||||
|
||||
class LogsAct(StrEnum):
|
||||
LIST = "l"
|
||||
OPEN = "o"
|
||||
TAIL = "t"
|
||||
SINCE = "s"
|
||||
GREP = "g"
|
||||
AGENT = "a"
|
||||
|
||||
|
||||
class LogsCb(CallbackData, prefix="lg"):
|
||||
a: LogsAct
|
||||
id: str = "" # 12-char container id prefix
|
||||
n: int = 0 # lines for TAIL, minutes for SINCE
|
||||
|
||||
|
||||
class LogsFlow(StatesGroup):
|
||||
grep = State()
|
||||
|
||||
|
||||
def period(t: LogsTexts, minutes: int) -> str:
|
||||
return t.hours.format(n=minutes // 60) if minutes >= 60 else t.minutes.format(n=minutes)
|
||||
|
||||
|
||||
def list_keyboard(texts: Texts, containers: list[Container]) -> InlineKeyboardMarkup:
|
||||
kb = InlineKeyboardBuilder()
|
||||
for c in containers:
|
||||
kb.button(text=f"{c.light.value} {c.name}", callback_data=LogsCb(a=LogsAct.OPEN, id=c.short_id))
|
||||
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
|
||||
kb.adjust(1)
|
||||
return kb.as_markup()
|
||||
|
||||
|
||||
def container_keyboard(texts: Texts, short_id: str) -> InlineKeyboardMarkup:
|
||||
t = texts.logs
|
||||
kb = InlineKeyboardBuilder()
|
||||
for n in TAILS:
|
||||
kb.button(text=t.tail.format(n=n), callback_data=LogsCb(a=LogsAct.TAIL, id=short_id, n=n))
|
||||
for minutes in PERIODS:
|
||||
kb.button(text=period(t, minutes), callback_data=LogsCb(a=LogsAct.SINCE, id=short_id, n=minutes))
|
||||
kb.button(text=t.grep_button, callback_data=LogsCb(a=LogsAct.GREP, id=short_id))
|
||||
kb.button(text=t.agent_button, callback_data=LogsCb(a=LogsAct.AGENT, id=short_id))
|
||||
kb.button(text=t.to_list, callback_data=LogsCb(a=LogsAct.LIST))
|
||||
kb.adjust(2, 3, 2, 1)
|
||||
return kb.as_markup()
|
||||
|
||||
|
||||
def notes(t: LogsTexts, result: GrepResult) -> list[str]:
|
||||
out = []
|
||||
if result.truncated:
|
||||
out.append(t.truncated.format(n=len(result.lines)))
|
||||
if result.partial:
|
||||
out.append(t.partial)
|
||||
if result.skipped:
|
||||
out.append(t.skipped.format(n=result.skipped))
|
||||
return out
|
||||
|
||||
|
||||
def grep_error(t: LogsTexts, e: GrepError) -> str:
|
||||
template = {
|
||||
GrepErrorCode.EMPTY: t.err_empty,
|
||||
GrepErrorCode.QUOTES: t.err_quotes,
|
||||
GrepErrorCode.FLAG: t.err_flag,
|
||||
GrepErrorCode.CONTEXT: t.err_context,
|
||||
GrepErrorCode.EXTRA: t.err_extra,
|
||||
GrepErrorCode.TOO_LONG: t.err_too_long,
|
||||
GrepErrorCode.REGEX: t.err_regex,
|
||||
}[e.code]
|
||||
return template.format(arg=html.escape(e.arg[:100]))
|
||||
|
||||
|
||||
async def send_result(
|
||||
msg: Message, texts: Texts, head: str, body: str, footer: list[str], kb: InlineKeyboardMarkup, filename: str
|
||||
) -> None:
|
||||
"""Short results inline as <pre>, long ones as a .log document (L-5); the container keyboard rides along."""
|
||||
t = texts.logs
|
||||
tail = "".join(f"\n{html.escape(n)}" for n in footer)
|
||||
if not body:
|
||||
await msg.answer(f"{head}\n{t.nothing}{tail}", parse_mode=ParseMode.HTML, reply_markup=kb)
|
||||
return
|
||||
escaped = html.escape(body)
|
||||
if len(escaped) <= MAX_MESSAGE:
|
||||
await msg.answer(f"{head}\n<pre>{escaped}</pre>{tail}", parse_mode=ParseMode.HTML, reply_markup=kb)
|
||||
return
|
||||
data = body.encode()[-MAX_FILE:]
|
||||
await msg.answer_document(
|
||||
BufferedInputFile(data, filename),
|
||||
caption=f"{head}\n{t.sent_as_file}{tail}",
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
def build_router(texts: Texts, docker: DockerClient | None, journal: LogJournal) -> Router:
|
||||
router = Router(name="logs")
|
||||
t = texts.logs
|
||||
|
||||
def need_docker() -> DockerClient:
|
||||
if docker is None:
|
||||
raise DockerError("docker is not configured (DOCKER_URL)")
|
||||
return docker
|
||||
|
||||
@router.message(F.text == texts.menu.logs)
|
||||
async def open_logs(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
try:
|
||||
containers = await need_docker().containers()
|
||||
except DockerError as e:
|
||||
log.warning("logs: %s", e)
|
||||
await message.answer(t.unavailable)
|
||||
return
|
||||
await message.answer(t.title if containers else t.empty, reply_markup=list_keyboard(texts, containers))
|
||||
|
||||
@router.callback_query(LogsCb.filter())
|
||||
async def on_action(query: CallbackQuery, callback_data: LogsCb, state: FSMContext, member: User) -> None:
|
||||
if not isinstance(query.message, Message):
|
||||
await query.answer()
|
||||
return
|
||||
msg = query.message
|
||||
try:
|
||||
d = need_docker()
|
||||
if callback_data.a is LogsAct.LIST:
|
||||
await state.clear()
|
||||
containers = await d.containers()
|
||||
await msg.edit_text(t.title if containers else t.empty, reply_markup=list_keyboard(texts, containers))
|
||||
await query.answer()
|
||||
return
|
||||
c = await d.get(callback_data.id)
|
||||
kb = container_keyboard(texts, c.short_id)
|
||||
match callback_data.a:
|
||||
case LogsAct.OPEN:
|
||||
await state.clear()
|
||||
await msg.edit_text(t.container.format(name=c.name), reply_markup=kb)
|
||||
case LogsAct.TAIL | LogsAct.SINCE:
|
||||
await query.answer()
|
||||
if callback_data.a is LogsAct.TAIL:
|
||||
n = min(max(callback_data.n, 1), max(TAILS))
|
||||
text = await d.logs(c.id, tail=n)
|
||||
scope, q = t.tail.format(n=n), {"tail": n}
|
||||
else:
|
||||
minutes = min(max(callback_data.n, 1), max(PERIODS))
|
||||
text = await d.logs(c.id, since=utcnow() - timedelta(minutes=minutes))
|
||||
scope, q = t.since.format(period=period(t, minutes)), {"since_minutes": minutes}
|
||||
lines = text.splitlines()
|
||||
await journal.record(member.tg_id, c.name, LogMode.TAIL, q, len(lines))
|
||||
head = t.head_tail.format(name=html.escape(c.name), scope=scope, lines=len(lines))
|
||||
await send_result(msg, texts, head, "\n".join(lines), [], kb, f"{c.name}.log")
|
||||
return
|
||||
case LogsAct.GREP:
|
||||
await state.set_state(LogsFlow.grep)
|
||||
await state.update_data(logs_id=c.short_id)
|
||||
back = InlineKeyboardBuilder()
|
||||
back.button(text=t.to_container, callback_data=LogsCb(a=LogsAct.OPEN, id=c.short_id))
|
||||
await msg.answer(
|
||||
t.ask_grep.format(name=html.escape(c.name)),
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=back.as_markup(),
|
||||
)
|
||||
case LogsAct.AGENT:
|
||||
await query.answer(t.agent_soon, show_alert=True)
|
||||
return
|
||||
except DockerError as e:
|
||||
log.warning("logs: %s", e)
|
||||
await query.answer(t.unavailable, show_alert=True)
|
||||
return
|
||||
await query.answer()
|
||||
|
||||
@router.message(LogsFlow.grep, F.text, free_input(texts))
|
||||
async def got_grep(message: Message, state: FSMContext, member: User) -> None:
|
||||
try:
|
||||
query = parse_grep(message.text or "")
|
||||
except GrepError as e:
|
||||
await message.answer(grep_error(t, e), parse_mode=ParseMode.HTML)
|
||||
return # stay in the state: fix and resend
|
||||
data = await state.get_data()
|
||||
try:
|
||||
d = need_docker()
|
||||
c = await d.get(data.get("logs_id", ""))
|
||||
text = await d.logs(c.id, since=utcnow() - GREP_WINDOW)
|
||||
except DockerError as e:
|
||||
log.warning("logs: %s", e)
|
||||
await message.answer(t.unavailable)
|
||||
return
|
||||
await state.clear()
|
||||
result = await asyncio.to_thread(run, query, text)
|
||||
await journal.record(member.tg_id, c.name, LogMode.GREP, query.to_dict(), result.matched)
|
||||
head = t.head_grep.format(
|
||||
command=html.escape(to_command(query)), name=html.escape(c.name), matched=result.matched
|
||||
)
|
||||
body = "" if query.count else "\n".join(result.lines)
|
||||
footer = notes(t, result)
|
||||
if query.count: # -c: the number in the header is the answer
|
||||
await message.answer(
|
||||
head + "".join(f"\n{html.escape(n)}" for n in footer),
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=container_keyboard(texts, c.short_id),
|
||||
)
|
||||
return
|
||||
await send_result(
|
||||
message, texts, head, body, footer, container_keyboard(texts, c.short_id), f"{c.name}-grep.log"
|
||||
)
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user