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
docker_orb/compose.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Docker Compose abstraction layer.
|
|
3
|
+
|
|
4
|
+
All interaction goes through this module via subprocess calls to the user's
|
|
5
|
+
existing `docker` CLI (Compose v2, i.e. `docker compose ...`) — inheriting
|
|
6
|
+
their Docker context/auth. No Python Docker SDK dependency, same philosophy
|
|
7
|
+
as kube-orb's kubectl.py: shell out, parse JSON, keep the rest of the app
|
|
8
|
+
ignorant of the underlying tool.
|
|
9
|
+
|
|
10
|
+
Log streaming uses `docker logs -f --timestamps <container>` per container
|
|
11
|
+
rather than a single multiplexed `docker compose logs -f`. Functionally this
|
|
12
|
+
is the same data `docker compose logs` streams (compose containers are
|
|
13
|
+
ordinary Docker containers carrying `com.docker.compose.*` labels) but
|
|
14
|
+
keeping one subprocess per container mirrors kube-orb's per-pod architecture
|
|
15
|
+
exactly, so the merge/color/filter machinery in viewer/ needed no changes.
|
|
16
|
+
|
|
17
|
+
NOTE: --since duration semantics below are carried over from kube-orb's
|
|
18
|
+
kubectl quirk-handling as a defensive default. Docker's own duration parsing
|
|
19
|
+
for `docker logs --since` has not been verified against a live daemon in
|
|
20
|
+
this environment — worth a smoke test against a real `docker compose`
|
|
21
|
+
project before relying on it in production.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import subprocess
|
|
29
|
+
from collections.abc import AsyncIterator
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
|
|
32
|
+
from .models import Service, LogLine, Container, ContainerStatus
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def _run(args: list[str], check: bool = True) -> str:
|
|
38
|
+
"""Run a `docker` command, return stdout as str. Raises on non-zero exit."""
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["docker", *args],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
check=check,
|
|
44
|
+
)
|
|
45
|
+
return result.stdout.strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run_json_lines(args: list[str]) -> list[dict]:
|
|
49
|
+
"""
|
|
50
|
+
Run a docker/compose command that emits `--format json` output and parse
|
|
51
|
+
it. Compose has shipped two shapes for this over the years: a single
|
|
52
|
+
JSON array, or newline-delimited JSON objects (one per line). Handle
|
|
53
|
+
both rather than pinning to one Compose version.
|
|
54
|
+
"""
|
|
55
|
+
out = _run(args)
|
|
56
|
+
if not out:
|
|
57
|
+
return []
|
|
58
|
+
try:
|
|
59
|
+
parsed = json.loads(out)
|
|
60
|
+
return parsed if isinstance(parsed, list) else [parsed]
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
pass
|
|
63
|
+
items = []
|
|
64
|
+
for line in out.splitlines():
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line:
|
|
67
|
+
continue
|
|
68
|
+
items.append(json.loads(line))
|
|
69
|
+
return items
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# docker logs --timestamps prefixes each line with an RFC3339Nano timestamp
|
|
73
|
+
# (up to 9 fractional-second digits) and a space, e.g.
|
|
74
|
+
# "2026-07-08T12:34:56.789012345Z log line content here".
|
|
75
|
+
_TIMESTAMP_RE = re.compile(
|
|
76
|
+
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2}) "
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _split_timestamp(raw: str) -> tuple[datetime | None, str]:
|
|
81
|
+
"""
|
|
82
|
+
Split a `docker logs --timestamps` line into (parsed timestamp, content).
|
|
83
|
+
Returns (None, raw) unchanged if the line doesn't start with a
|
|
84
|
+
recognizable timestamp (defensive — should always match in practice).
|
|
85
|
+
"""
|
|
86
|
+
m = _TIMESTAMP_RE.match(raw)
|
|
87
|
+
if not m:
|
|
88
|
+
return None, raw
|
|
89
|
+
base, frac, tz = m.groups()
|
|
90
|
+
# datetime.fromisoformat only accepts up to microsecond (6-digit)
|
|
91
|
+
# precision on Python 3.10/3.11; truncate rather than pad-and-fail.
|
|
92
|
+
frac = (frac or "").ljust(6, "0")[:6]
|
|
93
|
+
tz = "+00:00" if tz == "Z" else tz
|
|
94
|
+
try:
|
|
95
|
+
timestamp = datetime.fromisoformat(f"{base}.{frac}{tz}")
|
|
96
|
+
except ValueError:
|
|
97
|
+
return None, raw
|
|
98
|
+
return timestamp, raw[m.end():]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# A --since value that is empty/zero in every unit ("0", "0s", "0h", ...).
|
|
102
|
+
_ZERO_SINCE_RE = re.compile(r"^0+(?:\.0+)?(ns|us|µs|ms|s|m|h)?$")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _normalize_since(since: str | None) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Without a meaningful --since bound, `docker logs -f` dumps a container's
|
|
108
|
+
entire buffered history before it starts following. For a live stream
|
|
109
|
+
that means replaying everything the container has ever logged instead
|
|
110
|
+
of only new lines. Substitute a tiny positive duration for None/empty/
|
|
111
|
+
zero so the default (and a user typing a literal "0") gets "only new
|
|
112
|
+
lines" behavior instead of an unbounded history dump — same defensive
|
|
113
|
+
trick kube-orb uses for kubectl.
|
|
114
|
+
"""
|
|
115
|
+
if not since or _ZERO_SINCE_RE.match(since.strip()):
|
|
116
|
+
return "1s"
|
|
117
|
+
return since
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def wants_backfill(since: str | None) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
True if `since` represents a genuine, non-trivial look-back window —
|
|
123
|
+
i.e. whether the caller should expect (and handle) a historical
|
|
124
|
+
backfill burst. Mirrors _normalize_since()'s zero-detection.
|
|
125
|
+
"""
|
|
126
|
+
return bool(since) and not _ZERO_SINCE_RE.match(since.strip())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ─── Context / project ───────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def get_current_project() -> str:
|
|
132
|
+
"""
|
|
133
|
+
Return the Compose project name for the current working directory, the
|
|
134
|
+
way `docker compose` itself would resolve it (COMPOSE_PROJECT_NAME env
|
|
135
|
+
var, `name:` in the compose file, or the containing directory name).
|
|
136
|
+
Falls back to the first running project, then 'default', if the cwd
|
|
137
|
+
isn't a compose project.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
data = json.loads(_run(["compose", "config", "--format", "json"]))
|
|
141
|
+
name = data.get("name")
|
|
142
|
+
if name:
|
|
143
|
+
return name
|
|
144
|
+
except (subprocess.CalledProcessError, json.JSONDecodeError):
|
|
145
|
+
pass
|
|
146
|
+
projects = get_projects()
|
|
147
|
+
return projects[0] if projects else "default"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_projects() -> list[str]:
|
|
151
|
+
"""Return names of all Compose projects Docker currently knows about."""
|
|
152
|
+
items = _run_json_lines(["compose", "ls", "--all", "--format", "json"])
|
|
153
|
+
return [item["Name"] for item in items if item.get("Name")]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ─── Services / containers ────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def _list_containers(project: str) -> list[dict]:
|
|
159
|
+
"""Raw `docker compose ps` rows for every container in a project."""
|
|
160
|
+
return _run_json_lines([
|
|
161
|
+
"compose", "-p", project, "ps", "--all", "--format", "json",
|
|
162
|
+
])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_services(project: str) -> list[Service]:
|
|
166
|
+
"""
|
|
167
|
+
Return services in the project, each with the count of containers
|
|
168
|
+
currently backing it (a service may have 0 if it isn't running, or
|
|
169
|
+
more than 1 if scaled).
|
|
170
|
+
"""
|
|
171
|
+
rows = _list_containers(project)
|
|
172
|
+
counts: dict[str, int] = {}
|
|
173
|
+
for row in rows:
|
|
174
|
+
name = row.get("Service")
|
|
175
|
+
if name:
|
|
176
|
+
counts[name] = counts.get(name, 0) + 1
|
|
177
|
+
|
|
178
|
+
# Include services defined in the compose file even if currently
|
|
179
|
+
# stopped (0 containers), so they're still selectable in the wizard.
|
|
180
|
+
try:
|
|
181
|
+
defined = json.loads(_run(["compose", "-p", project, "config", "--format", "json"]))
|
|
182
|
+
for name in defined.get("services", {}):
|
|
183
|
+
counts.setdefault(name, 0)
|
|
184
|
+
except (subprocess.CalledProcessError, json.JSONDecodeError):
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
services = [
|
|
188
|
+
Service(name=name, project=project, container_count=count)
|
|
189
|
+
for name, count in counts.items()
|
|
190
|
+
]
|
|
191
|
+
return sorted(services, key=lambda s: s.name)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_containers_for_service(project: str, service: Service) -> list[Container]:
|
|
195
|
+
"""Return containers currently backing a single service."""
|
|
196
|
+
return get_containers_for_services(project, [service])
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_containers_for_services(project: str, services: list[Service]) -> list[Container]:
|
|
200
|
+
"""Return all containers across a list of services."""
|
|
201
|
+
if not services:
|
|
202
|
+
return []
|
|
203
|
+
wanted = {s.name for s in services}
|
|
204
|
+
containers: list[Container] = []
|
|
205
|
+
for row in _list_containers(project):
|
|
206
|
+
service_name = row.get("Service")
|
|
207
|
+
if service_name not in wanted:
|
|
208
|
+
continue
|
|
209
|
+
containers.append(Container(
|
|
210
|
+
name=row.get("Name", ""),
|
|
211
|
+
project=project,
|
|
212
|
+
service=service_name,
|
|
213
|
+
phase=_state_to_phase(row.get("State", "")),
|
|
214
|
+
restart_count=0, # not exposed by `ps`; see get_container_statuses
|
|
215
|
+
ready=(row.get("State") == "running" and row.get("Health", "") in ("", "healthy")),
|
|
216
|
+
))
|
|
217
|
+
return containers
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _state_to_phase(state: str) -> str:
|
|
221
|
+
"""Map Docker's lowercase container State to a k8s-style Title Case phase."""
|
|
222
|
+
return {
|
|
223
|
+
"running": "Running",
|
|
224
|
+
"exited": "Exited",
|
|
225
|
+
"restarting": "Restarting",
|
|
226
|
+
"paused": "Paused",
|
|
227
|
+
"created": "Created",
|
|
228
|
+
"dead": "Dead",
|
|
229
|
+
"removing": "Removing",
|
|
230
|
+
}.get(state, state.title() or "Unknown")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ─── Container health polling ─────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def get_container_statuses(project: str, container_names: list[str]) -> list[ContainerStatus]:
|
|
236
|
+
"""
|
|
237
|
+
Poll current health for a specific list of container names via
|
|
238
|
+
`docker inspect` (batched into a single call). Used by the health panel
|
|
239
|
+
on its interval. Unlike `docker compose ps`, inspect exposes the exact
|
|
240
|
+
RestartCount and StartedAt Docker tracks natively per container.
|
|
241
|
+
"""
|
|
242
|
+
if not container_names:
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
raw = _run(["inspect", *container_names])
|
|
247
|
+
items = json.loads(raw) if raw else []
|
|
248
|
+
except subprocess.CalledProcessError as exc:
|
|
249
|
+
# `docker inspect` still prints JSON for the containers it *did*
|
|
250
|
+
# find on stdout even when one name doesn't exist; fall back to that.
|
|
251
|
+
try:
|
|
252
|
+
items = json.loads(exc.stdout) if exc.stdout else []
|
|
253
|
+
except (json.JSONDecodeError, TypeError):
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
statuses = []
|
|
257
|
+
for item in items:
|
|
258
|
+
name = item.get("Name", "").lstrip("/")
|
|
259
|
+
state = item.get("State", {})
|
|
260
|
+
phase = _state_to_phase(state.get("Status", ""))
|
|
261
|
+
health = state.get("Health", {}).get("Status")
|
|
262
|
+
ready = state.get("Running", False) and health in (None, "healthy")
|
|
263
|
+
restarts = item.get("RestartCount", 0)
|
|
264
|
+
|
|
265
|
+
started_at = state.get("StartedAt")
|
|
266
|
+
age = 0.0
|
|
267
|
+
if started_at:
|
|
268
|
+
try:
|
|
269
|
+
start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
270
|
+
age = (datetime.now(start.tzinfo) - start).total_seconds()
|
|
271
|
+
except ValueError:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
labels = item.get("Config", {}).get("Labels", {}) or {}
|
|
275
|
+
service = labels.get("com.docker.compose.service", name)
|
|
276
|
+
|
|
277
|
+
statuses.append(ContainerStatus(
|
|
278
|
+
name=name,
|
|
279
|
+
service=service,
|
|
280
|
+
phase=phase,
|
|
281
|
+
restart_count=restarts,
|
|
282
|
+
ready=ready,
|
|
283
|
+
age_seconds=age,
|
|
284
|
+
))
|
|
285
|
+
|
|
286
|
+
return statuses
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ─── Log streaming ─────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
async def stream_logs(
|
|
292
|
+
container_name: str,
|
|
293
|
+
project: str,
|
|
294
|
+
since: str | None = None,
|
|
295
|
+
tail: int | None = None,
|
|
296
|
+
) -> AsyncIterator[LogLine]:
|
|
297
|
+
"""
|
|
298
|
+
Async generator yielding LogLine objects from a single container.
|
|
299
|
+
Runs `docker logs -f --timestamps <container>` in a subprocess, reads
|
|
300
|
+
lines as they arrive. Caller should run one coroutine per container and
|
|
301
|
+
merge with asyncio — same pattern as kube-orb's per-pod streaming.
|
|
302
|
+
|
|
303
|
+
Requests --timestamps so each LogLine gets a real log_timestamp (see
|
|
304
|
+
models.LogLine) — used to interleave a backfill burst across containers
|
|
305
|
+
correctly. The timestamp is parsed and stripped back out of `content`,
|
|
306
|
+
so displayed/matched text is identical to what --timestamps=false would
|
|
307
|
+
have produced.
|
|
308
|
+
"""
|
|
309
|
+
args = ["docker", "logs", "-f", "--timestamps", "--since", _normalize_since(since)]
|
|
310
|
+
if tail is not None:
|
|
311
|
+
args += ["--tail", str(tail)]
|
|
312
|
+
args.append(container_name)
|
|
313
|
+
|
|
314
|
+
proc = await asyncio.create_subprocess_exec(
|
|
315
|
+
*args,
|
|
316
|
+
stdout=asyncio.subprocess.PIPE,
|
|
317
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
assert proc.stdout is not None
|
|
321
|
+
try:
|
|
322
|
+
while True:
|
|
323
|
+
line_bytes = await proc.stdout.readline()
|
|
324
|
+
if not line_bytes:
|
|
325
|
+
break
|
|
326
|
+
raw = line_bytes.decode(errors="replace").rstrip("\n")
|
|
327
|
+
log_time, content = _split_timestamp(raw)
|
|
328
|
+
yield LogLine(
|
|
329
|
+
container_name=container_name,
|
|
330
|
+
content=content,
|
|
331
|
+
log_timestamp=log_time,
|
|
332
|
+
)
|
|
333
|
+
finally:
|
|
334
|
+
try:
|
|
335
|
+
proc.kill()
|
|
336
|
+
except ProcessLookupError:
|
|
337
|
+
pass
|
|
338
|
+
await proc.wait()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def dump_logs(
|
|
342
|
+
container_name: str,
|
|
343
|
+
project: str,
|
|
344
|
+
since: str | None = None,
|
|
345
|
+
tail: int | None = None,
|
|
346
|
+
) -> list[LogLine]:
|
|
347
|
+
"""
|
|
348
|
+
Fetch a bounded set of log lines from a container (no follow).
|
|
349
|
+
Used in dump mode.
|
|
350
|
+
"""
|
|
351
|
+
args = ["docker", "logs", "--timestamps=false"]
|
|
352
|
+
if since:
|
|
353
|
+
args += ["--since", since]
|
|
354
|
+
if tail is not None:
|
|
355
|
+
args += ["--tail", str(tail)]
|
|
356
|
+
args.append(container_name)
|
|
357
|
+
|
|
358
|
+
proc = await asyncio.create_subprocess_exec(
|
|
359
|
+
*args,
|
|
360
|
+
stdout=asyncio.subprocess.PIPE,
|
|
361
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
362
|
+
)
|
|
363
|
+
stdout, _ = await proc.communicate()
|
|
364
|
+
lines = stdout.decode(errors="replace").splitlines()
|
|
365
|
+
return [LogLine(container_name=container_name, content=line) for line in lines if line]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ─── Container restart actions ─────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
def restart_container(container_name: str, project: str) -> bool:
|
|
371
|
+
"""
|
|
372
|
+
Restart a single container in place (same container, same image —
|
|
373
|
+
Compose will not recreate it). Returns True on success.
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
_run(["restart", container_name])
|
|
377
|
+
return True
|
|
378
|
+
except subprocess.CalledProcessError:
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def restart_service(service_name: str, project: str) -> bool:
|
|
383
|
+
"""
|
|
384
|
+
Recreate every container backing a service (picks up a new image/env if
|
|
385
|
+
one is available, unlike restart_container). Returns True on success.
|
|
386
|
+
"""
|
|
387
|
+
try:
|
|
388
|
+
_run(["compose", "-p", project, "up", "-d", "--force-recreate", "--no-deps", service_name])
|
|
389
|
+
return True
|
|
390
|
+
except subprocess.CalledProcessError:
|
|
391
|
+
return False
|
docker_orb/config.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Config management for docker-orb.
|
|
3
|
+
|
|
4
|
+
Storage layout under ~/.config/docker-orb/:
|
|
5
|
+
strings.yaml — global saved string lists (filters/highlights/monitors)
|
|
6
|
+
projects/<proj>.yaml — per-project saved session configs
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from .models import HealthConfig, LogMode, SavedStrings, SessionConfig
|
|
16
|
+
|
|
17
|
+
CONFIG_DIR = Path.home() / ".config" / "docker-orb"
|
|
18
|
+
STRINGS_FILE = CONFIG_DIR / "strings.yaml"
|
|
19
|
+
PROJECTS_DIR = CONFIG_DIR / "projects"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def _ensure_dirs() -> None:
|
|
25
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_yaml(path: Path) -> dict:
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return {}
|
|
32
|
+
with path.open() as f:
|
|
33
|
+
return yaml.safe_load(f) or {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _normalize_string_list(value: object) -> list[str]:
|
|
37
|
+
if not isinstance(value, list):
|
|
38
|
+
return []
|
|
39
|
+
normalized: list[str] = []
|
|
40
|
+
for item in value:
|
|
41
|
+
if isinstance(item, str):
|
|
42
|
+
normalized.append(item)
|
|
43
|
+
elif isinstance(item, list):
|
|
44
|
+
# A hand-edited strings.yaml with an unquoted bracketed literal
|
|
45
|
+
# like "[debug]" parses as YAML flow-sequence syntax (a nested
|
|
46
|
+
# list) instead of the string the user meant. Reconstruct the
|
|
47
|
+
# original bracketed text rather than losing the pattern or
|
|
48
|
+
# emitting Python's list repr (e.g. "['debug']").
|
|
49
|
+
normalized.append(f"[{', '.join(str(x) for x in item)}]")
|
|
50
|
+
elif item is not None:
|
|
51
|
+
normalized.append(str(item))
|
|
52
|
+
return normalized
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _save_yaml(path: Path, data: dict) -> None:
|
|
56
|
+
_ensure_dirs()
|
|
57
|
+
with path.open("w") as f:
|
|
58
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ─── String parsing ───────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def parse_string_input(raw: str) -> list[str]:
|
|
64
|
+
"""
|
|
65
|
+
Parse a comma-separated input string into individual match patterns.
|
|
66
|
+
Quoted tokens (for strings containing commas) are handled.
|
|
67
|
+
Each token is stripped of whitespace.
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
'ERROR, timeout' → ['ERROR', 'timeout']
|
|
71
|
+
'/5[0-9]{2}/, WARN' → ['/5[0-9]{2}/', 'WARN']
|
|
72
|
+
'"GET /api, POST /api"' → ['GET /api, POST /api']
|
|
73
|
+
"""
|
|
74
|
+
tokens: list[str] = []
|
|
75
|
+
current = ""
|
|
76
|
+
in_quote = False
|
|
77
|
+
|
|
78
|
+
for char in raw:
|
|
79
|
+
if char == '"':
|
|
80
|
+
in_quote = not in_quote
|
|
81
|
+
elif char == "," and not in_quote:
|
|
82
|
+
token = current.strip()
|
|
83
|
+
if token:
|
|
84
|
+
tokens.append(token)
|
|
85
|
+
current = ""
|
|
86
|
+
else:
|
|
87
|
+
current += char
|
|
88
|
+
|
|
89
|
+
token = current.strip()
|
|
90
|
+
if token:
|
|
91
|
+
tokens.append(token)
|
|
92
|
+
|
|
93
|
+
return tokens
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_regex_pattern(s: str) -> bool:
|
|
97
|
+
"""Return True if the string uses /pattern/ regex syntax."""
|
|
98
|
+
return s.startswith("/") and s.endswith("/") and len(s) > 2
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def compile_pattern(s: str, ignore_case: bool = False) -> re.Pattern:
|
|
102
|
+
"""
|
|
103
|
+
Compile a match string into a regex Pattern.
|
|
104
|
+
/pattern/ strings are compiled as regex.
|
|
105
|
+
Plain strings are compiled as literal (re.escape).
|
|
106
|
+
"""
|
|
107
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
108
|
+
if is_regex_pattern(s):
|
|
109
|
+
return re.compile(s[1:-1], flags)
|
|
110
|
+
return re.compile(re.escape(s), flags)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def matches(line: str, patterns: list[re.Pattern]) -> bool:
|
|
114
|
+
"""Return True if line matches any of the compiled patterns."""
|
|
115
|
+
return any(p.search(line) for p in patterns)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def compile_patterns(strings: list[str], ignore_case: bool = False) -> list[re.Pattern]:
|
|
119
|
+
return [compile_pattern(s, ignore_case) for s in strings]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ─── Saved strings ────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def load_saved_strings() -> SavedStrings:
|
|
125
|
+
data = _load_yaml(STRINGS_FILE)
|
|
126
|
+
return SavedStrings(
|
|
127
|
+
filters=_normalize_string_list(data.get("filters", [])),
|
|
128
|
+
highlights=_normalize_string_list(data.get("highlights", [])),
|
|
129
|
+
monitors=_normalize_string_list(data.get("monitors", [])),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def save_saved_strings(strings: SavedStrings) -> None:
|
|
134
|
+
_save_yaml(STRINGS_FILE, {
|
|
135
|
+
"filters": strings.filters,
|
|
136
|
+
"highlights": strings.highlights,
|
|
137
|
+
"monitors": strings.monitors,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def add_to_saved_strings(
|
|
142
|
+
category: str,
|
|
143
|
+
new_strings: list[str],
|
|
144
|
+
) -> None:
|
|
145
|
+
"""
|
|
146
|
+
Append new_strings to the given category ('filters'|'highlights'|'monitors')
|
|
147
|
+
in the global strings file, deduplicating.
|
|
148
|
+
"""
|
|
149
|
+
saved = load_saved_strings()
|
|
150
|
+
existing = getattr(saved, category)
|
|
151
|
+
merged = existing + [s for s in new_strings if s not in existing]
|
|
152
|
+
setattr(saved, category, merged)
|
|
153
|
+
save_saved_strings(saved)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ─── Session configs ──────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def _ns_config_path(project: str, name: str) -> Path:
|
|
159
|
+
safe_name = re.sub(r"[^\w\-]", "_", name)
|
|
160
|
+
return PROJECTS_DIR / project / f"{safe_name}.yaml"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_saved_configs(project: str) -> list[str]:
|
|
164
|
+
"""Return names of saved configs for a project."""
|
|
165
|
+
proj_dir = PROJECTS_DIR / project
|
|
166
|
+
if not proj_dir.exists():
|
|
167
|
+
return []
|
|
168
|
+
return [p.stem for p in sorted(proj_dir.glob("*.yaml"))]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def list_all_saved_configs() -> list[tuple[str, str]]:
|
|
172
|
+
"""Return (project, name) for every saved config across all projects."""
|
|
173
|
+
if not PROJECTS_DIR.exists():
|
|
174
|
+
return []
|
|
175
|
+
result = []
|
|
176
|
+
for proj_dir in sorted(PROJECTS_DIR.iterdir()):
|
|
177
|
+
if proj_dir.is_dir():
|
|
178
|
+
for p in sorted(proj_dir.glob("*.yaml")):
|
|
179
|
+
result.append((proj_dir.name, p.stem))
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_session_config(project: str, name: str) -> SessionConfig | None:
|
|
184
|
+
path = _ns_config_path(project, name)
|
|
185
|
+
data = _load_yaml(path)
|
|
186
|
+
if not data:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
health_data = data.get("health", {})
|
|
190
|
+
return SessionConfig(
|
|
191
|
+
project=project,
|
|
192
|
+
services=data.get("services", []),
|
|
193
|
+
mode=LogMode(data.get("mode", "stream")),
|
|
194
|
+
tail=data.get("tail"),
|
|
195
|
+
since=data.get("since"),
|
|
196
|
+
filters=_normalize_string_list(data.get("filters", [])),
|
|
197
|
+
highlights=_normalize_string_list(data.get("highlights", [])),
|
|
198
|
+
monitors=_normalize_string_list(data.get("monitors", [])),
|
|
199
|
+
filters_ignore_case=data.get("filters_ignore_case", False),
|
|
200
|
+
highlights_ignore_case=data.get("highlights_ignore_case", False),
|
|
201
|
+
monitors_ignore_case=data.get("monitors_ignore_case", False),
|
|
202
|
+
line_wrap=data.get("line_wrap", True),
|
|
203
|
+
color_full_line=data.get("color_full_line", False),
|
|
204
|
+
json_format=data.get("json_format", False),
|
|
205
|
+
collapse_repeats=data.get("collapse_repeats", False),
|
|
206
|
+
health=HealthConfig(
|
|
207
|
+
enabled=health_data.get("enabled", False),
|
|
208
|
+
interval_minutes=health_data.get("interval_minutes", 5),
|
|
209
|
+
restart_threshold=health_data.get("restart_threshold", 1),
|
|
210
|
+
),
|
|
211
|
+
name=name,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def save_session_config(config: SessionConfig) -> None:
|
|
216
|
+
"""Save a session config under its project. config.name must be set."""
|
|
217
|
+
if not config.name:
|
|
218
|
+
raise ValueError("SessionConfig.name must be set before saving")
|
|
219
|
+
|
|
220
|
+
proj_dir = PROJECTS_DIR / config.project
|
|
221
|
+
proj_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
|
|
223
|
+
path = _ns_config_path(config.project, config.name)
|
|
224
|
+
_save_yaml(path, {
|
|
225
|
+
"services": config.services,
|
|
226
|
+
"mode": config.mode.value,
|
|
227
|
+
"tail": config.tail,
|
|
228
|
+
"since": config.since,
|
|
229
|
+
"filters": config.filters,
|
|
230
|
+
"highlights": config.highlights,
|
|
231
|
+
"monitors": config.monitors,
|
|
232
|
+
"filters_ignore_case": config.filters_ignore_case,
|
|
233
|
+
"highlights_ignore_case": config.highlights_ignore_case,
|
|
234
|
+
"monitors_ignore_case": config.monitors_ignore_case,
|
|
235
|
+
"line_wrap": config.line_wrap,
|
|
236
|
+
"color_full_line": config.color_full_line,
|
|
237
|
+
"json_format": config.json_format,
|
|
238
|
+
"collapse_repeats": config.collapse_repeats,
|
|
239
|
+
"health": {
|
|
240
|
+
"enabled": config.health.enabled,
|
|
241
|
+
"interval_minutes": config.health.interval_minutes,
|
|
242
|
+
"restart_threshold": config.health.restart_threshold,
|
|
243
|
+
},
|
|
244
|
+
})
|