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.
- tracetui/__init__.py +3 -0
- tracetui/__main__.py +6 -0
- tracetui/app.py +403 -0
- tracetui/cli.py +56 -0
- tracetui/models.py +103 -0
- tracetui/parsers/__init__.py +1 -0
- tracetui/parsers/log_parser.py +467 -0
- tracetui/widgets/__init__.py +1 -0
- tracetui/widgets/detail_panel.py +106 -0
- tracetui/widgets/filter_input.py +96 -0
- tracetui/widgets/live_monitor.py +230 -0
- tracetui/widgets/log_list.py +601 -0
- tracetui/widgets/source_panel.py +137 -0
- tracetui-2.0.0.dist-info/METADATA +162 -0
- tracetui-2.0.0.dist-info/RECORD +19 -0
- tracetui-2.0.0.dist-info/WHEEL +5 -0
- tracetui-2.0.0.dist-info/entry_points.txt +2 -0
- tracetui-2.0.0.dist-info/licenses/LICENSE +21 -0
- tracetui-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -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("")
|