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
tracetui/__init__.py
ADDED
tracetui/__main__.py
ADDED
tracetui/app.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Основной TUI экран TraceTUI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.widgets import Header, Footer
|
|
8
|
+
from textual.css.query import NoMatches
|
|
9
|
+
from textual.theme import Theme
|
|
10
|
+
|
|
11
|
+
from .widgets.log_list import LogList
|
|
12
|
+
from .widgets.source_panel import SourcePanel
|
|
13
|
+
from .widgets.detail_panel import DetailPanel
|
|
14
|
+
from .widgets.live_monitor import LiveMonitor
|
|
15
|
+
from .models import LogSource
|
|
16
|
+
from .parsers.log_parser import load_log_source
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TraceTUIApp(App):
|
|
20
|
+
"""Главное приложение TraceTUI."""
|
|
21
|
+
|
|
22
|
+
CSS = """
|
|
23
|
+
Screen {
|
|
24
|
+
layout: grid;
|
|
25
|
+
grid-size: 3;
|
|
26
|
+
grid-columns: 3fr 6fr 3fr;
|
|
27
|
+
grid-gutter: 1 2;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#source-panel {
|
|
31
|
+
width: 100%;
|
|
32
|
+
height: 95%;
|
|
33
|
+
background: $surface;
|
|
34
|
+
border: solid $accent;
|
|
35
|
+
padding: 1;
|
|
36
|
+
overflow: auto;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#log-list {
|
|
40
|
+
width: 100%;
|
|
41
|
+
height: 95%;
|
|
42
|
+
background: $boost;
|
|
43
|
+
border: solid $primary;
|
|
44
|
+
padding: 1;
|
|
45
|
+
overflow: auto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Стили DataTable — виртуализированный рендеринг логов */
|
|
49
|
+
LogList {
|
|
50
|
+
background: $boost;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
LogList .row-selected {
|
|
54
|
+
background: $primary;
|
|
55
|
+
color: white;
|
|
56
|
+
text-style: bold;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#detail-panel {
|
|
60
|
+
width: 100%;
|
|
61
|
+
height: 95%;
|
|
62
|
+
background: $surface;
|
|
63
|
+
border: solid $accent;
|
|
64
|
+
padding: 1;
|
|
65
|
+
overflow: auto;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Стили для чекбоксов источников в merge mode */
|
|
69
|
+
SourcePanel.merge-mode {
|
|
70
|
+
background: $surface;
|
|
71
|
+
}
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
BINDINGS = [
|
|
75
|
+
Binding("q", "quit", "Quit"),
|
|
76
|
+
Binding("f", "toggle_filter_mode", "Filter"),
|
|
77
|
+
Binding("e", "next_error", "Next error"),
|
|
78
|
+
Binding("t", "toggle_theme", "Theme"),
|
|
79
|
+
Binding("m", "toggle_merge_mode", "Timeline"),
|
|
80
|
+
Binding("left", "switch_source_left", "Prev source"),
|
|
81
|
+
Binding("right", "switch_source_right", "Next source"),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def __init__(self, file_paths: list[Path] | None = None, format_type: str = "auto", log_level: str | None = None, **kwargs):
|
|
85
|
+
super().__init__(**kwargs)
|
|
86
|
+
self._is_dark = True
|
|
87
|
+
self._pending_sources: list[LogSource] = []
|
|
88
|
+
# Пути к файлам для асинхронной загрузки
|
|
89
|
+
self._file_paths: list[Path] = file_paths or []
|
|
90
|
+
self._format_type: str = format_type
|
|
91
|
+
# Минимальный уровень логирования для фильтрации
|
|
92
|
+
self._log_level: str | None = log_level
|
|
93
|
+
# Live-мониторинг
|
|
94
|
+
self._live_monitor: LiveMonitor | None = None
|
|
95
|
+
# Timeline merge mode
|
|
96
|
+
self.merge_mode: bool = False
|
|
97
|
+
# Индекс выбранного чекбокса в merge mode
|
|
98
|
+
self._selected_source_index: int = 0
|
|
99
|
+
|
|
100
|
+
def on_mount(self) -> None:
|
|
101
|
+
"""Инициализация приложения."""
|
|
102
|
+
self._sources: list[LogSource] = []
|
|
103
|
+
self.title = "TraceTUI — Log Debugger"
|
|
104
|
+
self.sub_title = "↑↓ Navigate | ←→ Sources | F Filter | d Tag | E Next Error | T Theme | m Timeline | q Quit"
|
|
105
|
+
|
|
106
|
+
# Регистрируем светлую тему
|
|
107
|
+
self.register_theme(
|
|
108
|
+
Theme(
|
|
109
|
+
name="trace-light",
|
|
110
|
+
primary="#1976d2",
|
|
111
|
+
warning="#f57f17",
|
|
112
|
+
error="#c62828",
|
|
113
|
+
success="#2e7d32",
|
|
114
|
+
accent="#7b1fa2",
|
|
115
|
+
foreground="#1a1a1a",
|
|
116
|
+
background="#f0f0f0",
|
|
117
|
+
surface="#e0e0e0",
|
|
118
|
+
panel="#eeeeee",
|
|
119
|
+
boost="#ffffff",
|
|
120
|
+
dark=False,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
# Принудительно регистрируем тему "textual" из built-in
|
|
124
|
+
self.register_theme(
|
|
125
|
+
Theme(
|
|
126
|
+
name="textual",
|
|
127
|
+
primary="#0072ff",
|
|
128
|
+
warning="#ffaf00",
|
|
129
|
+
error="#e6193C",
|
|
130
|
+
success="#13a10e",
|
|
131
|
+
accent="#8888fc",
|
|
132
|
+
foreground="#f8f8f2",
|
|
133
|
+
background="#272822",
|
|
134
|
+
surface="#1f2229",
|
|
135
|
+
panel="#272822",
|
|
136
|
+
boost="#353b45",
|
|
137
|
+
dark=True,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Откладываем загрузку до полной инициализации экрана
|
|
142
|
+
self.call_later(self._load_pending_sources)
|
|
143
|
+
# Запускаем асинхронную загрузку файлов
|
|
144
|
+
self.call_later(self._load_sources_from_paths)
|
|
145
|
+
|
|
146
|
+
def on_stop(self) -> None:
|
|
147
|
+
"""Остановка при закрытии приложения."""
|
|
148
|
+
if self._live_monitor:
|
|
149
|
+
self._live_monitor.stop()
|
|
150
|
+
|
|
151
|
+
def compose(self):
|
|
152
|
+
"""Создание интерфейса с тремя панелями."""
|
|
153
|
+
yield Header()
|
|
154
|
+
yield SourcePanel(id="source-panel")
|
|
155
|
+
yield LogList(id="log-list")
|
|
156
|
+
yield DetailPanel(id="detail-panel")
|
|
157
|
+
yield Footer()
|
|
158
|
+
|
|
159
|
+
def add_log_source(self, source: LogSource) -> None:
|
|
160
|
+
"""Добавляет источник логов (откладывается до монтирования)."""
|
|
161
|
+
self._sources.append(source)
|
|
162
|
+
self._pending_sources.append(source)
|
|
163
|
+
|
|
164
|
+
async def _load_sources_from_paths(self) -> None:
|
|
165
|
+
"""Асинхронная загрузка файлов в фоне."""
|
|
166
|
+
if not self._file_paths:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
async def _parse_and_register(path: Path) -> None:
|
|
170
|
+
"""Парсит файл в фоне и регистрирует результат."""
|
|
171
|
+
# to_thread запускает синхронную функцию в пуле потоков
|
|
172
|
+
source = await asyncio.to_thread(load_log_source, path, self._format_type)
|
|
173
|
+
self.call_after_refresh(self._register_source, source)
|
|
174
|
+
|
|
175
|
+
tasks = []
|
|
176
|
+
for path in self._file_paths:
|
|
177
|
+
if path.exists():
|
|
178
|
+
tasks.append(_parse_and_register(path))
|
|
179
|
+
else:
|
|
180
|
+
self.call_after_refresh(lambda p=path: self.notify(f"Файл не найден: {p}", severity="error"))
|
|
181
|
+
|
|
182
|
+
await asyncio.gather(*tasks)
|
|
183
|
+
|
|
184
|
+
def _load_pending_sources(self) -> None:
|
|
185
|
+
"""Загружаем все отложенные источники после полной инициализации."""
|
|
186
|
+
for source in self._pending_sources:
|
|
187
|
+
self._register_source(source)
|
|
188
|
+
self._pending_sources.clear()
|
|
189
|
+
|
|
190
|
+
def _register_source(self, source: LogSource) -> None:
|
|
191
|
+
"""Регистрирует источник в панелях."""
|
|
192
|
+
# Добавляем в список, если ещё нет
|
|
193
|
+
if source not in self._sources:
|
|
194
|
+
self._sources.append(source)
|
|
195
|
+
|
|
196
|
+
# Фильтруем записи по минимальному уровню логирования
|
|
197
|
+
source.entries = self._filter_entries_by_level(source.entries)
|
|
198
|
+
|
|
199
|
+
source_panel = self.query_one("#source-panel", SourcePanel)
|
|
200
|
+
source_panel.add_source(source)
|
|
201
|
+
|
|
202
|
+
log_list = self.query_one("#log-list", LogList)
|
|
203
|
+
log_list.add_entries(source.entries)
|
|
204
|
+
log_list.set_sources(self._sources)
|
|
205
|
+
|
|
206
|
+
# Активируем первый источник
|
|
207
|
+
if self._sources.index(source) == 0:
|
|
208
|
+
source.is_active = True
|
|
209
|
+
source_panel.refresh()
|
|
210
|
+
|
|
211
|
+
# Запускаем мониторинг, если это первый файл
|
|
212
|
+
if len(self._sources) == 1 and self._file_paths and not self._live_monitor:
|
|
213
|
+
self._start_live_monitor()
|
|
214
|
+
|
|
215
|
+
def action_toggle_filter_mode(self) -> None:
|
|
216
|
+
"""Передать действие фильтрации в LogList."""
|
|
217
|
+
log_list = self.query_one("#log-list", LogList)
|
|
218
|
+
log_list.action_toggle_filter_mode()
|
|
219
|
+
|
|
220
|
+
def _filter_entries_by_level(self, entries: list) -> list:
|
|
221
|
+
"""Фильтрует записи по минимальному уровню логирования.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
entries: Список LogEntry для фильтрации
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Отфильтрованный список записей
|
|
228
|
+
"""
|
|
229
|
+
if not self._log_level:
|
|
230
|
+
return entries
|
|
231
|
+
|
|
232
|
+
# Определяем порядковый номер минимального уровня
|
|
233
|
+
level_order = {
|
|
234
|
+
"DEBUG": 0,
|
|
235
|
+
"INFO": 1,
|
|
236
|
+
"WARNING": 2,
|
|
237
|
+
"ERROR": 3,
|
|
238
|
+
"CRITICAL": 4,
|
|
239
|
+
}
|
|
240
|
+
min_order = level_order.get(self._log_level.upper(), 0)
|
|
241
|
+
|
|
242
|
+
return [
|
|
243
|
+
entry for entry in entries
|
|
244
|
+
if level_order.get(entry.level.value, 0) >= min_order
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
def _start_live_monitor(self) -> None:
|
|
248
|
+
"""Запускает фоновый мониторинг файлов."""
|
|
249
|
+
if not self._file_paths:
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
self._live_monitor = LiveMonitor(
|
|
253
|
+
file_paths=self._file_paths,
|
|
254
|
+
format_type=self._format_type,
|
|
255
|
+
on_new_entries=self._handle_new_entries,
|
|
256
|
+
poll_interval=1.0,
|
|
257
|
+
)
|
|
258
|
+
self._live_monitor.start()
|
|
259
|
+
self.notify("Live-мониторинг запущен", severity="information")
|
|
260
|
+
|
|
261
|
+
def _handle_new_entries(self, entries: list, file_path: Path) -> None:
|
|
262
|
+
"""Обработчик новых записей из файлов.
|
|
263
|
+
|
|
264
|
+
Вызывается из фонового воркера для обновления UI.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
entries: Список новых LogEntry
|
|
268
|
+
file_path: Путь к файлу, в котором появились записи
|
|
269
|
+
"""
|
|
270
|
+
self.call_after_refresh(self._process_new_entries, entries, file_path)
|
|
271
|
+
|
|
272
|
+
def _process_new_entries(self, entries: list, file_path: Path) -> None:
|
|
273
|
+
"""Добавляет новые записи в UI.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
entries: Список новых LogEntry
|
|
277
|
+
file_path: Путь к файлу
|
|
278
|
+
"""
|
|
279
|
+
if not entries:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
# Фильтруем по минимальному уровню логирования
|
|
283
|
+
entries = self._filter_entries_by_level(entries)
|
|
284
|
+
if not entries:
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
# Находим источник по пути
|
|
288
|
+
source = None
|
|
289
|
+
for s in self._sources:
|
|
290
|
+
if s.path == file_path:
|
|
291
|
+
source = s
|
|
292
|
+
break
|
|
293
|
+
|
|
294
|
+
if not source:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
# Добавляем новые записи с ограничением по памяти
|
|
298
|
+
source.add_entries(entries)
|
|
299
|
+
source.has_new = True
|
|
300
|
+
|
|
301
|
+
# Обновляем LogList
|
|
302
|
+
log_list = self.query_one("#log-list", LogList)
|
|
303
|
+
log_list.add_entries(entries)
|
|
304
|
+
|
|
305
|
+
# Обновляем счётчик в SourcePanel
|
|
306
|
+
source_panel = self.query_one("#source-panel", SourcePanel)
|
|
307
|
+
source_panel._refresh_sources()
|
|
308
|
+
|
|
309
|
+
# Показываем уведомление
|
|
310
|
+
self.notify(f"+{len(entries)} новых записей из {source.name}")
|
|
311
|
+
|
|
312
|
+
def action_next_error(self) -> None:
|
|
313
|
+
"""Переход к следующей ошибке."""
|
|
314
|
+
log_list = self.query_one("#log-list", LogList)
|
|
315
|
+
log_list.action_next_error()
|
|
316
|
+
|
|
317
|
+
def action_toggle_merge_mode(self) -> None:
|
|
318
|
+
"""Переключает режим таймлайна (merge mode)."""
|
|
319
|
+
self.merge_mode = not self.merge_mode
|
|
320
|
+
log_list = self.query_one("#log-list", LogList)
|
|
321
|
+
|
|
322
|
+
if self.merge_mode:
|
|
323
|
+
# Снимаем active flag у всех источников
|
|
324
|
+
for source in self._sources:
|
|
325
|
+
source.is_active = False
|
|
326
|
+
source.is_included = True # Все включены по умолчанию
|
|
327
|
+
self._selected_source_index = 0
|
|
328
|
+
self.sub_title = "↑↓ Navigate | Space Toggle Source | ←→ Select | F Filter | E Next Error | T Theme | m Timeline | q Quit"
|
|
329
|
+
self.notify("Timeline mode ON — merge all sources")
|
|
330
|
+
# Синхронизируем merge mode с LogList
|
|
331
|
+
log_list.set_merge_mode(True)
|
|
332
|
+
# Перезапускаем таймлайн асинхронно
|
|
333
|
+
self.call_later(self._refresh_timeline_async)
|
|
334
|
+
else:
|
|
335
|
+
# Восстанавливаем активный источник — первый источник
|
|
336
|
+
if self._sources:
|
|
337
|
+
self._sources[0].is_active = True
|
|
338
|
+
self.sub_title = "↑↓ Navigate | ←→ Sources | F Filter | d Tag | E Next Error | T Theme | m Timeline | q Quit"
|
|
339
|
+
self.notify("Timeline mode OFF — single source view")
|
|
340
|
+
# Синхронизируем merge mode с LogList
|
|
341
|
+
log_list.set_merge_mode(False)
|
|
342
|
+
|
|
343
|
+
async def _refresh_timeline_async(self) -> None:
|
|
344
|
+
"""Асинхронный запуск таймлайн-мержа в фоновом потоке."""
|
|
345
|
+
if not self.merge_mode:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
entries = await asyncio.to_thread(self._build_timeline_sync)
|
|
349
|
+
self.call_after_refresh(self._apply_timeline_entries, entries)
|
|
350
|
+
|
|
351
|
+
def _build_timeline_sync(self) -> list:
|
|
352
|
+
"""Синхронная функция для запуска в asyncio.to_thread.
|
|
353
|
+
|
|
354
|
+
Возвращает отфильтрованный список LogEntry из всех включённых источников.
|
|
355
|
+
"""
|
|
356
|
+
from .parsers.log_parser import timeline_merge
|
|
357
|
+
|
|
358
|
+
# Собираем пути только включённых источников
|
|
359
|
+
included_sources = [s for s in self._sources if s.is_included]
|
|
360
|
+
if not included_sources:
|
|
361
|
+
return []
|
|
362
|
+
|
|
363
|
+
all_paths = [source.path for source in included_sources]
|
|
364
|
+
|
|
365
|
+
entries = []
|
|
366
|
+
for entry, path in timeline_merge(all_paths):
|
|
367
|
+
# Подменяем file_path на имя файла для маркировки
|
|
368
|
+
entry.file_path = path.name
|
|
369
|
+
entries.append(entry)
|
|
370
|
+
|
|
371
|
+
# Фильтруем по уровню
|
|
372
|
+
entries = self._filter_entries_by_level(entries)
|
|
373
|
+
return entries
|
|
374
|
+
|
|
375
|
+
def _clear_timeline(self) -> None:
|
|
376
|
+
"""Очищает LogList в merge mode."""
|
|
377
|
+
log_list = self.query_one("#log-list", LogList)
|
|
378
|
+
log_list._entries = []
|
|
379
|
+
log_list._selectedIndex = -1
|
|
380
|
+
log_list._update_display()
|
|
381
|
+
|
|
382
|
+
def _apply_timeline_entries(self, entries: list) -> None:
|
|
383
|
+
"""Применяет объединённые записи в LogList."""
|
|
384
|
+
log_list = self.query_one("#log-list", LogList)
|
|
385
|
+
log_list._entries = entries
|
|
386
|
+
log_list._selectedIndex = -1
|
|
387
|
+
log_list._update_display()
|
|
388
|
+
|
|
389
|
+
def action_toggle_theme(self) -> None:
|
|
390
|
+
"""Переключает тему (светлая/тёмная)."""
|
|
391
|
+
self._is_dark = not self._is_dark
|
|
392
|
+
self.theme = "trace-light" if not self._is_dark else "textual"
|
|
393
|
+
self.notify("Theme: Dark" if self._is_dark else "Theme: Light")
|
|
394
|
+
|
|
395
|
+
def action_switch_source_left(self) -> None:
|
|
396
|
+
"""Переключить на предыдущий источник."""
|
|
397
|
+
log_list = self.query_one("#log-list", LogList)
|
|
398
|
+
log_list.switch_source(-1)
|
|
399
|
+
|
|
400
|
+
def action_switch_source_right(self) -> None:
|
|
401
|
+
"""Переключить на следующий источник."""
|
|
402
|
+
log_list = self.query_one("#log-list", LogList)
|
|
403
|
+
log_list.switch_source(1)
|
tracetui/cli.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Клиентский интерфейс для запуска TraceTUI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .app import TraceTUIApp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
"""Точка входа из CLI."""
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="TraceTUI — Interactive TUI debugger for logs"
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"files",
|
|
17
|
+
nargs="*",
|
|
18
|
+
type=Path,
|
|
19
|
+
help="Log files to open",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-f",
|
|
23
|
+
"--format",
|
|
24
|
+
choices=["auto", "loguru", "logging", "simple"],
|
|
25
|
+
default="auto",
|
|
26
|
+
help="Log format (default: auto-detect)",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"-l",
|
|
30
|
+
"--log-level",
|
|
31
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
32
|
+
default=None,
|
|
33
|
+
help="Filter by minimum log level",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
# Фильтруем несуществующие файлы
|
|
39
|
+
valid_paths = []
|
|
40
|
+
for file_path in args.files:
|
|
41
|
+
if file_path.exists():
|
|
42
|
+
valid_paths.append(file_path)
|
|
43
|
+
else:
|
|
44
|
+
print(f"File not found: {file_path}", file=sys.stderr)
|
|
45
|
+
|
|
46
|
+
app = TraceTUIApp(
|
|
47
|
+
file_paths=valid_paths if valid_paths else None,
|
|
48
|
+
format_type=args.format,
|
|
49
|
+
log_level=args.log_level,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
app.run()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
tracetui/models.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Модели данных для логов."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LogLevel(Enum):
|
|
11
|
+
"""Уровни логирования."""
|
|
12
|
+
|
|
13
|
+
DEBUG = "DEBUG"
|
|
14
|
+
INFO = "INFO"
|
|
15
|
+
WARNING = "WARNING"
|
|
16
|
+
ERROR = "ERROR"
|
|
17
|
+
CRITICAL = "CRITICAL"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def color(self) -> str:
|
|
21
|
+
"""Возвращает цвет для уровня лога."""
|
|
22
|
+
return {
|
|
23
|
+
self.DEBUG: "grey50",
|
|
24
|
+
self.INFO: "white",
|
|
25
|
+
self.WARNING: "orange1",
|
|
26
|
+
self.ERROR: "red1",
|
|
27
|
+
self.CRITICAL: "bright_red",
|
|
28
|
+
}[self]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def icon(self) -> str:
|
|
32
|
+
"""Возвращает иконку для уровня лога."""
|
|
33
|
+
return {
|
|
34
|
+
self.DEBUG: "⚬",
|
|
35
|
+
self.INFO: "ℹ",
|
|
36
|
+
self.WARNING: "⚠",
|
|
37
|
+
self.ERROR: "✕",
|
|
38
|
+
self.CRITICAL: "✕",
|
|
39
|
+
}[self]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class LogEntry:
|
|
44
|
+
"""Одна запись лога."""
|
|
45
|
+
|
|
46
|
+
level: LogLevel
|
|
47
|
+
message: str
|
|
48
|
+
timestamp: str = ""
|
|
49
|
+
logger_name: str = ""
|
|
50
|
+
file_path: str = ""
|
|
51
|
+
line_number: int = 0
|
|
52
|
+
traceback: str = ""
|
|
53
|
+
tags: list[str] = field(default_factory=list)
|
|
54
|
+
selected: bool = False
|
|
55
|
+
timestamp_dt: "datetime | None" = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def formatted_line(self) -> str:
|
|
59
|
+
"""Форматированная строка для отображения."""
|
|
60
|
+
parts = []
|
|
61
|
+
if self.timestamp:
|
|
62
|
+
parts.append(self.timestamp)
|
|
63
|
+
if self.logger_name:
|
|
64
|
+
parts.append(f"[{self.logger_name}]")
|
|
65
|
+
parts.append(f"{self.level.value}:")
|
|
66
|
+
parts.append(self.message)
|
|
67
|
+
return " ".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Максимальное количество записей в одном источнике (для предотвращения утечки памяти)
|
|
71
|
+
MAX_ENTRIES = 10_000
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class LogSource:
|
|
76
|
+
"""Источник логов (файл или поток)."""
|
|
77
|
+
|
|
78
|
+
name: str
|
|
79
|
+
path: Path
|
|
80
|
+
format_type: str = "auto" # auto, loguru, logging, custom
|
|
81
|
+
entries: list[LogEntry] = field(default_factory=list)
|
|
82
|
+
is_active: bool = False
|
|
83
|
+
has_new: bool = False
|
|
84
|
+
is_included: bool = True # Флаг для merge mode — включён ли в таймлайн
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def entry_count(self) -> int:
|
|
88
|
+
return len(self.entries)
|
|
89
|
+
|
|
90
|
+
def add_entries(self, entries: list[LogEntry]) -> None:
|
|
91
|
+
"""Добавляет новые записи с ограничением по максимальному количеству.
|
|
92
|
+
|
|
93
|
+
При превышении MAX_ENTRIES удаляет старые элементы с начала списка,
|
|
94
|
+
чтобы предотвратить утечку памяти.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
entries: Список новых LogEntry для добавления
|
|
98
|
+
"""
|
|
99
|
+
self.entries.extend(entries)
|
|
100
|
+
# Если превышен лимит — обрезаем с начала
|
|
101
|
+
if len(self.entries) > MAX_ENTRIES:
|
|
102
|
+
excess = len(self.entries) - MAX_ENTRIES
|
|
103
|
+
self.entries = self.entries[excess:]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Пакет парсеров логов."""
|