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
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared viewer widgets:
|
|
3
|
+
- DragResizeHeader — mixin: click toggles collapse, vertical drag resizes
|
|
4
|
+
- StringEditModal — modal editor for F/H/M keybinds
|
|
5
|
+
- ConfirmDialog — yes/no prompt for destructive actions
|
|
6
|
+
- SaveDialog — filename prompt for Ctrl+S
|
|
7
|
+
- ContainerSelectorModal — add/remove services from the live stream
|
|
8
|
+
- PaneSizeModal — set pane heights by percentage (L keybind)
|
|
9
|
+
- JsonDetailModal — full pretty-printed JSON for a clicked log line
|
|
10
|
+
- MonitorContextModal — ± context lines around a clicked monitor hit
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual import events
|
|
16
|
+
from textual.app import ComposeResult
|
|
17
|
+
from textual.binding import Binding
|
|
18
|
+
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
|
19
|
+
from textual.content import Content
|
|
20
|
+
from textual.screen import ModalScreen
|
|
21
|
+
from textual.widgets import Button, Checkbox, Input, Label, Static
|
|
22
|
+
|
|
23
|
+
from ..config import parse_string_input
|
|
24
|
+
from ..models import LogLine
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ─── Draggable/collapsible panel header ──────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
class DragResizeHeader(Static):
|
|
30
|
+
"""
|
|
31
|
+
Mixin for panel header bars: a plain click still toggles collapse (as
|
|
32
|
+
before); a vertical drag resizes the panel by calling
|
|
33
|
+
`self.app.resize_panel(self.parent, delta)` for each row of movement.
|
|
34
|
+
The app owns the actual size math (it needs visibility into every panel
|
|
35
|
+
to enforce the main stream's minimum height), this widget just reports
|
|
36
|
+
drag deltas.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
40
|
+
super().__init__(*args, **kwargs)
|
|
41
|
+
self._drag_start_y: int | None = None
|
|
42
|
+
self._dragging = False
|
|
43
|
+
|
|
44
|
+
def on_mouse_down(self, event: events.MouseDown) -> None:
|
|
45
|
+
if getattr(self.parent, "_collapsed", False):
|
|
46
|
+
return # collapsed panels only respond to a plain click (expand)
|
|
47
|
+
self._drag_start_y = event.screen_y
|
|
48
|
+
self._dragging = False
|
|
49
|
+
self.capture_mouse()
|
|
50
|
+
|
|
51
|
+
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
52
|
+
if self._drag_start_y is None:
|
|
53
|
+
return
|
|
54
|
+
delta = event.screen_y - self._drag_start_y
|
|
55
|
+
if delta == 0:
|
|
56
|
+
return
|
|
57
|
+
self._dragging = True
|
|
58
|
+
app = self.app
|
|
59
|
+
if hasattr(app, "resize_panel"):
|
|
60
|
+
app.resize_panel(self.parent, delta)
|
|
61
|
+
self._drag_start_y = event.screen_y
|
|
62
|
+
|
|
63
|
+
def on_mouse_up(self, event: events.MouseUp) -> None:
|
|
64
|
+
if self._drag_start_y is not None:
|
|
65
|
+
self.release_mouse()
|
|
66
|
+
was_dragging = self._dragging
|
|
67
|
+
self._drag_start_y = None
|
|
68
|
+
self._dragging = False
|
|
69
|
+
if not was_dragging:
|
|
70
|
+
self.parent.toggle_collapsed() # type: ignore[union-attr]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ─── String editor modal (F / H / M) ─────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
class StringEditModal(ModalScreen[tuple[list[str], bool] | None]):
|
|
76
|
+
"""
|
|
77
|
+
Modal opened by F / H / M — edit filters, highlights, or monitors.
|
|
78
|
+
Shows saved + currently-active strings as checkboxes (check to activate,
|
|
79
|
+
uncheck to deactivate — same pattern as the wizard's Patterns tab), plus
|
|
80
|
+
an input to add brand-new ones. Dismisses with (new_strings, save_to_settings),
|
|
81
|
+
or None if cancelled.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
DEFAULT_CSS = """
|
|
85
|
+
StringEditModal {
|
|
86
|
+
align: center middle;
|
|
87
|
+
}
|
|
88
|
+
#string-edit-dialog {
|
|
89
|
+
background: $surface;
|
|
90
|
+
border: thick $accent;
|
|
91
|
+
padding: 1 2;
|
|
92
|
+
width: 70;
|
|
93
|
+
height: auto;
|
|
94
|
+
max-height: 80%;
|
|
95
|
+
}
|
|
96
|
+
#string-edit-title {
|
|
97
|
+
text-style: bold;
|
|
98
|
+
color: $accent;
|
|
99
|
+
margin-bottom: 1;
|
|
100
|
+
}
|
|
101
|
+
#string-edit-hint {
|
|
102
|
+
color: $text-muted;
|
|
103
|
+
margin-bottom: 1;
|
|
104
|
+
}
|
|
105
|
+
#string-edit-checks-scroll {
|
|
106
|
+
height: auto;
|
|
107
|
+
max-height: 14;
|
|
108
|
+
border: tall $panel;
|
|
109
|
+
margin-bottom: 1;
|
|
110
|
+
}
|
|
111
|
+
#string-edit-checks {
|
|
112
|
+
height: auto;
|
|
113
|
+
}
|
|
114
|
+
#string-edit-input {
|
|
115
|
+
margin-bottom: 1;
|
|
116
|
+
}
|
|
117
|
+
#string-edit-save {
|
|
118
|
+
margin-bottom: 1;
|
|
119
|
+
}
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
BINDINGS = [
|
|
123
|
+
Binding("escape", "cancel", "Cancel"),
|
|
124
|
+
Binding("enter", "confirm", "Confirm"),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
def __init__(self, category: str, current: list[str], saved: list[str], **kwargs) -> None:
|
|
128
|
+
super().__init__(**kwargs)
|
|
129
|
+
self._category = category
|
|
130
|
+
self._active = set(current)
|
|
131
|
+
# Saved list first (stable, familiar order), then any currently-active
|
|
132
|
+
# strings that aren't saved (e.g. typed in ad-hoc without saving) so
|
|
133
|
+
# nothing currently in effect silently disappears from the list.
|
|
134
|
+
self._options = list(saved) + [s for s in current if s not in saved]
|
|
135
|
+
|
|
136
|
+
def compose(self) -> ComposeResult:
|
|
137
|
+
with Vertical(id="string-edit-dialog"):
|
|
138
|
+
yield Static(f"Edit {self._category}", id="string-edit-title")
|
|
139
|
+
yield Static(
|
|
140
|
+
"[dim]Check to activate · Enter to apply · Esc to cancel[/dim]",
|
|
141
|
+
id="string-edit-hint",
|
|
142
|
+
markup=True,
|
|
143
|
+
)
|
|
144
|
+
yield ScrollableContainer(
|
|
145
|
+
Vertical(id="string-edit-checks"),
|
|
146
|
+
id="string-edit-checks-scroll",
|
|
147
|
+
)
|
|
148
|
+
yield Input(id="string-edit-input", placeholder="Add new (comma-separated) …")
|
|
149
|
+
yield Checkbox("Save new to saved settings", id="string-edit-save", value=True)
|
|
150
|
+
yield Horizontal(
|
|
151
|
+
Button("Apply [Enter]", variant="primary", id="string-edit-apply"),
|
|
152
|
+
Button("Cancel [Esc]", id="string-edit-cancel"),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def on_mount(self) -> None:
|
|
156
|
+
container = self.query_one("#string-edit-checks", Vertical)
|
|
157
|
+
if not self._options:
|
|
158
|
+
container.mount(Label("[dim](none saved yet)[/dim]", markup=True))
|
|
159
|
+
else:
|
|
160
|
+
for s in self._options:
|
|
161
|
+
# Content(s) (not the raw str) — Checkbox labels parse Textual
|
|
162
|
+
# markup by default, and a pattern like "[debug]" is valid
|
|
163
|
+
# (empty) markup syntax that would otherwise render blank.
|
|
164
|
+
container.mount(
|
|
165
|
+
Checkbox(Content(s), value=(s in self._active), id=f"str-{abs(hash(s))}")
|
|
166
|
+
)
|
|
167
|
+
self.query_one("#string-edit-input", Input).focus()
|
|
168
|
+
|
|
169
|
+
def _checked(self) -> list[str]:
|
|
170
|
+
result = []
|
|
171
|
+
for s in self._options:
|
|
172
|
+
try:
|
|
173
|
+
if self.query_one(f"#str-{abs(hash(s))}", Checkbox).value:
|
|
174
|
+
result.append(s)
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
def _apply(self) -> None:
|
|
180
|
+
raw = self.query_one("#string-edit-input", Input).value.strip()
|
|
181
|
+
new_strings = parse_string_input(raw) if raw else []
|
|
182
|
+
checked = self._checked()
|
|
183
|
+
merged = checked + [s for s in new_strings if s not in checked]
|
|
184
|
+
save = self.query_one("#string-edit-save", Checkbox).value and bool(new_strings)
|
|
185
|
+
self.dismiss((merged, save))
|
|
186
|
+
|
|
187
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
188
|
+
if event.button.id == "string-edit-cancel":
|
|
189
|
+
self.dismiss(None)
|
|
190
|
+
elif event.button.id == "string-edit-apply":
|
|
191
|
+
self._apply()
|
|
192
|
+
|
|
193
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
194
|
+
if event.input.id == "string-edit-input":
|
|
195
|
+
self._apply()
|
|
196
|
+
|
|
197
|
+
def action_confirm(self) -> None:
|
|
198
|
+
self._apply()
|
|
199
|
+
|
|
200
|
+
def action_cancel(self) -> None:
|
|
201
|
+
self.dismiss(None)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ─── Confirm dialog ────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
class ConfirmDialog(ModalScreen[bool]):
|
|
207
|
+
"""Simple yes/no modal for destructive actions."""
|
|
208
|
+
|
|
209
|
+
BINDINGS = [
|
|
210
|
+
Binding("y", "confirm", "Yes"),
|
|
211
|
+
Binding("n", "cancel", "No"),
|
|
212
|
+
Binding("escape", "cancel", "No"),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
def __init__(self, message: str, **kwargs) -> None:
|
|
216
|
+
super().__init__(**kwargs)
|
|
217
|
+
self._message = message
|
|
218
|
+
|
|
219
|
+
def compose(self) -> ComposeResult:
|
|
220
|
+
yield Static(self._message, id="confirm-msg")
|
|
221
|
+
yield Horizontal(
|
|
222
|
+
Button("Yes [Y]", variant="error", id="confirm-yes"),
|
|
223
|
+
Button("No [N]", variant="primary", id="confirm-no"),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
227
|
+
self.dismiss(event.button.id == "confirm-yes")
|
|
228
|
+
|
|
229
|
+
def action_confirm(self) -> None:
|
|
230
|
+
self.dismiss(True)
|
|
231
|
+
|
|
232
|
+
def action_cancel(self) -> None:
|
|
233
|
+
self.dismiss(False)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ─── Save dialog ──────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
class SaveDialog(ModalScreen[str | None]):
|
|
239
|
+
"""Filename prompt for Ctrl+S log save."""
|
|
240
|
+
|
|
241
|
+
BINDINGS = [
|
|
242
|
+
Binding("escape", "cancel", "Cancel"),
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
def __init__(self, default_name: str, **kwargs) -> None:
|
|
246
|
+
super().__init__(**kwargs)
|
|
247
|
+
self._default = default_name
|
|
248
|
+
|
|
249
|
+
def compose(self) -> ComposeResult:
|
|
250
|
+
yield Static("Save log buffer to file: [dim]Enter to save · Esc to cancel[/dim]", id="save-label", markup=True)
|
|
251
|
+
yield Input(value=self._default, id="save-input")
|
|
252
|
+
yield Horizontal(
|
|
253
|
+
Button("Save [Enter]", variant="primary", id="save-go"),
|
|
254
|
+
Button("Cancel [Esc]", id="save-cancel"),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def on_mount(self) -> None:
|
|
258
|
+
inp = self.query_one("#save-input", Input)
|
|
259
|
+
inp.focus()
|
|
260
|
+
inp.cursor_position = len(self._default)
|
|
261
|
+
|
|
262
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
263
|
+
if event.button.id == "save-cancel":
|
|
264
|
+
self.dismiss(None)
|
|
265
|
+
elif event.button.id == "save-go":
|
|
266
|
+
path = self.query_one("#save-input", Input).value.strip()
|
|
267
|
+
self.dismiss(path or None)
|
|
268
|
+
|
|
269
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
270
|
+
path = event.value.strip()
|
|
271
|
+
self.dismiss(path or None)
|
|
272
|
+
|
|
273
|
+
def action_cancel(self) -> None:
|
|
274
|
+
self.dismiss(None)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ─── Container selector modal (P keybind) ──────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
class ContainerSelectorModal(ModalScreen[list[str] | None]):
|
|
280
|
+
"""
|
|
281
|
+
Modal for adding/removing services from the live log stream.
|
|
282
|
+
Returns the new list of selected service names, or None if cancelled.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
DEFAULT_CSS = """
|
|
286
|
+
ContainerSelectorModal {
|
|
287
|
+
align: center middle;
|
|
288
|
+
}
|
|
289
|
+
#container-selector-dialog {
|
|
290
|
+
background: $surface;
|
|
291
|
+
border: thick $accent;
|
|
292
|
+
padding: 1 2;
|
|
293
|
+
width: 60;
|
|
294
|
+
height: auto;
|
|
295
|
+
max-height: 80%;
|
|
296
|
+
}
|
|
297
|
+
#container-selector-title {
|
|
298
|
+
text-style: bold;
|
|
299
|
+
color: $accent;
|
|
300
|
+
margin-bottom: 1;
|
|
301
|
+
}
|
|
302
|
+
#container-selector-hint {
|
|
303
|
+
color: $text-muted;
|
|
304
|
+
margin-bottom: 1;
|
|
305
|
+
}
|
|
306
|
+
#container-checks-scroll {
|
|
307
|
+
height: auto;
|
|
308
|
+
max-height: 20;
|
|
309
|
+
border: tall $panel;
|
|
310
|
+
margin-bottom: 1;
|
|
311
|
+
}
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
BINDINGS = [
|
|
315
|
+
Binding("escape", "cancel", "Cancel"),
|
|
316
|
+
Binding("enter", "confirm", "Confirm"),
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
def __init__(
|
|
320
|
+
self,
|
|
321
|
+
project: str,
|
|
322
|
+
active_services: list[str],
|
|
323
|
+
**kwargs,
|
|
324
|
+
) -> None:
|
|
325
|
+
super().__init__(**kwargs)
|
|
326
|
+
self._project = project
|
|
327
|
+
self._active = set(active_services)
|
|
328
|
+
self._all_services: list[tuple[str, int]] = [] # (name, container_count)
|
|
329
|
+
|
|
330
|
+
def compose(self) -> ComposeResult:
|
|
331
|
+
with Vertical(id="container-selector-dialog"):
|
|
332
|
+
yield Static("Manage log streams", id="container-selector-title")
|
|
333
|
+
yield Static(
|
|
334
|
+
"[dim]Check services to stream · Esc to cancel · Enter to apply[/dim]",
|
|
335
|
+
id="container-selector-hint",
|
|
336
|
+
markup=True,
|
|
337
|
+
)
|
|
338
|
+
yield ScrollableContainer(
|
|
339
|
+
Vertical(id="container-checks"),
|
|
340
|
+
id="container-checks-scroll",
|
|
341
|
+
)
|
|
342
|
+
yield Horizontal(
|
|
343
|
+
Button("Apply [Enter]", variant="primary", id="container-sel-apply"),
|
|
344
|
+
Button("Cancel [Esc]", id="container-sel-cancel"),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def on_mount(self) -> None:
|
|
348
|
+
from .. import compose as k
|
|
349
|
+
try:
|
|
350
|
+
deps = k.get_services(self._project)
|
|
351
|
+
self._all_services = [(d.name, d.container_count) for d in deps]
|
|
352
|
+
except Exception:
|
|
353
|
+
self._all_services = [(name, 0) for name in self._active]
|
|
354
|
+
|
|
355
|
+
container = self.query_one("#container-checks", Vertical)
|
|
356
|
+
for name, count in self._all_services:
|
|
357
|
+
label = f"{name} ({count} container{'s' if count != 1 else ''})"
|
|
358
|
+
container.mount(
|
|
359
|
+
Checkbox(label, value=(name in self._active), id=f"pdep-{name}")
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _selected(self) -> list[str]:
|
|
363
|
+
result = []
|
|
364
|
+
for name, _ in self._all_services:
|
|
365
|
+
try:
|
|
366
|
+
cb = self.query_one(f"#pdep-{name}", Checkbox)
|
|
367
|
+
if cb.value:
|
|
368
|
+
result.append(name)
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
return result
|
|
372
|
+
|
|
373
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
374
|
+
if event.button.id == "container-sel-apply":
|
|
375
|
+
self.dismiss(self._selected())
|
|
376
|
+
elif event.button.id == "container-sel-cancel":
|
|
377
|
+
self.dismiss(None)
|
|
378
|
+
|
|
379
|
+
def action_confirm(self) -> None:
|
|
380
|
+
self.dismiss(self._selected())
|
|
381
|
+
|
|
382
|
+
def action_cancel(self) -> None:
|
|
383
|
+
self.dismiss(None)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ─── Pane size modal (L keybind) ──────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
class PaneSizeModal(ModalScreen[dict[str, int] | None]):
|
|
389
|
+
"""
|
|
390
|
+
Keyboard/click-driven alternative to dragging a panel's header border —
|
|
391
|
+
set each currently-visible, non-collapsed side panel's height as a
|
|
392
|
+
percentage of the viewer. Returns {label: percent} on Apply, or None if
|
|
393
|
+
cancelled. The app validates and applies the result (it owns the
|
|
394
|
+
MainStreamPanel-minimum-height math already used by drag-resize).
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
DEFAULT_CSS = """
|
|
398
|
+
PaneSizeModal {
|
|
399
|
+
align: center middle;
|
|
400
|
+
}
|
|
401
|
+
#pane-size-dialog {
|
|
402
|
+
background: $surface;
|
|
403
|
+
border: thick $accent;
|
|
404
|
+
padding: 1 2;
|
|
405
|
+
width: 50;
|
|
406
|
+
height: auto;
|
|
407
|
+
}
|
|
408
|
+
#pane-size-title {
|
|
409
|
+
text-style: bold;
|
|
410
|
+
color: $accent;
|
|
411
|
+
margin-bottom: 1;
|
|
412
|
+
}
|
|
413
|
+
#pane-size-hint {
|
|
414
|
+
color: $text-muted;
|
|
415
|
+
margin-bottom: 1;
|
|
416
|
+
}
|
|
417
|
+
.pane-size-row {
|
|
418
|
+
height: auto;
|
|
419
|
+
margin-bottom: 1;
|
|
420
|
+
}
|
|
421
|
+
.pane-size-row Label {
|
|
422
|
+
width: 14;
|
|
423
|
+
padding-top: 1;
|
|
424
|
+
}
|
|
425
|
+
.pane-size-row Input {
|
|
426
|
+
width: 10;
|
|
427
|
+
}
|
|
428
|
+
.pane-size-row Static {
|
|
429
|
+
width: auto;
|
|
430
|
+
padding: 1 0 0 1;
|
|
431
|
+
}
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
BINDINGS = [
|
|
435
|
+
Binding("escape", "cancel", "Cancel"),
|
|
436
|
+
Binding("enter", "confirm", "Confirm"),
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
def __init__(self, entries: list[tuple[str, int]], **kwargs) -> None:
|
|
440
|
+
"""entries: (label, current_percent) for each visible, non-collapsed side panel."""
|
|
441
|
+
super().__init__(**kwargs)
|
|
442
|
+
self._entries = entries
|
|
443
|
+
|
|
444
|
+
def compose(self) -> ComposeResult:
|
|
445
|
+
with Vertical(id="pane-size-dialog"):
|
|
446
|
+
yield Static("Pane sizes", id="pane-size-title")
|
|
447
|
+
yield Static(
|
|
448
|
+
"[dim]Percent of viewer height for each visible pane · "
|
|
449
|
+
"the main stream takes whatever's left · "
|
|
450
|
+
"Enter to apply · Esc to cancel[/dim]",
|
|
451
|
+
id="pane-size-hint",
|
|
452
|
+
markup=True,
|
|
453
|
+
)
|
|
454
|
+
for label, pct in self._entries:
|
|
455
|
+
with Horizontal(classes="pane-size-row"):
|
|
456
|
+
yield Label(label)
|
|
457
|
+
yield Input(value=str(pct), id=f"pane-size-{label.lower()}", type="integer")
|
|
458
|
+
yield Static("%")
|
|
459
|
+
yield Horizontal(
|
|
460
|
+
Button("Apply [Enter]", variant="primary", id="pane-size-apply"),
|
|
461
|
+
Button("Cancel [Esc]", id="pane-size-cancel"),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def on_mount(self) -> None:
|
|
465
|
+
if self._entries:
|
|
466
|
+
first_label = self._entries[0][0]
|
|
467
|
+
self.query_one(f"#pane-size-{first_label.lower()}", Input).focus()
|
|
468
|
+
|
|
469
|
+
def _collect(self) -> dict[str, int] | None:
|
|
470
|
+
result: dict[str, int] = {}
|
|
471
|
+
for label, _pct in self._entries:
|
|
472
|
+
raw = self.query_one(f"#pane-size-{label.lower()}", Input).value.strip()
|
|
473
|
+
try:
|
|
474
|
+
pct = int(raw)
|
|
475
|
+
except ValueError:
|
|
476
|
+
self.notify(f"{label}: enter a whole number percentage.", severity="warning")
|
|
477
|
+
return None
|
|
478
|
+
if not (0 <= pct <= 100):
|
|
479
|
+
self.notify(f"{label}: percentage must be between 0 and 100.", severity="warning")
|
|
480
|
+
return None
|
|
481
|
+
result[label] = pct
|
|
482
|
+
return result
|
|
483
|
+
|
|
484
|
+
def _apply(self) -> None:
|
|
485
|
+
result = self._collect()
|
|
486
|
+
if result is not None:
|
|
487
|
+
self.dismiss(result)
|
|
488
|
+
|
|
489
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
490
|
+
if event.button.id == "pane-size-cancel":
|
|
491
|
+
self.dismiss(None)
|
|
492
|
+
elif event.button.id == "pane-size-apply":
|
|
493
|
+
self._apply()
|
|
494
|
+
|
|
495
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
496
|
+
self._apply()
|
|
497
|
+
|
|
498
|
+
def action_confirm(self) -> None:
|
|
499
|
+
self._apply()
|
|
500
|
+
|
|
501
|
+
def action_cancel(self) -> None:
|
|
502
|
+
self.dismiss(None)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ─── JSON detail modal (Enter, after clicking a JSON line) ───────────────────
|
|
506
|
+
|
|
507
|
+
class JsonDetailModal(ModalScreen[None]):
|
|
508
|
+
"""Full pretty-printed JSON for a detected JSON log line."""
|
|
509
|
+
|
|
510
|
+
DEFAULT_CSS = """
|
|
511
|
+
JsonDetailModal {
|
|
512
|
+
align: center middle;
|
|
513
|
+
}
|
|
514
|
+
#json-detail-dialog {
|
|
515
|
+
background: $surface;
|
|
516
|
+
border: thick $accent;
|
|
517
|
+
padding: 1 2;
|
|
518
|
+
width: 80%;
|
|
519
|
+
height: 80%;
|
|
520
|
+
}
|
|
521
|
+
#json-detail-title {
|
|
522
|
+
text-style: bold;
|
|
523
|
+
color: $accent;
|
|
524
|
+
margin-bottom: 1;
|
|
525
|
+
}
|
|
526
|
+
#json-detail-scroll {
|
|
527
|
+
height: 1fr;
|
|
528
|
+
border: tall $panel;
|
|
529
|
+
}
|
|
530
|
+
#json-detail-body {
|
|
531
|
+
width: auto;
|
|
532
|
+
padding: 0 1;
|
|
533
|
+
}
|
|
534
|
+
#json-detail-hint {
|
|
535
|
+
color: $text-muted;
|
|
536
|
+
margin-top: 1;
|
|
537
|
+
}
|
|
538
|
+
"""
|
|
539
|
+
|
|
540
|
+
BINDINGS = [
|
|
541
|
+
Binding("escape", "close", "Close"),
|
|
542
|
+
Binding("enter", "close", "Close"),
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
def __init__(self, pretty_json: str, **kwargs) -> None:
|
|
546
|
+
super().__init__(**kwargs)
|
|
547
|
+
self._pretty = pretty_json
|
|
548
|
+
|
|
549
|
+
def compose(self) -> ComposeResult:
|
|
550
|
+
with Vertical(id="json-detail-dialog"):
|
|
551
|
+
yield Static("JSON detail", id="json-detail-title")
|
|
552
|
+
with ScrollableContainer(id="json-detail-scroll"):
|
|
553
|
+
# markup=False — JSON is full of [ ] { } which Rich/Textual
|
|
554
|
+
# would otherwise try to parse as markup tags.
|
|
555
|
+
yield Static(self._pretty, id="json-detail-body", markup=False)
|
|
556
|
+
yield Static("[dim]Esc or Enter to close[/dim]", id="json-detail-hint", markup=True)
|
|
557
|
+
|
|
558
|
+
def action_close(self) -> None:
|
|
559
|
+
self.dismiss(None)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# ─── Monitor context modal (Enter, after clicking a monitor hit) ─────────────
|
|
563
|
+
|
|
564
|
+
class MonitorContextModal(ModalScreen[None]):
|
|
565
|
+
"""
|
|
566
|
+
± N lines of same-container context around a clicked monitor hit, like
|
|
567
|
+
`grep -C`. Starts small and grows/shrinks live via +/- — filtering the
|
|
568
|
+
container's lines out of the buffer already costs the same regardless of how
|
|
569
|
+
many end up shown, and the body scrolls, so there's no real reason to
|
|
570
|
+
hard-cap the window at a small fixed number.
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
DEFAULT_CONTEXT = 3
|
|
574
|
+
STEP = 5
|
|
575
|
+
MAX_CONTEXT = 500 # sanity cap, not a meaningful UX limit
|
|
576
|
+
|
|
577
|
+
DEFAULT_CSS = """
|
|
578
|
+
MonitorContextModal {
|
|
579
|
+
align: center middle;
|
|
580
|
+
}
|
|
581
|
+
#monitor-context-dialog {
|
|
582
|
+
background: $surface;
|
|
583
|
+
border: thick $accent;
|
|
584
|
+
padding: 1 2;
|
|
585
|
+
width: 90%;
|
|
586
|
+
height: 80%;
|
|
587
|
+
}
|
|
588
|
+
#monitor-context-title {
|
|
589
|
+
text-style: bold;
|
|
590
|
+
color: $accent;
|
|
591
|
+
margin-bottom: 1;
|
|
592
|
+
}
|
|
593
|
+
#monitor-context-scroll {
|
|
594
|
+
height: 1fr;
|
|
595
|
+
border: tall $panel;
|
|
596
|
+
}
|
|
597
|
+
#monitor-context-body {
|
|
598
|
+
width: auto;
|
|
599
|
+
padding: 0 1;
|
|
600
|
+
}
|
|
601
|
+
#monitor-context-hint {
|
|
602
|
+
color: $text-muted;
|
|
603
|
+
margin-top: 1;
|
|
604
|
+
}
|
|
605
|
+
"""
|
|
606
|
+
|
|
607
|
+
BINDINGS = [
|
|
608
|
+
Binding("escape", "close", "Close"),
|
|
609
|
+
Binding("enter", "close", "Close"),
|
|
610
|
+
Binding("+", "grow", "More context", show=True),
|
|
611
|
+
Binding("=", "grow", "More context", show=False),
|
|
612
|
+
Binding("-", "shrink", "Less context", show=True),
|
|
613
|
+
]
|
|
614
|
+
|
|
615
|
+
def __init__(self, container_lines: list[LogLine], idx: int, container_name: str, **kwargs) -> None:
|
|
616
|
+
"""container_lines: every buffered line from this hit's container, in order.
|
|
617
|
+
idx: the hit's own position within container_lines."""
|
|
618
|
+
super().__init__(**kwargs)
|
|
619
|
+
self._container_lines = container_lines
|
|
620
|
+
self._idx = idx
|
|
621
|
+
self._container_name = container_name
|
|
622
|
+
self._context_n = self.DEFAULT_CONTEXT
|
|
623
|
+
|
|
624
|
+
def compose(self) -> ComposeResult:
|
|
625
|
+
with Vertical(id="monitor-context-dialog"):
|
|
626
|
+
# Scoped to one container — the title makes that explicit, since the
|
|
627
|
+
# lines shown are neighbors in that container's own stream, not
|
|
628
|
+
# whatever else was arriving around the same wall-clock moment.
|
|
629
|
+
yield Static(f"Monitor hit context — {self._container_name}", id="monitor-context-title")
|
|
630
|
+
with ScrollableContainer(id="monitor-context-scroll"):
|
|
631
|
+
yield Static(self._build_body(), id="monitor-context-body", markup=False)
|
|
632
|
+
yield Static(self._hint(), id="monitor-context-hint", markup=True)
|
|
633
|
+
|
|
634
|
+
def _build_body(self) -> Text:
|
|
635
|
+
start = max(0, self._idx - self._context_n)
|
|
636
|
+
end = self._idx + self._context_n + 1
|
|
637
|
+
body = Text()
|
|
638
|
+
for i, line in enumerate(self._container_lines[start:end]):
|
|
639
|
+
if i:
|
|
640
|
+
body.append("\n")
|
|
641
|
+
if start + i == self._idx:
|
|
642
|
+
body.append("▶ ", style="bold #ff8800")
|
|
643
|
+
body.append(line.content, style="bold white on #2a2a00")
|
|
644
|
+
else:
|
|
645
|
+
body.append(" ")
|
|
646
|
+
body.append(line.content, style="dim")
|
|
647
|
+
return body
|
|
648
|
+
|
|
649
|
+
def _hint(self) -> str:
|
|
650
|
+
return (
|
|
651
|
+
f"[dim]±{self._context_n} lines · +/- for more or less · "
|
|
652
|
+
"Esc or Enter to close[/dim]"
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
def _refresh(self) -> None:
|
|
656
|
+
self.query_one("#monitor-context-body", Static).update(self._build_body())
|
|
657
|
+
self.query_one("#monitor-context-hint", Static).update(self._hint())
|
|
658
|
+
|
|
659
|
+
def action_grow(self) -> None:
|
|
660
|
+
self._context_n = min(self._context_n + self.STEP, self.MAX_CONTEXT)
|
|
661
|
+
self._refresh()
|
|
662
|
+
|
|
663
|
+
def action_shrink(self) -> None:
|
|
664
|
+
self._context_n = max(self._context_n - self.STEP, 0)
|
|
665
|
+
self._refresh()
|
|
666
|
+
|
|
667
|
+
def action_close(self) -> None:
|
|
668
|
+
self.dismiss(None)
|
|
File without changes
|
docker_orb/wizard/app.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
docker-orb setup wizard — single-page form.
|
|
3
|
+
Returns a SessionConfig on completion or None if the user cancels.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from textual.app import App
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
|
|
10
|
+
from .. import _scrollbar
|
|
11
|
+
from ..models import SessionConfig
|
|
12
|
+
from .screens import SinglePageWizard
|
|
13
|
+
|
|
14
|
+
_scrollbar.install()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WizardApp(App[SessionConfig | None]):
|
|
18
|
+
|
|
19
|
+
TITLE = "docker-orb setup"
|
|
20
|
+
CSS_PATH = "wizard.tcss"
|
|
21
|
+
|
|
22
|
+
BINDINGS = [
|
|
23
|
+
Binding("ctrl+q", "quit", "Cancel", show=True),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def __init__(self, initial_project: str = "default") -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._initial_project = initial_project
|
|
29
|
+
|
|
30
|
+
def on_mount(self) -> None:
|
|
31
|
+
self.push_screen(
|
|
32
|
+
SinglePageWizard(initial_project=self._initial_project),
|
|
33
|
+
self._on_done,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def _on_done(self, config: SessionConfig | None) -> None:
|
|
37
|
+
self.exit(config)
|
|
38
|
+
|
|
39
|
+
def action_quit(self) -> None:
|
|
40
|
+
self.exit(None)
|