tracetui 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,467 @@
1
+ """Парсеры для различных форматов логов."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Iterator
7
+
8
+ from ..models import LogEntry, LogSource, LogLevel
9
+
10
+ # Сепаратор формата loguru
11
+ _LOGURU_SEP = " | "
12
+
13
+
14
+ class LogParser:
15
+ """Базовый парсер логов с автоопределением формата."""
16
+
17
+ # Паттерн для стандартного logging (безопасный, фиксированная длина timestamp)
18
+ LOGGING_PATTERN = re.compile(
19
+ r"(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s*-\s*"
20
+ r"(?P<logger>[^:]+?)\s*-\s*"
21
+ r"(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)\s*-\s*"
22
+ r"(?P<message>.+)"
23
+ )
24
+
25
+ # Паттерн с простым уровнем
26
+ SIMPLE_PATTERN = re.compile(
27
+ r"(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)\s*\-\s*(?P<message>.+)"
28
+ )
29
+
30
+ # Regex только для валидации формата даты (лёгкий, без жадного поиска)
31
+ _DATE_PREFIX_RE = re.compile(r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}")
32
+
33
+ @staticmethod
34
+ def parse_timestamp(ts_str: str) -> datetime | None:
35
+ """Преобразует строку timestamp в datetime.
36
+
37
+ Поддерживаемые форматы:
38
+ - logging: "2024-01-15 10:30:45,123"
39
+ - loguru: "2024-01-15 10:30:45"
40
+
41
+ Args:
42
+ ts_str: Строка timestamp из LogEntry.timestamp
43
+
44
+ Returns:
45
+ datetime объект или None, если строка пустая/неизвестного формата.
46
+ """
47
+ if not ts_str:
48
+ return None
49
+ # Попробуем с миллисекундами (logging format)
50
+ try:
51
+ return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S,%f")
52
+ except ValueError:
53
+ pass
54
+ # Без миллисекунд (loguru format)
55
+ try:
56
+ return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
57
+ except ValueError:
58
+ return None
59
+
60
+ @staticmethod
61
+ def _looks_like_loguru(line: str) -> bool:
62
+ """Проверяет, является ли строка форматом loguru (безопасно через split).
63
+
64
+ Безопасен для сверхдлинных строк: split с maxsplit=3,
65
+ а не жадная регулярка (?P<message>.+).
66
+ """
67
+ if _LOGURU_SEP not in line:
68
+ return False
69
+ parts = line.split(_LOGURU_SEP, 3)
70
+ if len(parts) < 3:
71
+ return False
72
+ # Проверяем префикс даты (лёгкая regex без жадности)
73
+ timestamp_part = parts[0].strip()
74
+ if not LogParser._DATE_PREFIX_RE.match(timestamp_part):
75
+ return False
76
+ # Проверяем уровень
77
+ level = parts[1].strip()
78
+ return level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
79
+
80
+ @staticmethod
81
+ def _make_entry(
82
+ level: LogLevel,
83
+ message: str,
84
+ timestamp: str = "",
85
+ logger_name: str = "",
86
+ file_path: str = "",
87
+ line_number: int = 0,
88
+ traceback: str = "",
89
+ ) -> LogEntry:
90
+ """Создаёт LogEntry с заполненным timestamp_dt."""
91
+ return LogEntry(
92
+ level=level,
93
+ message=message,
94
+ timestamp=timestamp,
95
+ logger_name=logger_name,
96
+ file_path=file_path,
97
+ line_number=line_number,
98
+ traceback=traceback,
99
+ timestamp_dt=LogParser.parse_timestamp(timestamp) if timestamp else None,
100
+ )
101
+
102
+ @staticmethod
103
+ def detect_format(line: str) -> str:
104
+ """Определяет формат лога по первой строке."""
105
+ if LogParser._looks_like_loguru(line):
106
+ return "loguru"
107
+ if LogParser.LOGGING_PATTERN.match(line):
108
+ return "logging"
109
+ if LogParser.SIMPLE_PATTERN.match(line):
110
+ return "simple"
111
+ return "unknown"
112
+
113
+ @staticmethod
114
+ def parse_line(line: str, format_type: str = "auto") -> LogEntry | None:
115
+ """Парсит одну строку лога."""
116
+ line = line.strip()
117
+ if not line:
118
+ return None
119
+
120
+ # Auto-detect
121
+ if format_type == "auto":
122
+ format_type = LogParser.detect_format(line)
123
+
124
+ parsers = {
125
+ "loguru": LogParser._parse_loguru,
126
+ "logging": LogParser._parse_logging,
127
+ "simple": LogParser._parse_simple,
128
+ }
129
+
130
+ parser = parsers.get(format_type)
131
+ if not parser:
132
+ return None
133
+
134
+ return parser(line)
135
+
136
+ @classmethod
137
+ def _parse_loguru(cls, line: str) -> LogEntry | None:
138
+ """Парсит строку в формате loguru (безопасно через split).
139
+
140
+ Отказ от жадной регулярки (?P<message>.+) для защиты от Regex DoS.
141
+ Строка разбивается через str.split(' | ', maxsplit=3).
142
+ """
143
+ parts = line.split(_LOGURU_SEP, 3)
144
+ if len(parts) < 3:
145
+ return None
146
+
147
+ timestamp = parts[0].strip()
148
+ level_str = parts[1].strip()
149
+
150
+ if level_str not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
151
+ return None
152
+
153
+ level = LogLevel[level_str]
154
+
155
+ # Формат: "timestamp | LEVEL | source:line - message"
156
+ # Третий элемент содержит "source:line - message" — разделяем по " - "
157
+ source_message_raw = parts[2].strip()
158
+ message = ""
159
+ source_part = source_message_raw
160
+
161
+ # Ищем разделитель " - " между source и message
162
+ dash_idx = source_message_raw.find(" - ")
163
+ if dash_idx != -1:
164
+ source_part = source_message_raw[:dash_idx].strip()
165
+ message = source_message_raw[dash_idx + 3:].strip()
166
+ # Если есть ещё части после " - ", добавляем к сообщению
167
+ if len(parts) > 3:
168
+ message += _LOGURU_SEP + parts[3]
169
+ elif len(parts) > 3:
170
+ message = parts[3].strip()
171
+
172
+ # Парсим source: может быть "module.py:42" или "logger.name"
173
+ file_path = source_part
174
+ line_number = 0
175
+ logger_name = source_part
176
+
177
+ # Сначала проверяем формат "module.py:42" — двоеточие + число в конце
178
+ last_colon = source_part.rfind(":")
179
+ if last_colon > 0 and source_part[last_colon + 1:].strip().isdigit():
180
+ file_path = source_part[:last_colon].strip()
181
+ line_number = int(source_part[last_colon + 1:].strip())
182
+ logger_name = file_path.split("/")[-1] if "/" in file_path else file_path
183
+ elif "." in source_part:
184
+ # Это logger name (например, "app.services.auth")
185
+ logger_name = source_part.split(".")[-1]
186
+
187
+ return cls._make_entry(
188
+ level=level,
189
+ message=message,
190
+ timestamp=timestamp,
191
+ logger_name=logger_name,
192
+ file_path=file_path,
193
+ line_number=line_number,
194
+ )
195
+
196
+ @classmethod
197
+ def _parse_logging(cls, line: str) -> LogEntry | None:
198
+ """Парсит строку в формате standard logging."""
199
+ match = cls.LOGGING_PATTERN.match(line)
200
+ if not match:
201
+ return None
202
+
203
+ data = match.groupdict()
204
+ level = LogLevel[data["level"]]
205
+
206
+ return cls._make_entry(
207
+ level=level,
208
+ message=data["message"],
209
+ timestamp=data["timestamp"],
210
+ logger_name=data["logger"],
211
+ )
212
+
213
+ @classmethod
214
+ def _parse_simple(cls, line: str) -> LogEntry | None:
215
+ """Парсит строку с простым форматом."""
216
+ match = cls.SIMPLE_PATTERN.match(line)
217
+ if not match:
218
+ return None
219
+
220
+ data = match.groupdict()
221
+ level = LogLevel[data["level"]]
222
+
223
+ return LogEntry(
224
+ level=level,
225
+ message=data["message"],
226
+ )
227
+
228
+ @classmethod
229
+ def _is_new_log_line(cls, line: str, format_type: str) -> bool:
230
+ """Проверяет, является ли строкой новой записи лога."""
231
+ stripped = line.strip()
232
+ if not stripped:
233
+ return False
234
+ # Если формат уже известен — проверяем по конкретному паттерну
235
+ if format_type != "auto":
236
+ # Безопасные проверки без жадных regex
237
+ if format_type == "loguru":
238
+ return cls._looks_like_loguru(stripped)
239
+ pattern = {
240
+ "logging": cls.LOGGING_PATTERN,
241
+ "simple": cls.SIMPLE_PATTERN,
242
+ }.get(format_type)
243
+ if pattern and pattern.match(stripped):
244
+ return True
245
+ return False
246
+ # Auto-режим: проверяем по всем паттернам
247
+ return bool(cls.detect_format(stripped) != "unknown")
248
+
249
+ @classmethod
250
+ def parse_file(cls, path: Path, format_type: str = "auto") -> Iterator[LogEntry]:
251
+ """Парсит файл логов построчно, собирая многострочные traceback."""
252
+ # 1. Определяем формат по первыми строкам
253
+ if format_type == "auto":
254
+ detected = False
255
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
256
+ for _ in range(10):
257
+ line = f.readline()
258
+ if not line:
259
+ break
260
+ fmt = cls.detect_format(line)
261
+ if fmt != "unknown":
262
+ format_type = fmt
263
+ detected = True
264
+ break
265
+ # Фиксируем формат — если не определили ни один из трёх,
266
+ # ставим "simple" чтобы не гонять detect_format() на каждую строку
267
+ if not detected:
268
+ format_type = "simple"
269
+
270
+ # 2. Буферизация: накапливаем continuation lines до новой записи лога
271
+ current_entry: LogEntry | None = None
272
+ continuation_buffer: list[str] = []
273
+
274
+ def flush_buffer() -> None:
275
+ """Отдаёт накопленный entry с собранным traceback."""
276
+ nonlocal current_entry, continuation_buffer
277
+ if current_entry is not None:
278
+ if continuation_buffer:
279
+ # Собираем traceback из буфера
280
+ tb_lines = []
281
+ for cline in continuation_buffer:
282
+ stripped = cline.rstrip()
283
+ if stripped: # пропускаем полностью пустые строки
284
+ tb_lines.append(stripped)
285
+ if tb_lines:
286
+ current_entry.traceback = "\n".join(tb_lines)
287
+ yield current_entry
288
+ current_entry = None
289
+ continuation_buffer = []
290
+
291
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
292
+ for line in f:
293
+ stripped = line.strip()
294
+ # Пустые строки — тоже continuation (разделители traceback)
295
+ if not stripped:
296
+ continuation_buffer.append(line)
297
+ continue
298
+
299
+ if cls._is_new_log_line(stripped, format_type):
300
+ # Новая запись — сначала отдаём предыдущую
301
+ if current_entry is not None:
302
+ yield from flush_buffer()
303
+ # Начинаем новый entry
304
+ current_entry = cls.parse_line(stripped, format_type)
305
+ continuation_buffer = []
306
+ else:
307
+ # Continuation line (traceback, stack trace, и т.д.)
308
+ if current_entry is not None:
309
+ continuation_buffer.append(line)
310
+ # Если entry ещё нет — пропускаем сиротские строки
311
+
312
+ # Финальная флущение последнего entry
313
+ if current_entry is not None:
314
+ yield from flush_buffer()
315
+
316
+ @classmethod
317
+ def parse_lines(
318
+ cls,
319
+ lines: list[str],
320
+ format_type: str = "auto",
321
+ partial_entry: LogEntry | None = None,
322
+ partial_buffer: list[str] | None = None,
323
+ ) -> tuple[list[LogEntry], LogEntry | None, list[str], str]:
324
+ """Потоковый парсинг списка строк с сохранением состояния незавершённого traceback.
325
+
326
+ Этот метод позволяет корректно обрабатывать новые строки, которые могут
327
+ оборваться посередине многострочного traceback.
328
+
329
+ Args:
330
+ lines: Список новых строк для парсинга
331
+ format_type: Тип формата логов
332
+ partial_entry: Не завершённый LogEntry из предыдущей порции
333
+ partial_buffer: Накопленные continuation lines из предыдущей порции
334
+
335
+ Returns:
336
+ Кортеж из:
337
+ - list[LogEntry]: Список полностью распарсенных записей
338
+ - LogEntry | None: Не завершённый entry (если есть)
339
+ - list[str]: Накопленные continuation lines для незавершённого entry
340
+ - str: Текущий формат (если был auto-detect)
341
+ """
342
+ # Инициализируем состояние
343
+ if partial_entry is None:
344
+ current_entry: LogEntry | None = None
345
+ continuation_buffer: list[str] = []
346
+ else:
347
+ current_entry = partial_entry
348
+ continuation_buffer = partial_buffer if partial_buffer is not None else []
349
+
350
+ # Определяем формат, если auto
351
+ if format_type == "auto":
352
+ # Пробуем определить формат по первым непустым строкам
353
+ for line in lines:
354
+ stripped = line.strip()
355
+ if stripped:
356
+ detected_fmt = cls.detect_format(stripped)
357
+ if detected_fmt != "unknown":
358
+ format_type = detected_fmt
359
+ break
360
+ else:
361
+ format_type = "simple"
362
+
363
+ completed_entries: list[LogEntry] = []
364
+
365
+ for line in lines:
366
+ stripped = line.strip()
367
+ # Пустые строки — тоже continuation (разделители traceback)
368
+ if not stripped:
369
+ continuation_buffer.append(line)
370
+ continue
371
+
372
+ if cls._is_new_log_line(stripped, format_type):
373
+ # Новая запись — сначала отдаём предыдущую, если она есть
374
+ if current_entry is not None:
375
+ # Формируем traceback из буфера
376
+ if continuation_buffer:
377
+ tb_lines = []
378
+ for cline in continuation_buffer:
379
+ s_line = cline.rstrip()
380
+ if s_line:
381
+ tb_lines.append(s_line)
382
+ if tb_lines:
383
+ current_entry.traceback = "\n".join(tb_lines)
384
+ completed_entries.append(current_entry)
385
+ current_entry = None
386
+ continuation_buffer = []
387
+
388
+ # Начинаем новый entry
389
+ current_entry = cls.parse_line(stripped, format_type)
390
+ continuation_buffer = []
391
+ else:
392
+ # Continuation line (traceback, stack trace, и т.д.)
393
+ if current_entry is not None:
394
+ continuation_buffer.append(line)
395
+ # Если entry ещё нет — пропускаем сиротские строки
396
+
397
+ # Возвращаем завершённые записи и состояние для незавершённого entry
398
+ return completed_entries, current_entry, continuation_buffer, format_type
399
+
400
+
401
+ def load_log_source(path: Path, format_type: str = "auto") -> LogSource:
402
+ """Загружает источник логов из файла."""
403
+ source = LogSource(
404
+ name=path.name,
405
+ path=path,
406
+ format_type=format_type if format_type != "auto" else "auto",
407
+ )
408
+
409
+ entries = list(LogParser.parse_file(path, format_type))
410
+ source.entries = entries
411
+
412
+ return source
413
+
414
+
415
+ def timeline_merge(paths: list[Path]) -> Iterator[tuple[LogEntry, Path]]:
416
+ """Мержит логи из нескольких файлов по единому таймлайну.
417
+
418
+ Гарантии:
419
+ - Каждый LogEntry возвращается с меткой источника (Path файла).
420
+ - Traceback остаётся целым — parse_file уже склеивает многострочные
421
+ traceback в один LogEntry, так что heapq.merge оперирует готовыми
422
+ объектами.
423
+ - Записи без timestamp (simple формат, "сиротские" строки) получают
424
+ fallback: они идут в конце своего генератора, сохраняя относительный
425
+ порядок (stable sort).
426
+
427
+ Args:
428
+ paths: Список путей к файлам логов.
429
+
430
+ Yields:
431
+ Пары (LogEntry, Path) — запись и путь к файлу-источнику,
432
+ отсортированные по времени в хронологическом порядке.
433
+ """
434
+ import heapq
435
+
436
+ def _annotate(
437
+ gen: Iterator[LogEntry], path: Path
438
+ ) -> Iterator[tuple[LogEntry, Path]]:
439
+ """Оборачивает генератор, добавляя путь к каждому элементу."""
440
+ for entry in gen:
441
+ yield (entry, path)
442
+
443
+ # Создаём именованные генераторы для heapq.merge
444
+ generators = [
445
+ _annotate(LogParser.parse_file(path), path)
446
+ for path in paths
447
+ ]
448
+
449
+ # Ключ сравнения: (has_ts, dt)
450
+ # has_ts=0 для записей с timestamp, has_ts=1 для записей без
451
+ # Это гарантирует, что записи с timestamp идут раньше,
452
+ # а записи без timestamp сохраняют свой относительный порядок
453
+ def _sort_key(
454
+ item: tuple[LogEntry, Path],
455
+ ) -> tuple[int, datetime | None]:
456
+ entry, _ = item
457
+ dt = entry.timestamp_dt
458
+ if dt is not None:
459
+ return (0, dt)
460
+ else:
461
+ return (1, None)
462
+
463
+ # Unpack generators as separate arguments (heapq.merge expects *iterables)
464
+ merged = heapq.merge(*generators, key=_sort_key)
465
+
466
+ for item in merged:
467
+ yield item
@@ -0,0 +1 @@
1
+ """Пакет виджетов TUI."""
@@ -0,0 +1,106 @@
1
+ """Панель деталей выбранной строки (правая панель)."""
2
+
3
+ from textual.widgets import Static
4
+ from textual.binding import Binding
5
+ from textual.reactive import reactive
6
+
7
+ from ..models import LogEntry
8
+
9
+
10
+ class DetailPanel(Static):
11
+ """Правая панель с деталями выбранной записи лога."""
12
+
13
+ BINDINGS = [
14
+ Binding("c", "copy_traceback", "Copy traceback"),
15
+ Binding("e", "toggle_traceback", "Toggle traceback"),
16
+ ]
17
+
18
+ # Реактивное свойство
19
+ text = reactive("", init=False)
20
+
21
+ def __init__(self, **kwargs):
22
+ super().__init__("", **kwargs)
23
+ self._current_entry: LogEntry | None = None
24
+ self._show_traceback = True
25
+
26
+ def on_mount(self) -> None:
27
+ """Настройка панели."""
28
+ self.add_class("detail-panel")
29
+ self._update_text()
30
+
31
+ def watch_text(self, text: str) -> None:
32
+ """Реакция на изменение text."""
33
+ self.update(text)
34
+
35
+ def _update_text(self) -> None:
36
+ """Обновляет текстовое свойство панели."""
37
+ if not self._current_entry:
38
+ self.text = "[dim]Select a log entry to see details[/dim]"
39
+ return
40
+
41
+ parts = []
42
+ parts.append(f"[bold]{self._current_entry.level.icon} {self._current_entry.level.value}[/bold]")
43
+
44
+ if self._current_entry.timestamp:
45
+ parts.append(f"[dim]Timestamp:[/dim] {self._current_entry.timestamp}")
46
+
47
+ if self._current_entry.logger_name:
48
+ parts.append(f"[dim]Logger:[/dim] {self._current_entry.logger_name}")
49
+
50
+ if self._current_entry.file_path:
51
+ line_info = self._current_entry.file_path
52
+ if self._current_entry.line_number:
53
+ line_info += f":{self._current_entry.line_number}"
54
+ parts.append(f"[dim]File:[/dim] {line_info}")
55
+
56
+ parts.append("")
57
+ parts.append(f"[bold]Message:[/bold]")
58
+ parts.append(self._current_entry.message)
59
+
60
+ if self._current_entry.tags:
61
+ tags_str = ", ".join(self._current_entry.tags)
62
+ parts.append(f"[dim]Tags:[/dim] {tags_str}")
63
+
64
+ if self._current_entry.traceback:
65
+ parts.append("")
66
+ parts.append(f"[dim]Traceback:[/dim]")
67
+ if self._show_traceback:
68
+ parts.append(f"[{self._current_entry.level.color}]{self._current_entry.traceback}[/]")
69
+
70
+ self.text = "\n".join(parts)
71
+
72
+ def update_detail(self, entry: LogEntry | None) -> None:
73
+ """Обновляет детали выбранной записи."""
74
+ self._current_entry = entry
75
+ self._update_text()
76
+
77
+ def toggle_traceback(self) -> None:
78
+ """Переключает отображение traceback."""
79
+ self._show_traceback = not self._show_traceback
80
+ if self._current_entry:
81
+ self._update_text()
82
+
83
+ def action_toggle_traceback(self) -> None:
84
+ """Переключает отображение traceback."""
85
+ self.toggle_traceback()
86
+
87
+ def action_copy_traceback(self) -> None:
88
+ """Копирует traceback в буфер обмена."""
89
+ if self._current_entry and self._current_entry.traceback:
90
+ try:
91
+ self.app.write_clipboard(self._current_entry.traceback) # type: ignore[attr-defined]
92
+ self._safe_notify("Traceback copied to clipboard")
93
+ except Exception:
94
+ pass
95
+
96
+ def _safe_notify(self, message: str) -> None:
97
+ """Безопасное уведомление."""
98
+ try:
99
+ self.app.notify(message)
100
+ except Exception:
101
+ pass
102
+
103
+ @property
104
+ def current_entry(self) -> LogEntry | None:
105
+ """Возвращает текущую выбранную запись."""
106
+ return self._current_entry
@@ -0,0 +1,96 @@
1
+ """Модальное окно ввода текста фильтра."""
2
+
3
+ from textual.screen import ModalScreen
4
+ from textual.widgets import Input, Label
5
+ from textual.containers import Vertical
6
+ from textual.binding import Binding
7
+
8
+
9
+ class FilterModal(ModalScreen[str | None]):
10
+ """Модальное окно для ввода текстового фильтра.
11
+
12
+ Пользователь вводит текст, нажимает Enter для применения или Esc для отмены.
13
+ """
14
+
15
+ CSS = """
16
+ FilterModal {
17
+ align: center middle;
18
+ background: $surface 70%;
19
+ }
20
+
21
+ #filter-container {
22
+ width: 60;
23
+ height: auto;
24
+ padding: 2;
25
+ border: solid $primary;
26
+ background: $boost;
27
+ layout: vertical;
28
+ align: center middle;
29
+ }
30
+
31
+ #filter-title {
32
+ text-align: center;
33
+ height: 1;
34
+ text-style: bold;
35
+ color: $primary;
36
+ }
37
+
38
+ #filter-hint {
39
+ text-align: center;
40
+ height: 1;
41
+ text-style: dim;
42
+ color: $text-muted;
43
+ }
44
+
45
+ #filter-input {
46
+ width: 50;
47
+ height: 3;
48
+ margin-top: 1;
49
+ }
50
+
51
+ #filter-actions {
52
+ height: 2;
53
+ margin-top: 1;
54
+ text-align: center;
55
+ text-style: dim;
56
+ color: $text-muted;
57
+ }
58
+ """
59
+
60
+ BINDINGS = [
61
+ Binding("escape", "cancel", "Cancel"),
62
+ ]
63
+
64
+ def __init__(self, initial_text: str = "", **kwargs):
65
+ """Инициализация модального окна.
66
+
67
+ Args:
68
+ initial_text: Начальный текст в поле ввода (если применяем существующий фильтр).
69
+ """
70
+ super().__init__(**kwargs)
71
+ self._initial_text = initial_text
72
+
73
+ def compose(self):
74
+ """Создание интерфейса модального окна."""
75
+ with Vertical(id="filter-container"):
76
+ yield Label("[bold]Фильтрация логов[/]", id="filter-title")
77
+ yield Label("[dim]Введите текст для поиска[/dim]", id="filter-hint")
78
+ yield Input(
79
+ placeholder="Поиск по сообщению, уровню или метке...",
80
+ value=self._initial_text,
81
+ id="filter-input",
82
+ )
83
+ yield Label("[dim]Enter: Применить | Esc: Отмена[/dim]", id="filter-actions")
84
+
85
+ def action_cancel(self) -> None:
86
+ """Отмена фильтрации."""
87
+ self.dismiss(None)
88
+
89
+ def on_input_submitted(self, event: Input.Submitted) -> None:
90
+ """Обработка нажатия Enter."""
91
+ value = event.value.strip()
92
+ if value:
93
+ self.dismiss(value)
94
+ else:
95
+ # Если поле пустое — снимаем фильтр
96
+ self.dismiss("")