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,233 @@
1
+ """
2
+ MonitorPanel — passive accumulation of monitor-string hits.
3
+
4
+ Lines matching monitor patterns are copied here without interrupting the main stream.
5
+ A single click selects a hit (Enter then shows ± context lines from the buffer,
6
+ like `grep -C`); double-clicking pauses the main stream and jumps to it.
7
+ Stream mode only.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import time
13
+
14
+ from rich.text import Text
15
+ from textual.app import ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.containers import ScrollableContainer, Vertical
18
+ from textual.message import Message
19
+ from textual.widgets import Label
20
+
21
+ from ...config import matches
22
+ from ...models import LogLine
23
+ from ..widgets import DragResizeHeader
24
+
25
+ # Cap the number of rendered rows to avoid layout slowdown with large hit counts.
26
+ # _lines tracks the true total; only the most recent MAX_ROWS are shown.
27
+ _MAX_ROWS = 500
28
+
29
+ # Two clicks on the same row within this window count as a double-click.
30
+ _DOUBLE_CLICK_SECONDS = 0.5
31
+
32
+
33
+ class MonitorPanel(Vertical):
34
+ """Collapsible monitor accumulation panel."""
35
+
36
+ DEFAULT_HEIGHT = "20%"
37
+
38
+ class LineSelected(Message):
39
+ """Posted when the user double-clicks a monitor hit."""
40
+ def __init__(self, line: LogLine) -> None:
41
+ super().__init__()
42
+ self.line = line
43
+
44
+ def __init__(self, **kwargs) -> None:
45
+ super().__init__(**kwargs)
46
+ self._collapsed = False
47
+ self._pre_collapse_height = None
48
+ self._lines: list[LogLine] = []
49
+ self._color_map: dict[str, str] = {}
50
+ self._color_full_line = False
51
+ self._patterns: list[re.Pattern] = []
52
+
53
+ def compose(self) -> ComposeResult:
54
+ yield _MonitorHeader(id="monitor-header")
55
+ with ScrollableContainer(id="monitor-scroll"):
56
+ yield Vertical(id="monitor-list")
57
+
58
+ def set_color_map(self, color_map: dict[str, str]) -> None:
59
+ self._color_map = color_map
60
+
61
+ def set_color_mode(self, full_line: bool) -> None:
62
+ self._color_full_line = full_line
63
+
64
+ def set_patterns(self, patterns: list[re.Pattern]) -> None:
65
+ self._patterns = patterns
66
+
67
+ def set_wrap(self, wrap: bool) -> None:
68
+ sc = self.query_one("#monitor-scroll", ScrollableContainer)
69
+ sc.set_class(wrap, "wrap-on")
70
+ sc.set_class(not wrap, "wrap-off")
71
+
72
+ def add_line(self, line: LogLine, color: str) -> None:
73
+ self._lines.append(line)
74
+ scroll = self.query_one("#monitor-scroll", ScrollableContainer)
75
+ inner = self.query_one("#monitor-list", Vertical)
76
+
77
+ at_top = scroll.scroll_y <= 1
78
+
79
+ text = _build_text(line, color, self._patterns, self._color_full_line)
80
+ # Prepend — newest hit at the top. mount(before=0) on Vertical is reliable.
81
+ inner.mount(_MonitorRow(text, line), before=0)
82
+
83
+ # Evict the oldest rendered row when we exceed the cap
84
+ if len(inner.children) > _MAX_ROWS:
85
+ inner.children[-1].remove()
86
+
87
+ if at_top:
88
+ scroll.scroll_home(animate=False)
89
+
90
+ self.query_one(_MonitorHeader).update_count(len(self._lines))
91
+
92
+ def rebuild(self, buffer: list[LogLine]) -> None:
93
+ """
94
+ Recompute the entire hit list from the full log buffer through the
95
+ current monitor patterns. Called when monitors are edited live so
96
+ unchecking a pattern drops its accumulated hits (and checking a new
97
+ one back-fills matches already in the buffer).
98
+ """
99
+ inner = self.query_one("#monitor-list", Vertical)
100
+ inner.remove_children()
101
+
102
+ matched = (
103
+ [line for line in buffer if matches(line.content, self._patterns)]
104
+ if self._patterns
105
+ else []
106
+ )
107
+ self._lines = matched
108
+
109
+ # Render only the most recent _MAX_ROWS, prepending in order so the
110
+ # newest hit ends up on top (matching add_line's ordering).
111
+ for line in matched[-_MAX_ROWS:]:
112
+ color = self._color_map.get(line.container_name, "#ffffff")
113
+ inner.mount(_MonitorRow(_build_text(line, color, self._patterns, self._color_full_line), line), before=0)
114
+
115
+ self.query_one(_MonitorHeader).update_count(len(self._lines))
116
+
117
+ def toggle_collapsed(self) -> None:
118
+ self._collapsed = not self._collapsed
119
+ self.query_one("#monitor-scroll").display = not self._collapsed
120
+ self.set_class(self._collapsed, "-collapsed")
121
+ if self._collapsed:
122
+ self._pre_collapse_height = self.styles.height
123
+ self.styles.height = "auto"
124
+ else:
125
+ self.styles.height = self._pre_collapse_height
126
+ self.query_one(_MonitorHeader).update_collapsed(self._collapsed)
127
+
128
+
129
+ class _MonitorRow(Label):
130
+ """
131
+ A single monitor hit. Click selects (and focuses) it — Enter then opens
132
+ a modal with ± context lines from the buffer around it. Double-click
133
+ instead posts MonitorPanel.LineSelected, pausing and jumping to it in
134
+ the main stream.
135
+ """
136
+
137
+ can_focus = True
138
+
139
+ BINDINGS = [
140
+ Binding("enter", "show_context", "Context", show=True),
141
+ ]
142
+
143
+ def __init__(self, text: Text, line: LogLine, **kwargs) -> None:
144
+ super().__init__(text, **kwargs)
145
+ self.line = line
146
+ self._last_click = 0.0
147
+
148
+ def on_click(self) -> None:
149
+ self.focus()
150
+ now = time.monotonic()
151
+ if now - self._last_click < _DOUBLE_CLICK_SECONDS:
152
+ self.post_message(MonitorPanel.LineSelected(self.line))
153
+ self._last_click = 0.0 # require two fresh clicks for the next jump
154
+ else:
155
+ self._last_click = now
156
+
157
+ def action_show_context(self) -> None:
158
+ app = self.app
159
+ if hasattr(app, "show_monitor_context"):
160
+ app.show_monitor_context(self.line) # type: ignore[union-attr]
161
+
162
+
163
+ def _build_text(
164
+ line: LogLine,
165
+ color: str,
166
+ patterns: list[re.Pattern],
167
+ full_line: bool = False,
168
+ ) -> Text:
169
+ text = Text()
170
+ if full_line:
171
+ text.append(f"[{line.container_name}] ", style=f"bold {color}")
172
+ if patterns:
173
+ _append_highlighted(text, line.content, patterns,
174
+ base_style=color, hl_style=f"bold {color} on #333300")
175
+ else:
176
+ text.append(line.content, style=color)
177
+ else:
178
+ text.append(f"[{line.container_name}] ", style=color)
179
+ if patterns:
180
+ _append_highlighted(text, line.content, patterns)
181
+ else:
182
+ text.append(line.content)
183
+ return text
184
+
185
+
186
+ def _append_highlighted(
187
+ text: Text,
188
+ content: str,
189
+ patterns: list[re.Pattern],
190
+ base_style: str = "",
191
+ hl_style: str = "bold #ffff00 on #333300",
192
+ ) -> None:
193
+ spans: list[tuple[int, int]] = []
194
+ for pat in patterns:
195
+ for m in pat.finditer(content):
196
+ if m.start() < m.end():
197
+ spans.append((m.start(), m.end()))
198
+ spans.sort()
199
+ merged: list[tuple[int, int]] = []
200
+ for start, end in spans:
201
+ if merged and start <= merged[-1][1]:
202
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
203
+ else:
204
+ merged.append((start, end))
205
+ cursor = 0
206
+ for start, end in merged:
207
+ if cursor < start:
208
+ text.append(content[cursor:start], style=base_style)
209
+ text.append(content[start:end], style=hl_style)
210
+ cursor = end
211
+ if cursor < len(content):
212
+ text.append(content[cursor:], style=base_style)
213
+
214
+
215
+ class _MonitorHeader(DragResizeHeader):
216
+ def __init__(self, **kwargs) -> None:
217
+ super().__init__("", **kwargs)
218
+ self._count = 0
219
+ self._collapsed = False
220
+ self._refresh()
221
+
222
+ def update_count(self, count: int) -> None:
223
+ self._count = count
224
+ self._refresh()
225
+
226
+ def update_collapsed(self, collapsed: bool) -> None:
227
+ self._collapsed = collapsed
228
+ self._refresh()
229
+
230
+ def _refresh(self) -> None:
231
+ arrow = "▶" if self._collapsed else "▼"
232
+ suffix = f" ({self._count} hits)" if self._count else ""
233
+ self.update(f"{arrow} Monitor{suffix}")
@@ -0,0 +1,182 @@
1
+ """
2
+ SearchPanel — live search across the log buffer.
3
+
4
+ Filters the main stream buffer in real time as the user types.
5
+ Double-clicking a result pauses the main stream and jumps to that line.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import re
11
+ import time
12
+
13
+ from rich.text import Text
14
+ from textual.app import ComposeResult
15
+ from textual.binding import Binding
16
+ from textual.containers import Vertical
17
+ from textual.message import Message
18
+ from textual.widgets import Input, Label, ListItem, ListView
19
+
20
+ from ...models import LogLine
21
+ from ..widgets import DragResizeHeader
22
+
23
+
24
+ class SearchPanel(Vertical):
25
+ """Collapsible search panel."""
26
+
27
+ DEFAULT_HEIGHT = "20%"
28
+
29
+ BINDINGS = [
30
+ Binding("escape", "close", "Close search", show=False),
31
+ ]
32
+
33
+ class LineSelected(Message):
34
+ """Posted when user double-clicks a search result."""
35
+ def __init__(self, line: LogLine) -> None:
36
+ super().__init__()
37
+ self.line = line
38
+
39
+ def __init__(self, **kwargs) -> None:
40
+ super().__init__(**kwargs)
41
+ self._collapsed = False
42
+ self._pre_collapse_height = None
43
+ self._results: list[LogLine] = []
44
+ self._color_map: dict[str, str] = {}
45
+ self._color_full_line = False
46
+ self._pattern: re.Pattern | None = None
47
+ self._debounce_task: asyncio.Task | None = None
48
+ self._last_click: dict[int, float] = {}
49
+
50
+ def compose(self) -> ComposeResult:
51
+ yield _SearchHeader(id="search-header")
52
+ yield Input(placeholder="Search logs …", id="search-input")
53
+ yield ListView(id="search-results")
54
+
55
+ def set_color_map(self, color_map: dict[str, str]) -> None:
56
+ self._color_map = color_map
57
+
58
+ def set_color_mode(self, full_line: bool) -> None:
59
+ self._color_full_line = full_line
60
+
61
+ def set_wrap(self, wrap: bool) -> None:
62
+ lv = self.query_one("#search-results", ListView)
63
+ lv.set_class(wrap, "wrap-on")
64
+ lv.set_class(not wrap, "wrap-off")
65
+
66
+ def search(self, buffer: list[LogLine], query: str) -> None:
67
+ lv = self.query_one("#search-results", ListView)
68
+ lv.clear()
69
+ self._results = []
70
+ self._pattern = None
71
+ self._last_click.clear()
72
+
73
+ if not query:
74
+ self.query_one(_SearchHeader).update_count(0)
75
+ return
76
+
77
+ try:
78
+ pattern = re.compile(query, re.IGNORECASE)
79
+ except re.error:
80
+ pattern = re.compile(re.escape(query), re.IGNORECASE)
81
+
82
+ self._pattern = pattern
83
+ matched = [line for line in buffer if pattern.search(line.content)]
84
+ self._results = matched
85
+
86
+ for line in matched[-500:]:
87
+ color = self._color_map.get(line.container_name, "#ffffff")
88
+ text = Text()
89
+ if self._color_full_line:
90
+ text.append(f"[{line.container_name}] ", style=f"bold {color}")
91
+ _append_highlighted(text, line.content, pattern,
92
+ base_style=color, hl_style=f"bold {color} on #333300")
93
+ else:
94
+ text.append(f"[{line.container_name}] ", style=color)
95
+ _append_highlighted(text, line.content, pattern)
96
+ lv.append(ListItem(Label(text)))
97
+
98
+ self.query_one(_SearchHeader).update_count(len(matched))
99
+
100
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
101
+ idx = event.list_view.index
102
+ if idx is None:
103
+ return
104
+ now = time.monotonic()
105
+ last = self._last_click.get(idx, 0.0)
106
+ self._last_click[idx] = now
107
+ if now - last < 0.5:
108
+ # Double-click
109
+ if idx < len(self._results):
110
+ self.post_message(self.LineSelected(self._results[idx]))
111
+
112
+ def on_input_changed(self, event: Input.Changed) -> None:
113
+ if event.input.id == "search-input":
114
+ if self._debounce_task is not None:
115
+ self._debounce_task.cancel()
116
+ self._debounce_task = asyncio.get_event_loop().create_task(
117
+ self._debounced_search(event.value)
118
+ )
119
+
120
+ async def _debounced_search(self, query: str) -> None:
121
+ await asyncio.sleep(0.3)
122
+ app = self.app
123
+ if hasattr(app, "_buffer"):
124
+ self.search(app._buffer, query) # type: ignore[union-attr]
125
+
126
+ def toggle_collapsed(self) -> None:
127
+ self._collapsed = not self._collapsed
128
+ for widget in self.query("#search-input, #search-results"):
129
+ widget.display = not self._collapsed
130
+ self.set_class(self._collapsed, "-collapsed")
131
+ if self._collapsed:
132
+ self._pre_collapse_height = self.styles.height
133
+ self.styles.height = "auto"
134
+ else:
135
+ self.styles.height = self._pre_collapse_height
136
+ self.query_one(_SearchHeader).update_collapsed(self._collapsed)
137
+
138
+ def action_close(self) -> None:
139
+ self.display = False
140
+ try:
141
+ self.app.query_one("#stream-log").focus()
142
+ except Exception:
143
+ pass
144
+
145
+
146
+ def _append_highlighted(
147
+ text: Text,
148
+ content: str,
149
+ pattern: re.Pattern,
150
+ base_style: str = "",
151
+ hl_style: str = "bold #ffff00",
152
+ ) -> None:
153
+ cursor = 0
154
+ for m in pattern.finditer(content):
155
+ if m.start() > cursor:
156
+ text.append(content[cursor:m.start()], style=base_style)
157
+ text.append(content[m.start():m.end()], style=hl_style)
158
+ cursor = m.end()
159
+ if cursor < len(content):
160
+ text.append(content[cursor:], style=base_style)
161
+
162
+
163
+ class _SearchHeader(DragResizeHeader):
164
+ def __init__(self, **kwargs) -> None:
165
+ super().__init__("", markup=True, **kwargs)
166
+ self._count = 0
167
+ self._collapsed = False
168
+ self._refresh()
169
+
170
+ def update_count(self, count: int) -> None:
171
+ self._count = count
172
+ self._refresh()
173
+
174
+ def update_collapsed(self, collapsed: bool) -> None:
175
+ self._collapsed = collapsed
176
+ self._refresh()
177
+
178
+ def _refresh(self) -> None:
179
+ arrow = "▶" if self._collapsed else "▼"
180
+ suffix = f" ({self._count} results)" if self._count else ""
181
+ hint = "" if self._collapsed else " [dim]/ or Esc to close[/dim]"
182
+ self.update(f"{arrow} Search{suffix}{hint}")
@@ -0,0 +1,205 @@
1
+ /* Viewer layout */
2
+
3
+ Checkbox {
4
+ height: auto;
5
+ margin: 0;
6
+ padding: 0 1;
7
+ background: transparent;
8
+ border: none;
9
+ }
10
+
11
+ Checkbox > .toggle--button {
12
+ color: $panel;
13
+ background: $panel-darken-1;
14
+ }
15
+
16
+ Checkbox.-on > .toggle--button {
17
+ color: $text;
18
+ background: $success;
19
+ text-style: bold;
20
+ }
21
+
22
+ Screen {
23
+ layers: base overlay;
24
+ background: $background;
25
+ }
26
+
27
+ #panels {
28
+ height: 1fr;
29
+ }
30
+
31
+ /* ── Main stream ──────────────────────────────────────────────────────────── */
32
+
33
+ MainStreamPanel {
34
+ height: 1fr;
35
+ min-height: 20;
36
+ }
37
+
38
+ _StreamHeader {
39
+ background: $panel;
40
+ color: $text;
41
+ padding: 0 1;
42
+ text-style: bold;
43
+ height: 1;
44
+ }
45
+
46
+ /* Paused: bright, high-contrast bar that flashes (see _StreamHeader). */
47
+ _StreamHeader.-paused {
48
+ background: $warning;
49
+ color: black;
50
+ text-style: bold;
51
+ }
52
+
53
+ /* Every other tick the timer flips this on, dimming the bar to create a
54
+ visible flash without moving anything. */
55
+ _StreamHeader.-paused.-blink-off {
56
+ background: $panel;
57
+ color: $warning;
58
+ }
59
+
60
+ _LogDisplay {
61
+ height: 1fr;
62
+ scrollbar-gutter: stable;
63
+ }
64
+
65
+ /* ── Search panel ─────────────────────────────────────────────────────────── */
66
+
67
+ SearchPanel {
68
+ height: 10;
69
+ min-height: 4;
70
+ border-top: tall $accent;
71
+ }
72
+
73
+ _SearchHeader {
74
+ background: $panel;
75
+ color: $accent;
76
+ padding: 0 1;
77
+ height: 1;
78
+ }
79
+
80
+ _SearchHeader:hover {
81
+ background: $accent 30%;
82
+ }
83
+
84
+ #search-input {
85
+ height: 3;
86
+ dock: top;
87
+ }
88
+
89
+ #search-results {
90
+ height: 1fr;
91
+ }
92
+
93
+ /* ── Monitor panel ────────────────────────────────────────────────────────── */
94
+
95
+ MonitorPanel {
96
+ height: 10;
97
+ min-height: 4;
98
+ border-top: tall $warning;
99
+ }
100
+
101
+ _MonitorHeader {
102
+ background: $panel;
103
+ color: $warning;
104
+ padding: 0 1;
105
+ height: 1;
106
+ }
107
+
108
+ _MonitorHeader:hover {
109
+ background: $warning 30%;
110
+ }
111
+
112
+ #monitor-scroll {
113
+ height: 1fr;
114
+ }
115
+
116
+ #monitor-list {
117
+ height: auto;
118
+ }
119
+
120
+ #monitor-scroll.wrap-on Label {
121
+ width: 1fr;
122
+ }
123
+
124
+ #monitor-scroll.wrap-off Label {
125
+ width: auto;
126
+ }
127
+
128
+ /* ── Health panel ─────────────────────────────────────────────────────────── */
129
+
130
+ HealthPanel {
131
+ height: 8;
132
+ min-height: 4;
133
+ border-top: tall $error;
134
+ }
135
+
136
+ _HealthHeader {
137
+ background: $panel;
138
+ padding: 0 1;
139
+ height: 1;
140
+ }
141
+
142
+ _HealthHeader:hover {
143
+ background: $error 30%;
144
+ }
145
+
146
+ #health-table {
147
+ height: 1fr;
148
+ }
149
+
150
+ /* ── Modal dialogs ────────────────────────────────────────────────────────── */
151
+
152
+ ConfirmDialog {
153
+ align: center middle;
154
+ background: $surface;
155
+ border: tall $error;
156
+ padding: 1 2;
157
+ width: 60;
158
+ height: auto;
159
+ layer: overlay;
160
+ }
161
+
162
+ SaveDialog {
163
+ align: center middle;
164
+ background: $surface;
165
+ border: tall $accent;
166
+ padding: 1 2;
167
+ width: 70;
168
+ height: auto;
169
+ layer: overlay;
170
+ }
171
+
172
+ ConfirmDialog Horizontal, SaveDialog Horizontal {
173
+ height: auto;
174
+ margin-top: 1;
175
+ }
176
+
177
+ ConfirmDialog Button, SaveDialog Button {
178
+ margin-right: 1;
179
+ }
180
+
181
+ /* ── ListView wrap control ───────────────────────────────────────────────── */
182
+
183
+ ListView.wrap-on ListItem Label {
184
+ width: 1fr;
185
+ }
186
+
187
+ ListView.wrap-off ListItem Label {
188
+ width: auto;
189
+ }
190
+
191
+ /* ── Collapsed state ─────────────────────────────────────────────────────── */
192
+
193
+ /* Panels shrink to header-only height when collapsed. auto (not a fixed 1)
194
+ because SearchPanel/MonitorPanel/HealthPanel also carry a border-top —
195
+ a hardcoded height: 1 leaves no room for that border row and clips the
196
+ header (with the collapse arrow) out of view entirely, along with any
197
+ way to click it back open. auto sizes to whatever the visible header
198
+ plus border chrome actually need. */
199
+ MainStreamPanel.-collapsed,
200
+ SearchPanel.-collapsed,
201
+ MonitorPanel.-collapsed,
202
+ HealthPanel.-collapsed {
203
+ height: auto;
204
+ min-height: 1;
205
+ }