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.
File without changes
@@ -0,0 +1,239 @@
1
+ """
2
+ HealthPanel — container health alerts.
3
+
4
+ Hidden when all containers are healthy.
5
+ Appears when any watched container is not Running or has new restarts.
6
+ Rows persist until the user double-clicks to dismiss.
7
+ R = delete container (with confirmation), Shift+R = rollout restart service.
8
+ Stream mode only.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from datetime import timedelta
14
+
15
+ from textual.app import ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.containers import Vertical
18
+ from textual.message import Message
19
+ from textual.widgets import DataTable
20
+
21
+ from ...models import HealthConfig, ContainerStatus
22
+ from ..widgets import DragResizeHeader
23
+
24
+
25
+ @dataclass
26
+ class _HealthRow:
27
+ status: ContainerStatus
28
+ restart_delta: int
29
+ dismissed: bool = False
30
+
31
+
32
+ class HealthPanel(Vertical):
33
+ """
34
+ Collapsible health panel.
35
+ Hidden when no unhealthy rows exist.
36
+ Double-click a row to add that service to the log stream.
37
+ D key dismisses the selected row.
38
+ """
39
+
40
+ class AddToStream(Message):
41
+ """Posted when the user double-clicks a container row to add it to the stream."""
42
+ def __init__(self, service_name: str) -> None:
43
+ super().__init__()
44
+ self.service_name = service_name
45
+
46
+ BINDINGS = [
47
+ Binding("r", "restart_container", "Restart container", show=True),
48
+ Binding("shift+r", "rollout_restart", "Rollout restart", show=True),
49
+ Binding("d", "dismiss_row", "Dismiss row", show=True),
50
+ ]
51
+
52
+ def __init__(self, config: HealthConfig, **kwargs) -> None:
53
+ super().__init__(**kwargs)
54
+ self._config = config
55
+ self._rows: dict[str, _HealthRow] = {} # container_name → row
56
+ self._collapsed = False
57
+ self._pre_collapse_height = None
58
+ self.display = False # hidden until first unhealthy container
59
+
60
+ def compose(self) -> ComposeResult:
61
+ yield _HealthHeader(id="health-header")
62
+ yield DataTable(id="health-table", show_cursor=True, zebra_stripes=True)
63
+
64
+ def on_mount(self) -> None:
65
+ table = self.query_one(DataTable)
66
+ table.add_columns("Container", "Status", "Restarts (Δ+total)", "Age", "Hint")
67
+
68
+ def update_container(self, status: ContainerStatus, restart_delta: int) -> None:
69
+ """Called by the health poller with fresh status for an unhealthy container."""
70
+ table = self.query_one(DataTable)
71
+
72
+ row = self._rows.get(status.name)
73
+ if row is None:
74
+ # New unhealthy container — add a row
75
+ row = _HealthRow(status=status, restart_delta=restart_delta)
76
+ self._rows[status.name] = row
77
+ table.add_row(
78
+ *self._format_row(row),
79
+ key=status.name,
80
+ )
81
+ else:
82
+ # Update existing row
83
+ row.status = status
84
+ row.restart_delta = restart_delta
85
+ self._update_table_row(table, row)
86
+
87
+ self._show_panel()
88
+
89
+ def _format_row(self, row: _HealthRow) -> tuple[str, str, str, str, str]:
90
+ s = row.status
91
+ age = _fmt_age(s.age_seconds)
92
+ restarts = f"{row.restart_delta} (+{s.restart_count})"
93
+ status_style = s.phase if s.is_healthy else f"[red]{s.phase}[/red]"
94
+ hint = "dbl-click=stream D=dismiss R=restart"
95
+ return s.name, status_style, restarts, age, hint
96
+
97
+ def _update_table_row(self, table: DataTable, row: _HealthRow) -> None:
98
+ fmt = self._format_row(row)
99
+ # table.columns is a dict[ColumnKey, Column], not a positional list —
100
+ # indexing it by an int (e.g. table.columns[0]) raises KeyError.
101
+ # ordered_columns is the list form, in display order.
102
+ for column, value in zip(table.ordered_columns, fmt):
103
+ table.update_cell(row.status.name, column.key, value)
104
+
105
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
106
+ # Single click — just focus, no action
107
+ pass
108
+
109
+ def on_data_table_cell_selected(self, event) -> None:
110
+ pass
111
+
112
+ def _dismiss_row(self, container_name: str) -> None:
113
+ table = self.query_one(DataTable)
114
+ try:
115
+ table.remove_row(container_name)
116
+ except Exception:
117
+ pass
118
+ self._rows.pop(container_name, None)
119
+ if not self._rows:
120
+ self.display = False
121
+
122
+ # Double-click to dismiss — Textual fires on_data_table_row_highlighted
123
+ # on first click and on_data_table_row_selected on second.
124
+ # We track click timestamps per row to detect double-click.
125
+ _last_click: dict[str, float] = {}
126
+
127
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
128
+ import time
129
+ key = str(event.row_key.value)
130
+ now = time.monotonic()
131
+ last = self._last_click.get(key, 0.0)
132
+ if now - last < 0.5:
133
+ # Double-click → add this container's service to the stream
134
+ row = self._rows.get(key)
135
+ if row:
136
+ self.post_message(self.AddToStream(row.status.service))
137
+ self._last_click[key] = now
138
+
139
+ def action_dismiss_row(self) -> None:
140
+ table = self.query_one(DataTable)
141
+ if table.cursor_row < 0:
142
+ return
143
+ container_name = str(table.get_cell_at((table.cursor_row, 0)))
144
+ self._dismiss_row(container_name)
145
+
146
+ def action_restart_container(self) -> None:
147
+ table = self.query_one(DataTable)
148
+ if table.cursor_row < 0:
149
+ return
150
+ row_key = table.get_row_at(table.cursor_row)
151
+ container_name = str(table.get_row_index(table.cursor_row))
152
+ # Get container name from first cell
153
+ container_name = str(table.get_cell_at((table.cursor_row, 0)))
154
+ self._confirm_restart(container_name, rollout=False)
155
+
156
+ def action_rollout_restart(self) -> None:
157
+ table = self.query_one(DataTable)
158
+ if table.cursor_row < 0:
159
+ return
160
+ container_name = str(table.get_cell_at((table.cursor_row, 0)))
161
+ row = self._rows.get(container_name)
162
+ if row:
163
+ self._confirm_restart(container_name, rollout=True,
164
+ service=row.status.service)
165
+
166
+ def _confirm_restart(self, container_name: str, rollout: bool,
167
+ service: str | None = None) -> None:
168
+ from ..widgets import ConfirmDialog
169
+ if rollout:
170
+ msg = f"Rollout restart service/{service}? [y/N]"
171
+ else:
172
+ msg = f"Delete container {container_name} (will be recreated)? [y/N]"
173
+
174
+ def _on_confirm(confirmed: bool) -> None:
175
+ if not confirmed:
176
+ return
177
+ from ... import compose as k
178
+ if rollout and service:
179
+ ok = k.restart_service(service, self.app._config.project) # type: ignore
180
+ action = f"rollout restart service/{service}"
181
+ else:
182
+ ok = k.restart_container(container_name, self.app._config.project) # type: ignore
183
+ action = f"delete container {container_name}"
184
+
185
+ if ok:
186
+ self.app.notify(f"✓ {action}", severity="information")
187
+ else:
188
+ self.app.notify(f"✗ {action} failed", severity="error")
189
+
190
+ self.app.push_screen(ConfirmDialog(message=msg), _on_confirm)
191
+
192
+ def _show_panel(self) -> None:
193
+ self.display = True
194
+ self.query_one(_HealthHeader).update_count(len(self._rows))
195
+
196
+ def toggle_collapsed(self) -> None:
197
+ self._collapsed = not self._collapsed
198
+ self.query_one("#health-table").display = not self._collapsed
199
+ self.set_class(self._collapsed, "-collapsed")
200
+ if self._collapsed:
201
+ self._pre_collapse_height = self.styles.height
202
+ self.styles.height = "auto"
203
+ else:
204
+ self.styles.height = self._pre_collapse_height
205
+ self.query_one(_HealthHeader).update_collapsed(self._collapsed)
206
+
207
+
208
+ class _HealthHeader(DragResizeHeader):
209
+ def __init__(self, **kwargs) -> None:
210
+ super().__init__("", **kwargs)
211
+ self._count = 0
212
+ self._collapsed = False
213
+ self._refresh()
214
+
215
+ def update_count(self, count: int) -> None:
216
+ self._count = count
217
+ self._refresh()
218
+
219
+ def update_collapsed(self, collapsed: bool) -> None:
220
+ self._collapsed = collapsed
221
+ self._refresh()
222
+
223
+ def _refresh(self) -> None:
224
+ arrow = "▶" if self._collapsed else "▼"
225
+ suffix = f" ({self._count} unhealthy)" if self._count else ""
226
+ self.update(f"{arrow} [red]Container Health{suffix}[/red]")
227
+
228
+
229
+ def _fmt_age(seconds: float) -> str:
230
+ td = timedelta(seconds=int(seconds))
231
+ if td.days:
232
+ return f"{td.days}d{td.seconds // 3600}h"
233
+ h, rem = divmod(td.seconds, 3600)
234
+ m, s = divmod(rem, 60)
235
+ if h:
236
+ return f"{h}h{m}m"
237
+ if m:
238
+ return f"{m}m{s}s"
239
+ return f"{s}s"
@@ -0,0 +1,389 @@
1
+ """
2
+ MainStreamPanel — primary log display.
3
+
4
+ Features:
5
+ - Colored container-name prefix per line
6
+ - Highlight support (bold + color emphasis)
7
+ - Auto-scroll to bottom unless paused
8
+ - Pause triggered by: scroll-up, clicking a line, or line-select in other panels
9
+ - PAUSED indicator in header showing buffered line count
10
+ - Jump-to-line from search/monitor panels
11
+ - Optional readable reformatting of detected JSON lines, with a detail
12
+ modal (Enter, after clicking a line) showing the full pretty-printed JSON
13
+ - Optional collapsing of consecutive identical lines from the same container
14
+ into a single "last line repeated N times" marker (journalctl-style)
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ from rich.style import Style
21
+ from rich.text import Text
22
+ from textual.binding import Binding
23
+ from textual.widgets import RichLog, Static
24
+ from textual.containers import Vertical
25
+
26
+ from ...jsonlog import LEVEL_STYLES, ParsedJsonLine, parse_json_log_line
27
+ from ...models import LogLine
28
+
29
+
30
+ def _append_with_highlights(
31
+ text: Text,
32
+ content: str,
33
+ patterns: list[re.Pattern],
34
+ base_style: str,
35
+ hl_style: str,
36
+ ) -> None:
37
+ """Append content to text, highlighting only the matched spans."""
38
+ # Collect all match spans, merge overlaps, sort
39
+ spans: list[tuple[int, int]] = []
40
+ for pat in patterns:
41
+ for m in pat.finditer(content):
42
+ if m.start() < m.end():
43
+ spans.append((m.start(), m.end()))
44
+ spans.sort()
45
+
46
+ # Merge overlapping spans
47
+ merged: list[tuple[int, int]] = []
48
+ for start, end in spans:
49
+ if merged and start <= merged[-1][1]:
50
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
51
+ else:
52
+ merged.append((start, end))
53
+
54
+ cursor = 0
55
+ for start, end in merged:
56
+ if cursor < start:
57
+ text.append(content[cursor:start], style=base_style)
58
+ text.append(content[start:end], style=hl_style)
59
+ cursor = end
60
+ if cursor < len(content):
61
+ text.append(content[cursor:], style=base_style)
62
+
63
+
64
+ def _formatted_json_content(
65
+ parsed: ParsedJsonLine,
66
+ force_style: str | None,
67
+ highlight_style: str | None,
68
+ ) -> Text:
69
+ """
70
+ Build the readable one-line rendering of a parsed JSON line:
71
+ `HH:MM:SS LEVEL message key=val key=val`.
72
+
73
+ force_style: color-full-line mode passes the container color here so it wins
74
+ throughout instead of semantic level coloring.
75
+ highlight_style: set when the line matched a highlight pattern — spans
76
+ computed against the raw JSON text don't line up with this reformatted
77
+ text, so a highlight match emphasizes the whole message instead of a
78
+ precise span.
79
+ """
80
+ t = Text()
81
+ dim_style = force_style or "dim"
82
+ if parsed.timestamp:
83
+ t.append(parsed.timestamp + " ", style=dim_style)
84
+ if parsed.level:
85
+ # A highlight match wins over the level's own semantic color —
86
+ # otherwise a pattern that matches the level word itself (very
87
+ # common: "ERROR", "WARN") would highlight everywhere in the raw/
88
+ # monitor view but silently do nothing to the level badge here.
89
+ level_style = highlight_style or force_style or LEVEL_STYLES.get(parsed.level.upper(), "bold")
90
+ t.append(f"{parsed.level.upper():<5} ", style=level_style)
91
+ t.append(parsed.message, style=highlight_style or force_style or "")
92
+ if parsed.extras:
93
+ t.append(" " + parsed.extras_text, style=dim_style)
94
+ return t
95
+
96
+
97
+ class MainStreamPanel(Vertical):
98
+ """Collapsible container wrapping the log display."""
99
+
100
+ # When collapse-repeats is on and a run of identical lines never breaks
101
+ # (e.g. a crash loop logging the exact same line forever), flush a
102
+ # "still repeating" marker every this-many repeats rather than holding
103
+ # it indefinitely — otherwise the display would look frozen even though
104
+ # lines are still arriving.
105
+ REPEAT_CHECKPOINT = 500
106
+
107
+ def __init__(self, **kwargs) -> None:
108
+ super().__init__(**kwargs)
109
+ self._collapsed = False
110
+ self._pre_collapse_height = None
111
+ self._paused = False
112
+ self._pending_count = 0
113
+ self._color_full_line = False
114
+ self._json_format = False
115
+ self._json_lines: dict[int, ParsedJsonLine] = {}
116
+ self._next_line_idx = 0
117
+ self._collapse_repeats = False
118
+ # (line, color) of the most recently WRITTEN line, while collapsing
119
+ # is on — used to recognize the next line as a repeat of it.
120
+ self._pending_repeat: tuple[LogLine, str] | None = None
121
+ self._repeat_count = 0
122
+
123
+ def set_color_mode(self, full_line: bool) -> None:
124
+ self._color_full_line = full_line
125
+
126
+ def set_wrap(self, wrap: bool) -> None:
127
+ self.query_one(_LogDisplay).wrap = wrap
128
+
129
+ def set_json_format(self, enabled: bool) -> None:
130
+ self._json_format = enabled
131
+
132
+ def set_collapse_repeats(self, enabled: bool) -> None:
133
+ self._collapse_repeats = enabled
134
+
135
+ def get_parsed_json(self, idx: int) -> ParsedJsonLine | None:
136
+ return self._json_lines.get(idx)
137
+
138
+ def compose(self):
139
+ yield _StreamHeader(id="stream-header")
140
+ yield _LogDisplay(id="stream-log", highlight=False, markup=False,
141
+ wrap=True, auto_scroll=True)
142
+
143
+ def add_line(
144
+ self,
145
+ line: LogLine,
146
+ color: str,
147
+ highlight_patterns: list[re.Pattern] | None = None,
148
+ is_target: bool = False,
149
+ ) -> None:
150
+ # is_target lines (the jump-to-context / JSON-context views) are
151
+ # always their own one-off render — never folded into a repeat run.
152
+ if self._collapse_repeats and not is_target and self._is_repeat(line):
153
+ self._repeat_count += 1
154
+ if self._repeat_count % self.REPEAT_CHECKPOINT == 0:
155
+ orig_line, orig_color = self._pending_repeat
156
+ self._write_repeat_marker(orig_line, orig_color, self._repeat_count, ongoing=True)
157
+ self._repeat_count = 0
158
+ return
159
+
160
+ self._flush_pending_repeat()
161
+ self._write_line(line, color, highlight_patterns, is_target)
162
+ if self._collapse_repeats and not is_target:
163
+ self._pending_repeat = (line, color)
164
+ self._repeat_count = 0
165
+ else:
166
+ self._pending_repeat = None
167
+
168
+ def _is_repeat(self, line: LogLine) -> bool:
169
+ if self._pending_repeat is None:
170
+ return False
171
+ prev_line, _ = self._pending_repeat
172
+ return prev_line.container_name == line.container_name and prev_line.content == line.content
173
+
174
+ def _flush_pending_repeat(self) -> None:
175
+ """Emit the "repeated N times" marker for a just-ended run, if any."""
176
+ if self._repeat_count > 0 and self._pending_repeat is not None:
177
+ orig_line, orig_color = self._pending_repeat
178
+ self._write_repeat_marker(orig_line, orig_color, self._repeat_count, ongoing=False)
179
+ self._pending_repeat = None
180
+ self._repeat_count = 0
181
+
182
+ def _write_repeat_marker(self, orig_line: LogLine, color: str, n: int, ongoing: bool) -> None:
183
+ log = self.query_one(_LogDisplay)
184
+ text = Text()
185
+ text.append(f"[{orig_line.container_name}] ", style=color)
186
+ times = "time" if n == 1 else "times"
187
+ msg = f"↻ last line repeated {n} {times}"
188
+ if ongoing:
189
+ msg += " so far — still repeating…"
190
+ text.append(msg, style="dim italic")
191
+ log.write(text)
192
+
193
+ def _write_line(
194
+ self,
195
+ line: LogLine,
196
+ color: str,
197
+ highlight_patterns: list[re.Pattern] | None,
198
+ is_target: bool,
199
+ ) -> None:
200
+ log = self.query_one(_LogDisplay)
201
+ text = Text()
202
+ prefix = f"[{line.container_name}] "
203
+ content = line.content
204
+
205
+ # Detection always runs (cheap early-exit for non-JSON lines) so the
206
+ # Enter-for-detail feature works even when display formatting is off.
207
+ parsed = parse_json_log_line(content)
208
+ use_formatted = parsed is not None and self._json_format
209
+
210
+ if is_target:
211
+ text.append("▶ ", style="bold #ff8800")
212
+ text.append(prefix, style=f"bold {color}")
213
+ if use_formatted:
214
+ text.append_text(_formatted_json_content(
215
+ parsed, force_style="bold white on #2a2a00", highlight_style=None))
216
+ else:
217
+ text.append(content, style="bold white on #2a2a00")
218
+ elif self._color_full_line:
219
+ text.append(prefix, style=f"bold {color}")
220
+ if use_formatted:
221
+ hl = f"bold {color} on #333300" if highlight_patterns else None
222
+ text.append_text(_formatted_json_content(parsed, force_style=color, highlight_style=hl))
223
+ elif highlight_patterns:
224
+ _append_with_highlights(text, content, highlight_patterns,
225
+ base_style=color, hl_style=f"bold {color} on #333300")
226
+ else:
227
+ text.append(content, style=color)
228
+ else:
229
+ text.append(prefix, style=color)
230
+ if use_formatted:
231
+ hl = "bold #ffff00 on #333300" if highlight_patterns else None
232
+ text.append_text(_formatted_json_content(parsed, force_style=None, highlight_style=hl))
233
+ elif highlight_patterns:
234
+ _append_with_highlights(text, content, highlight_patterns,
235
+ base_style="", hl_style="bold #ffff00 on #333300")
236
+ else:
237
+ text.append(content)
238
+
239
+ if parsed is not None:
240
+ idx = self._next_line_idx
241
+ self._next_line_idx += 1
242
+ self._json_lines[idx] = parsed
243
+ text.stylize(Style(meta={"line_idx": idx}), 0, len(text))
244
+
245
+ log.write(text)
246
+
247
+ def clear(self) -> None:
248
+ self.query_one(_LogDisplay).clear()
249
+ self._json_lines.clear()
250
+ self._next_line_idx = 0
251
+ self._pending_repeat = None
252
+ self._repeat_count = 0
253
+
254
+ def set_paused(self, paused: bool, pending: int = 0) -> None:
255
+ self._paused = paused
256
+ self._pending_count = pending
257
+ log = self.query_one(_LogDisplay)
258
+ log.auto_scroll = not paused
259
+ self.query_one(_StreamHeader).update_pause(paused, pending)
260
+
261
+ def scroll_to_end(self) -> None:
262
+ self.query_one(_LogDisplay).scroll_end(animate=False)
263
+
264
+ def toggle_collapsed(self) -> None:
265
+ log = self.query_one(_LogDisplay)
266
+ self._collapsed = not self._collapsed
267
+ log.display = not self._collapsed
268
+ self.set_class(self._collapsed, "-collapsed")
269
+ # The CSS class alone doesn't reliably win over an inline height set
270
+ # by a previous drag-resize/pane-size-modal action (inline styles
271
+ # take precedence over stylesheet rules), so also set/restore the
272
+ # panel's own height explicitly — otherwise a collapsed panel keeps
273
+ # its old reserved space instead of giving it back to MainStreamPanel.
274
+ if self._collapsed:
275
+ self._pre_collapse_height = self.styles.height
276
+ self.styles.height = "auto"
277
+ else:
278
+ self.styles.height = self._pre_collapse_height
279
+ self.query_one(_StreamHeader).update_collapsed(self._collapsed)
280
+
281
+
282
+ class _StreamHeader(Static):
283
+ """Header bar showing title and pause status.
284
+
285
+ When paused, the header adds a `-paused` class (styled as a bright,
286
+ high-contrast bar in viewer.tcss) and flashes by toggling `-blink-off`
287
+ on a timer, so a paused stream is impossible to miss.
288
+ """
289
+
290
+ def __init__(self, **kwargs) -> None:
291
+ super().__init__("", **kwargs)
292
+ self._paused = False
293
+ self._pending = 0
294
+ self._collapsed = False
295
+ self._blink_timer = None
296
+ self._refresh()
297
+
298
+ def update_pause(self, paused: bool, pending: int) -> None:
299
+ self._paused = paused
300
+ self._pending = pending
301
+ self.set_class(paused, "-paused")
302
+ if paused and self._blink_timer is None:
303
+ self._blink_timer = self.set_interval(0.5, self._toggle_blink)
304
+ elif not paused and self._blink_timer is not None:
305
+ self._blink_timer.stop()
306
+ self._blink_timer = None
307
+ self.remove_class("-blink-off")
308
+ self._refresh()
309
+
310
+ def _toggle_blink(self) -> None:
311
+ self.toggle_class("-blink-off")
312
+
313
+ def update_collapsed(self, collapsed: bool) -> None:
314
+ self._collapsed = collapsed
315
+ self._refresh()
316
+
317
+ def _refresh(self) -> None:
318
+ arrow = "▶" if self._collapsed else "▼"
319
+ title = f"{arrow} Main Stream"
320
+ if self._paused:
321
+ if self._pending:
322
+ title += f" ⏸ PAUSED +{self._pending} lines — Space to resume"
323
+ else:
324
+ title += " ⏸ PAUSED — Space to resume"
325
+ self.update(title)
326
+
327
+ def on_click(self) -> None:
328
+ self.parent.toggle_collapsed() # type: ignore[union-attr]
329
+
330
+
331
+ class _LogDisplay(RichLog):
332
+ """The actual scrollable log widget. Pauses on scroll-up, click, or drag.
333
+
334
+ Clicking a line also selects it (tagged via a "line_idx" style meta —
335
+ see MainStreamPanel.add_line) so Enter can open its JSON detail view,
336
+ if it was a detected JSON line.
337
+ """
338
+
339
+ BINDINGS = [
340
+ Binding("enter", "show_json_detail", "JSON detail", show=True),
341
+ ]
342
+
343
+ def __init__(self, *args, **kwargs) -> None:
344
+ super().__init__(*args, **kwargs)
345
+ self.selected_line_idx: int | None = None
346
+
347
+ def on_scroll_up(self) -> None:
348
+ # ScrollUp message = clicking the scrollbar track above the thumb.
349
+ app = self.app
350
+ if hasattr(app, "set_paused"):
351
+ app.set_paused(True) # type: ignore[union-attr]
352
+
353
+ def on_click(self, event) -> None:
354
+ style = self.get_style_at(event.x, event.y)
355
+ self.selected_line_idx = style.meta.get("line_idx")
356
+ self.refresh_bindings() # footer's "JSON detail" visibility depends on the selection
357
+ app = self.app
358
+ if hasattr(app, "set_paused"):
359
+ app.set_paused(True) # type: ignore[union-attr]
360
+
361
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
362
+ if action == "show_json_detail":
363
+ # Hide the hotkey from the footer entirely unless the currently
364
+ # selected line is actually a detected JSON line — otherwise
365
+ # it's advertised even when pressing it would do nothing.
366
+ return self.selected_line_idx is not None
367
+ return super().check_action(action, parameters)
368
+
369
+ def action_show_json_detail(self) -> None:
370
+ app = self.app
371
+ if hasattr(app, "show_json_detail"):
372
+ app.show_json_detail(self.selected_line_idx) # type: ignore[union-attr]
373
+
374
+ def _on_scroll_to(self, message) -> None:
375
+ # Dragging the scrollbar thumb posts ScrollTo but does NOT trigger
376
+ # on_scroll_up (that fires for track-clicks / the wheel), so without
377
+ # this the app never pauses on a drag: auto_scroll stays on and the
378
+ # next incoming line snaps the view back to the bottom, making the
379
+ # thumb impossible to drag in a live stream. Pause while dragging;
380
+ # if the drag lands at the bottom, resume live-follow.
381
+ super()._on_scroll_to(message)
382
+ app = self.app
383
+ if hasattr(app, "set_paused") and message.y is not None:
384
+ want_paused = message.y < self.max_scroll_y - 1
385
+ # Only toggle when the state actually changes (auto_scroll is the
386
+ # inverse of paused), to avoid redundant header re-renders and
387
+ # repeated flush-worker spawns during a drag.
388
+ if self.auto_scroll == want_paused:
389
+ app.set_paused(want_paused) # type: ignore[union-attr]