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.
Files changed (42) hide show
  1. {dockerbrain-1.0.2/dockerbrain.egg-info → dockerbrain-1.1.0}/PKG-INFO +3 -2
  2. dockerbrain-1.1.0/core/__init__.py +1 -0
  3. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/monitor/__init__.py +2 -4
  4. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/monitor/collector.py +29 -54
  5. dockerbrain-1.1.0/core/monitor/display.py +501 -0
  6. {dockerbrain-1.0.2 → dockerbrain-1.1.0/dockerbrain.egg-info}/PKG-INFO +3 -2
  7. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/requires.txt +1 -0
  8. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/pyproject.toml +3 -2
  9. dockerbrain-1.0.2/core/__init__.py +0 -1
  10. dockerbrain-1.0.2/core/monitor/display.py +0 -279
  11. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/LICENSE +0 -0
  12. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/PYPI_README.md +0 -0
  13. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/README.md +0 -0
  14. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/__main__.py +0 -0
  15. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/ai_advisor.py +0 -0
  16. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/cli.py +0 -0
  17. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/dockerizer.py +0 -0
  18. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/fixer/__init__.py +0 -0
  19. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/fixer/container.py +0 -0
  20. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/fixer/dockerfile.py +0 -0
  21. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/llm.py +0 -0
  22. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/monitor/snapshot.py +0 -0
  23. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/optimizer/__init__.py +0 -0
  24. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/optimizer/engine.py +0 -0
  25. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/optimizer/rules.py +0 -0
  26. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/storage.py +0 -0
  27. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/templates.py +0 -0
  28. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/core/utils.py +0 -0
  29. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/SOURCES.txt +0 -0
  30. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/dependency_links.txt +0 -0
  31. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/entry_points.txt +0 -0
  32. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/dockerbrain.egg-info/top_level.txt +0 -0
  33. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/setup.cfg +0 -0
  34. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_ai_advisor.py +0 -0
  35. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_dockerizer.py +0 -0
  36. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_fixer.py +0 -0
  37. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_llm.py +0 -0
  38. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_monitor.py +0 -0
  39. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_optimizer.py +0 -0
  40. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_storage.py +0 -0
  41. {dockerbrain-1.0.2 → dockerbrain-1.1.0}/tests/test_templates.py +0 -0
  42. {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.2
4
- Summary: AI-powered Docker container monitoring, analysis, and optimization CLI.
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
- build_monitor_layout,
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
- from rich.live import Live
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
- from core.monitor.display import build_monitor_layout
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
- started_at = ctr.attrs.get("State", {}).get("StartedAt", "")
89
- if started_at:
90
- try:
91
- start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
92
- uptime_secs = (datetime.now(timezone.utc) - start_dt).total_seconds()
93
- except (ValueError, TypeError):
94
- pass
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 start polling."""
166
+ """Create a ContainerMonitor and launch the interactive TUI."""
196
167
  monitor = ContainerMonitor(interval=interval)
197
- monitor.run(duration=duration)
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.2
4
- Summary: AI-powered Docker container monitoring, analysis, and optimization CLI.
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"
@@ -3,6 +3,7 @@ docker>=6.0
3
3
  google-genai>=1.0
4
4
  openai>=1.0
5
5
  rich>=13.0
6
+ textual>=0.85
6
7
 
7
8
  [dev]
8
9
  pytest>=7.0
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dockerbrain"
7
- version = "1.0.2"
8
- description = "AI-powered Docker container monitoring, analysis, and optimization CLI."
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