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
|
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]
|