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/inject.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
docker-orb-inject — test utility for injecting log messages into running
|
|
3
|
+
Compose containers.
|
|
4
|
+
|
|
5
|
+
Writes directly to the container's PID 1 stdout fd via `docker exec` so the
|
|
6
|
+
message appears in the live log stream exactly like a real log line.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
docker-orb-inject # interactive: pick container, loop for messages
|
|
10
|
+
docker-orb-inject -n myapp -d api -m "ERROR: test error"
|
|
11
|
+
docker-orb-inject -n myapp -c myapp-api-1 -m "WARN: test"
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _list_containers(project: str) -> list[tuple[str, str]]:
|
|
22
|
+
"""Return list of (container_name, service_name) tuples for a project."""
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
["docker", "compose", "-p", project, "ps", "--all",
|
|
25
|
+
"--format", "{{.Name}}\t{{.Service}}"],
|
|
26
|
+
capture_output=True, text=True,
|
|
27
|
+
)
|
|
28
|
+
containers = []
|
|
29
|
+
for line in result.stdout.strip().splitlines():
|
|
30
|
+
parts = line.split("\t")
|
|
31
|
+
if len(parts) >= 2:
|
|
32
|
+
containers.append((parts[0], parts[1]))
|
|
33
|
+
return containers
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_container(project: str, service: str | None, container: str | None) -> str | None:
|
|
37
|
+
"""Resolve a specific container name from either a service name or direct container name."""
|
|
38
|
+
if container:
|
|
39
|
+
return container
|
|
40
|
+
|
|
41
|
+
containers = _list_containers(project)
|
|
42
|
+
if not containers:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
if service:
|
|
46
|
+
matches = [c for c, s in containers if s == service]
|
|
47
|
+
if not matches:
|
|
48
|
+
click.echo(f"No containers found for service '{service}'.", err=True)
|
|
49
|
+
return None
|
|
50
|
+
# Pick the first one (could add round-robin later)
|
|
51
|
+
return matches[0]
|
|
52
|
+
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _inject(container_name: str, message: str, prefix: str) -> bool:
|
|
57
|
+
"""Write a message to the container's stdout. Returns True on success."""
|
|
58
|
+
full_message = f"{prefix}{message}" if prefix else message
|
|
59
|
+
# Write to /proc/1/fd/1 — the main process's stdout fd — so it appears in `docker logs`
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
["docker", "exec", container_name,
|
|
62
|
+
"sh", "-c", f"echo '{full_message}' > /proc/1/fd/1"],
|
|
63
|
+
capture_output=True, text=True,
|
|
64
|
+
)
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
click.echo(f"Inject failed: {result.stderr.strip()}", err=True)
|
|
67
|
+
return False
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
72
|
+
@click.option("-n", "--project", default=None,
|
|
73
|
+
help="Docker Compose project name (default: current directory's compose project).")
|
|
74
|
+
@click.option("-d", "--service", default=None, metavar="NAME",
|
|
75
|
+
help="Service name — a container from this service will be chosen.")
|
|
76
|
+
@click.option("-c", "--container", default=None, metavar="NAME",
|
|
77
|
+
help="Exact container name to inject into.")
|
|
78
|
+
@click.option("-m", "--message", "messages", multiple=True, metavar="MSG",
|
|
79
|
+
help="Message(s) to inject. Omit for interactive mode.")
|
|
80
|
+
@click.option("--prefix", default="[TEST] ", show_default=True,
|
|
81
|
+
help="Prefix prepended to every injected message.")
|
|
82
|
+
@click.option("--no-prefix", is_flag=True,
|
|
83
|
+
help="Suppress the prefix entirely.")
|
|
84
|
+
def main(
|
|
85
|
+
project: str | None,
|
|
86
|
+
service: str | None,
|
|
87
|
+
container: str | None,
|
|
88
|
+
messages: tuple[str, ...],
|
|
89
|
+
prefix: str,
|
|
90
|
+
no_prefix: bool,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Inject log messages into a running container for testing docker-orb
|
|
94
|
+
filters/monitors.
|
|
95
|
+
|
|
96
|
+
Messages are written to the container's stdout so they appear in the
|
|
97
|
+
live log stream.
|
|
98
|
+
|
|
99
|
+
\b
|
|
100
|
+
Examples:
|
|
101
|
+
docker-orb-inject # fully interactive
|
|
102
|
+
docker-orb-inject -d api -m "ERROR: test"
|
|
103
|
+
docker-orb-inject -d worker -m "job failed" -m "OOM killed"
|
|
104
|
+
docker-orb-inject -c myapp-api-1 --no-prefix -m "raw message"
|
|
105
|
+
"""
|
|
106
|
+
from . import compose as k
|
|
107
|
+
|
|
108
|
+
if no_prefix:
|
|
109
|
+
prefix = ""
|
|
110
|
+
|
|
111
|
+
# Resolve project
|
|
112
|
+
if project is None:
|
|
113
|
+
project = k.get_current_project()
|
|
114
|
+
|
|
115
|
+
# If neither service nor container given, show a picker
|
|
116
|
+
if not service and not container:
|
|
117
|
+
containers = _list_containers(project)
|
|
118
|
+
if not containers:
|
|
119
|
+
click.echo(f"No containers found for project '{project}'.", err=True)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
click.echo(f"\nContainers in project '{project}':\n")
|
|
123
|
+
for i, (cname, sname) in enumerate(containers):
|
|
124
|
+
click.echo(f" [{i}] {cname} ({sname})")
|
|
125
|
+
click.echo()
|
|
126
|
+
|
|
127
|
+
choice = click.prompt("Select container number", type=int)
|
|
128
|
+
if choice < 0 or choice >= len(containers):
|
|
129
|
+
click.echo("Invalid choice.", err=True)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
container = containers[choice][0]
|
|
132
|
+
|
|
133
|
+
# Resolve to a specific container name
|
|
134
|
+
target_container = _resolve_container(project, service, container)
|
|
135
|
+
if not target_container:
|
|
136
|
+
click.echo("Could not resolve a target container.", err=True)
|
|
137
|
+
sys.exit(1)
|
|
138
|
+
|
|
139
|
+
click.echo(f"Injecting into container: {target_container} (project: {project})")
|
|
140
|
+
click.echo(f"Prefix: '{prefix}'\n")
|
|
141
|
+
|
|
142
|
+
# One-shot mode
|
|
143
|
+
if messages:
|
|
144
|
+
for msg in messages:
|
|
145
|
+
ok = _inject(target_container, msg, prefix)
|
|
146
|
+
if ok:
|
|
147
|
+
click.echo(f" ✓ {prefix}{msg}")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Interactive loop
|
|
151
|
+
click.echo("Interactive mode — type a message and press Enter. Ctrl+C to quit.\n")
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
msg = click.prompt("Message")
|
|
155
|
+
ok = _inject(target_container, msg, prefix)
|
|
156
|
+
if ok:
|
|
157
|
+
click.echo(f" ✓ Injected")
|
|
158
|
+
except (click.Abort, KeyboardInterrupt):
|
|
159
|
+
click.echo("\nDone.")
|
|
160
|
+
break
|
docker_orb/jsonlog.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Detection and readable-formatting for structured JSON log lines.
|
|
3
|
+
|
|
4
|
+
Most k8s services that emit structured logs use JSON — nearly unreadable as
|
|
5
|
+
a raw line. parse_json_log_line() detects a JSON object line and extracts
|
|
6
|
+
common level/message/timestamp fields (checking a few conventional key
|
|
7
|
+
names, since loggers disagree on naming), leaving everything else as
|
|
8
|
+
trailing key=value context. The caller decides whether to actually display
|
|
9
|
+
the formatted form or the raw line — detection always runs regardless, so
|
|
10
|
+
the "press Enter for full JSON" detail view works even in raw mode.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
# Checked in order; first key present wins. Covers the common structured
|
|
19
|
+
# logging libraries (zap, logrus, structlog, bunyan, stdlib json logging, ...).
|
|
20
|
+
_LEVEL_KEYS = ("level", "lvl", "severity", "loglevel", "log.level")
|
|
21
|
+
_MESSAGE_KEYS = ("message", "msg", "text")
|
|
22
|
+
_TIME_KEYS = ("timestamp", "time", "ts", "@timestamp", "asctime")
|
|
23
|
+
|
|
24
|
+
# Level name -> Rich style, for coloring the level token in the main stream.
|
|
25
|
+
LEVEL_STYLES = {
|
|
26
|
+
"TRACE": "dim",
|
|
27
|
+
"DEBUG": "dim cyan",
|
|
28
|
+
"INFO": "cyan",
|
|
29
|
+
"NOTICE": "cyan",
|
|
30
|
+
"WARN": "bold yellow",
|
|
31
|
+
"WARNING": "bold yellow",
|
|
32
|
+
"ERROR": "bold red",
|
|
33
|
+
"FATAL": "bold white on red",
|
|
34
|
+
"CRITICAL": "bold white on red",
|
|
35
|
+
"PANIC": "bold white on red",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ParsedJsonLine:
|
|
41
|
+
raw: dict # the full decoded object, for the detail modal
|
|
42
|
+
level: str | None # as found in the data, original casing
|
|
43
|
+
message: str # falls back to a placeholder if no message-like key found
|
|
44
|
+
timestamp: str | None # already shortened to HH:MM:SS where parseable
|
|
45
|
+
extras: dict = field(default_factory=dict) # remaining top-level keys
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def extras_text(self) -> str:
|
|
49
|
+
return " ".join(f"{k}={_format_value(v)}" for k, v in self.extras.items())
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def display_text(self) -> str:
|
|
53
|
+
"""Single-line readable rendering: `HH:MM:SS LEVEL message k=v k=v`."""
|
|
54
|
+
parts = []
|
|
55
|
+
if self.timestamp:
|
|
56
|
+
parts.append(self.timestamp)
|
|
57
|
+
if self.level:
|
|
58
|
+
parts.append(f"{self.level.upper():<5}")
|
|
59
|
+
parts.append(self.message)
|
|
60
|
+
if self.extras:
|
|
61
|
+
parts.append(self.extras_text)
|
|
62
|
+
return " ".join(parts)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def pretty(self) -> str:
|
|
66
|
+
"""Full pretty-printed JSON, for the detail modal."""
|
|
67
|
+
return json.dumps(self.raw, indent=2, sort_keys=False, default=str)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_json_log_line(content: str) -> ParsedJsonLine | None:
|
|
71
|
+
"""
|
|
72
|
+
Return a ParsedJsonLine if `content` is a single JSON object, else None.
|
|
73
|
+
Cheap to call on every line: bails before attempting to parse anything
|
|
74
|
+
that doesn't even look like an object.
|
|
75
|
+
"""
|
|
76
|
+
stripped = content.strip()
|
|
77
|
+
if len(stripped) < 2 or stripped[0] != "{" or stripped[-1] != "}":
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(stripped)
|
|
81
|
+
except (json.JSONDecodeError, ValueError, RecursionError):
|
|
82
|
+
return None
|
|
83
|
+
if not isinstance(data, dict) or not data:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
level_key = _first_key(data, _LEVEL_KEYS)
|
|
87
|
+
message_key = _first_key(data, _MESSAGE_KEYS)
|
|
88
|
+
time_key = _first_key(data, _TIME_KEYS)
|
|
89
|
+
|
|
90
|
+
level = str(data[level_key]) if level_key else None
|
|
91
|
+
message = str(data[message_key]) if message_key else "(no message field)"
|
|
92
|
+
timestamp = _short_time(data[time_key]) if time_key else None
|
|
93
|
+
|
|
94
|
+
used = {k for k in (level_key, message_key, time_key) if k is not None}
|
|
95
|
+
extras = {k: v for k, v in data.items() if k not in used}
|
|
96
|
+
|
|
97
|
+
return ParsedJsonLine(raw=data, level=level, message=message,
|
|
98
|
+
timestamp=timestamp, extras=extras)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _first_key(data: dict, candidates: tuple[str, ...]) -> str | None:
|
|
102
|
+
for key in candidates:
|
|
103
|
+
if key in data and data[key] not in (None, ""):
|
|
104
|
+
return key
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _short_time(value: object) -> str:
|
|
109
|
+
if isinstance(value, (int, float)):
|
|
110
|
+
try:
|
|
111
|
+
return datetime.fromtimestamp(value, tz=timezone.utc).strftime("%H:%M:%S")
|
|
112
|
+
except (ValueError, OSError, OverflowError):
|
|
113
|
+
return str(value)
|
|
114
|
+
s = str(value)
|
|
115
|
+
iso = s[:-1] + "+00:00" if s.endswith("Z") else s
|
|
116
|
+
try:
|
|
117
|
+
return datetime.fromisoformat(iso).strftime("%H:%M:%S")
|
|
118
|
+
except ValueError:
|
|
119
|
+
return s
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _format_value(value: object) -> str:
|
|
123
|
+
if isinstance(value, (dict, list)):
|
|
124
|
+
return json.dumps(value, separators=(",", ":"), default=str)
|
|
125
|
+
return str(value)
|
docker_orb/models.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core domain models for docker-orb.
|
|
3
|
+
All data passed between layers is typed via these dataclasses.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LogMode(str, Enum):
|
|
13
|
+
STREAM = "stream"
|
|
14
|
+
DUMP = "dump"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ─── Docker Compose objects ────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Service:
|
|
21
|
+
name: str
|
|
22
|
+
project: str
|
|
23
|
+
container_count: int # number of containers currently backing this service
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Container:
|
|
28
|
+
name: str
|
|
29
|
+
project: str
|
|
30
|
+
service: str # owning service name
|
|
31
|
+
phase: str # Running, Pending, Failed, etc.
|
|
32
|
+
restart_count: int
|
|
33
|
+
ready: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ContainerStatus:
|
|
38
|
+
"""Snapshot of a container's health at a point in time."""
|
|
39
|
+
name: str
|
|
40
|
+
service: str
|
|
41
|
+
phase: str
|
|
42
|
+
restart_count: int
|
|
43
|
+
ready: bool
|
|
44
|
+
age_seconds: float
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_healthy(self) -> bool:
|
|
48
|
+
return self.phase == "Running" and self.ready
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ─── Log lines ────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class LogLine:
|
|
55
|
+
container_name: str
|
|
56
|
+
content: str # raw log text
|
|
57
|
+
received_at: datetime = field(default_factory=datetime.now)
|
|
58
|
+
# The real per-line timestamp Docker attaches (from `docker logs
|
|
59
|
+
# --timestamps`), parsed and stripped back out of `content`. None
|
|
60
|
+
# when unavailable (dump mode doesn't request it) or unparseable.
|
|
61
|
+
# received_at is "when docker-orb read this"; log_timestamp is "when the
|
|
62
|
+
# container actually emitted it" — used to interleave a backfill burst
|
|
63
|
+
# (see ViewerApp._handle_backfill_line) since arrival order across
|
|
64
|
+
# concurrent per-container streams doesn't reflect true chronological order.
|
|
65
|
+
log_timestamp: datetime | None = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def display(self) -> str:
|
|
69
|
+
return f"[{self.container_name}] {self.content}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ─── String matching ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class SavedStrings:
|
|
76
|
+
"""
|
|
77
|
+
Global saved string lists, persisted to ~/.config/docker-orb/strings.yaml.
|
|
78
|
+
Strings prefixed and suffixed with '/' are treated as regex patterns.
|
|
79
|
+
"""
|
|
80
|
+
filters: list[str] = field(default_factory=list)
|
|
81
|
+
highlights: list[str] = field(default_factory=list)
|
|
82
|
+
monitors: list[str] = field(default_factory=list)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ─── Session config ───────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class HealthConfig:
|
|
89
|
+
enabled: bool = False
|
|
90
|
+
interval_minutes: int = 5
|
|
91
|
+
restart_threshold: int = 1 # CLI-only tuning knob, default 1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class SessionConfig:
|
|
96
|
+
"""
|
|
97
|
+
Complete configuration for one viewer session.
|
|
98
|
+
Produced by the wizard or assembled from CLI flags.
|
|
99
|
+
Persisted per-project to ~/.config/docker-orb/projects/<proj>.yaml.
|
|
100
|
+
"""
|
|
101
|
+
project: str
|
|
102
|
+
services: list[str] # selected service names
|
|
103
|
+
mode: LogMode = LogMode.STREAM
|
|
104
|
+
|
|
105
|
+
tail: int | None = None # last N lines (dump mode only)
|
|
106
|
+
# `docker logs --since` value, e.g. "1h", "30m". Used by both modes:
|
|
107
|
+
# bounds how far back dump mode fetches (None = full history). In stream
|
|
108
|
+
# mode, a None/empty value is normalized to "1s" (see
|
|
109
|
+
# compose._normalize_since) so a live session only collects new lines
|
|
110
|
+
# instead of replaying a container's full buffered history.
|
|
111
|
+
since: str | None = None
|
|
112
|
+
|
|
113
|
+
# Active string sets for this session
|
|
114
|
+
filters: list[str] = field(default_factory=list)
|
|
115
|
+
highlights: list[str] = field(default_factory=list)
|
|
116
|
+
monitors: list[str] = field(default_factory=list) # stream only
|
|
117
|
+
|
|
118
|
+
# Case sensitivity (False = case-sensitive, True = ignore case)
|
|
119
|
+
filters_ignore_case: bool = False
|
|
120
|
+
highlights_ignore_case: bool = False
|
|
121
|
+
monitors_ignore_case: bool = False
|
|
122
|
+
|
|
123
|
+
health: HealthConfig = field(default_factory=HealthConfig)
|
|
124
|
+
|
|
125
|
+
# Display options
|
|
126
|
+
color_full_line: bool = False # True = color entire line; False = color container name prefix only
|
|
127
|
+
line_wrap: bool = True
|
|
128
|
+
json_format: bool = False # True = reformat detected JSON lines (level/message/time); False = raw
|
|
129
|
+
collapse_repeats: bool = False # True = collapse consecutive identical lines (journalctl-style)
|
|
130
|
+
|
|
131
|
+
# Config name — set when user chooses to save
|
|
132
|
+
name: str | None = None
|
|
File without changes
|