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,692 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Three-tab wizard:
|
|
3
|
+
Tab 1 · Targets — project, services, log mode
|
|
4
|
+
Tab 2 · Strings — filters, highlights, monitors
|
|
5
|
+
Tab 3 · Options — health, display, save config, launch
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
|
12
|
+
from textual.content import Content
|
|
13
|
+
from textual.screen import Screen
|
|
14
|
+
from textual.widgets import (
|
|
15
|
+
Button,
|
|
16
|
+
Checkbox,
|
|
17
|
+
Input,
|
|
18
|
+
Label,
|
|
19
|
+
RadioButton,
|
|
20
|
+
RadioSet,
|
|
21
|
+
Rule,
|
|
22
|
+
Select,
|
|
23
|
+
Static,
|
|
24
|
+
TabbedContent,
|
|
25
|
+
TabPane,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from ..models import HealthConfig, LogMode, SessionConfig
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _section(title: str) -> Static:
|
|
32
|
+
return Static(title, classes="section-title")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SinglePageWizard(Screen[SessionConfig | None]):
|
|
36
|
+
"""Three-tab setup wizard. Class name kept for compatibility with wizard/app.py."""
|
|
37
|
+
|
|
38
|
+
BINDINGS = [
|
|
39
|
+
Binding("ctrl+q", "cancel", "Cancel", show=True),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def __init__(self, initial_project: str = "default") -> None:
|
|
43
|
+
super().__init__()
|
|
44
|
+
self._initial_project = initial_project
|
|
45
|
+
self._services: list[str] = []
|
|
46
|
+
self._container_counts: dict[str, int] = {}
|
|
47
|
+
# Which services should start checked on the *next* checkbox mount.
|
|
48
|
+
# None = default all-checked. Set by _apply_saved_config so the mount
|
|
49
|
+
# (which may happen asynchronously, racing the caller) reflects the
|
|
50
|
+
# saved config instead of blindly checking everything. Consumed once.
|
|
51
|
+
self._pending_services: set[str] | None = None
|
|
52
|
+
# Project most recently passed to _refresh_services(). Lets
|
|
53
|
+
# on_select_changed skip a redundant refresh when Select.Changed for
|
|
54
|
+
# proj-select is still queued after _apply_saved_config already
|
|
55
|
+
# refreshed for that same project itself.
|
|
56
|
+
self._last_refreshed_project: str | None = None
|
|
57
|
+
self._saved_filters: list[str] = []
|
|
58
|
+
self._saved_highlights: list[str] = []
|
|
59
|
+
self._saved_monitors: list[str] = []
|
|
60
|
+
self._saved_configs: list[str] = []
|
|
61
|
+
self._known_projects: list[str] = []
|
|
62
|
+
self._initializing_ns = False
|
|
63
|
+
|
|
64
|
+
# ── Layout ────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def compose(self) -> ComposeResult:
|
|
67
|
+
yield Static("docker-orb · setup", id="wizard-title")
|
|
68
|
+
|
|
69
|
+
with TabbedContent(id="wizard-tabs"):
|
|
70
|
+
|
|
71
|
+
# ── Tab 1: Targets ────────────────────────────────────────────────
|
|
72
|
+
with TabPane("Targets", id="tab-targets"):
|
|
73
|
+
with ScrollableContainer(id="targets-scroll"):
|
|
74
|
+
|
|
75
|
+
yield _section("Load saved config")
|
|
76
|
+
yield Select(
|
|
77
|
+
options=[],
|
|
78
|
+
prompt="Select a saved config to pre-fill …",
|
|
79
|
+
id="cfg-select",
|
|
80
|
+
allow_blank=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
yield Rule()
|
|
84
|
+
yield _section("Project")
|
|
85
|
+
yield Static(
|
|
86
|
+
f"compose context: {self._initial_project}",
|
|
87
|
+
id="proj-header",
|
|
88
|
+
classes="field-hint",
|
|
89
|
+
)
|
|
90
|
+
yield Select(
|
|
91
|
+
options=[(self._initial_project, self._initial_project)],
|
|
92
|
+
id="proj-select",
|
|
93
|
+
allow_blank=False,
|
|
94
|
+
value=self._initial_project,
|
|
95
|
+
)
|
|
96
|
+
yield Input(placeholder="Or type a project manually …", id="proj-input")
|
|
97
|
+
|
|
98
|
+
yield Rule()
|
|
99
|
+
yield _section("Services to watch")
|
|
100
|
+
with Horizontal(classes="row"):
|
|
101
|
+
yield Button("Select all", id="dep-all", classes="btn-small")
|
|
102
|
+
yield Button("Clear all", id="dep-clear", classes="btn-small")
|
|
103
|
+
yield ScrollableContainer(
|
|
104
|
+
Vertical(id="dep-checks"),
|
|
105
|
+
id="dep-scroll",
|
|
106
|
+
)
|
|
107
|
+
yield Static(
|
|
108
|
+
"No services found — select a project and click Refresh",
|
|
109
|
+
id="dep-empty",
|
|
110
|
+
classes="field-warn",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
yield Rule()
|
|
114
|
+
yield _section("Log mode")
|
|
115
|
+
with RadioSet(id="mode-radio"):
|
|
116
|
+
yield RadioButton("Stream — live tail", value=True, id="mode-stream")
|
|
117
|
+
yield RadioButton("Dump — fetch existing logs then stop", id="mode-dump")
|
|
118
|
+
with Vertical(id="dump-opts"):
|
|
119
|
+
yield Label("Last N lines:", classes="field-label")
|
|
120
|
+
yield Input(placeholder="e.g. 500", id="dump-tail")
|
|
121
|
+
yield Label("— or since:", classes="field-label")
|
|
122
|
+
yield Input(placeholder="e.g. 1h, 30m", id="dump-since")
|
|
123
|
+
with Vertical(id="stream-opts"):
|
|
124
|
+
yield Label("Since:", classes="field-label")
|
|
125
|
+
yield Input(placeholder="e.g. 1h, 30m — leave blank for new lines only", id="stream-since")
|
|
126
|
+
yield Static(
|
|
127
|
+
"[dim]Leave blank (or enter 0) to only collect new lines from the "
|
|
128
|
+
"moment the stream starts. Set a duration to also pull in recent "
|
|
129
|
+
"history when it starts.[/dim]\n"
|
|
130
|
+
"[dim]Note: with a duration set, nothing is displayed until every "
|
|
131
|
+
"container's backlog is fetched and sorted into order — a long duration "
|
|
132
|
+
"or high-volume containers can mean a delay of several seconds before the "
|
|
133
|
+
"stream first appears.[/dim]",
|
|
134
|
+
classes="field-hint",
|
|
135
|
+
markup=True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
with Horizontal(classes="row nav-row"):
|
|
139
|
+
yield Button("Cancel", variant="error", id="cancel-1", classes="btn-cancel")
|
|
140
|
+
yield Button("Next →", variant="primary", id="next-1", classes="btn-next")
|
|
141
|
+
|
|
142
|
+
# ── Tab 2: Patterns ───────────────────────────────────────────────
|
|
143
|
+
with TabPane("Patterns", id="tab-strings"):
|
|
144
|
+
with ScrollableContainer(id="strings-scroll"):
|
|
145
|
+
|
|
146
|
+
with Horizontal(classes="row", id="patterns-edit-row"):
|
|
147
|
+
yield Static(
|
|
148
|
+
"Saved patterns are stored in ~/.config/docker-orb/strings.yaml",
|
|
149
|
+
classes="field-hint",
|
|
150
|
+
id="patterns-file-hint",
|
|
151
|
+
)
|
|
152
|
+
yield Button("Edit in text editor", id="edit-patterns-file", classes="btn-small", variant="primary")
|
|
153
|
+
yield Button("↺ Reload", id="reload-patterns", classes="btn-small")
|
|
154
|
+
|
|
155
|
+
yield Rule()
|
|
156
|
+
yield _section("Filters — hide matching lines")
|
|
157
|
+
yield Label("Saved (check to activate):", classes="field-label saved-label")
|
|
158
|
+
yield ScrollableContainer(Vertical(id="filter-checks"), id="filter-scroll")
|
|
159
|
+
yield Label("Add new:", classes="field-label new-label")
|
|
160
|
+
yield Input(placeholder="e.g. ERROR, /5[0-9]{2}/ (comma-separated)", id="filter-input")
|
|
161
|
+
with Horizontal(classes="row option-row"):
|
|
162
|
+
yield Checkbox("Save new to list", id="filter-save", value=True)
|
|
163
|
+
yield Checkbox("Ignore case", id="filter-icase", value=False)
|
|
164
|
+
|
|
165
|
+
yield Rule()
|
|
166
|
+
yield _section("Highlights — emphasise matching lines")
|
|
167
|
+
yield Label("Saved (check to activate):", classes="field-label saved-label")
|
|
168
|
+
yield ScrollableContainer(Vertical(id="hl-checks"), id="hl-scroll")
|
|
169
|
+
yield Label("Add new:", classes="field-label new-label")
|
|
170
|
+
yield Input(placeholder="e.g. WARN, brute force (comma-separated)", id="hl-input")
|
|
171
|
+
with Horizontal(classes="row option-row"):
|
|
172
|
+
yield Checkbox("Save new to list", id="hl-save", value=True)
|
|
173
|
+
yield Checkbox("Ignore case", id="hl-icase", value=False)
|
|
174
|
+
|
|
175
|
+
with Vertical(id="monitor-section"):
|
|
176
|
+
yield Rule()
|
|
177
|
+
yield _section("Monitors — copy matches to monitor panel")
|
|
178
|
+
yield Label("Saved (check to activate):", classes="field-label saved-label")
|
|
179
|
+
yield ScrollableContainer(Vertical(id="mon-checks"), id="mon-scroll")
|
|
180
|
+
yield Label("Add new:", classes="field-label new-label")
|
|
181
|
+
yield Input(placeholder="e.g. job failed, OOM (comma-separated)", id="mon-input")
|
|
182
|
+
with Horizontal(classes="row option-row"):
|
|
183
|
+
yield Checkbox("Save new to list", id="mon-save", value=True)
|
|
184
|
+
yield Checkbox("Ignore case", id="mon-icase", value=False)
|
|
185
|
+
|
|
186
|
+
with Horizontal(classes="row nav-row"):
|
|
187
|
+
yield Button("Cancel", variant="error", id="cancel-2", classes="btn-cancel")
|
|
188
|
+
yield Button("← Back", variant="default", id="back-2", classes="btn-back")
|
|
189
|
+
yield Button("Next →", variant="primary", id="next-2", classes="btn-next")
|
|
190
|
+
|
|
191
|
+
# ── Tab 3: Options ────────────────────────────────────────────────
|
|
192
|
+
with TabPane("Options", id="tab-options"):
|
|
193
|
+
with ScrollableContainer(id="options-scroll"):
|
|
194
|
+
|
|
195
|
+
with Vertical(id="health-section"):
|
|
196
|
+
yield _section("Container health monitoring")
|
|
197
|
+
yield Checkbox("Enable health panel", id="health-enable", value=False)
|
|
198
|
+
with Vertical(id="health-opts"):
|
|
199
|
+
yield Label("Check interval (minutes, min 1):", classes="field-label")
|
|
200
|
+
yield Input("5", id="health-interval")
|
|
201
|
+
|
|
202
|
+
yield Rule()
|
|
203
|
+
yield _section("Display")
|
|
204
|
+
yield Checkbox(
|
|
205
|
+
"Color full line (default: color container name only)",
|
|
206
|
+
id="color-full-line",
|
|
207
|
+
value=False,
|
|
208
|
+
)
|
|
209
|
+
yield Checkbox(
|
|
210
|
+
"Line wrap (default: on)",
|
|
211
|
+
id="line-wrap",
|
|
212
|
+
value=True,
|
|
213
|
+
)
|
|
214
|
+
yield Checkbox(
|
|
215
|
+
"Format JSON logs (extract level/message/time; raw otherwise)",
|
|
216
|
+
id="json-format",
|
|
217
|
+
value=False,
|
|
218
|
+
)
|
|
219
|
+
yield Checkbox(
|
|
220
|
+
"Collapse repeated lines (consecutive identical lines from the "
|
|
221
|
+
"same container become 'last line repeated N times')",
|
|
222
|
+
id="collapse-repeats",
|
|
223
|
+
value=False,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
yield Rule()
|
|
227
|
+
yield _section("Save config for reuse")
|
|
228
|
+
yield Label("Name — leave blank to skip:", classes="field-label")
|
|
229
|
+
yield Input(placeholder="e.g. prod-debug", id="save-name")
|
|
230
|
+
|
|
231
|
+
with Horizontal(classes="row nav-row"):
|
|
232
|
+
yield Button("Cancel", variant="error", id="cancel", classes="btn-cancel")
|
|
233
|
+
yield Button("← Back", variant="default", id="back-3", classes="btn-back")
|
|
234
|
+
yield Button("Launch →", variant="primary", id="launch", classes="btn-next")
|
|
235
|
+
|
|
236
|
+
yield Static("Ctrl+Q to cancel at any time", id="wizard-footer")
|
|
237
|
+
|
|
238
|
+
# ── Mount ─────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
def on_mount(self) -> None:
|
|
241
|
+
self.query_one("#dump-opts").display = False
|
|
242
|
+
self.query_one("#stream-opts").display = True
|
|
243
|
+
self.query_one("#health-opts").display = False
|
|
244
|
+
self._load_saved_strings()
|
|
245
|
+
self._populate_projects()
|
|
246
|
+
# Explicitly focus the first form field so the wizard opens ready to use
|
|
247
|
+
# rather than leaving focus on the TabbedContent tab strip.
|
|
248
|
+
self.call_after_refresh(lambda: self.set_focus(self.query_one("#cfg-select", Select)))
|
|
249
|
+
|
|
250
|
+
# ── Project helpers ─────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def _populate_projects(self) -> None:
|
|
253
|
+
from .. import compose as k
|
|
254
|
+
try:
|
|
255
|
+
projects = k.get_projects()
|
|
256
|
+
except Exception:
|
|
257
|
+
projects = [self._initial_project]
|
|
258
|
+
|
|
259
|
+
self._known_projects = projects
|
|
260
|
+
self._initializing_ns = True
|
|
261
|
+
sel = self.query_one("#proj-select", Select)
|
|
262
|
+
options = [(proj, proj) for proj in projects]
|
|
263
|
+
sel.set_options(options)
|
|
264
|
+
|
|
265
|
+
if self._initial_project in projects:
|
|
266
|
+
sel.value = self._initial_project
|
|
267
|
+
elif projects:
|
|
268
|
+
sel.value = projects[0]
|
|
269
|
+
|
|
270
|
+
self.query_one("#proj-header", Static).update(
|
|
271
|
+
f"compose context: {self._initial_project}"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _after_init() -> None:
|
|
275
|
+
self._initializing_ns = False
|
|
276
|
+
self._refresh_services()
|
|
277
|
+
self._refresh_saved_configs()
|
|
278
|
+
|
|
279
|
+
self.call_after_refresh(_after_init)
|
|
280
|
+
|
|
281
|
+
def _active_project(self) -> str:
|
|
282
|
+
manual = self.query_one("#proj-input", Input).value.strip()
|
|
283
|
+
if manual:
|
|
284
|
+
return manual
|
|
285
|
+
val = self.query_one("#proj-select", Select).value
|
|
286
|
+
if val and val is not Select.BLANK:
|
|
287
|
+
return str(val)
|
|
288
|
+
return self._initial_project
|
|
289
|
+
|
|
290
|
+
def _refresh_services(self) -> None:
|
|
291
|
+
from .. import compose as k
|
|
292
|
+
proj = self._active_project()
|
|
293
|
+
self._last_refreshed_project = proj
|
|
294
|
+
self._services = []
|
|
295
|
+
self._container_counts = {}
|
|
296
|
+
self.query_one("#dep-checks", Vertical).remove_children()
|
|
297
|
+
|
|
298
|
+
# Consume immediately — this is the one mount this pending selection
|
|
299
|
+
# was set for. Any later, unrelated refresh should default to all-checked.
|
|
300
|
+
pending = self._pending_services
|
|
301
|
+
self._pending_services = None
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
deps = k.get_services(proj)
|
|
305
|
+
self._services = [d.name for d in deps]
|
|
306
|
+
self._container_counts = {d.name: d.container_count for d in deps}
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
self.query_one("#dep-empty", Static).update(f"Error: {exc}")
|
|
309
|
+
self.query_one("#dep-empty").display = True
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
if not self._services:
|
|
313
|
+
self.query_one("#dep-empty", Static).update(
|
|
314
|
+
f"No services found in '{proj}'"
|
|
315
|
+
)
|
|
316
|
+
self.query_one("#dep-empty").display = True
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
self.query_one("#dep-empty").display = False
|
|
320
|
+
|
|
321
|
+
names = list(self._services)
|
|
322
|
+
counts = dict(self._container_counts)
|
|
323
|
+
self.run_worker(
|
|
324
|
+
self._mount_dep_checks(names, counts, pending),
|
|
325
|
+
exclusive=True,
|
|
326
|
+
name="dep-mount",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def _mount_dep_checks(
|
|
330
|
+
self, names: list[str], counts: dict[str, int], pending: set[str] | None
|
|
331
|
+
) -> None:
|
|
332
|
+
dep_container = self.query_one("#dep-checks", Vertical)
|
|
333
|
+
await dep_container.remove_children()
|
|
334
|
+
for name in names:
|
|
335
|
+
count = counts.get(name, 0)
|
|
336
|
+
checked = True if pending is None else (name in pending)
|
|
337
|
+
await dep_container.mount(
|
|
338
|
+
Checkbox(
|
|
339
|
+
f"{name} ({count} container{'s' if count != 1 else ''})",
|
|
340
|
+
value=checked,
|
|
341
|
+
id=f"dep-{name}",
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ── String helpers ────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
def _load_saved_strings(self) -> None:
|
|
349
|
+
from ..config import load_saved_strings
|
|
350
|
+
saved = load_saved_strings()
|
|
351
|
+
self._saved_filters = saved.filters
|
|
352
|
+
self._saved_highlights = saved.highlights
|
|
353
|
+
self._saved_monitors = saved.monitors
|
|
354
|
+
self._populate_string_checks("filter-checks", self._saved_filters, [])
|
|
355
|
+
self._populate_string_checks("hl-checks", self._saved_highlights, [])
|
|
356
|
+
self._populate_string_checks("mon-checks", self._saved_monitors, [])
|
|
357
|
+
|
|
358
|
+
def _populate_string_checks(
|
|
359
|
+
self,
|
|
360
|
+
container_id: str,
|
|
361
|
+
strings: list[str],
|
|
362
|
+
active: list[str],
|
|
363
|
+
) -> None:
|
|
364
|
+
container = self.query_one(f"#{container_id}", Vertical)
|
|
365
|
+
container.remove_children()
|
|
366
|
+
|
|
367
|
+
def _do_mount() -> None:
|
|
368
|
+
for s in strings:
|
|
369
|
+
# Content(s) (not the raw str) — Checkbox labels parse Textual
|
|
370
|
+
# markup by default, and a pattern like "[debug]" is valid
|
|
371
|
+
# (empty) markup syntax that would otherwise render blank.
|
|
372
|
+
# No ID needed — these are never queried individually.
|
|
373
|
+
container.mount(Checkbox(Content(s), value=(s in active)))
|
|
374
|
+
|
|
375
|
+
self.call_after_refresh(_do_mount)
|
|
376
|
+
|
|
377
|
+
# ── Saved config helpers ──────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
def _refresh_saved_configs(self) -> None:
|
|
380
|
+
from ..config import list_all_saved_configs
|
|
381
|
+
all_configs = list_all_saved_configs()
|
|
382
|
+
sel = self.query_one("#cfg-select", Select)
|
|
383
|
+
options = [(f"{name} [{proj}]", f"{proj}::{name}") for proj, name in all_configs]
|
|
384
|
+
sel.set_options(options if options else [("(no saved configs)", "__none__")])
|
|
385
|
+
|
|
386
|
+
def _apply_saved_config(self, proj: str, name: str) -> None:
|
|
387
|
+
from ..config import load_session_config
|
|
388
|
+
cfg = load_session_config(proj, name)
|
|
389
|
+
if cfg is None:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
# Set before triggering any service refresh, so whichever refresh
|
|
393
|
+
# actually mounts the checkboxes (sync or async) picks up the saved
|
|
394
|
+
# selection instead of defaulting every service to checked.
|
|
395
|
+
self._pending_services = set(cfg.services)
|
|
396
|
+
|
|
397
|
+
# Restore project. Setting the dropdown may also (asynchronously)
|
|
398
|
+
# fire on_select_changed, which would call _refresh_services()
|
|
399
|
+
# itself — but that handler skips redundant refreshes via
|
|
400
|
+
# _last_refreshed_project, so our explicit call below always wins.
|
|
401
|
+
ns_sel = self.query_one("#proj-select", Select)
|
|
402
|
+
if proj in self._known_projects:
|
|
403
|
+
ns_sel.value = proj
|
|
404
|
+
self.query_one("#proj-input", Input).value = ""
|
|
405
|
+
else:
|
|
406
|
+
# Unknown project — use manual input instead of the dropdown
|
|
407
|
+
self.query_one("#proj-input", Input).value = proj
|
|
408
|
+
self._refresh_services()
|
|
409
|
+
|
|
410
|
+
self._populate_string_checks("filter-checks", self._saved_filters, cfg.filters)
|
|
411
|
+
self._populate_string_checks("hl-checks", self._saved_highlights, cfg.highlights)
|
|
412
|
+
self._populate_string_checks("mon-checks", self._saved_monitors, cfg.monitors)
|
|
413
|
+
|
|
414
|
+
buttons = list(self.query_one("#mode-radio", RadioSet).query(RadioButton))
|
|
415
|
+
target = 1 if cfg.mode == LogMode.DUMP else 0
|
|
416
|
+
if target < len(buttons):
|
|
417
|
+
buttons[target].value = True
|
|
418
|
+
|
|
419
|
+
if cfg.tail:
|
|
420
|
+
self.query_one("#dump-tail", Input).value = str(cfg.tail)
|
|
421
|
+
if cfg.since:
|
|
422
|
+
self.query_one("#dump-since", Input).value = cfg.since
|
|
423
|
+
self.query_one("#stream-since", Input).value = cfg.since
|
|
424
|
+
|
|
425
|
+
self.query_one("#health-enable", Checkbox).value = cfg.health.enabled
|
|
426
|
+
self.query_one("#health-interval", Input).value = str(cfg.health.interval_minutes)
|
|
427
|
+
self.query_one("#color-full-line", Checkbox).value = cfg.color_full_line
|
|
428
|
+
self.query_one("#line-wrap", Checkbox).value = cfg.line_wrap
|
|
429
|
+
self.query_one("#json-format", Checkbox).value = cfg.json_format
|
|
430
|
+
self.query_one("#collapse-repeats", Checkbox).value = cfg.collapse_repeats
|
|
431
|
+
self.query_one("#filter-icase", Checkbox).value = cfg.filters_ignore_case
|
|
432
|
+
self.query_one("#hl-icase", Checkbox).value = cfg.highlights_ignore_case
|
|
433
|
+
self.query_one("#mon-icase", Checkbox).value = cfg.monitors_ignore_case
|
|
434
|
+
if cfg.name:
|
|
435
|
+
self.query_one("#save-name", Input).value = cfg.name
|
|
436
|
+
|
|
437
|
+
def _reset_to_defaults(self) -> None:
|
|
438
|
+
"""
|
|
439
|
+
Undo _apply_saved_config: restore the project/service pickers and
|
|
440
|
+
every option to their initial defaults. Called when the "Load saved
|
|
441
|
+
config" dropdown is switched back to blank, so a previously-applied
|
|
442
|
+
config (which may have pointed at a project that no longer exists)
|
|
443
|
+
doesn't just sit there unrefreshed.
|
|
444
|
+
"""
|
|
445
|
+
self._pending_services = None
|
|
446
|
+
|
|
447
|
+
self.query_one("#proj-input", Input).value = ""
|
|
448
|
+
proj_sel = self.query_one("#proj-select", Select)
|
|
449
|
+
if self._initial_project in self._known_projects:
|
|
450
|
+
proj_sel.value = self._initial_project
|
|
451
|
+
elif self._known_projects:
|
|
452
|
+
proj_sel.value = self._known_projects[0]
|
|
453
|
+
self._refresh_services()
|
|
454
|
+
|
|
455
|
+
self._populate_string_checks("filter-checks", self._saved_filters, [])
|
|
456
|
+
self._populate_string_checks("hl-checks", self._saved_highlights, [])
|
|
457
|
+
self._populate_string_checks("mon-checks", self._saved_monitors, [])
|
|
458
|
+
self.query_one("#filter-input", Input).value = ""
|
|
459
|
+
self.query_one("#hl-input", Input).value = ""
|
|
460
|
+
self.query_one("#mon-input", Input).value = ""
|
|
461
|
+
self.query_one("#filter-icase", Checkbox).value = False
|
|
462
|
+
self.query_one("#hl-icase", Checkbox).value = False
|
|
463
|
+
self.query_one("#mon-icase", Checkbox).value = False
|
|
464
|
+
|
|
465
|
+
buttons = list(self.query_one("#mode-radio", RadioSet).query(RadioButton))
|
|
466
|
+
if buttons:
|
|
467
|
+
buttons[0].value = True
|
|
468
|
+
|
|
469
|
+
self.query_one("#dump-tail", Input).value = ""
|
|
470
|
+
self.query_one("#dump-since", Input).value = ""
|
|
471
|
+
self.query_one("#stream-since", Input).value = ""
|
|
472
|
+
|
|
473
|
+
self.query_one("#health-enable", Checkbox).value = False
|
|
474
|
+
self.query_one("#health-interval", Input).value = "5"
|
|
475
|
+
self.query_one("#color-full-line", Checkbox).value = False
|
|
476
|
+
self.query_one("#line-wrap", Checkbox).value = True
|
|
477
|
+
self.query_one("#json-format", Checkbox).value = False
|
|
478
|
+
self.query_one("#collapse-repeats", Checkbox).value = False
|
|
479
|
+
self.query_one("#save-name", Input).value = ""
|
|
480
|
+
|
|
481
|
+
# ── Events ────────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
def _switch_tab(self, tab_id: str) -> None:
|
|
484
|
+
from textual.widgets import Tab
|
|
485
|
+
from textual.widgets._tabbed_content import ContentTab, ContentTabs
|
|
486
|
+
content_tabs = self.query_one("#wizard-tabs", TabbedContent).query_one(ContentTabs)
|
|
487
|
+
tab = content_tabs.query_one(f"#{ContentTab.add_prefix(tab_id)}", Tab)
|
|
488
|
+
tab.post_message(Tab.Clicked(tab))
|
|
489
|
+
|
|
490
|
+
def _open_patterns_file(self) -> None:
|
|
491
|
+
import os
|
|
492
|
+
import subprocess
|
|
493
|
+
from pathlib import Path
|
|
494
|
+
path = Path.home() / ".config" / "docker-orb" / "strings.yaml"
|
|
495
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
if not path.exists():
|
|
497
|
+
path.write_text("filters: []\nhighlights: []\nmonitors: []\n")
|
|
498
|
+
editor = os.environ.get("EDITOR", "")
|
|
499
|
+
try:
|
|
500
|
+
if editor:
|
|
501
|
+
subprocess.Popen([editor, str(path)])
|
|
502
|
+
elif os.uname().sysname == "Darwin":
|
|
503
|
+
subprocess.Popen(["open", "-t", str(path)])
|
|
504
|
+
else:
|
|
505
|
+
subprocess.Popen(["xdg-open", str(path)])
|
|
506
|
+
self.notify(f"Opened {path}", severity="information")
|
|
507
|
+
except Exception as exc:
|
|
508
|
+
self.notify(f"Could not open editor: {exc}", severity="error")
|
|
509
|
+
|
|
510
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
511
|
+
match event.button.id:
|
|
512
|
+
case "edit-patterns-file":
|
|
513
|
+
self._open_patterns_file()
|
|
514
|
+
case "reload-patterns":
|
|
515
|
+
self._load_saved_strings()
|
|
516
|
+
self.notify("Saved patterns reloaded")
|
|
517
|
+
case "dep-all":
|
|
518
|
+
for cb in self.query("#dep-checks Checkbox"):
|
|
519
|
+
cb.value = True
|
|
520
|
+
case "dep-clear":
|
|
521
|
+
for cb in self.query("#dep-checks Checkbox"):
|
|
522
|
+
cb.value = False
|
|
523
|
+
case "next-1":
|
|
524
|
+
self._switch_tab("tab-strings")
|
|
525
|
+
case "next-2":
|
|
526
|
+
self._switch_tab("tab-options")
|
|
527
|
+
case "back-2":
|
|
528
|
+
self._switch_tab("tab-targets")
|
|
529
|
+
case "back-3":
|
|
530
|
+
self._switch_tab("tab-strings")
|
|
531
|
+
case "launch":
|
|
532
|
+
self._launch()
|
|
533
|
+
case "cancel" | "cancel-1" | "cancel-2":
|
|
534
|
+
self.action_cancel()
|
|
535
|
+
|
|
536
|
+
def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
|
|
537
|
+
is_dump = event.index == 1
|
|
538
|
+
self.query_one("#dump-opts").display = is_dump
|
|
539
|
+
self.query_one("#stream-opts").display = not is_dump
|
|
540
|
+
self.query_one("#monitor-section").display = not is_dump
|
|
541
|
+
self.query_one("#health-section").display = not is_dump
|
|
542
|
+
|
|
543
|
+
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
544
|
+
if event.checkbox.id == "health-enable":
|
|
545
|
+
self.query_one("#health-opts").display = event.value
|
|
546
|
+
|
|
547
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
548
|
+
if event.input.id == "proj-input" and event.value.strip():
|
|
549
|
+
self._refresh_services()
|
|
550
|
+
self._refresh_saved_configs()
|
|
551
|
+
|
|
552
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
553
|
+
if event.select.id == "proj-select":
|
|
554
|
+
if self._initializing_ns:
|
|
555
|
+
return
|
|
556
|
+
self.query_one("#proj-input", Input).value = ""
|
|
557
|
+
if self._active_project() == self._last_refreshed_project:
|
|
558
|
+
# _apply_saved_config already refreshed for this project
|
|
559
|
+
# itself; this is a queued Select.Changed catching up. Skip
|
|
560
|
+
# it so it doesn't remount checkboxes without the saved
|
|
561
|
+
# service selection applied.
|
|
562
|
+
return
|
|
563
|
+
self._refresh_services()
|
|
564
|
+
self._refresh_saved_configs()
|
|
565
|
+
elif event.select.id == "cfg-select":
|
|
566
|
+
value = event.value
|
|
567
|
+
if value and value != "__none__" and value is not Select.BLANK:
|
|
568
|
+
proj, _, name = str(value).partition("::")
|
|
569
|
+
self._apply_saved_config(proj, name)
|
|
570
|
+
else:
|
|
571
|
+
# Switched back to "no saved config" — undo whatever
|
|
572
|
+
# _apply_saved_config left behind (a manually-typed project
|
|
573
|
+
# that may no longer exist, a stale service selection, etc.)
|
|
574
|
+
# rather than leaving those fields stuck on the last config's
|
|
575
|
+
# values with nothing to repopulate them.
|
|
576
|
+
self._reset_to_defaults()
|
|
577
|
+
|
|
578
|
+
# ── Launch ────────────────────────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
def _launch(self) -> None:
|
|
581
|
+
from ..config import add_to_saved_strings, parse_string_input
|
|
582
|
+
|
|
583
|
+
proj = self._active_project()
|
|
584
|
+
if not proj:
|
|
585
|
+
self.notify("Select or enter a project.", severity="warning")
|
|
586
|
+
return
|
|
587
|
+
|
|
588
|
+
services = [
|
|
589
|
+
cb.label.plain.split(" (")[0]
|
|
590
|
+
for cb in self.query("#dep-checks Checkbox")
|
|
591
|
+
if cb.value
|
|
592
|
+
]
|
|
593
|
+
if not services:
|
|
594
|
+
self.notify("Select at least one service.", severity="warning")
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
rs = self.query_one("#mode-radio", RadioSet)
|
|
598
|
+
is_dump = rs.pressed_index == 1
|
|
599
|
+
mode = LogMode.DUMP if is_dump else LogMode.STREAM
|
|
600
|
+
|
|
601
|
+
tail: int | None = None
|
|
602
|
+
since: str | None = None
|
|
603
|
+
if is_dump:
|
|
604
|
+
tail_raw = self.query_one("#dump-tail", Input).value.strip()
|
|
605
|
+
since_raw = self.query_one("#dump-since", Input).value.strip()
|
|
606
|
+
if tail_raw:
|
|
607
|
+
try:
|
|
608
|
+
tail = int(tail_raw)
|
|
609
|
+
except ValueError:
|
|
610
|
+
self.notify("Tail must be a number.", severity="warning")
|
|
611
|
+
return
|
|
612
|
+
if since_raw:
|
|
613
|
+
since = since_raw
|
|
614
|
+
else:
|
|
615
|
+
since_raw = self.query_one("#stream-since", Input).value.strip()
|
|
616
|
+
if since_raw:
|
|
617
|
+
since = since_raw
|
|
618
|
+
|
|
619
|
+
def collect(
|
|
620
|
+
checks_id: str, saved: list[str], input_id: str, save_id: str
|
|
621
|
+
) -> list[str]:
|
|
622
|
+
active = [
|
|
623
|
+
s for cb, s in zip(self.query(f"#{checks_id} Checkbox"), saved)
|
|
624
|
+
if cb.value
|
|
625
|
+
]
|
|
626
|
+
raw = self.query_one(f"#{input_id}", Input).value.strip()
|
|
627
|
+
new = parse_string_input(raw) if raw else []
|
|
628
|
+
merged = active + [s for s in new if s not in active]
|
|
629
|
+
if new and self.query_one(f"#{save_id}", Checkbox).value:
|
|
630
|
+
truly_new = [s for s in new if s not in saved]
|
|
631
|
+
if truly_new:
|
|
632
|
+
cat = save_id.split("-")[0]
|
|
633
|
+
cat_map = {"filter": "filters", "hl": "highlights", "mon": "monitors"}
|
|
634
|
+
add_to_saved_strings(cat_map[cat], truly_new)
|
|
635
|
+
return merged
|
|
636
|
+
|
|
637
|
+
filters = collect("filter-checks", self._saved_filters, "filter-input", "filter-save")
|
|
638
|
+
highlights = collect("hl-checks", self._saved_highlights, "hl-input", "hl-save")
|
|
639
|
+
monitors: list[str] = []
|
|
640
|
+
if not is_dump:
|
|
641
|
+
monitors = collect("mon-checks", self._saved_monitors, "mon-input", "mon-save")
|
|
642
|
+
|
|
643
|
+
filters_ignore_case = self.query_one("#filter-icase", Checkbox).value
|
|
644
|
+
highlights_ignore_case = self.query_one("#hl-icase", Checkbox).value
|
|
645
|
+
monitors_ignore_case = self.query_one("#mon-icase", Checkbox).value if not is_dump else False
|
|
646
|
+
|
|
647
|
+
health = HealthConfig()
|
|
648
|
+
if not is_dump:
|
|
649
|
+
health.enabled = self.query_one("#health-enable", Checkbox).value
|
|
650
|
+
if health.enabled:
|
|
651
|
+
try:
|
|
652
|
+
health.interval_minutes = max(
|
|
653
|
+
1, int(self.query_one("#health-interval", Input).value.strip() or "5")
|
|
654
|
+
)
|
|
655
|
+
except ValueError:
|
|
656
|
+
self.notify("Health interval must be a number.", severity="warning")
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
color_full_line = self.query_one("#color-full-line", Checkbox).value
|
|
660
|
+
line_wrap = self.query_one("#line-wrap", Checkbox).value
|
|
661
|
+
json_format = self.query_one("#json-format", Checkbox).value
|
|
662
|
+
collapse_repeats = self.query_one("#collapse-repeats", Checkbox).value
|
|
663
|
+
save_name = self.query_one("#save-name", Input).value.strip() or None
|
|
664
|
+
|
|
665
|
+
config = SessionConfig(
|
|
666
|
+
project=proj,
|
|
667
|
+
services=services,
|
|
668
|
+
mode=mode,
|
|
669
|
+
tail=tail,
|
|
670
|
+
since=since,
|
|
671
|
+
filters=filters,
|
|
672
|
+
highlights=highlights,
|
|
673
|
+
monitors=monitors,
|
|
674
|
+
filters_ignore_case=filters_ignore_case,
|
|
675
|
+
highlights_ignore_case=highlights_ignore_case,
|
|
676
|
+
monitors_ignore_case=monitors_ignore_case,
|
|
677
|
+
health=health,
|
|
678
|
+
color_full_line=color_full_line,
|
|
679
|
+
line_wrap=line_wrap,
|
|
680
|
+
json_format=json_format,
|
|
681
|
+
collapse_repeats=collapse_repeats,
|
|
682
|
+
name=save_name,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
if save_name:
|
|
686
|
+
from ..config import save_session_config
|
|
687
|
+
save_session_config(config)
|
|
688
|
+
|
|
689
|
+
self.dismiss(config)
|
|
690
|
+
|
|
691
|
+
def action_cancel(self) -> None:
|
|
692
|
+
self.dismiss(None)
|