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/__init__.py
ADDED
|
File without changes
|
docker_orb/__main__.py
ADDED
docker_orb/_scrollbar.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enforces a minimum scrollbar thumb size, app-wide.
|
|
3
|
+
|
|
4
|
+
Textual's default ScrollBarRender lets the thumb shrink to a single cell for
|
|
5
|
+
very long content (e.g. the main log stream, which buffers up to 20,000
|
|
6
|
+
lines — see ViewerApp._buffer_cap). A 1-cell thumb is nearly impossible to
|
|
7
|
+
grab with a real mouse in a terminal: clicks land on the track instead,
|
|
8
|
+
which pages the view rather than dragging it, making click-drag scrolling
|
|
9
|
+
feel broken. Clamping the *visual* thumb size doesn't affect drag math
|
|
10
|
+
(ScrollBar._on_mouse_move scales purely from window_size/virtual_size, not
|
|
11
|
+
from how big the thumb is drawn), so this is purely a grabbability fix.
|
|
12
|
+
|
|
13
|
+
render_bar() below is a fork of textual.scrollbar.ScrollBarRender.render_bar
|
|
14
|
+
(textual==8.2.7) with a single change: the thumb-size floor is MIN_THUMB_SIZE
|
|
15
|
+
instead of 1. It can't be done by pre-inflating the `window_size` argument
|
|
16
|
+
and delegating to super().render_bar() — window_size also drives the thumb's
|
|
17
|
+
*position* ratio there, so inflating it shifts the thumb as well as
|
|
18
|
+
resizing it. The size floor has to be applied after position is derived from
|
|
19
|
+
the real window_size, which means owning the whole method body.
|
|
20
|
+
|
|
21
|
+
Call install() once, early, before any scrollable widgets are composed.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from math import ceil
|
|
26
|
+
|
|
27
|
+
from rich.color import Color
|
|
28
|
+
from rich.segment import Segment
|
|
29
|
+
from rich.segment import Segments
|
|
30
|
+
from rich.style import Style
|
|
31
|
+
|
|
32
|
+
from textual.scrollbar import ScrollBar, ScrollBarRender
|
|
33
|
+
|
|
34
|
+
MIN_THUMB_SIZE = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _MinThumbScrollBarRender(ScrollBarRender):
|
|
38
|
+
@classmethod
|
|
39
|
+
def render_bar(
|
|
40
|
+
cls,
|
|
41
|
+
size: int = 25,
|
|
42
|
+
virtual_size: float = 50,
|
|
43
|
+
window_size: float = 20,
|
|
44
|
+
position: float = 0,
|
|
45
|
+
thickness: int = 1,
|
|
46
|
+
vertical: bool = True,
|
|
47
|
+
back_color: Color = Color.parse("#555555"),
|
|
48
|
+
bar_color: Color = Color.parse("bright_magenta"),
|
|
49
|
+
) -> Segments:
|
|
50
|
+
if vertical:
|
|
51
|
+
bars = cls.VERTICAL_BARS
|
|
52
|
+
else:
|
|
53
|
+
bars = cls.HORIZONTAL_BARS
|
|
54
|
+
|
|
55
|
+
back = back_color
|
|
56
|
+
bar = bar_color
|
|
57
|
+
|
|
58
|
+
len_bars = len(bars)
|
|
59
|
+
|
|
60
|
+
width_thickness = thickness if vertical else 1
|
|
61
|
+
|
|
62
|
+
_Segment = Segment
|
|
63
|
+
_Style = Style
|
|
64
|
+
blank = cls.BLANK_GLYPH * width_thickness
|
|
65
|
+
|
|
66
|
+
foreground_meta = {"@mouse.down": "grab"}
|
|
67
|
+
if window_size and size and virtual_size and size != virtual_size:
|
|
68
|
+
bar_ratio = virtual_size / size
|
|
69
|
+
thumb_size = max(MIN_THUMB_SIZE, window_size / bar_ratio)
|
|
70
|
+
|
|
71
|
+
position_ratio = position / (virtual_size - window_size)
|
|
72
|
+
position = (size - thumb_size) * position_ratio
|
|
73
|
+
|
|
74
|
+
start = int(position * len_bars)
|
|
75
|
+
end = start + ceil(thumb_size * len_bars)
|
|
76
|
+
|
|
77
|
+
start_index, start_bar = divmod(max(0, start), len_bars)
|
|
78
|
+
end_index, end_bar = divmod(max(0, end), len_bars)
|
|
79
|
+
|
|
80
|
+
upper = {"@mouse.down": "scroll_up"}
|
|
81
|
+
lower = {"@mouse.down": "scroll_down"}
|
|
82
|
+
|
|
83
|
+
upper_back_segment = Segment(blank, _Style(bgcolor=back, meta=upper))
|
|
84
|
+
lower_back_segment = Segment(blank, _Style(bgcolor=back, meta=lower))
|
|
85
|
+
|
|
86
|
+
segments = [upper_back_segment] * int(size)
|
|
87
|
+
segments[end_index:] = [lower_back_segment] * (size - end_index)
|
|
88
|
+
|
|
89
|
+
segments[start_index:end_index] = [
|
|
90
|
+
_Segment(blank, _Style(color=bar, reverse=True, meta=foreground_meta))
|
|
91
|
+
] * (end_index - start_index)
|
|
92
|
+
|
|
93
|
+
# Apply the smaller bar characters to head and tail of scrollbar for more "granularity"
|
|
94
|
+
if start_index < len(segments):
|
|
95
|
+
bar_character = bars[len_bars - 1 - start_bar]
|
|
96
|
+
if bar_character != " ":
|
|
97
|
+
segments[start_index] = _Segment(
|
|
98
|
+
bar_character * width_thickness,
|
|
99
|
+
(
|
|
100
|
+
_Style(bgcolor=back, color=bar, meta=foreground_meta)
|
|
101
|
+
if vertical
|
|
102
|
+
else _Style(
|
|
103
|
+
bgcolor=back,
|
|
104
|
+
color=bar,
|
|
105
|
+
meta=foreground_meta,
|
|
106
|
+
reverse=True,
|
|
107
|
+
)
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
if end_index < len(segments):
|
|
111
|
+
bar_character = bars[len_bars - 1 - end_bar]
|
|
112
|
+
if bar_character != " ":
|
|
113
|
+
segments[end_index] = _Segment(
|
|
114
|
+
bar_character * width_thickness,
|
|
115
|
+
(
|
|
116
|
+
_Style(
|
|
117
|
+
bgcolor=back,
|
|
118
|
+
color=bar,
|
|
119
|
+
meta=foreground_meta,
|
|
120
|
+
reverse=True,
|
|
121
|
+
)
|
|
122
|
+
if vertical
|
|
123
|
+
else _Style(bgcolor=back, color=bar, meta=foreground_meta)
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
style = _Style(bgcolor=back)
|
|
128
|
+
segments = [_Segment(blank, style=style)] * int(size)
|
|
129
|
+
if vertical:
|
|
130
|
+
return Segments(segments, new_lines=True)
|
|
131
|
+
else:
|
|
132
|
+
return Segments((segments + [_Segment.line()]) * thickness, new_lines=False)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def install() -> None:
|
|
136
|
+
ScrollBar.renderer = _MinThumbScrollBarRender
|
docker_orb/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for docker-orb.
|
|
3
|
+
|
|
4
|
+
If enough arguments are provided to fully specify a session, the viewer
|
|
5
|
+
launches directly. Otherwise (or with --wizard), the interactive wizard runs.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from .models import HealthConfig, LogMode, SessionConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
17
|
+
@click.option("-n", "--project", default=None,
|
|
18
|
+
help="Docker Compose project name (default: current directory's compose project).")
|
|
19
|
+
@click.option("-p", "--container", "containers", multiple=True, metavar="NAME",
|
|
20
|
+
help="Service name to watch. Repeatable. Use multiple times for multiple services.")
|
|
21
|
+
@click.option("--all-containers", "all_containers", is_flag=True,
|
|
22
|
+
help="Watch all services in the project.")
|
|
23
|
+
@click.option("--stream", "mode", flag_value="stream", default=True,
|
|
24
|
+
help="Live tail mode (default).")
|
|
25
|
+
@click.option("--dump", "mode", flag_value="dump",
|
|
26
|
+
help="Fetch existing logs and exit. No live stream.")
|
|
27
|
+
@click.option("--tail", type=int, default=None, metavar="N",
|
|
28
|
+
help="(Dump mode) Last N lines to fetch.")
|
|
29
|
+
@click.option("--since", default=None, metavar="DURATION",
|
|
30
|
+
help="(Dump mode) Fetch logs from this far back, e.g. 1h, 30m.")
|
|
31
|
+
@click.option("-c", "--config", "config_name", default=None, metavar="NAME",
|
|
32
|
+
help="Load a saved config by name (scoped to project).")
|
|
33
|
+
@click.option("--save-config", default=None, metavar="NAME",
|
|
34
|
+
help="Save this session's config under NAME before launching.")
|
|
35
|
+
@click.option("-f", "--filter", "filters", multiple=True, metavar="PATTERN",
|
|
36
|
+
help="Hide lines matching PATTERN. Repeatable. Use /regex/ for regex.")
|
|
37
|
+
@click.option("-H", "--highlight", "highlights", multiple=True, metavar="PATTERN",
|
|
38
|
+
help="Highlight lines matching PATTERN. Repeatable.")
|
|
39
|
+
@click.option("-m", "--monitor", "monitors", multiple=True, metavar="PATTERN",
|
|
40
|
+
help="(Stream mode) Collect matching lines in monitor panel. Repeatable.")
|
|
41
|
+
@click.option("--health", is_flag=True,
|
|
42
|
+
help="(Stream mode) Enable container health monitoring panel.")
|
|
43
|
+
@click.option("--health-interval", type=int, default=5, show_default=True, metavar="MINUTES",
|
|
44
|
+
help="Container health check interval in minutes (min 1).")
|
|
45
|
+
@click.option("--health-restart-threshold", type=int, default=1, show_default=True,
|
|
46
|
+
metavar="N", hidden=True,
|
|
47
|
+
help="Show containers in health panel when restart delta >= N (default 1).")
|
|
48
|
+
@click.option("--wizard", is_flag=True,
|
|
49
|
+
help="Force the interactive setup wizard even if all args are provided.")
|
|
50
|
+
def main(
|
|
51
|
+
project: str | None,
|
|
52
|
+
containers: tuple[str, ...],
|
|
53
|
+
all_containers: bool,
|
|
54
|
+
mode: str,
|
|
55
|
+
tail: int | None,
|
|
56
|
+
since: str | None,
|
|
57
|
+
config_name: str | None,
|
|
58
|
+
save_config: str | None,
|
|
59
|
+
filters: tuple[str, ...],
|
|
60
|
+
highlights: tuple[str, ...],
|
|
61
|
+
monitors: tuple[str, ...],
|
|
62
|
+
health: bool,
|
|
63
|
+
health_interval: int,
|
|
64
|
+
health_restart_threshold: int,
|
|
65
|
+
wizard: bool,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""
|
|
68
|
+
docker-orb — Interactive Docker Compose log viewer.
|
|
69
|
+
|
|
70
|
+
Run with no arguments to launch the setup wizard.
|
|
71
|
+
Provide --project and --container flags to skip the wizard.
|
|
72
|
+
|
|
73
|
+
\b
|
|
74
|
+
Examples:
|
|
75
|
+
docker-orb # wizard
|
|
76
|
+
docker-orb -n myapp -p api -p worker --stream
|
|
77
|
+
docker-orb -n myapp --all-containers --dump --tail 500
|
|
78
|
+
docker-orb -n myapp -p worker -f DEBUG -H ERROR --health
|
|
79
|
+
"""
|
|
80
|
+
from . import compose as k
|
|
81
|
+
from .config import (
|
|
82
|
+
add_to_saved_strings,
|
|
83
|
+
load_session_config,
|
|
84
|
+
parse_string_input,
|
|
85
|
+
save_session_config,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ── Resolve project ────────────────────────────────────────────────────
|
|
89
|
+
if project is None:
|
|
90
|
+
project = k.get_current_project()
|
|
91
|
+
|
|
92
|
+
# ── Decide: wizard or direct launch ─────────────────────────────────────
|
|
93
|
+
needs_wizard = wizard or (not containers and not all_containers and not config_name)
|
|
94
|
+
if needs_wizard:
|
|
95
|
+
_run_wizard(project)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# ── Load saved config (pre-fill) ─────────────────────────────────────────
|
|
99
|
+
session: SessionConfig | None = None
|
|
100
|
+
if config_name:
|
|
101
|
+
session = load_session_config(project, config_name)
|
|
102
|
+
if session is None:
|
|
103
|
+
click.echo(f"No saved config '{config_name}' found for project '{project}'.",
|
|
104
|
+
err=True)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
# ── Build / override SessionConfig ───────────────────────────────────────
|
|
108
|
+
# CLI flags override anything from the loaded config
|
|
109
|
+
if session is None:
|
|
110
|
+
session = SessionConfig(project=project, services=[])
|
|
111
|
+
|
|
112
|
+
if containers:
|
|
113
|
+
session.services = list(containers)
|
|
114
|
+
elif all_containers:
|
|
115
|
+
session.services = [svc.name for svc in k.get_services(project)]
|
|
116
|
+
|
|
117
|
+
session.mode = LogMode(mode)
|
|
118
|
+
if tail is not None:
|
|
119
|
+
session.tail = tail
|
|
120
|
+
if since is not None:
|
|
121
|
+
session.since = since
|
|
122
|
+
|
|
123
|
+
# Merge CLI strings with any loaded-config strings
|
|
124
|
+
def _merge(existing: list[str], incoming: tuple[str, ...]) -> list[str]:
|
|
125
|
+
extra: list[str] = []
|
|
126
|
+
for raw in incoming:
|
|
127
|
+
extra.extend(parse_string_input(raw))
|
|
128
|
+
return existing + [s for s in extra if s not in existing]
|
|
129
|
+
|
|
130
|
+
session.filters = _merge(session.filters, filters)
|
|
131
|
+
session.highlights = _merge(session.highlights, highlights)
|
|
132
|
+
session.monitors = _merge(session.monitors, monitors)
|
|
133
|
+
|
|
134
|
+
if health:
|
|
135
|
+
session.health.enabled = True
|
|
136
|
+
session.health.interval_minutes = max(1, health_interval)
|
|
137
|
+
session.health.restart_threshold = health_restart_threshold
|
|
138
|
+
|
|
139
|
+
# ── Validate ─────────────────────────────────────────────────────────────
|
|
140
|
+
if not session.services:
|
|
141
|
+
click.echo("No services specified. Use --container NAME or --all-containers.", err=True)
|
|
142
|
+
sys.exit(1)
|
|
143
|
+
|
|
144
|
+
# ── Optionally save config ────────────────────────────────────────────────
|
|
145
|
+
if save_config:
|
|
146
|
+
session.name = save_config
|
|
147
|
+
save_session_config(session)
|
|
148
|
+
click.echo(f"Config saved as '{save_config}' for project '{project}'.")
|
|
149
|
+
|
|
150
|
+
# ── Launch viewer ─────────────────────────────────────────────────────────
|
|
151
|
+
_run_viewer(session)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _run_wizard(project: str) -> None:
|
|
155
|
+
from .wizard.app import WizardApp
|
|
156
|
+
app = WizardApp(initial_project=project)
|
|
157
|
+
result = app.run()
|
|
158
|
+
if result is None:
|
|
159
|
+
# User cancelled
|
|
160
|
+
sys.exit(0)
|
|
161
|
+
_run_viewer(result)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _run_viewer(config: SessionConfig) -> None:
|
|
165
|
+
from .viewer.app import ViewerApp
|
|
166
|
+
app = ViewerApp(config=config)
|
|
167
|
+
app.run()
|
docker_orb/colors.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Color assignment for containers using the golden angle method.
|
|
3
|
+
|
|
4
|
+
Distributes hues at 137.508° (the golden angle) intervals in HSL space,
|
|
5
|
+
which mathematically maximises perceptual distance between consecutive colors
|
|
6
|
+
regardless of how many containers are selected.
|
|
7
|
+
|
|
8
|
+
With 2 containers → hues ~180° apart (near-complementary)
|
|
9
|
+
With 5 containers → hues evenly spread ~137° apart
|
|
10
|
+
With 10 containers → still well-separated, no two adjacent colors look alike
|
|
11
|
+
|
|
12
|
+
Saturation and lightness are fixed for good readability on dark terminals.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import colorsys
|
|
17
|
+
|
|
18
|
+
# Golden angle in degrees
|
|
19
|
+
GOLDEN_ANGLE = 137.508
|
|
20
|
+
|
|
21
|
+
# HSL fixed axes — tuned for dark terminal backgrounds
|
|
22
|
+
SATURATION = 0.75 # vivid but not neon
|
|
23
|
+
LIGHTNESS = 0.65 # bright enough to read, not washed out
|
|
24
|
+
|
|
25
|
+
# Starting hue offset — avoids starting at pure red (0°) which can look like error color
|
|
26
|
+
_START_HUE = 30.0 # degrees
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _hsl_to_rgb(h_deg: float, s: float, l: float) -> tuple[int, int, int]:
|
|
30
|
+
"""Convert HSL (h in degrees, s and l in [0,1]) to RGB tuple (0–255)."""
|
|
31
|
+
h = h_deg / 360.0
|
|
32
|
+
r, g, b = colorsys.hls_to_rgb(h, l, s)
|
|
33
|
+
return int(r * 255), int(g * 255), int(b * 255)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _rgb_to_hex(r: int, g: int, b: int) -> str:
|
|
37
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def assign_colors(container_names: list[str]) -> dict[str, str]:
|
|
41
|
+
"""
|
|
42
|
+
Return a mapping of container_name → hex color string.
|
|
43
|
+
|
|
44
|
+
Colors are assigned in the order containers appear in the list.
|
|
45
|
+
The same container list always produces the same colors (deterministic).
|
|
46
|
+
"""
|
|
47
|
+
result: dict[str, str] = {}
|
|
48
|
+
for i, name in enumerate(container_names):
|
|
49
|
+
hue = (_START_HUE + i * GOLDEN_ANGLE) % 360.0
|
|
50
|
+
rgb = _hsl_to_rgb(hue, SATURATION, LIGHTNESS)
|
|
51
|
+
result[name] = _rgb_to_hex(*rgb)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_color(container_name: str, color_map: dict[str, str]) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Look up a container's color, returning a safe fallback if not found.
|
|
58
|
+
The fallback is white, which is always readable on dark backgrounds.
|
|
59
|
+
"""
|
|
60
|
+
return color_map.get(container_name, "#ffffff")
|