dockerbrain 1.0.2__tar.gz → 1.1.0__tar.gz
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.
- {dockerbrain-1.0.2/dockerbrain.egg-info → dockerbrain-1.1.0}/PKG-INFO +3 -2
- dockerbrain-1.1.0/core/__init__.py +1 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/monitor/__init__.py +2 -4
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/monitor/collector.py +29 -54
- dockerbrain-1.1.0/core/monitor/display.py +501 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0/dockerbrain.egg-info}/PKG-INFO +3 -2
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/requires.txt +1 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/pyproject.toml +3 -2
- dockerbrain-1.0.2/core/__init__.py +0 -1
- dockerbrain-1.0.2/core/monitor/display.py +0 -279
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/LICENSE +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/PYPI_README.md +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/README.md +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/__main__.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/ai_advisor.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/cli.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/dockerizer.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/fixer/__init__.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/fixer/container.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/fixer/dockerfile.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/llm.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/monitor/snapshot.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/optimizer/__init__.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/optimizer/engine.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/optimizer/rules.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/storage.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/templates.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/utils.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/SOURCES.txt +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/dependency_links.txt +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/entry_points.txt +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/top_level.txt +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/setup.cfg +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_ai_advisor.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_dockerizer.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_fixer.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_llm.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_monitor.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_optimizer.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_storage.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_templates.py +0 -0
- {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_utils.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dockerbrain
|
|
3
|
-
Version: 1.0
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Dockerfile and container monitoring, analysis, and optimization CLI.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/iamPulakesh/DockerBrain
|
|
7
7
|
Project-URL: Repository, https://github.com/iamPulakesh/DockerBrain
|
|
@@ -29,6 +29,7 @@ Requires-Dist: docker>=6.0
|
|
|
29
29
|
Requires-Dist: google-genai>=1.0
|
|
30
30
|
Requires-Dist: openai>=1.0
|
|
31
31
|
Requires-Dist: rich>=13.0
|
|
32
|
+
Requires-Dist: textual>=0.85
|
|
32
33
|
Provides-Extra: dev
|
|
33
34
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
34
35
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.0"
|
|
@@ -7,10 +7,9 @@ from core.monitor.snapshot import (
|
|
|
7
7
|
)
|
|
8
8
|
from core.monitor.display import (
|
|
9
9
|
_format_uptime,
|
|
10
|
-
_make_bar,
|
|
11
10
|
_cpu_color,
|
|
12
11
|
_mem_color,
|
|
13
|
-
|
|
12
|
+
DockerBrainMonitor,
|
|
14
13
|
)
|
|
15
14
|
from core.monitor.collector import (
|
|
16
15
|
ContainerMonitor,
|
|
@@ -20,14 +19,13 @@ from core.monitor.collector import (
|
|
|
20
19
|
__all__ = [
|
|
21
20
|
"ContainerSnapshot",
|
|
22
21
|
"ContainerMonitor",
|
|
22
|
+
"DockerBrainMonitor",
|
|
23
23
|
"run_monitor",
|
|
24
|
-
"build_monitor_layout",
|
|
25
24
|
"IDLE_CPU_THRESHOLD",
|
|
26
25
|
"IDLE_CONSECUTIVE_POLLS",
|
|
27
26
|
"MEM_WARNING_PCT",
|
|
28
27
|
"MEM_CRITICAL_PCT",
|
|
29
28
|
"_format_uptime",
|
|
30
|
-
"_make_bar",
|
|
31
29
|
"_cpu_color",
|
|
32
30
|
"_mem_color",
|
|
33
31
|
]
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import threading
|
|
4
|
-
import time
|
|
5
4
|
from collections import defaultdict
|
|
6
5
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
6
|
from datetime import datetime, timezone
|
|
@@ -9,7 +8,7 @@ from datetime import datetime, timezone
|
|
|
9
8
|
import docker
|
|
10
9
|
from docker.errors import APIError, DockerException
|
|
11
10
|
from rich.console import Console
|
|
12
|
-
|
|
11
|
+
|
|
13
12
|
from rich.panel import Panel
|
|
14
13
|
|
|
15
14
|
from core.monitor.snapshot import (
|
|
@@ -17,7 +16,7 @@ from core.monitor.snapshot import (
|
|
|
17
16
|
IDLE_CPU_THRESHOLD,
|
|
18
17
|
IDLE_CONSECUTIVE_POLLS,
|
|
19
18
|
)
|
|
20
|
-
|
|
19
|
+
|
|
21
20
|
from core.storage import store_snapshot
|
|
22
21
|
from core.utils import calc_cpu_percent, get_docker_offline_hint
|
|
23
22
|
|
|
@@ -31,8 +30,8 @@ class ContainerMonitor:
|
|
|
31
30
|
self.interval = interval
|
|
32
31
|
self.client = self._connect()
|
|
33
32
|
self._idle_counter: dict[str, int] = defaultdict(int)
|
|
33
|
+
self._paused_uptime: dict[str, float] = {}
|
|
34
34
|
self._lock = threading.Lock()
|
|
35
|
-
self._latest_snapshots: list[ContainerSnapshot] = []
|
|
36
35
|
|
|
37
36
|
@staticmethod
|
|
38
37
|
def _connect() -> docker.DockerClient:
|
|
@@ -85,13 +84,25 @@ class ContainerMonitor:
|
|
|
85
84
|
net_tx_drop = sum(v.get("tx_dropped", 0) for v in networks.values())
|
|
86
85
|
|
|
87
86
|
uptime_secs = 0.0
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
87
|
+
status = ctr.status
|
|
88
|
+
|
|
89
|
+
if status == "running":
|
|
90
|
+
started_at = ctr.attrs.get("State", {}).get("StartedAt", "")
|
|
91
|
+
if started_at:
|
|
92
|
+
try:
|
|
93
|
+
start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
94
|
+
uptime_secs = (datetime.now(timezone.utc) - start_dt).total_seconds()
|
|
95
|
+
except (ValueError, TypeError):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
self._paused_uptime[ctr.name] = uptime_secs
|
|
99
|
+
elif status == "paused":
|
|
100
|
+
|
|
101
|
+
uptime_secs = self._paused_uptime.get(ctr.name, 0.0)
|
|
102
|
+
else:
|
|
103
|
+
|
|
104
|
+
uptime_secs = 0.0
|
|
105
|
+
self._paused_uptime.pop(ctr.name, None)
|
|
95
106
|
|
|
96
107
|
restart_count = ctr.attrs.get("RestartCount", 0)
|
|
97
108
|
image_tag = ctr.image.tags[0] if ctr.image.tags else ctr.image.short_id
|
|
@@ -133,7 +144,7 @@ class ContainerMonitor:
|
|
|
133
144
|
Uses a thread pool so N containers finish in ~1s (time of slowest
|
|
134
145
|
single stats call), not N seconds.
|
|
135
146
|
"""
|
|
136
|
-
containers = self.client.containers.list()
|
|
147
|
+
containers = self.client.containers.list(all=True)
|
|
137
148
|
if not containers:
|
|
138
149
|
return []
|
|
139
150
|
|
|
@@ -150,48 +161,12 @@ class ContainerMonitor:
|
|
|
150
161
|
snapshots.sort(key=lambda s: s.name)
|
|
151
162
|
return snapshots
|
|
152
163
|
|
|
153
|
-
def run(self, duration: int | None = None) -> None:
|
|
154
|
-
"""Poll containers in a background thread, refresh display every second.
|
|
155
|
-
|
|
156
|
-
Polling runs in a daemon thread using a thread pool.
|
|
157
|
-
The display loop redraws every second from the latest shared snapshot.
|
|
158
|
-
"""
|
|
159
|
-
start = time.monotonic()
|
|
160
|
-
stop_event = threading.Event()
|
|
161
|
-
|
|
162
|
-
def _poll_loop() -> None:
|
|
163
|
-
while not stop_event.is_set():
|
|
164
|
-
snaps = self.poll()
|
|
165
|
-
with self._lock:
|
|
166
|
-
self._latest_snapshots = snaps
|
|
167
|
-
stop_event.wait(self.interval)
|
|
168
|
-
|
|
169
|
-
poll_thread = threading.Thread(target=_poll_loop, daemon=True)
|
|
170
|
-
poll_thread.start()
|
|
171
|
-
|
|
172
|
-
try:
|
|
173
|
-
with Live(console=console, refresh_per_second=4, screen=True) as live:
|
|
174
|
-
while True:
|
|
175
|
-
elapsed = time.monotonic() - start
|
|
176
|
-
if duration and elapsed >= duration:
|
|
177
|
-
break
|
|
178
|
-
|
|
179
|
-
with self._lock:
|
|
180
|
-
snaps = list(self._latest_snapshots)
|
|
181
|
-
|
|
182
|
-
live.update(build_monitor_layout(snaps))
|
|
183
|
-
time.sleep(1)
|
|
184
|
-
|
|
185
|
-
except KeyboardInterrupt:
|
|
186
|
-
pass
|
|
187
|
-
finally:
|
|
188
|
-
stop_event.set()
|
|
189
|
-
|
|
190
|
-
elapsed = time.monotonic() - start
|
|
191
|
-
console.print(f"\n[yellow]Monitoring stopped after {elapsed:.0f}s.[/]")
|
|
192
|
-
|
|
193
164
|
|
|
194
165
|
def run_monitor(interval: int = 1, duration: int | None = None) -> None:
|
|
195
|
-
"""Create a ContainerMonitor and
|
|
166
|
+
"""Create a ContainerMonitor and launch the interactive TUI."""
|
|
196
167
|
monitor = ContainerMonitor(interval=interval)
|
|
197
|
-
|
|
168
|
+
|
|
169
|
+
from core.monitor.display import DockerBrainMonitor
|
|
170
|
+
|
|
171
|
+
app = DockerBrainMonitor(monitor=monitor, duration=duration)
|
|
172
|
+
app.run()
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Container, Vertical
|
|
9
|
+
from textual.widgets import (
|
|
10
|
+
Header,
|
|
11
|
+
Footer,
|
|
12
|
+
DataTable,
|
|
13
|
+
Label,
|
|
14
|
+
Static,
|
|
15
|
+
ProgressBar,
|
|
16
|
+
TabbedContent,
|
|
17
|
+
TabPane,
|
|
18
|
+
Log,
|
|
19
|
+
)
|
|
20
|
+
from textual import work
|
|
21
|
+
|
|
22
|
+
from core.monitor.snapshot import (
|
|
23
|
+
ContainerSnapshot,
|
|
24
|
+
MEM_WARNING_PCT,
|
|
25
|
+
MEM_CRITICAL_PCT,
|
|
26
|
+
)
|
|
27
|
+
from core.utils import format_bytes
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from core.monitor.collector import ContainerMonitor
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _format_uptime(seconds: float) -> str:
|
|
34
|
+
"""Convert seconds to a human-readable uptime string."""
|
|
35
|
+
if seconds < 60:
|
|
36
|
+
return f"{seconds:.0f}s"
|
|
37
|
+
if seconds < 3600:
|
|
38
|
+
return f"{seconds / 60:.0f}m"
|
|
39
|
+
hours = seconds // 3600
|
|
40
|
+
mins = (seconds % 3600) // 60
|
|
41
|
+
if hours < 24:
|
|
42
|
+
return f"{hours:.0f}h {mins:.0f}m"
|
|
43
|
+
days = hours // 24
|
|
44
|
+
hours = hours % 24
|
|
45
|
+
return f"{days:.0f}d {hours:.0f}h"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cpu_color(pct: float) -> str:
|
|
49
|
+
"""Return a color name for a CPU percentage."""
|
|
50
|
+
if pct > 80:
|
|
51
|
+
return "red"
|
|
52
|
+
if pct > 50:
|
|
53
|
+
return "yellow"
|
|
54
|
+
return "green"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _mem_color(pct: float) -> str:
|
|
58
|
+
"""Return a color name for a memory percentage."""
|
|
59
|
+
if pct > MEM_CRITICAL_PCT:
|
|
60
|
+
return "red"
|
|
61
|
+
if pct > MEM_WARNING_PCT:
|
|
62
|
+
return "yellow"
|
|
63
|
+
return "cyan"
|
|
64
|
+
|
|
65
|
+
STATUS_ICON = {
|
|
66
|
+
"running": "● ",
|
|
67
|
+
"exited": "● ",
|
|
68
|
+
"paused": "● ",
|
|
69
|
+
"created": "◌ ",
|
|
70
|
+
"dead": "✗ ",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
STATUS_STYLE = {
|
|
74
|
+
"running": "bold green",
|
|
75
|
+
"exited": "bold #FF0000",
|
|
76
|
+
"paused": "bold #ff8c00",
|
|
77
|
+
"created": "cyan",
|
|
78
|
+
"dead": "bold red",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class StatBar(Static):
|
|
82
|
+
"""A labeled progress bar for a single metric."""
|
|
83
|
+
|
|
84
|
+
DEFAULT_CSS = """
|
|
85
|
+
StatBar {
|
|
86
|
+
height: 3;
|
|
87
|
+
margin-bottom: 1;
|
|
88
|
+
}
|
|
89
|
+
StatBar Label {
|
|
90
|
+
color: $text-muted;
|
|
91
|
+
}
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, label: str, bar_id: str, **kwargs):
|
|
95
|
+
super().__init__(**kwargs)
|
|
96
|
+
self._label = label
|
|
97
|
+
self._bar_id = bar_id
|
|
98
|
+
|
|
99
|
+
def compose(self) -> ComposeResult:
|
|
100
|
+
yield Label(f"{self._label}: —")
|
|
101
|
+
yield ProgressBar(total=100, show_eta=False, show_percentage=True, id=self._bar_id)
|
|
102
|
+
|
|
103
|
+
def update_stat(self, pct: float, detail: str):
|
|
104
|
+
try:
|
|
105
|
+
self.query_one(Label).update(f"{self._label}: {detail}")
|
|
106
|
+
self.query_one(ProgressBar).progress = min(pct, 100)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
class ContainerDetail(Static):
|
|
111
|
+
"""Expandable detail panel for the selected container."""
|
|
112
|
+
|
|
113
|
+
DEFAULT_CSS = """
|
|
114
|
+
ContainerDetail {
|
|
115
|
+
border: round $accent;
|
|
116
|
+
padding: 1 2;
|
|
117
|
+
height: auto;
|
|
118
|
+
margin: 1 0;
|
|
119
|
+
}
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def compose(self) -> ComposeResult:
|
|
123
|
+
yield Label("Select a container from the table above", id="detail_header")
|
|
124
|
+
yield StatBar("CPU ", bar_id="bar_cpu", id="cpu_stat")
|
|
125
|
+
yield StatBar("MEM ", bar_id="bar_mem", id="mem_stat")
|
|
126
|
+
yield StatBar("NET↓", bar_id="bar_net", id="net_stat")
|
|
127
|
+
yield Label("", id="detail_footer")
|
|
128
|
+
|
|
129
|
+
def refresh_snapshot(self, snap: ContainerSnapshot):
|
|
130
|
+
"""Update every element in the detail panel from a snapshot."""
|
|
131
|
+
try:
|
|
132
|
+
icon = STATUS_ICON.get(snap.status, "? ")
|
|
133
|
+
style = STATUS_STYLE.get(snap.status, "white")
|
|
134
|
+
status_text = "stopped" if snap.status == "exited" else snap.status
|
|
135
|
+
self.query_one("#detail_header", Label).update(
|
|
136
|
+
f"[bold cyan]{snap.name}[/] "
|
|
137
|
+
f"[dim]{snap.image_tag}[/] "
|
|
138
|
+
f"[{style}]{icon}{status_text}[/] "
|
|
139
|
+
f"[dim]uptime {_format_uptime(snap.uptime_seconds)}[/]"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
cpu_c = _cpu_color(snap.cpu_percent)
|
|
143
|
+
self.query_one("#cpu_stat", StatBar).update_stat(
|
|
144
|
+
snap.cpu_percent,
|
|
145
|
+
f"[{cpu_c}]{snap.cpu_percent:.1f}%[/]",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
mem_c = _mem_color(snap.mem_percent)
|
|
149
|
+
self.query_one("#mem_stat", StatBar).update_stat(
|
|
150
|
+
snap.mem_percent,
|
|
151
|
+
f"[{mem_c}]{snap.mem_usage_mb:.1f} / {snap.mem_limit_mb:.0f} MB "
|
|
152
|
+
f"({snap.mem_percent:.1f}%)[/]",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
self.query_one("#net_stat", StatBar).update_stat(
|
|
156
|
+
min(snap.net_rx_bytes / 1_000_000, 100),
|
|
157
|
+
f"↓{format_bytes(snap.net_rx_bytes)} ↑{format_bytes(snap.net_tx_bytes)}",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
errs = snap.net_rx_errors + snap.net_tx_errors
|
|
161
|
+
drops = snap.net_rx_dropped + snap.net_tx_dropped
|
|
162
|
+
err_s = "bold red" if errs else "dim"
|
|
163
|
+
drop_s = "bold red" if drops else "dim"
|
|
164
|
+
|
|
165
|
+
self.query_one("#detail_footer", Label).update(
|
|
166
|
+
f" [bold {snap.health_style}]{snap.health_label}[/] "
|
|
167
|
+
f"[dim]restarts:[/] {snap.restart_count} "
|
|
168
|
+
f"[dim]idle polls:[/] {snap.idle_polls} "
|
|
169
|
+
f"[{err_s}]errors: {errs}[/] "
|
|
170
|
+
f"[{drop_s}]drops: {drops}[/]"
|
|
171
|
+
)
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class DockerBrainMonitor(App):
|
|
177
|
+
"""DockerBrain — Interactive Docker Monitor TUI."""
|
|
178
|
+
|
|
179
|
+
TITLE = "DockerBrain Monitor"
|
|
180
|
+
|
|
181
|
+
CSS = """
|
|
182
|
+
Screen {
|
|
183
|
+
background: $background;
|
|
184
|
+
}
|
|
185
|
+
#summary_bar {
|
|
186
|
+
height: 3;
|
|
187
|
+
background: $surface;
|
|
188
|
+
border: hkey $accent;
|
|
189
|
+
padding: 0 2;
|
|
190
|
+
color: $text-muted;
|
|
191
|
+
}
|
|
192
|
+
#main_table {
|
|
193
|
+
height: 1fr;
|
|
194
|
+
border: round $primary;
|
|
195
|
+
}
|
|
196
|
+
DataTable {
|
|
197
|
+
height: 1fr;
|
|
198
|
+
}
|
|
199
|
+
DataTable > .datatable--header {
|
|
200
|
+
background: $primary-darken-2;
|
|
201
|
+
color: $accent;
|
|
202
|
+
text-style: bold;
|
|
203
|
+
}
|
|
204
|
+
DataTable > .datatable--cursor {
|
|
205
|
+
background: $accent 30%;
|
|
206
|
+
color: $text;
|
|
207
|
+
}
|
|
208
|
+
#detail_panel {
|
|
209
|
+
height: auto;
|
|
210
|
+
max-height: 16;
|
|
211
|
+
}
|
|
212
|
+
#log_pane {
|
|
213
|
+
height: 1fr;
|
|
214
|
+
border: round $primary;
|
|
215
|
+
}
|
|
216
|
+
Footer {
|
|
217
|
+
background: $surface;
|
|
218
|
+
}
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
BINDINGS = [
|
|
222
|
+
Binding("q", "quit", "Quit"),
|
|
223
|
+
Binding("r", "refresh", "Refresh"),
|
|
224
|
+
Binding("s", "stop_container", "Stop"),
|
|
225
|
+
Binding("p", "pause_container", "Start/Pause"),
|
|
226
|
+
Binding("t", "restart_container", "Restart"),
|
|
227
|
+
Binding("x", "remove_container", "Remove"),
|
|
228
|
+
Binding("d", "toggle_detail", "Detail"),
|
|
229
|
+
|
|
230
|
+
Binding("c", "sort_cpu", "Sort:CPU"),
|
|
231
|
+
Binding("m", "sort_mem", "Sort:Mem"),
|
|
232
|
+
Binding("1", "tab_monitor", "1:Monitor", show=False),
|
|
233
|
+
Binding("2", "view_logs", "2:Logs", show=False),
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
_snapshots: list[ContainerSnapshot] = []
|
|
237
|
+
_selected_name: str | None = None
|
|
238
|
+
_sort_key: str = "name"
|
|
239
|
+
_sort_reverse: bool = False
|
|
240
|
+
|
|
241
|
+
def __init__(self, monitor: ContainerMonitor, duration: int | None = None, **kwargs):
|
|
242
|
+
super().__init__(**kwargs)
|
|
243
|
+
self._monitor = monitor
|
|
244
|
+
self._duration = duration
|
|
245
|
+
|
|
246
|
+
def compose(self) -> ComposeResult:
|
|
247
|
+
yield Header(show_clock=False)
|
|
248
|
+
with TabbedContent(initial="monitor"):
|
|
249
|
+
with TabPane(" Monitor ", id="monitor"):
|
|
250
|
+
with Vertical():
|
|
251
|
+
yield Static(id="summary_bar")
|
|
252
|
+
with Container(id="main_table"):
|
|
253
|
+
yield DataTable(zebra_stripes=True, cursor_type="row")
|
|
254
|
+
with Container(id="detail_panel"):
|
|
255
|
+
yield ContainerDetail(id="detail_view")
|
|
256
|
+
with TabPane(" Logs ", id="logs"):
|
|
257
|
+
yield Log(id="log_pane", auto_scroll=True, max_lines=500)
|
|
258
|
+
yield Footer()
|
|
259
|
+
|
|
260
|
+
def on_mount(self) -> None:
|
|
261
|
+
table = self.query_one(DataTable)
|
|
262
|
+
table.add_columns(
|
|
263
|
+
" State", "Name", "Image", "CPU %", "MEM",
|
|
264
|
+
"MEM %", "Net ↓ / ↑", "Status", "Uptime",
|
|
265
|
+
)
|
|
266
|
+
self.set_interval(self._monitor.interval, self._poll_stats)
|
|
267
|
+
if self._duration:
|
|
268
|
+
self.set_timer(self._duration, lambda: self.exit())
|
|
269
|
+
self._poll_stats()
|
|
270
|
+
|
|
271
|
+
@work(thread=True)
|
|
272
|
+
def _poll_stats(self) -> None:
|
|
273
|
+
"""Run ContainerMonitor.poll() in a background thread."""
|
|
274
|
+
try:
|
|
275
|
+
snaps = self._monitor.poll()
|
|
276
|
+
self.call_from_thread(self._update_ui, snaps)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
self.call_from_thread(
|
|
279
|
+
self.query_one("#summary_bar", Static).update,
|
|
280
|
+
f"[red]Error: {e}[/]",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def _update_ui(self, snaps: list[ContainerSnapshot]) -> None:
|
|
284
|
+
"""Redraw the table and summary bar from fresh snapshots."""
|
|
285
|
+
self._snapshots = snaps
|
|
286
|
+
table = self.query_one(DataTable)
|
|
287
|
+
table.clear()
|
|
288
|
+
|
|
289
|
+
running = sum(1 for s in snaps if s.status == "running")
|
|
290
|
+
stopped = sum(1 for s in snaps if s.status == "exited")
|
|
291
|
+
paused = sum(1 for s in snaps if s.status == "paused")
|
|
292
|
+
idle = sum(1 for s in snaps if s.is_idle)
|
|
293
|
+
total_cpu = sum(s.cpu_percent for s in snaps)
|
|
294
|
+
total_mem = sum(s.mem_usage_mb for s in snaps)
|
|
295
|
+
|
|
296
|
+
# Apply sorting
|
|
297
|
+
sort_map = {
|
|
298
|
+
"name": lambda s: s.name.lower(),
|
|
299
|
+
"cpu": lambda s: s.cpu_percent,
|
|
300
|
+
"mem": lambda s: s.mem_usage_mb,
|
|
301
|
+
}
|
|
302
|
+
key_fn = sort_map.get(self._sort_key, sort_map["name"])
|
|
303
|
+
snaps = sorted(snaps, key=key_fn, reverse=self._sort_reverse)
|
|
304
|
+
self._snapshots = snaps
|
|
305
|
+
|
|
306
|
+
arrow = "↑" if not self._sort_reverse else "↓"
|
|
307
|
+
sort_label = f"[dim cyan]sort: {self._sort_key} {arrow}[/]"
|
|
308
|
+
|
|
309
|
+
summary = (
|
|
310
|
+
f"[ansi_bright_green]● {running} running[/] "
|
|
311
|
+
f"[bold red]● {stopped} stopped[/] "
|
|
312
|
+
f"[bold #ff8c00]● {paused} paused[/] "
|
|
313
|
+
)
|
|
314
|
+
summary += (
|
|
315
|
+
f"[dim]total: {len(snaps)}[/] "
|
|
316
|
+
f"[dim]CPU: {total_cpu:.1f}%[/] "
|
|
317
|
+
f"[dim]MEM: {total_mem:.0f} MB[/] "
|
|
318
|
+
f"{sort_label}"
|
|
319
|
+
)
|
|
320
|
+
self.query_one("#summary_bar", Static).update(summary)
|
|
321
|
+
|
|
322
|
+
for s in snaps:
|
|
323
|
+
icon = STATUS_ICON.get(s.status, "? ")
|
|
324
|
+
style = STATUS_STYLE.get(s.status, "white")
|
|
325
|
+
cpu_c = _cpu_color(s.cpu_percent)
|
|
326
|
+
mem_c = _mem_color(s.mem_percent)
|
|
327
|
+
|
|
328
|
+
status_text = "stopped" if s.status == "exited" else s.status
|
|
329
|
+
table.add_row(
|
|
330
|
+
f"[{style}]{icon}{status_text}[/]",
|
|
331
|
+
f"[bold]{s.name}[/]",
|
|
332
|
+
f"[dim]{s.image_tag}[/]",
|
|
333
|
+
f"[{cpu_c}]{s.cpu_percent:.1f}%[/]",
|
|
334
|
+
f"{s.mem_usage_mb:.1f} MB",
|
|
335
|
+
f"[{mem_c}]{s.mem_percent:.1f}%[/]",
|
|
336
|
+
f"↓{format_bytes(s.net_rx_bytes)} ↑{format_bytes(s.net_tx_bytes)}",
|
|
337
|
+
f"[bold {s.health_style}]{s.health_label}[/]",
|
|
338
|
+
f"[dim]{_format_uptime(s.uptime_seconds)}[/]",
|
|
339
|
+
key=s.name,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
if self._selected_name:
|
|
343
|
+
try:
|
|
344
|
+
for idx, key in enumerate(table.rows.keys()):
|
|
345
|
+
if str(key.value) == self._selected_name:
|
|
346
|
+
table.move_cursor(row=idx)
|
|
347
|
+
break
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
self._update_detail(self._selected_name)
|
|
351
|
+
|
|
352
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
353
|
+
self._selected_name = str(event.row_key.value)
|
|
354
|
+
self._update_detail(self._selected_name)
|
|
355
|
+
|
|
356
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
357
|
+
if event.row_key:
|
|
358
|
+
self._selected_name = str(event.row_key.value)
|
|
359
|
+
self._update_detail(self._selected_name)
|
|
360
|
+
|
|
361
|
+
def _update_detail(self, name: str) -> None:
|
|
362
|
+
for s in self._snapshots:
|
|
363
|
+
if s.name == name:
|
|
364
|
+
self.query_one("#detail_view", ContainerDetail).refresh_snapshot(s)
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
def _toggle_sort(self, key: str, default_reverse: bool = False) -> None:
|
|
368
|
+
if self._sort_key == key:
|
|
369
|
+
self._sort_reverse = not self._sort_reverse
|
|
370
|
+
else:
|
|
371
|
+
self._sort_key = key
|
|
372
|
+
self._sort_reverse = default_reverse
|
|
373
|
+
self._update_ui(self._snapshots)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def action_sort_cpu(self) -> None:
|
|
378
|
+
self._toggle_sort("cpu", default_reverse=True)
|
|
379
|
+
|
|
380
|
+
def action_sort_mem(self) -> None:
|
|
381
|
+
self._toggle_sort("mem", default_reverse=True)
|
|
382
|
+
|
|
383
|
+
def action_refresh(self) -> None:
|
|
384
|
+
self._poll_stats()
|
|
385
|
+
|
|
386
|
+
def action_toggle_detail(self) -> None:
|
|
387
|
+
panel = self.query_one("#detail_panel")
|
|
388
|
+
panel.display = not panel.display
|
|
389
|
+
|
|
390
|
+
def action_tab_monitor(self) -> None:
|
|
391
|
+
self.query_one(TabbedContent).active = "monitor"
|
|
392
|
+
|
|
393
|
+
def action_stop_container(self) -> None:
|
|
394
|
+
if not self._selected_name:
|
|
395
|
+
self.notify("No container selected", severity="warning")
|
|
396
|
+
return
|
|
397
|
+
for s in self._snapshots:
|
|
398
|
+
if s.name == self._selected_name and s.status == "running":
|
|
399
|
+
self._do_stop(s.name)
|
|
400
|
+
return
|
|
401
|
+
self.notify("Container is not running", severity="warning")
|
|
402
|
+
|
|
403
|
+
@work(thread=True)
|
|
404
|
+
def _do_stop(self, name: str) -> None:
|
|
405
|
+
try:
|
|
406
|
+
ctr = self._monitor.client.containers.get(name)
|
|
407
|
+
ctr.stop()
|
|
408
|
+
self.call_from_thread(self.notify, f"Stopped {name}")
|
|
409
|
+
self._poll_stats()
|
|
410
|
+
except Exception as e:
|
|
411
|
+
self.call_from_thread(self.notify, str(e), severity="error")
|
|
412
|
+
|
|
413
|
+
def action_pause_container(self) -> None:
|
|
414
|
+
if not self._selected_name:
|
|
415
|
+
self.notify("No container selected", severity="warning")
|
|
416
|
+
return
|
|
417
|
+
for s in self._snapshots:
|
|
418
|
+
if s.name == self._selected_name:
|
|
419
|
+
self._do_pause(s.name, s.status)
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
@work(thread=True)
|
|
423
|
+
def _do_pause(self, name: str, status: str) -> None:
|
|
424
|
+
try:
|
|
425
|
+
ctr = self._monitor.client.containers.get(name)
|
|
426
|
+
if status == "running":
|
|
427
|
+
ctr.pause()
|
|
428
|
+
self.call_from_thread(self.notify, f"Paused {name}")
|
|
429
|
+
elif status == "paused":
|
|
430
|
+
ctr.unpause()
|
|
431
|
+
self.call_from_thread(self.notify, f"Resumed {name}")
|
|
432
|
+
elif status in ("exited", "created"):
|
|
433
|
+
ctr.start()
|
|
434
|
+
self.call_from_thread(self.notify, f"Started {name}")
|
|
435
|
+
self._poll_stats()
|
|
436
|
+
except Exception as e:
|
|
437
|
+
self.call_from_thread(self.notify, str(e), severity="error")
|
|
438
|
+
|
|
439
|
+
def action_restart_container(self) -> None:
|
|
440
|
+
if not self._selected_name:
|
|
441
|
+
self.notify("No container selected", severity="warning")
|
|
442
|
+
return
|
|
443
|
+
for s in self._snapshots:
|
|
444
|
+
if s.name == self._selected_name and s.status in ("running", "paused"):
|
|
445
|
+
self._do_restart(s.name)
|
|
446
|
+
return
|
|
447
|
+
self.notify("Container is not running", severity="warning")
|
|
448
|
+
|
|
449
|
+
@work(thread=True)
|
|
450
|
+
def _do_restart(self, name: str) -> None:
|
|
451
|
+
try:
|
|
452
|
+
ctr = self._monitor.client.containers.get(name)
|
|
453
|
+
self.call_from_thread(self.notify, f"Restarting {name}...")
|
|
454
|
+
ctr.restart()
|
|
455
|
+
self.call_from_thread(self.notify, f"Restarted {name}")
|
|
456
|
+
self._poll_stats()
|
|
457
|
+
except Exception as e:
|
|
458
|
+
self.call_from_thread(self.notify, str(e), severity="error")
|
|
459
|
+
|
|
460
|
+
def action_remove_container(self) -> None:
|
|
461
|
+
if not self._selected_name:
|
|
462
|
+
self.notify("No container selected", severity="warning")
|
|
463
|
+
return
|
|
464
|
+
for s in self._snapshots:
|
|
465
|
+
if s.name == self._selected_name and s.status in ("exited", "created"):
|
|
466
|
+
self._do_remove(s.name)
|
|
467
|
+
return
|
|
468
|
+
self.notify("Only stopped containers can be removed", severity="warning")
|
|
469
|
+
|
|
470
|
+
@work(thread=True)
|
|
471
|
+
def _do_remove(self, name: str) -> None:
|
|
472
|
+
try:
|
|
473
|
+
ctr = self._monitor.client.containers.get(name)
|
|
474
|
+
ctr.remove(force=True)
|
|
475
|
+
self.call_from_thread(self.notify, f"Removed {name}")
|
|
476
|
+
self._selected_name = None
|
|
477
|
+
self._poll_stats()
|
|
478
|
+
except Exception as e:
|
|
479
|
+
self.call_from_thread(self.notify, str(e), severity="error")
|
|
480
|
+
|
|
481
|
+
@work(thread=True)
|
|
482
|
+
def action_view_logs(self) -> None:
|
|
483
|
+
if not self._selected_name:
|
|
484
|
+
self.call_from_thread(
|
|
485
|
+
self.notify, "No container selected", severity="warning",
|
|
486
|
+
)
|
|
487
|
+
return
|
|
488
|
+
try:
|
|
489
|
+
ctr = self._monitor.client.containers.get(self._selected_name)
|
|
490
|
+
logs = ctr.logs(tail=200, timestamps=True).decode("utf-8", errors="replace")
|
|
491
|
+
log_widget = self.query_one("#log_pane", Log)
|
|
492
|
+
self.call_from_thread(log_widget.clear)
|
|
493
|
+
self.call_from_thread(
|
|
494
|
+
log_widget.write,
|
|
495
|
+
f"=== Logs: {self._selected_name} ===\n{logs}",
|
|
496
|
+
)
|
|
497
|
+
self.call_from_thread(
|
|
498
|
+
setattr, self.query_one(TabbedContent), "active", "logs",
|
|
499
|
+
)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
self.call_from_thread(self.notify, str(e), severity="error")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dockerbrain
|
|
3
|
-
Version: 1.0
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Dockerfile and container monitoring, analysis, and optimization CLI.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/iamPulakesh/DockerBrain
|
|
7
7
|
Project-URL: Repository, https://github.com/iamPulakesh/DockerBrain
|
|
@@ -29,6 +29,7 @@ Requires-Dist: docker>=6.0
|
|
|
29
29
|
Requires-Dist: google-genai>=1.0
|
|
30
30
|
Requires-Dist: openai>=1.0
|
|
31
31
|
Requires-Dist: rich>=13.0
|
|
32
|
+
Requires-Dist: textual>=0.85
|
|
32
33
|
Provides-Extra: dev
|
|
33
34
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
34
35
|
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dockerbrain"
|
|
7
|
-
version = "1.0
|
|
8
|
-
description = "
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Dockerfile and container monitoring, analysis, and optimization CLI."
|
|
9
9
|
readme = "PYPI_README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = {text = "Apache-2.0"}
|
|
@@ -32,6 +32,7 @@ dependencies = [
|
|
|
32
32
|
"google-genai>=1.0",
|
|
33
33
|
"openai>=1.0",
|
|
34
34
|
"rich>=13.0",
|
|
35
|
+
"textual>=0.85",
|
|
35
36
|
]
|
|
36
37
|
|
|
37
38
|
[project.optional-dependencies]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.0.2"
|
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from rich.align import Align
|
|
4
|
-
from rich.layout import Layout
|
|
5
|
-
from rich.panel import Panel
|
|
6
|
-
from rich.table import Table
|
|
7
|
-
from rich.text import Text
|
|
8
|
-
|
|
9
|
-
from core.monitor.snapshot import (
|
|
10
|
-
ContainerSnapshot,
|
|
11
|
-
MEM_WARNING_PCT,
|
|
12
|
-
MEM_CRITICAL_PCT,
|
|
13
|
-
)
|
|
14
|
-
from core.utils import format_bytes
|
|
15
|
-
|
|
16
|
-
_BLOCKS = " ▏▎▍▌▋▊▉█"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _format_uptime(seconds: float) -> str:
|
|
20
|
-
"""Convert seconds to a human-readable uptime string."""
|
|
21
|
-
if seconds < 60:
|
|
22
|
-
return f"{seconds:.0f}s"
|
|
23
|
-
if seconds < 3600:
|
|
24
|
-
return f"{seconds / 60:.0f}m"
|
|
25
|
-
hours = seconds // 3600
|
|
26
|
-
mins = (seconds % 3600) // 60
|
|
27
|
-
if hours < 24:
|
|
28
|
-
return f"{hours:.0f}h {mins:.0f}m"
|
|
29
|
-
days = hours // 24
|
|
30
|
-
hours = hours % 24
|
|
31
|
-
return f"{days:.0f}d {hours:.0f}h"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _make_bar(value: float, max_value: float, width: int, color: str) -> Text:
|
|
35
|
-
"""Build a Unicode bar with sub-character precision.
|
|
36
|
-
|
|
37
|
-
Uses eighth-block characters (▏▎▍▌▋▊▉█) so a 30-char bar
|
|
38
|
-
effectively has 240 discrete steps of resolution.
|
|
39
|
-
"""
|
|
40
|
-
if max_value <= 0:
|
|
41
|
-
return Text("░" * width, style="dim")
|
|
42
|
-
|
|
43
|
-
ratio = min(value / max_value, 1.0)
|
|
44
|
-
total_eighths = int(ratio * width * 8)
|
|
45
|
-
full = total_eighths // 8
|
|
46
|
-
frac = total_eighths % 8
|
|
47
|
-
empty = width - full - (1 if frac else 0)
|
|
48
|
-
|
|
49
|
-
bar = Text()
|
|
50
|
-
bar.append("█" * full, style=color)
|
|
51
|
-
if frac:
|
|
52
|
-
bar.append(_BLOCKS[frac], style=color)
|
|
53
|
-
bar.append("░" * empty, style="dim")
|
|
54
|
-
return bar
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _cpu_color(pct: float) -> str:
|
|
58
|
-
"""Return a Rich style for a CPU percentage."""
|
|
59
|
-
if pct > 80:
|
|
60
|
-
return "bold red"
|
|
61
|
-
if pct > 50:
|
|
62
|
-
return "yellow"
|
|
63
|
-
return "green"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _mem_color(pct: float) -> str:
|
|
67
|
-
"""Return a Rich style for a memory percentage."""
|
|
68
|
-
if pct > MEM_CRITICAL_PCT:
|
|
69
|
-
return "bold red"
|
|
70
|
-
if pct > MEM_WARNING_PCT:
|
|
71
|
-
return "yellow"
|
|
72
|
-
return "cyan"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def build_monitor_layout(snapshots: list[ContainerSnapshot]) -> Layout:
|
|
76
|
-
"""Build a full-screen Rich Layout with bar graphs and panels."""
|
|
77
|
-
n = max(len(snapshots), 1)
|
|
78
|
-
BAR_WIDTH = 30
|
|
79
|
-
|
|
80
|
-
header_text = Text()
|
|
81
|
-
header_text.append("DockerBrain", style="bold cyan")
|
|
82
|
-
header_text.append(" Monitor", style="dim")
|
|
83
|
-
|
|
84
|
-
header = Panel(
|
|
85
|
-
Align.center(header_text),
|
|
86
|
-
border_style="bright_blue",
|
|
87
|
-
style="on #1a1a2e",
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
running = sum(1 for s in snapshots if s.status == "running")
|
|
91
|
-
idle = sum(1 for s in snapshots if s.is_idle)
|
|
92
|
-
healthy = sum(1 for s in snapshots if s.health_label == "HEALTHY")
|
|
93
|
-
warning = sum(1 for s in snapshots if s.health_label == "WARNING")
|
|
94
|
-
critical = sum(1 for s in snapshots if s.health_label == "CRITICAL")
|
|
95
|
-
|
|
96
|
-
total_cpu = sum(s.cpu_percent for s in snapshots)
|
|
97
|
-
total_mem = sum(s.mem_usage_mb for s in snapshots)
|
|
98
|
-
total_mem_limit = max((s.mem_limit_mb for s in snapshots), default=1)
|
|
99
|
-
avg_mem_pct = (total_mem / total_mem_limit) * 100 if snapshots else 0
|
|
100
|
-
total_cache = sum(s.mem_cache_mb for s in snapshots)
|
|
101
|
-
|
|
102
|
-
stats = Text()
|
|
103
|
-
stats.append(" Containers ", style="bold")
|
|
104
|
-
stats.append(f"{len(snapshots)}", style="bold cyan")
|
|
105
|
-
stats.append(" Running ", style="bold")
|
|
106
|
-
stats.append(f"{running}", style="bold green")
|
|
107
|
-
stats.append(" Idle ", style="bold")
|
|
108
|
-
stats.append(f"{idle}", style="bold red" if idle else "dim")
|
|
109
|
-
stats.append(" Healthy ", style="bold")
|
|
110
|
-
stats.append(f"{healthy}", style="bold green")
|
|
111
|
-
stats.append(" Warn ", style="bold")
|
|
112
|
-
stats.append(f"{warning}", style="bold yellow" if warning else "dim")
|
|
113
|
-
stats.append(" Crit ", style="bold")
|
|
114
|
-
stats.append(f"{critical}\n", style="bold red" if critical else "dim")
|
|
115
|
-
stats.append(" CPU ", style="bold")
|
|
116
|
-
stats.append(f"{total_cpu:.1f}%", style=_cpu_color(total_cpu))
|
|
117
|
-
stats.append(" Mem ", style="bold")
|
|
118
|
-
stats.append(f"{total_mem:.0f}/{total_mem_limit:.0f} MB", style=_mem_color(avg_mem_pct))
|
|
119
|
-
stats.append(" Cache ", style="bold")
|
|
120
|
-
stats.append(f"{total_cache:.0f} MB", style="dim")
|
|
121
|
-
|
|
122
|
-
overview_panel = Panel(
|
|
123
|
-
stats,
|
|
124
|
-
title="[bold cyan]System Overview[/]",
|
|
125
|
-
border_style="cyan",
|
|
126
|
-
padding=(0, 1),
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
table = Table(expand=True, border_style="dim", show_lines=False, pad_edge=False)
|
|
130
|
-
table.add_column("Container", style="bold cyan", no_wrap=True)
|
|
131
|
-
table.add_column("Image", style="dim", no_wrap=True, max_width=28)
|
|
132
|
-
table.add_column("Status", justify="center")
|
|
133
|
-
table.add_column("Uptime", justify="right")
|
|
134
|
-
table.add_column("Health", justify="center")
|
|
135
|
-
|
|
136
|
-
for s in snapshots:
|
|
137
|
-
dot_style = "green" if s.status == "running" else "red"
|
|
138
|
-
status_text = Text()
|
|
139
|
-
status_text.append("● ", style=dot_style)
|
|
140
|
-
status_text.append(s.status)
|
|
141
|
-
|
|
142
|
-
table.add_row(
|
|
143
|
-
s.name,
|
|
144
|
-
s.image_tag,
|
|
145
|
-
status_text,
|
|
146
|
-
_format_uptime(s.uptime_seconds),
|
|
147
|
-
Text(s.health_label, style=f"bold {s.health_style}"),
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
if not snapshots:
|
|
151
|
-
table.add_row(
|
|
152
|
-
Text("No running containers", style="yellow"),
|
|
153
|
-
"", "", "", "",
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
table_panel = Panel(
|
|
157
|
-
table,
|
|
158
|
-
title="[bold cyan]Containers[/]",
|
|
159
|
-
border_style="cyan",
|
|
160
|
-
padding=(0, 1),
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
cpu_content = Text()
|
|
164
|
-
for i, s in enumerate(snapshots):
|
|
165
|
-
color = _cpu_color(s.cpu_percent)
|
|
166
|
-
label = s.name if len(s.name) <= 14 else s.name[:11] + "…"
|
|
167
|
-
cpu_content.append(f" {label:<14} ", style="cyan")
|
|
168
|
-
cpu_content.append_text(_make_bar(s.cpu_percent, 100, BAR_WIDTH, color))
|
|
169
|
-
cpu_content.append(f" {s.cpu_percent:>5.1f}%", style=f"bold {color}")
|
|
170
|
-
if s.is_idle:
|
|
171
|
-
cpu_content.append(" idle", style="bold red")
|
|
172
|
-
if i < len(snapshots) - 1:
|
|
173
|
-
cpu_content.append("\n")
|
|
174
|
-
|
|
175
|
-
if not snapshots:
|
|
176
|
-
cpu_content.append(" Waiting for containers…", style="dim italic")
|
|
177
|
-
|
|
178
|
-
cpu_panel = Panel(
|
|
179
|
-
cpu_content,
|
|
180
|
-
title="[bold green]CPU Usage[/]",
|
|
181
|
-
border_style="green",
|
|
182
|
-
padding=(0, 1),
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
mem_content = Text()
|
|
186
|
-
for i, s in enumerate(snapshots):
|
|
187
|
-
color = _mem_color(s.mem_percent)
|
|
188
|
-
label = s.name if len(s.name) <= 14 else s.name[:11] + "…"
|
|
189
|
-
mem_content.append(f" {label:<14} ", style="cyan")
|
|
190
|
-
mem_content.append_text(_make_bar(s.mem_percent, 100, BAR_WIDTH, color))
|
|
191
|
-
mem_content.append(
|
|
192
|
-
f" {s.mem_usage_mb:>6.1f}/{s.mem_limit_mb:>.0f}MB",
|
|
193
|
-
style=color,
|
|
194
|
-
)
|
|
195
|
-
mem_content.append(f" {s.mem_percent:.0f}%", style=f"bold {color}")
|
|
196
|
-
if i < len(snapshots) - 1:
|
|
197
|
-
mem_content.append("\n")
|
|
198
|
-
|
|
199
|
-
if not snapshots:
|
|
200
|
-
mem_content.append(" Waiting for containers…", style="dim italic")
|
|
201
|
-
|
|
202
|
-
mem_panel = Panel(
|
|
203
|
-
mem_content,
|
|
204
|
-
title="[bold blue]Memory Usage[/]",
|
|
205
|
-
border_style="blue",
|
|
206
|
-
padding=(0, 1),
|
|
207
|
-
)
|
|
208
|
-
|
|
209
|
-
net_table = Table(expand=True, show_header=True, show_lines=False, border_style="dim", pad_edge=False)
|
|
210
|
-
net_table.add_column("Container", style="cyan", no_wrap=True)
|
|
211
|
-
net_table.add_column("↓ Recv", justify="right", style="green")
|
|
212
|
-
net_table.add_column("↑ Sent", justify="right", style="yellow")
|
|
213
|
-
net_table.add_column("Rx Pkts", justify="right", style="blue")
|
|
214
|
-
net_table.add_column("Tx Pkts", justify="right", style="blue")
|
|
215
|
-
net_table.add_column("Err", justify="right")
|
|
216
|
-
net_table.add_column("Drop", justify="right")
|
|
217
|
-
|
|
218
|
-
total_rx = total_tx = 0
|
|
219
|
-
total_rx_pkts = total_tx_pkts = 0
|
|
220
|
-
total_errs = total_drops = 0
|
|
221
|
-
for s in snapshots:
|
|
222
|
-
total_rx += s.net_rx_bytes
|
|
223
|
-
total_tx += s.net_tx_bytes
|
|
224
|
-
total_rx_pkts += s.net_rx_packets
|
|
225
|
-
total_tx_pkts += s.net_tx_packets
|
|
226
|
-
errs = s.net_rx_errors + s.net_tx_errors
|
|
227
|
-
drops = s.net_rx_dropped + s.net_tx_dropped
|
|
228
|
-
total_errs += errs
|
|
229
|
-
total_drops += drops
|
|
230
|
-
|
|
231
|
-
err_style = "bold red" if errs > 0 else "dim"
|
|
232
|
-
drop_style = "bold red" if drops > 0 else "dim"
|
|
233
|
-
|
|
234
|
-
net_table.add_row(
|
|
235
|
-
s.name,
|
|
236
|
-
format_bytes(s.net_rx_bytes),
|
|
237
|
-
format_bytes(s.net_tx_bytes),
|
|
238
|
-
f"{s.net_rx_packets:,}",
|
|
239
|
-
f"{s.net_tx_packets:,}",
|
|
240
|
-
Text(str(errs), style=err_style),
|
|
241
|
-
Text(str(drops), style=drop_style),
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if not snapshots:
|
|
246
|
-
net_table.add_row(
|
|
247
|
-
Text("Waiting for containers…", style="dim italic"),
|
|
248
|
-
"", "", "", "", "", "",
|
|
249
|
-
)
|
|
250
|
-
|
|
251
|
-
net_panel = Panel(
|
|
252
|
-
net_table,
|
|
253
|
-
title="[bold magenta]Network I/O[/]",
|
|
254
|
-
border_style="magenta",
|
|
255
|
-
padding=(0, 1),
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
layout = Layout()
|
|
259
|
-
|
|
260
|
-
layout.split_column(
|
|
261
|
-
Layout(name="header", size=3),
|
|
262
|
-
Layout(name="overview", size=4),
|
|
263
|
-
Layout(name="table", ratio=2),
|
|
264
|
-
Layout(name="bars_row", ratio=1),
|
|
265
|
-
Layout(name="network", ratio=2),
|
|
266
|
-
)
|
|
267
|
-
|
|
268
|
-
layout["header"].update(header)
|
|
269
|
-
layout["overview"].update(overview_panel)
|
|
270
|
-
layout["table"].update(table_panel)
|
|
271
|
-
layout["bars_row"].split_row(
|
|
272
|
-
Layout(name="cpu", ratio=1),
|
|
273
|
-
Layout(name="mem", ratio=1),
|
|
274
|
-
)
|
|
275
|
-
layout["cpu"].update(cpu_panel)
|
|
276
|
-
layout["mem"].update(mem_panel)
|
|
277
|
-
layout["network"].update(net_panel)
|
|
278
|
-
|
|
279
|
-
return layout
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|