docker-orb 1.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.
- docker_orb/__init__.py +0 -0
- docker_orb/__main__.py +5 -0
- docker_orb/_scrollbar.py +136 -0
- docker_orb/cli.py +167 -0
- docker_orb/colors.py +60 -0
- docker_orb/compose.py +391 -0
- docker_orb/config.py +244 -0
- docker_orb/inject.py +160 -0
- docker_orb/jsonlog.py +125 -0
- docker_orb/models.py +132 -0
- docker_orb/viewer/__init__.py +0 -0
- docker_orb/viewer/app.py +824 -0
- docker_orb/viewer/panels/__init__.py +0 -0
- docker_orb/viewer/panels/health.py +239 -0
- docker_orb/viewer/panels/main_stream.py +389 -0
- docker_orb/viewer/panels/monitor.py +233 -0
- docker_orb/viewer/panels/search.py +182 -0
- docker_orb/viewer/viewer.tcss +205 -0
- docker_orb/viewer/widgets.py +668 -0
- docker_orb/wizard/__init__.py +0 -0
- docker_orb/wizard/app.py +40 -0
- docker_orb/wizard/screens.py +692 -0
- docker_orb/wizard/wizard.tcss +329 -0
- docker_orb-1.0.0.dist-info/METADATA +192 -0
- docker_orb-1.0.0.dist-info/RECORD +28 -0
- docker_orb-1.0.0.dist-info/WHEEL +4 -0
- docker_orb-1.0.0.dist-info/entry_points.txt +3 -0
- docker_orb-1.0.0.dist-info/licenses/LICENSE +21 -0
docker_orb/viewer/app.py
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main viewer application.
|
|
3
|
+
|
|
4
|
+
Launches with a SessionConfig and wires together:
|
|
5
|
+
- One log-streaming coroutine per container (or a dump loader)
|
|
6
|
+
- MainStreamPanel — the primary log display
|
|
7
|
+
- SearchPanel — live search
|
|
8
|
+
- MonitorPanel — passive string accumulation (stream mode only)
|
|
9
|
+
- HealthPanel — container health alerts (stream mode only)
|
|
10
|
+
- String-editing overlays for F / H / M keybinds
|
|
11
|
+
- Ctrl+S save-to-file dialog
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import re
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from textual.app import App, ComposeResult
|
|
21
|
+
from textual.binding import Binding
|
|
22
|
+
from textual.containers import Vertical
|
|
23
|
+
from textual.widgets import Footer, Header, Input, Rule
|
|
24
|
+
|
|
25
|
+
from .. import _scrollbar
|
|
26
|
+
from ..colors import assign_colors
|
|
27
|
+
from ..config import compile_patterns, matches
|
|
28
|
+
from ..models import LogLine, LogMode, SessionConfig
|
|
29
|
+
from .panels.health import HealthPanel
|
|
30
|
+
from .panels.main_stream import MainStreamPanel
|
|
31
|
+
from .panels.monitor import MonitorPanel
|
|
32
|
+
from .panels.search import SearchPanel
|
|
33
|
+
from .widgets import (
|
|
34
|
+
JsonDetailModal,
|
|
35
|
+
MonitorContextModal,
|
|
36
|
+
PaneSizeModal,
|
|
37
|
+
ContainerSelectorModal,
|
|
38
|
+
SaveDialog,
|
|
39
|
+
StringEditModal,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_scrollbar.install()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ViewerApp(App):
|
|
46
|
+
"""The log viewer."""
|
|
47
|
+
|
|
48
|
+
TITLE = "docker-orb"
|
|
49
|
+
CSS_PATH = "viewer.tcss"
|
|
50
|
+
|
|
51
|
+
# Dragging a side panel's header trades height with MainStreamPanel (the
|
|
52
|
+
# only auto-filling `1fr` panel) — never with a neighboring side panel.
|
|
53
|
+
MAIN_STREAM_MIN_HEIGHT = 20
|
|
54
|
+
SIDE_PANEL_MIN_HEIGHT = 4
|
|
55
|
+
|
|
56
|
+
# Force an early backfill flush past this many buffered lines, rather
|
|
57
|
+
# than waiting on every container to catch up (or the watchdog) — bounds the
|
|
58
|
+
# worst case (e.g. a deliberately long `since` on a very busy container) to a
|
|
59
|
+
# sort+dispatch of a few thousand lines instead of an unbounded one that
|
|
60
|
+
# could noticeably stall the UI. Below the main buffer's own 20,000 cap
|
|
61
|
+
# since this is meant to catch a burst, not hold a whole session.
|
|
62
|
+
BACKFILL_BUFFER_CAP = 3000
|
|
63
|
+
|
|
64
|
+
BINDINGS = [
|
|
65
|
+
Binding("f", "edit_filters", "Edit Filters", show=True, priority=True),
|
|
66
|
+
Binding("h", "edit_highlights", "Edit highlights", show=True, priority=True),
|
|
67
|
+
Binding("m", "edit_monitors", "Edit monitors", show=True, priority=True),
|
|
68
|
+
Binding("space", "toggle_pause", "Pause / Resume", show=True, priority=True),
|
|
69
|
+
Binding("ctrl+s", "save_logs", "Save logs", show=True, priority=True),
|
|
70
|
+
Binding("ctrl+q", "quit", "Quit", show=True, priority=True),
|
|
71
|
+
Binding("/", "toggle_search", "Search", show=True, priority=True),
|
|
72
|
+
Binding("t", "toggle_color", "Color mode", show=True, priority=True),
|
|
73
|
+
Binding("w", "toggle_wrap", "Wrap", show=True, priority=True),
|
|
74
|
+
Binding("p", "manage_containers", "Containers", show=True, priority=True),
|
|
75
|
+
Binding("l", "edit_layout", "Pane sizes", show=True, priority=True),
|
|
76
|
+
Binding("j", "toggle_json", "JSON format", show=True, priority=True),
|
|
77
|
+
Binding("c", "toggle_collapse", "Collapse repeats", show=True, priority=True),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def __init__(self, config: SessionConfig) -> None:
|
|
81
|
+
super().__init__()
|
|
82
|
+
self._config = config
|
|
83
|
+
self._is_stream = config.mode == LogMode.STREAM
|
|
84
|
+
|
|
85
|
+
# Compiled match patterns — updated live when user edits strings
|
|
86
|
+
self._filter_patterns: list[re.Pattern] = compile_patterns(config.filters, config.filters_ignore_case)
|
|
87
|
+
self._highlight_patterns: list[re.Pattern] = compile_patterns(config.highlights, config.highlights_ignore_case)
|
|
88
|
+
self._monitor_patterns: list[re.Pattern] = compile_patterns(config.monitors, config.monitors_ignore_case)
|
|
89
|
+
|
|
90
|
+
# Full log buffer — capped to avoid unbounded memory / slow rerenders
|
|
91
|
+
self._buffer: list[LogLine] = []
|
|
92
|
+
self._buffer_cap = 20_000
|
|
93
|
+
|
|
94
|
+
# Container → color mapping
|
|
95
|
+
self._color_map: dict[str, str] = {}
|
|
96
|
+
|
|
97
|
+
self._paused = False
|
|
98
|
+
self._pending_lines: list[LogLine] = [] # buffered while paused
|
|
99
|
+
self._paused_since_last_ui = 0 # lines received since last indicator update
|
|
100
|
+
|
|
101
|
+
# Service → list of container names currently streaming
|
|
102
|
+
self._service_containers: dict[str, list[str]] = {}
|
|
103
|
+
|
|
104
|
+
# Backfill interleaving (only active when config.since requests a
|
|
105
|
+
# look-back window in stream mode) — see _handle_backfill_line().
|
|
106
|
+
# Concurrent per-container streams don't arrive in chronological order, so
|
|
107
|
+
# a backfill burst is held and sorted by real log_timestamp before
|
|
108
|
+
# display, rather than shown in arrival order and clumped per container.
|
|
109
|
+
# None = no backfill in progress (the common case — zero overhead).
|
|
110
|
+
self._backfill_pending: set[str] | None = None
|
|
111
|
+
self._backfill_buffer: list[LogLine] = []
|
|
112
|
+
self._backfill_cutoff: datetime | None = None
|
|
113
|
+
self._backfill_watchdog = None
|
|
114
|
+
|
|
115
|
+
# ── Layout ────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def compose(self) -> ComposeResult:
|
|
118
|
+
yield Header()
|
|
119
|
+
with Vertical(id="panels"):
|
|
120
|
+
yield MainStreamPanel(id="main-stream")
|
|
121
|
+
yield SearchPanel(id="search-panel")
|
|
122
|
+
if self._is_stream:
|
|
123
|
+
yield MonitorPanel(id="monitor-panel")
|
|
124
|
+
yield HealthPanel(
|
|
125
|
+
config=self._config.health,
|
|
126
|
+
id="health-panel",
|
|
127
|
+
)
|
|
128
|
+
yield Rule()
|
|
129
|
+
yield Footer()
|
|
130
|
+
|
|
131
|
+
def _side_panels(self) -> list[Vertical]:
|
|
132
|
+
"""Every panel other than MainStreamPanel that can be dragged/resized."""
|
|
133
|
+
panels = [self.query_one(SearchPanel)]
|
|
134
|
+
if self._is_stream:
|
|
135
|
+
panels.append(self.query_one(MonitorPanel))
|
|
136
|
+
panels.append(self.query_one(HealthPanel))
|
|
137
|
+
return panels
|
|
138
|
+
|
|
139
|
+
def _visible_side_panels(self) -> list[tuple[str, Vertical]]:
|
|
140
|
+
"""(label, panel) for side panels currently shown and expanded —
|
|
141
|
+
the only ones a percentage-based size actually means anything for."""
|
|
142
|
+
labeled: list[tuple[str, Vertical]] = [("Search", self.query_one(SearchPanel))]
|
|
143
|
+
if self._is_stream:
|
|
144
|
+
labeled.append(("Monitor", self.query_one(MonitorPanel)))
|
|
145
|
+
labeled.append(("Health", self.query_one(HealthPanel)))
|
|
146
|
+
return [
|
|
147
|
+
(label, panel) for label, panel in labeled
|
|
148
|
+
if panel.display and not getattr(panel, "_collapsed", False)
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
def resize_panel(self, panel: Vertical, delta: int) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Grow/shrink one side panel by `delta` rows, taking the difference
|
|
154
|
+
from (or giving it back to) MainStreamPanel — never from another
|
|
155
|
+
side panel — and never letting MainStreamPanel drop below
|
|
156
|
+
MAIN_STREAM_MIN_HEIGHT.
|
|
157
|
+
"""
|
|
158
|
+
if delta == 0:
|
|
159
|
+
return
|
|
160
|
+
total = self.query_one("#panels").outer_size.height
|
|
161
|
+
if total <= 0:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
# outer_size (not size) — size excludes a panel's own border row, but
|
|
165
|
+
# styles.height (what we're setting below) is border-inclusive. Mixing
|
|
166
|
+
# the two produces an off-by-one every time a bordered panel resizes.
|
|
167
|
+
others_height = sum(
|
|
168
|
+
p.outer_size.height for p in self._side_panels() if p is not panel
|
|
169
|
+
)
|
|
170
|
+
new_height = panel.outer_size.height + delta
|
|
171
|
+
new_height = max(new_height, self.SIDE_PANEL_MIN_HEIGHT)
|
|
172
|
+
max_allowed = total - others_height - self.MAIN_STREAM_MIN_HEIGHT
|
|
173
|
+
new_height = min(new_height, max(max_allowed, self.SIDE_PANEL_MIN_HEIGHT))
|
|
174
|
+
panel.styles.height = new_height
|
|
175
|
+
|
|
176
|
+
async def on_mount(self) -> None:
|
|
177
|
+
self.query_one(SearchPanel).display = False
|
|
178
|
+
panel = self.query_one(MainStreamPanel)
|
|
179
|
+
panel.set_color_mode(self._config.color_full_line)
|
|
180
|
+
panel.set_wrap(self._config.line_wrap)
|
|
181
|
+
panel.set_json_format(self._config.json_format)
|
|
182
|
+
panel.set_collapse_repeats(self._config.collapse_repeats)
|
|
183
|
+
search_panel = self.query_one(SearchPanel)
|
|
184
|
+
search_panel.set_wrap(self._config.line_wrap)
|
|
185
|
+
search_panel.set_color_mode(self._config.color_full_line)
|
|
186
|
+
if self._is_stream:
|
|
187
|
+
monitor_panel = self.query_one(MonitorPanel)
|
|
188
|
+
monitor_panel.set_wrap(self._config.line_wrap)
|
|
189
|
+
monitor_panel.set_color_mode(self._config.color_full_line)
|
|
190
|
+
|
|
191
|
+
# Give focus to the log display so all key bindings are reachable
|
|
192
|
+
self.query_one("#stream-log").focus()
|
|
193
|
+
|
|
194
|
+
# Run blocking compose calls off the event loop so the TUI stays responsive
|
|
195
|
+
self.run_worker(self._init_containers(), name="init-containers")
|
|
196
|
+
|
|
197
|
+
async def _init_containers(self) -> None:
|
|
198
|
+
from .. import compose as k
|
|
199
|
+
|
|
200
|
+
# Resolve containers from selected services (blocking subprocess, runs in worker)
|
|
201
|
+
services = await asyncio.get_event_loop().run_in_executor(
|
|
202
|
+
None, k.get_services, self._config.project
|
|
203
|
+
)
|
|
204
|
+
dep_map = {d.name: d for d in services}
|
|
205
|
+
selected_deps = [dep_map[n] for n in self._config.services if n in dep_map]
|
|
206
|
+
containers = await asyncio.get_event_loop().run_in_executor(
|
|
207
|
+
None, k.get_containers_for_services, self._config.project, selected_deps
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if not containers:
|
|
211
|
+
self.notify("No containers found for selected services.", severity="error")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
self._color_map = assign_colors([p.name for p in containers])
|
|
215
|
+
self.query_one(SearchPanel).set_color_map(self._color_map)
|
|
216
|
+
if self._is_stream:
|
|
217
|
+
self.query_one(MonitorPanel).set_color_map(self._color_map)
|
|
218
|
+
self.query_one(MonitorPanel).set_patterns(self._monitor_patterns)
|
|
219
|
+
|
|
220
|
+
if self._is_stream:
|
|
221
|
+
self._start_streaming(selected_deps, containers)
|
|
222
|
+
if self._config.health.enabled:
|
|
223
|
+
container_names = [p.name for p in containers]
|
|
224
|
+
self.run_worker(
|
|
225
|
+
self._poll_health(container_names),
|
|
226
|
+
name="health-poll",
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
self.run_worker(self._load_dump(containers), name="dump-loader")
|
|
230
|
+
|
|
231
|
+
# ── Container stream management ─────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def _start_streaming(self, deps, containers) -> None:
|
|
234
|
+
"""Start streaming workers for a set of services and their containers."""
|
|
235
|
+
from .. import compose as k
|
|
236
|
+
|
|
237
|
+
for svc in deps:
|
|
238
|
+
self._service_containers[svc.name] = []
|
|
239
|
+
|
|
240
|
+
if k.wants_backfill(self._config.since):
|
|
241
|
+
# A look-back window means every container's stream opens with a
|
|
242
|
+
# backfill burst. Hold lines from all of them until every container
|
|
243
|
+
# has either caught up to live or the watchdog fires, then
|
|
244
|
+
# deliver the whole burst sorted by real timestamp — otherwise
|
|
245
|
+
# they show up in arrival order, clumped one container at a time.
|
|
246
|
+
# (wants_backfill, not a truthiness check: a user-entered "0"
|
|
247
|
+
# is non-empty but compose treats it as no window at all — see
|
|
248
|
+
# compose._normalize_since — so it shouldn't trigger a 5-second
|
|
249
|
+
# wait for a backfill that will never arrive.)
|
|
250
|
+
self._backfill_pending = {p.name for p in containers}
|
|
251
|
+
self._backfill_cutoff = datetime.now(timezone.utc)
|
|
252
|
+
self._backfill_watchdog = self.set_timer(5.0, self._backfill_watchdog_fire)
|
|
253
|
+
|
|
254
|
+
for container in containers:
|
|
255
|
+
self._service_containers.setdefault(container.service, []).append(container.name)
|
|
256
|
+
self.run_worker(
|
|
257
|
+
self._stream_container(container.name),
|
|
258
|
+
name=f"stream-{container.name}",
|
|
259
|
+
exclusive=False,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def add_service_to_stream(self, service_name: str) -> None:
|
|
263
|
+
"""Dynamically add a service's containers to the live stream."""
|
|
264
|
+
if service_name in self._service_containers:
|
|
265
|
+
self.notify(f"Already streaming {service_name}", severity="warning")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
from .. import compose as k
|
|
269
|
+
try:
|
|
270
|
+
all_deps = k.get_services(self._config.project)
|
|
271
|
+
dep = next((d for d in all_deps if d.name == service_name), None)
|
|
272
|
+
if dep is None:
|
|
273
|
+
self.notify(f"Service '{service_name}' not found", severity="error")
|
|
274
|
+
return
|
|
275
|
+
containers = k.get_containers_for_services(self._config.project, [dep])
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
self.notify(f"Failed to get containers: {exc}", severity="error")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
if not containers:
|
|
281
|
+
self.notify(f"No containers found for {service_name}", severity="warning")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# Assign colors for new containers
|
|
285
|
+
new_names = [p.name for p in containers if p.name not in self._color_map]
|
|
286
|
+
if new_names:
|
|
287
|
+
all_names = list(self._color_map) + new_names
|
|
288
|
+
self._color_map = assign_colors(all_names)
|
|
289
|
+
|
|
290
|
+
self._service_containers[service_name] = [p.name for p in containers]
|
|
291
|
+
self._config.services.append(service_name)
|
|
292
|
+
|
|
293
|
+
for container in containers:
|
|
294
|
+
self.run_worker(
|
|
295
|
+
self._stream_container(container.name),
|
|
296
|
+
name=f"stream-{container.name}",
|
|
297
|
+
exclusive=False,
|
|
298
|
+
)
|
|
299
|
+
self.notify(f"Now streaming {service_name} ({len(containers)} container{'s' if len(containers) != 1 else ''})")
|
|
300
|
+
|
|
301
|
+
def remove_service_from_stream(self, service_name: str) -> None:
|
|
302
|
+
"""Stop streaming a service's containers."""
|
|
303
|
+
container_names = self._service_containers.pop(service_name, [])
|
|
304
|
+
if not container_names:
|
|
305
|
+
return
|
|
306
|
+
for worker in self.workers:
|
|
307
|
+
if worker.name in {f"stream-{p}" for p in container_names}:
|
|
308
|
+
worker.cancel()
|
|
309
|
+
if service_name in self._config.services:
|
|
310
|
+
self._config.services.remove(service_name)
|
|
311
|
+
self.notify(f"Stopped streaming {service_name}")
|
|
312
|
+
|
|
313
|
+
# ── Log ingestion ──────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
async def _stream_container(self, container_name: str) -> None:
|
|
316
|
+
from .. import compose as k
|
|
317
|
+
import time
|
|
318
|
+
backoff = 2.0
|
|
319
|
+
error_count = 0 # consecutive errors; reset on clean stream
|
|
320
|
+
while True:
|
|
321
|
+
try:
|
|
322
|
+
batch: list[LogLine] = []
|
|
323
|
+
last_flush = time.monotonic()
|
|
324
|
+
async for line in k.stream_logs(
|
|
325
|
+
container_name,
|
|
326
|
+
self._config.project,
|
|
327
|
+
since=self._config.since,
|
|
328
|
+
tail=self._config.tail,
|
|
329
|
+
):
|
|
330
|
+
batch.append(line)
|
|
331
|
+
now = time.monotonic()
|
|
332
|
+
if len(batch) >= 20 or (now - last_flush) >= 0.1:
|
|
333
|
+
for l in batch:
|
|
334
|
+
self._ingest(l)
|
|
335
|
+
batch.clear()
|
|
336
|
+
last_flush = now
|
|
337
|
+
await asyncio.sleep(0)
|
|
338
|
+
for l in batch:
|
|
339
|
+
self._ingest(l)
|
|
340
|
+
# Stream ended cleanly — reconnect silently
|
|
341
|
+
error_count = 0
|
|
342
|
+
backoff = 2.0
|
|
343
|
+
await asyncio.sleep(2)
|
|
344
|
+
except asyncio.CancelledError:
|
|
345
|
+
return
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
error_count += 1
|
|
348
|
+
# Only notify on the first error per container; suppress repetitive reconnect spam
|
|
349
|
+
if error_count == 1:
|
|
350
|
+
self.notify(f"{container_name}: stream error — {exc}",
|
|
351
|
+
severity="warning", timeout=5)
|
|
352
|
+
await asyncio.sleep(backoff)
|
|
353
|
+
backoff = min(backoff * 2, 30.0)
|
|
354
|
+
|
|
355
|
+
async def _load_dump(self, containers) -> None:
|
|
356
|
+
from .. import compose as k
|
|
357
|
+
all_lines: list[LogLine] = []
|
|
358
|
+
for container in containers:
|
|
359
|
+
lines = await k.dump_logs(
|
|
360
|
+
container.name,
|
|
361
|
+
self._config.project,
|
|
362
|
+
since=self._config.since,
|
|
363
|
+
tail=self._config.tail,
|
|
364
|
+
)
|
|
365
|
+
all_lines.extend(lines)
|
|
366
|
+
# Sort by receive time (they each have received_at from when fetched)
|
|
367
|
+
all_lines.sort(key=lambda l: l.received_at)
|
|
368
|
+
for line in all_lines:
|
|
369
|
+
self._ingest(line)
|
|
370
|
+
|
|
371
|
+
def _ingest(self, line: LogLine) -> None:
|
|
372
|
+
"""Process one incoming log line through filters and routing."""
|
|
373
|
+
if self._filter_patterns and matches(line.content, self._filter_patterns):
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
if self._backfill_pending is not None:
|
|
377
|
+
self._handle_backfill_line(line)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
self._buffer.append(line)
|
|
381
|
+
if len(self._buffer) > self._buffer_cap:
|
|
382
|
+
del self._buffer[: self._buffer_cap // 10] # trim 10% at a time
|
|
383
|
+
|
|
384
|
+
if self._paused:
|
|
385
|
+
self._pending_lines.append(line)
|
|
386
|
+
self._paused_since_last_ui += 1
|
|
387
|
+
if self._paused_since_last_ui >= 100:
|
|
388
|
+
self._paused_since_last_ui = 0
|
|
389
|
+
self._update_pause_indicator()
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
self._deliver_to_panels(line)
|
|
393
|
+
|
|
394
|
+
def _deliver_to_panels(self, line: LogLine) -> None:
|
|
395
|
+
color = self._color_map.get(line.container_name, "#ffffff")
|
|
396
|
+
hl = self._highlight_patterns if (self._highlight_patterns and
|
|
397
|
+
matches(line.content, self._highlight_patterns)) else None
|
|
398
|
+
|
|
399
|
+
self.query_one(MainStreamPanel).add_line(line, color, hl)
|
|
400
|
+
|
|
401
|
+
if self._is_stream and self._monitor_patterns:
|
|
402
|
+
if matches(line.content, self._monitor_patterns):
|
|
403
|
+
try:
|
|
404
|
+
self.query_one(MonitorPanel).add_line(line, color)
|
|
405
|
+
except Exception:
|
|
406
|
+
pass
|
|
407
|
+
|
|
408
|
+
# ── Backfill interleaving ────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
def _handle_backfill_line(self, line: LogLine) -> None:
|
|
411
|
+
"""
|
|
412
|
+
Route one line while a backfill burst is in progress (see
|
|
413
|
+
_start_streaming). Lines from every container are held until all containers have
|
|
414
|
+
either caught up to live, the watchdog fires, or BACKFILL_BUFFER_CAP
|
|
415
|
+
is hit, then flushed sorted by real timestamp — otherwise each container's
|
|
416
|
+
backlog displays as one arrival-order clump instead of properly
|
|
417
|
+
interleaved history.
|
|
418
|
+
"""
|
|
419
|
+
is_backfill = (
|
|
420
|
+
line.log_timestamp is not None
|
|
421
|
+
and self._backfill_cutoff is not None
|
|
422
|
+
and line.log_timestamp < self._backfill_cutoff
|
|
423
|
+
)
|
|
424
|
+
if not is_backfill:
|
|
425
|
+
# This container has caught up to live. Still hold the line (rather
|
|
426
|
+
# than display it immediately) if other containers are still behind,
|
|
427
|
+
# so a fast container's live lines don't jump ahead of a slow container's
|
|
428
|
+
# still-pending backfill.
|
|
429
|
+
self._backfill_pending.discard(line.container_name)
|
|
430
|
+
|
|
431
|
+
self._backfill_buffer.append(line)
|
|
432
|
+
if not self._backfill_pending or len(self._backfill_buffer) >= self.BACKFILL_BUFFER_CAP:
|
|
433
|
+
self._flush_backfill()
|
|
434
|
+
|
|
435
|
+
def _backfill_watchdog_fire(self) -> None:
|
|
436
|
+
self._backfill_watchdog = None
|
|
437
|
+
if self._backfill_pending:
|
|
438
|
+
self._flush_backfill()
|
|
439
|
+
|
|
440
|
+
def _flush_backfill(self) -> None:
|
|
441
|
+
lines, self._backfill_buffer = self._backfill_buffer, []
|
|
442
|
+
self._backfill_pending = None
|
|
443
|
+
self._backfill_cutoff = None
|
|
444
|
+
if self._backfill_watchdog is not None:
|
|
445
|
+
self._backfill_watchdog.stop()
|
|
446
|
+
self._backfill_watchdog = None
|
|
447
|
+
# Fallback for the rare line whose timestamp didn't parse — a single
|
|
448
|
+
# shared "now" (not received_at, which is naive local time and would
|
|
449
|
+
# misorder against real UTC timestamps by the local tz offset).
|
|
450
|
+
fallback = datetime.now(timezone.utc)
|
|
451
|
+
lines.sort(key=lambda l: l.log_timestamp or fallback)
|
|
452
|
+
for line in lines:
|
|
453
|
+
self._ingest(line) # _backfill_pending is now None, so this goes through normally
|
|
454
|
+
|
|
455
|
+
# ── Pause / resume ─────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
def set_paused(self, paused: bool) -> None:
|
|
458
|
+
self._paused = paused
|
|
459
|
+
# Update indicator first — this sets auto_scroll=True on the RichLog before
|
|
460
|
+
# scroll_to_end() fires, so the widget is already in live-follow mode when
|
|
461
|
+
# new lines arrive from the pending flush.
|
|
462
|
+
self._update_pause_indicator()
|
|
463
|
+
if not paused:
|
|
464
|
+
self.query_one(MainStreamPanel).scroll_to_end()
|
|
465
|
+
if self._pending_lines:
|
|
466
|
+
self.run_worker(self._flush_pending(), group="flush-pending", exclusive=True)
|
|
467
|
+
|
|
468
|
+
async def _flush_pending(self) -> None:
|
|
469
|
+
lines, self._pending_lines = self._pending_lines, []
|
|
470
|
+
# Cap at 500 — discard oldest if the backlog is huge
|
|
471
|
+
if len(lines) > 500:
|
|
472
|
+
lines = lines[-500:]
|
|
473
|
+
for i, line in enumerate(lines):
|
|
474
|
+
self._deliver_to_panels(line)
|
|
475
|
+
if i % 50 == 49:
|
|
476
|
+
await asyncio.sleep(0)
|
|
477
|
+
self._update_pause_indicator()
|
|
478
|
+
|
|
479
|
+
def _update_pause_indicator(self) -> None:
|
|
480
|
+
n = len(self._pending_lines)
|
|
481
|
+
panel = self.query_one(MainStreamPanel)
|
|
482
|
+
panel.set_paused(self._paused, n)
|
|
483
|
+
|
|
484
|
+
# ── Pattern updates (from live string editing) ─────────────────────────────
|
|
485
|
+
|
|
486
|
+
def update_filters(self, new_strings: list[str]) -> None:
|
|
487
|
+
self._config.filters = new_strings
|
|
488
|
+
self._filter_patterns = compile_patterns(new_strings, self._config.filters_ignore_case)
|
|
489
|
+
|
|
490
|
+
def update_highlights(self, new_strings: list[str]) -> None:
|
|
491
|
+
self._config.highlights = new_strings
|
|
492
|
+
self._highlight_patterns = compile_patterns(new_strings, self._config.highlights_ignore_case)
|
|
493
|
+
|
|
494
|
+
def update_monitors(self, new_strings: list[str]) -> None:
|
|
495
|
+
self._config.monitors = new_strings
|
|
496
|
+
self._monitor_patterns = compile_patterns(new_strings, self._config.monitors_ignore_case)
|
|
497
|
+
self.query_one(MonitorPanel).set_patterns(self._monitor_patterns)
|
|
498
|
+
|
|
499
|
+
def _apply_edits_and_resume(self) -> None:
|
|
500
|
+
"""
|
|
501
|
+
Rebuild both panels from the full buffer, then re-engage live-follow.
|
|
502
|
+
Called when an F/H/M edit modal closes. Rebuilding from `_buffer`
|
|
503
|
+
(which already holds every line received while paused) makes the
|
|
504
|
+
pending queue redundant, so we clear it rather than running the async
|
|
505
|
+
flush — avoiding double-delivery when a rebuild and a flush would
|
|
506
|
+
otherwise race. Also drops hits for monitors that were just unchecked
|
|
507
|
+
and re-shows everything buffered while the modal was open.
|
|
508
|
+
"""
|
|
509
|
+
self._rerender_main_stream()
|
|
510
|
+
if self._is_stream:
|
|
511
|
+
self.query_one(MonitorPanel).rebuild(self._buffer)
|
|
512
|
+
if self._is_stream:
|
|
513
|
+
self._pending_lines.clear()
|
|
514
|
+
self._paused_since_last_ui = 0
|
|
515
|
+
self._paused = False
|
|
516
|
+
self._update_pause_indicator()
|
|
517
|
+
self.query_one(MainStreamPanel).scroll_to_end()
|
|
518
|
+
|
|
519
|
+
def _rerender_main_stream(self) -> None:
|
|
520
|
+
"""Replay buffer through updated filters and highlights."""
|
|
521
|
+
panel = self.query_one(MainStreamPanel)
|
|
522
|
+
panel.clear()
|
|
523
|
+
for line in self._buffer:
|
|
524
|
+
if self._filter_patterns and matches(line.content, self._filter_patterns):
|
|
525
|
+
continue
|
|
526
|
+
color = self._color_map.get(line.container_name, "#ffffff")
|
|
527
|
+
hl = self._highlight_patterns if (self._highlight_patterns and
|
|
528
|
+
matches(line.content, self._highlight_patterns)) else None
|
|
529
|
+
panel.add_line(line, color, hl)
|
|
530
|
+
|
|
531
|
+
# ── Health polling ─────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
async def _poll_health(self, container_names: list[str]) -> None:
|
|
534
|
+
from .. import compose as k
|
|
535
|
+
interval = max(1, self._config.health.interval_minutes) * 60
|
|
536
|
+
baseline: dict[str, int] = {} # container_name → restart count at session start
|
|
537
|
+
|
|
538
|
+
while True:
|
|
539
|
+
statuses = k.get_container_statuses(self._config.project, container_names)
|
|
540
|
+
health_panel = self.query_one(HealthPanel)
|
|
541
|
+
threshold = self._config.health.restart_threshold
|
|
542
|
+
|
|
543
|
+
for status in statuses:
|
|
544
|
+
if status.name not in baseline:
|
|
545
|
+
baseline[status.name] = status.restart_count
|
|
546
|
+
|
|
547
|
+
restart_delta = status.restart_count - baseline[status.name]
|
|
548
|
+
is_unhealthy = not status.is_healthy or restart_delta >= threshold
|
|
549
|
+
|
|
550
|
+
if is_unhealthy:
|
|
551
|
+
health_panel.update_container(status, restart_delta)
|
|
552
|
+
|
|
553
|
+
await asyncio.sleep(interval)
|
|
554
|
+
|
|
555
|
+
# ── Keybind actions ────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
def action_toggle_pause(self) -> None:
|
|
558
|
+
if not self._is_stream:
|
|
559
|
+
return
|
|
560
|
+
self.set_paused(not self._paused)
|
|
561
|
+
|
|
562
|
+
def action_edit_filters(self) -> None:
|
|
563
|
+
from ..config import load_saved_strings
|
|
564
|
+
self.push_screen(
|
|
565
|
+
StringEditModal("filters", self._config.filters, load_saved_strings().filters),
|
|
566
|
+
self._on_filters_edited,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def action_edit_highlights(self) -> None:
|
|
570
|
+
from ..config import load_saved_strings
|
|
571
|
+
self.push_screen(
|
|
572
|
+
StringEditModal("highlights", self._config.highlights, load_saved_strings().highlights),
|
|
573
|
+
self._on_highlights_edited,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def action_edit_monitors(self) -> None:
|
|
577
|
+
if not self._is_stream:
|
|
578
|
+
return
|
|
579
|
+
from ..config import load_saved_strings
|
|
580
|
+
self.push_screen(
|
|
581
|
+
StringEditModal("monitors", self._config.monitors, load_saved_strings().monitors),
|
|
582
|
+
self._on_monitors_edited,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
def _on_filters_edited(self, result: tuple[list[str], bool] | None) -> None:
|
|
586
|
+
if result is not None:
|
|
587
|
+
new_strings, save = result
|
|
588
|
+
if save:
|
|
589
|
+
from ..config import add_to_saved_strings, load_saved_strings
|
|
590
|
+
saved = load_saved_strings().filters
|
|
591
|
+
truly_new = [s for s in new_strings if s not in saved]
|
|
592
|
+
if truly_new:
|
|
593
|
+
add_to_saved_strings("filters", truly_new)
|
|
594
|
+
self.notify(f"Saved {len(truly_new)} filter(s) to saved settings")
|
|
595
|
+
self.update_filters(new_strings)
|
|
596
|
+
# Resume live-follow on close, whether applied or cancelled.
|
|
597
|
+
self._apply_edits_and_resume()
|
|
598
|
+
|
|
599
|
+
def _on_highlights_edited(self, result: tuple[list[str], bool] | None) -> None:
|
|
600
|
+
if result is not None:
|
|
601
|
+
new_strings, save = result
|
|
602
|
+
if save:
|
|
603
|
+
from ..config import add_to_saved_strings, load_saved_strings
|
|
604
|
+
saved = load_saved_strings().highlights
|
|
605
|
+
truly_new = [s for s in new_strings if s not in saved]
|
|
606
|
+
if truly_new:
|
|
607
|
+
add_to_saved_strings("highlights", truly_new)
|
|
608
|
+
self.notify(f"Saved {len(truly_new)} highlight(s) to saved settings")
|
|
609
|
+
self.update_highlights(new_strings)
|
|
610
|
+
self._apply_edits_and_resume()
|
|
611
|
+
|
|
612
|
+
def _on_monitors_edited(self, result: tuple[list[str], bool] | None) -> None:
|
|
613
|
+
if result is not None:
|
|
614
|
+
new_strings, save = result
|
|
615
|
+
if save:
|
|
616
|
+
from ..config import add_to_saved_strings, load_saved_strings
|
|
617
|
+
saved = load_saved_strings().monitors
|
|
618
|
+
truly_new = [s for s in new_strings if s not in saved]
|
|
619
|
+
if truly_new:
|
|
620
|
+
add_to_saved_strings("monitors", truly_new)
|
|
621
|
+
self.notify(f"Saved {len(truly_new)} monitor(s) to saved settings")
|
|
622
|
+
self.update_monitors(new_strings)
|
|
623
|
+
self._apply_edits_and_resume()
|
|
624
|
+
|
|
625
|
+
def action_save_logs(self) -> None:
|
|
626
|
+
proj = self._config.project
|
|
627
|
+
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
628
|
+
default_name = f"docker-orb-{proj}-{ts}.log"
|
|
629
|
+
self.push_screen(
|
|
630
|
+
SaveDialog(default_name=default_name),
|
|
631
|
+
self._do_save,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def _do_save(self, path_str: str | None) -> None:
|
|
635
|
+
if not path_str:
|
|
636
|
+
return
|
|
637
|
+
path = Path(path_str)
|
|
638
|
+
lines = [line.display for line in self._buffer]
|
|
639
|
+
try:
|
|
640
|
+
path.write_text("\n".join(lines) + "\n")
|
|
641
|
+
self.notify(f"Saved {len(lines)} lines to {path}", severity="information")
|
|
642
|
+
except OSError as exc:
|
|
643
|
+
self.notify(f"Save failed: {exc}", severity="error")
|
|
644
|
+
|
|
645
|
+
def action_toggle_color(self) -> None:
|
|
646
|
+
self._config.color_full_line = not self._config.color_full_line
|
|
647
|
+
full_line = self._config.color_full_line
|
|
648
|
+
|
|
649
|
+
self.query_one(MainStreamPanel).set_color_mode(full_line)
|
|
650
|
+
self._rerender_main_stream()
|
|
651
|
+
|
|
652
|
+
search_panel = self.query_one(SearchPanel)
|
|
653
|
+
search_panel.set_color_mode(full_line)
|
|
654
|
+
query = search_panel.query_one("#search-input", Input).value
|
|
655
|
+
search_panel.search(self._buffer, query)
|
|
656
|
+
|
|
657
|
+
if self._is_stream:
|
|
658
|
+
monitor_panel = self.query_one(MonitorPanel)
|
|
659
|
+
monitor_panel.set_color_mode(full_line)
|
|
660
|
+
monitor_panel.rebuild(self._buffer)
|
|
661
|
+
|
|
662
|
+
mode = "full line" if full_line else "name only"
|
|
663
|
+
self.notify(f"Color mode: {mode}")
|
|
664
|
+
|
|
665
|
+
def action_toggle_wrap(self) -> None:
|
|
666
|
+
self._config.line_wrap = not self._config.line_wrap
|
|
667
|
+
wrap = self._config.line_wrap
|
|
668
|
+
self.query_one(MainStreamPanel).set_wrap(wrap)
|
|
669
|
+
self.query_one(SearchPanel).set_wrap(wrap)
|
|
670
|
+
if self._is_stream:
|
|
671
|
+
self.query_one(MonitorPanel).set_wrap(wrap)
|
|
672
|
+
# RichLog only applies wrap to new writes — re-render buffer so existing lines reflow
|
|
673
|
+
self._rerender_main_stream()
|
|
674
|
+
self.notify(f"Line wrap: {'on' if wrap else 'off'}")
|
|
675
|
+
|
|
676
|
+
def action_toggle_json(self) -> None:
|
|
677
|
+
self._config.json_format = not self._config.json_format
|
|
678
|
+
panel = self.query_one(MainStreamPanel)
|
|
679
|
+
panel.set_json_format(self._config.json_format)
|
|
680
|
+
self._rerender_main_stream()
|
|
681
|
+
self.notify(f"JSON format: {'on' if self._config.json_format else 'raw'}")
|
|
682
|
+
|
|
683
|
+
def action_toggle_collapse(self) -> None:
|
|
684
|
+
self._config.collapse_repeats = not self._config.collapse_repeats
|
|
685
|
+
panel = self.query_one(MainStreamPanel)
|
|
686
|
+
panel.set_collapse_repeats(self._config.collapse_repeats)
|
|
687
|
+
self._rerender_main_stream()
|
|
688
|
+
self.notify(f"Collapse repeats: {'on' if self._config.collapse_repeats else 'off'}")
|
|
689
|
+
|
|
690
|
+
def show_json_detail(self, idx: int | None) -> None:
|
|
691
|
+
if idx is None:
|
|
692
|
+
return
|
|
693
|
+
parsed = self.query_one(MainStreamPanel).get_parsed_json(idx)
|
|
694
|
+
if parsed is None:
|
|
695
|
+
return
|
|
696
|
+
self.push_screen(JsonDetailModal(parsed.pretty))
|
|
697
|
+
|
|
698
|
+
def show_monitor_context(self, target: LogLine) -> None:
|
|
699
|
+
"""Open a modal with lines of context around a monitor hit, like
|
|
700
|
+
`grep -C` — the monitor panel itself only ever shows the matching
|
|
701
|
+
line, which is often not enough to tell what happened. The modal
|
|
702
|
+
starts with a small window and grows/shrinks it live via +/-.
|
|
703
|
+
|
|
704
|
+
Context is scoped to the SAME container as the hit, not sliced from the
|
|
705
|
+
raw session buffer — that buffer interleaves every concurrently
|
|
706
|
+
streamed container in arrival order, so its neighboring entries are
|
|
707
|
+
essentially unrelated lines from whichever other container happened to log
|
|
708
|
+
around the same moment, not anything that led up to the hit.
|
|
709
|
+
"""
|
|
710
|
+
container_lines = [l for l in self._buffer if l.container_name == target.container_name]
|
|
711
|
+
# Try identity first, fall back to content match if buffer was trimmed
|
|
712
|
+
idx = next((i for i, l in enumerate(container_lines) if l is target), None)
|
|
713
|
+
if idx is None:
|
|
714
|
+
idx = next((i for i, l in enumerate(container_lines) if l.content == target.content), None)
|
|
715
|
+
if idx is None:
|
|
716
|
+
self.notify("Line no longer in buffer.", severity="warning")
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
self.push_screen(MonitorContextModal(container_lines, idx, target.container_name))
|
|
720
|
+
|
|
721
|
+
def action_toggle_search(self) -> None:
|
|
722
|
+
panel = self.query_one(SearchPanel)
|
|
723
|
+
panel.display = not panel.display
|
|
724
|
+
if panel.display:
|
|
725
|
+
panel.query_one("#search-input").focus()
|
|
726
|
+
else:
|
|
727
|
+
self.query_one("#stream-log").focus()
|
|
728
|
+
|
|
729
|
+
def action_manage_containers(self) -> None:
|
|
730
|
+
self.push_screen(
|
|
731
|
+
ContainerSelectorModal(
|
|
732
|
+
project=self._config.project,
|
|
733
|
+
active_services=list(self._config.services),
|
|
734
|
+
),
|
|
735
|
+
self._on_containers_selected,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
def _on_containers_selected(self, new_services: list[str] | None) -> None:
|
|
739
|
+
if new_services is None:
|
|
740
|
+
return
|
|
741
|
+
current = set(self._config.services)
|
|
742
|
+
new = set(new_services)
|
|
743
|
+
for dep in new - current:
|
|
744
|
+
self.add_service_to_stream(dep)
|
|
745
|
+
for dep in current - new:
|
|
746
|
+
self.remove_service_from_stream(dep)
|
|
747
|
+
|
|
748
|
+
def action_edit_layout(self) -> None:
|
|
749
|
+
panels = self._visible_side_panels()
|
|
750
|
+
if not panels:
|
|
751
|
+
self.notify("No resizable panes are currently visible.", severity="warning")
|
|
752
|
+
return
|
|
753
|
+
total = self.query_one("#panels").outer_size.height
|
|
754
|
+
if total <= 0:
|
|
755
|
+
return
|
|
756
|
+
entries = [
|
|
757
|
+
(label, round(panel.outer_size.height / total * 100))
|
|
758
|
+
for label, panel in panels
|
|
759
|
+
]
|
|
760
|
+
self.push_screen(PaneSizeModal(entries), self._on_layout_edited)
|
|
761
|
+
|
|
762
|
+
def _on_layout_edited(self, result: dict[str, int] | None) -> None:
|
|
763
|
+
if result is None:
|
|
764
|
+
return
|
|
765
|
+
panels = dict(self._visible_side_panels())
|
|
766
|
+
total = self.query_one("#panels").outer_size.height
|
|
767
|
+
if total <= 0:
|
|
768
|
+
return
|
|
769
|
+
|
|
770
|
+
heights = {
|
|
771
|
+
label: max(self.SIDE_PANEL_MIN_HEIGHT, round(pct / 100 * total))
|
|
772
|
+
for label, pct in result.items()
|
|
773
|
+
if label in panels
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
remaining = total - sum(heights.values())
|
|
777
|
+
if remaining < self.MAIN_STREAM_MIN_HEIGHT:
|
|
778
|
+
self.notify(
|
|
779
|
+
f"Those sizes would leave only {remaining} rows for the main "
|
|
780
|
+
f"stream (minimum {self.MAIN_STREAM_MIN_HEIGHT}). Reduce one "
|
|
781
|
+
f"or more percentages and try again.",
|
|
782
|
+
severity="error",
|
|
783
|
+
)
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
for label, height in heights.items():
|
|
787
|
+
panels[label].styles.height = height
|
|
788
|
+
|
|
789
|
+
# ── Cross-panel: line-select jump ─────────────────────────────────────────
|
|
790
|
+
|
|
791
|
+
def on_health_panel_add_to_stream(self, event) -> None:
|
|
792
|
+
self.add_service_to_stream(event.service_name)
|
|
793
|
+
|
|
794
|
+
def on_search_panel_line_selected(self, event) -> None:
|
|
795
|
+
self.set_paused(True)
|
|
796
|
+
self._jump_to_line(event.line)
|
|
797
|
+
|
|
798
|
+
def on_monitor_panel_line_selected(self, event) -> None:
|
|
799
|
+
self.set_paused(True)
|
|
800
|
+
self._jump_to_line(event.line)
|
|
801
|
+
|
|
802
|
+
def _jump_to_line(self, target: LogLine) -> None:
|
|
803
|
+
"""Rebuild the main stream showing context around target, with it highlighted."""
|
|
804
|
+
# Try identity first, fall back to content match if buffer was trimmed
|
|
805
|
+
idx = next((i for i, l in enumerate(self._buffer) if l is target), None)
|
|
806
|
+
if idx is None:
|
|
807
|
+
idx = next((i for i, l in enumerate(self._buffer)
|
|
808
|
+
if l.container_name == target.container_name and l.content == target.content), None)
|
|
809
|
+
if idx is None:
|
|
810
|
+
self.notify("Line no longer in buffer.", severity="warning")
|
|
811
|
+
return
|
|
812
|
+
start = max(0, idx - 100)
|
|
813
|
+
context = self._buffer[start: idx + 11]
|
|
814
|
+
panel = self.query_one(MainStreamPanel)
|
|
815
|
+
panel.clear()
|
|
816
|
+
for line in context:
|
|
817
|
+
if self._filter_patterns and matches(line.content, self._filter_patterns):
|
|
818
|
+
continue
|
|
819
|
+
color = self._color_map.get(line.container_name, "#ffffff")
|
|
820
|
+
is_target = line is target
|
|
821
|
+
hl = self._highlight_patterns if (not is_target and self._highlight_patterns and
|
|
822
|
+
matches(line.content, self._highlight_patterns)) else None
|
|
823
|
+
panel.add_line(line, color, hl, is_target=is_target)
|
|
824
|
+
panel.scroll_to_end()
|