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.
@@ -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()