topdock 0.2.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.
topdock-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: topdock
3
+ Version: 0.2.0
4
+ Summary: ⚡ Cyberpunk Docker Stats Dashboard for your terminal
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: docker>=6.1.0
9
+ Requires-Dist: rich>=13.7.0
10
+
11
+ # ⚡ TopDock
12
+
13
+ A cyberpunk-themed real-time Docker container stats dashboard for your terminal — CPU, memory, network, and block I/O at a glance.
14
+
15
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10+-cc00ff?style=flat-square)
16
+ ![License MIT](https://img.shields.io/badge/license-MIT-00ffe0?style=flat-square)
17
+ ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-00ffe0?style=flat-square)
18
+
19
+ ---
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ # recommended
25
+ pipx install topdock
26
+
27
+ # or
28
+ pip install topdock
29
+
30
+ # or directly from source
31
+ pipx install git+https://github.com/yourusername/topdock.git
32
+ ```
33
+
34
+ **Requires:** Python 3.10+, Docker
35
+
36
+ ---
37
+
38
+ ## Usage
39
+
40
+ ```bash
41
+ topdock # live dashboard
42
+ topdock --sort mem # sort by memory
43
+ topdock --refresh 5 --alert 90 # custom refresh and alert threshold
44
+ topdock --snapshot --format json # one-shot JSON output
45
+ topdock --host tcp://192.168.1.10:2375 # remote Docker host
46
+ topdock --version
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Keyboard Controls
52
+
53
+ | Key | Action |
54
+ |------------------|-------------------|
55
+ | `↑` / `↓` | Scroll rows |
56
+ | `PgUp` / `PgDn` | Scroll 10 rows |
57
+ | `c` | Sort by CPU |
58
+ | `m` | Sort by Memory |
59
+ | `n` | Sort by Network |
60
+ | `b` | Sort by Block I/O |
61
+ | `e` | Export CSV + JSON |
62
+ | `a` | Clear alerts |
63
+ | `q` | Quit |
64
+
65
+ ---
66
+
67
+ ## Uninstall
68
+
69
+ ```bash
70
+ pipx uninstall topdock
71
+ # or
72
+ pip uninstall topdock
73
+ ```
74
+
75
+ ---
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,69 @@
1
+ # ⚡ TopDock
2
+
3
+ A cyberpunk-themed real-time Docker container stats dashboard for your terminal — CPU, memory, network, and block I/O at a glance.
4
+
5
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10+-cc00ff?style=flat-square)
6
+ ![License MIT](https://img.shields.io/badge/license-MIT-00ffe0?style=flat-square)
7
+ ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-00ffe0?style=flat-square)
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ # recommended
15
+ pipx install topdock
16
+
17
+ # or
18
+ pip install topdock
19
+
20
+ # or directly from source
21
+ pipx install git+https://github.com/yourusername/topdock.git
22
+ ```
23
+
24
+ **Requires:** Python 3.10+, Docker
25
+
26
+ ---
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ topdock # live dashboard
32
+ topdock --sort mem # sort by memory
33
+ topdock --refresh 5 --alert 90 # custom refresh and alert threshold
34
+ topdock --snapshot --format json # one-shot JSON output
35
+ topdock --host tcp://192.168.1.10:2375 # remote Docker host
36
+ topdock --version
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Keyboard Controls
42
+
43
+ | Key | Action |
44
+ |------------------|-------------------|
45
+ | `↑` / `↓` | Scroll rows |
46
+ | `PgUp` / `PgDn` | Scroll 10 rows |
47
+ | `c` | Sort by CPU |
48
+ | `m` | Sort by Memory |
49
+ | `n` | Sort by Network |
50
+ | `b` | Sort by Block I/O |
51
+ | `e` | Export CSV + JSON |
52
+ | `a` | Clear alerts |
53
+ | `q` | Quit |
54
+
55
+ ---
56
+
57
+ ## Uninstall
58
+
59
+ ```bash
60
+ pipx uninstall topdock
61
+ # or
62
+ pip uninstall topdock
63
+ ```
64
+
65
+ ---
66
+
67
+ ## License
68
+
69
+ MIT
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "topdock"
7
+ version = "0.2.0"
8
+ description = "⚡ Cyberpunk Docker Stats Dashboard for your terminal"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "docker>=6.1.0",
14
+ "rich>=13.7.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ topdock = "topdock:main"
19
+
20
+ [tool.setuptools]
21
+ py-modules = ["topdock"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: topdock
3
+ Version: 0.2.0
4
+ Summary: ⚡ Cyberpunk Docker Stats Dashboard for your terminal
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: docker>=6.1.0
9
+ Requires-Dist: rich>=13.7.0
10
+
11
+ # ⚡ TopDock
12
+
13
+ A cyberpunk-themed real-time Docker container stats dashboard for your terminal — CPU, memory, network, and block I/O at a glance.
14
+
15
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10+-cc00ff?style=flat-square)
16
+ ![License MIT](https://img.shields.io/badge/license-MIT-00ffe0?style=flat-square)
17
+ ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-00ffe0?style=flat-square)
18
+
19
+ ---
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ # recommended
25
+ pipx install topdock
26
+
27
+ # or
28
+ pip install topdock
29
+
30
+ # or directly from source
31
+ pipx install git+https://github.com/yourusername/topdock.git
32
+ ```
33
+
34
+ **Requires:** Python 3.10+, Docker
35
+
36
+ ---
37
+
38
+ ## Usage
39
+
40
+ ```bash
41
+ topdock # live dashboard
42
+ topdock --sort mem # sort by memory
43
+ topdock --refresh 5 --alert 90 # custom refresh and alert threshold
44
+ topdock --snapshot --format json # one-shot JSON output
45
+ topdock --host tcp://192.168.1.10:2375 # remote Docker host
46
+ topdock --version
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Keyboard Controls
52
+
53
+ | Key | Action |
54
+ |------------------|-------------------|
55
+ | `↑` / `↓` | Scroll rows |
56
+ | `PgUp` / `PgDn` | Scroll 10 rows |
57
+ | `c` | Sort by CPU |
58
+ | `m` | Sort by Memory |
59
+ | `n` | Sort by Network |
60
+ | `b` | Sort by Block I/O |
61
+ | `e` | Export CSV + JSON |
62
+ | `a` | Clear alerts |
63
+ | `q` | Quit |
64
+
65
+ ---
66
+
67
+ ## Uninstall
68
+
69
+ ```bash
70
+ pipx uninstall topdock
71
+ # or
72
+ pip uninstall topdock
73
+ ```
74
+
75
+ ---
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ topdock.py
4
+ topdock.egg-info/PKG-INFO
5
+ topdock.egg-info/SOURCES.txt
6
+ topdock.egg-info/dependency_links.txt
7
+ topdock.egg-info/entry_points.txt
8
+ topdock.egg-info/requires.txt
9
+ topdock.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ topdock = topdock:main
@@ -0,0 +1,2 @@
1
+ docker>=6.1.0
2
+ rich>=13.7.0
@@ -0,0 +1 @@
1
+ topdock
@@ -0,0 +1,704 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TopDock - Cyberpunk Docker Stats Dashboard
4
+ """
5
+
6
+ __version__ = "0.2.0"
7
+
8
+ import sys
9
+ import time
10
+ import json
11
+ import csv
12
+ import threading
13
+ import argparse
14
+ from datetime import datetime
15
+
16
+ # ── dependency checks with helpful messages ───────────────────────────────────
17
+ def _check_deps():
18
+ missing = []
19
+ try:
20
+ import docker # noqa: F401
21
+ except ImportError:
22
+ missing.append("docker")
23
+ try:
24
+ import rich # noqa: F401
25
+ except ImportError:
26
+ missing.append("rich")
27
+ if missing:
28
+ print(f"Missing dependencies: {', '.join(missing)}")
29
+ print(f"Fix: pip install {' '.join(missing)}")
30
+ sys.exit(1)
31
+
32
+ _check_deps()
33
+
34
+ import docker
35
+ from rich.console import Console
36
+ from rich.table import Table
37
+ from rich.live import Live
38
+ from rich.layout import Layout
39
+ from rich.panel import Panel
40
+ from rich.text import Text
41
+ from rich import box
42
+ from rich.align import Align
43
+
44
+ # ─────────────────────────────────────────────
45
+ # THEME
46
+ # ─────────────────────────────────────────────
47
+ THEME = {
48
+ "bg": "#0d0d0f",
49
+ "border": "#2a0a3a",
50
+ "accent": "#cc00ff",
51
+ "accent2": "#ff003c",
52
+ "accent3": "#00ffe0",
53
+ "text": "#e0d7f5",
54
+ "muted": "#5a4f72",
55
+ "ok": "#39ff14",
56
+ "warn": "#ffaa00",
57
+ "crit": "#ff003c",
58
+ "cpu_bar": "#cc00ff",
59
+ "mem_bar": "#00ffe0",
60
+ "header_bg": "#1a0028",
61
+ "sel_bg": "#2a0a3a",
62
+ }
63
+
64
+ console = Console()
65
+
66
+ ALERT_THRESHOLD = 80.0
67
+ _alerts: list[dict] = []
68
+ _alerts_lock = threading.Lock()
69
+
70
+ # ─────────────────────────────────────────────
71
+ # DOCKER STATS
72
+ # ─────────────────────────────────────────────
73
+
74
+ def bytes_to_human(n: float) -> str:
75
+ """Convert bytes to human-readable string. Clamps negatives to 0."""
76
+ n = max(0.0, n)
77
+ for unit in ("B", "KB", "MB", "GB", "TB"):
78
+ if n < 1024.0:
79
+ return f"{n:6.1f} {unit}"
80
+ n /= 1024.0
81
+ return f"{n:.1f} PB"
82
+
83
+ def calc_cpu_percent(stats: dict) -> float:
84
+ try:
85
+ cpu_delta = (stats["cpu_stats"]["cpu_usage"]["total_usage"]
86
+ - stats["precpu_stats"]["cpu_usage"]["total_usage"])
87
+ system_delta = (stats["cpu_stats"]["system_cpu_usage"]
88
+ - stats["precpu_stats"]["system_cpu_usage"])
89
+ num_cpus = (stats["cpu_stats"].get("online_cpus")
90
+ or len(stats["cpu_stats"]["cpu_usage"].get("percpu_usage", [1])))
91
+ if system_delta > 0 and cpu_delta >= 0 and num_cpus > 0:
92
+ return (cpu_delta / system_delta) * num_cpus * 100.0
93
+ except (KeyError, ZeroDivisionError, TypeError):
94
+ pass
95
+ return 0.0
96
+
97
+ def calc_mem(stats: dict) -> tuple[float, float, float]:
98
+ """Returns (used_bytes, limit_bytes, percent). Clamps used to >= 0."""
99
+ try:
100
+ mem = stats["memory_stats"]
101
+ cache = mem.get("stats", {}).get("cache", 0)
102
+ used = max(0.0, mem["usage"] - cache) # FIX: clamp negative
103
+ limit = mem["limit"]
104
+ pct = (used / limit * 100.0) if limit > 0 else 0.0
105
+ return used, limit, pct
106
+ except (KeyError, TypeError):
107
+ return 0.0, 0.0, 0.0
108
+
109
+ def calc_net(stats: dict) -> tuple[float, float]:
110
+ try:
111
+ nets = stats.get("networks") or {}
112
+ rx = sum(v.get("rx_bytes", 0) for v in nets.values())
113
+ tx = sum(v.get("tx_bytes", 0) for v in nets.values())
114
+ return float(rx), float(tx)
115
+ except (AttributeError, TypeError):
116
+ return 0.0, 0.0
117
+
118
+ def calc_blk(stats: dict) -> tuple[float, float]:
119
+ try:
120
+ blk_list = stats["blkio_stats"].get("io_service_bytes_recursive") or []
121
+ r = sum(e["value"] for e in blk_list if e.get("op") == "read")
122
+ w = sum(e["value"] for e in blk_list if e.get("op") == "write")
123
+ return float(r), float(w)
124
+ except (KeyError, TypeError):
125
+ return 0.0, 0.0
126
+
127
+ def get_all_stats(client: docker.DockerClient) -> list[dict]:
128
+ """Fetch stats for all running containers concurrently."""
129
+ try:
130
+ containers = client.containers.list()
131
+ except docker.errors.APIError as e:
132
+ # Docker daemon hiccup — return empty, dashboard will show stale data
133
+ return []
134
+
135
+ results: list[dict] = []
136
+ results_lock = threading.Lock() # FIX: separate lock from _alerts_lock
137
+
138
+ def fetch(c):
139
+ try:
140
+ raw = c.stats(stream=False)
141
+ cpu = calc_cpu_percent(raw)
142
+ mu, ml, mp = calc_mem(raw)
143
+ rx, tx = calc_net(raw)
144
+ br, bw = calc_blk(raw)
145
+ entry = {
146
+ "id": c.short_id,
147
+ "name": c.name,
148
+ "status": c.status,
149
+ "image": (c.image.tags[0] if c.image.tags else c.image.short_id),
150
+ "cpu_pct": min(cpu, 999.9), # cap runaway values
151
+ "mem_used": mu,
152
+ "mem_lim": ml,
153
+ "mem_pct": min(mp, 100.0),
154
+ "net_rx": rx,
155
+ "net_tx": tx,
156
+ "blk_r": br,
157
+ "blk_w": bw,
158
+ "ts": datetime.now().isoformat(),
159
+ }
160
+ with results_lock:
161
+ results.append(entry)
162
+ # FIX: fire alerts AFTER releasing results_lock to avoid
163
+ # inconsistent lock ordering with _alerts_lock
164
+ if cpu > ALERT_THRESHOLD:
165
+ _fire_alert(c.name, "CPU", cpu)
166
+ if mp > ALERT_THRESHOLD:
167
+ _fire_alert(c.name, "MEM", mp)
168
+ except docker.errors.NotFound:
169
+ pass # container removed between list() and stats()
170
+ except Exception:
171
+ pass
172
+
173
+ threads = [threading.Thread(target=fetch, args=(c,), daemon=True)
174
+ for c in containers]
175
+ for t in threads:
176
+ t.start()
177
+ for t in threads:
178
+ t.join(timeout=10)
179
+ return results
180
+
181
+ def _fire_alert(container: str, kind: str, value: float):
182
+ with _alerts_lock:
183
+ # dedupe: skip if same container+kind already pending within 30s
184
+ now = datetime.now()
185
+ for a in reversed(_alerts[-5:]):
186
+ if a["container"] == container and a["kind"] == kind:
187
+ break
188
+ else:
189
+ _alerts.append({
190
+ "ts": now.strftime("%H:%M:%S"),
191
+ "container": container,
192
+ "kind": kind,
193
+ "value": value,
194
+ })
195
+ if len(_alerts) > 50:
196
+ _alerts.pop(0)
197
+
198
+ # ─────────────────────────────────────────────
199
+ # SORT
200
+ # ─────────────────────────────────────────────
201
+ SORT_KEYS = {
202
+ "cpu": "cpu_pct",
203
+ "mem": "mem_pct",
204
+ "net": "net_rx",
205
+ "blk": "blk_r",
206
+ "name": "name",
207
+ }
208
+
209
+ def sort_stats(data: list[dict], sort_by: str) -> list[dict]:
210
+ key = SORT_KEYS.get(sort_by, "cpu_pct")
211
+ rev = sort_by != "name"
212
+ # FIX: use type-safe defaults — "" for str keys, 0.0 for numeric
213
+ default = "" if sort_by == "name" else 0.0
214
+ return sorted(data, key=lambda x: x.get(key, default), reverse=rev)
215
+
216
+ # ─────────────────────────────────────────────
217
+ # RENDERING
218
+ # ─────────────────────────────────────────────
219
+
220
+ def pct_bar(pct: float, color: str, width: int = 10) -> Text:
221
+ pct = max(0.0, min(pct, 100.0)) # clamp to [0, 100]
222
+ if pct >= 90:
223
+ color = THEME["crit"]
224
+ elif pct >= 70:
225
+ color = THEME["warn"]
226
+ filled = int((pct / 100.0) * width)
227
+ bar = "█" * filled + "░" * (width - filled)
228
+ t = Text()
229
+ t.append(f"{bar} ", style=f"bold {color}")
230
+ t.append(f"{pct:5.1f}%", style=f"bold {color}")
231
+ return t
232
+
233
+ def status_dot(status: str) -> Text:
234
+ t = Text()
235
+ color = (THEME["ok"] if status == "running" else
236
+ THEME["warn"] if status == "paused" else
237
+ THEME["crit"])
238
+ t.append("● ", style=f"bold {color}")
239
+ t.append(status, style=THEME["muted"])
240
+ return t
241
+
242
+ def make_header(sort_by: str, alert_count: int, total: int, refresh: float,
243
+ scroll_offset: int, visible_rows: int,
244
+ docker_ok: bool) -> Panel:
245
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
246
+ title = Text()
247
+ title.append("⚡ DOCK", style=f"bold {THEME['accent']}")
248
+ title.append("WATCH", style=f"bold {THEME['accent2']}")
249
+ title.append(" // ", style=THEME["muted"])
250
+ title.append(now, style=THEME["accent3"])
251
+
252
+ meta = Text()
253
+ if not docker_ok:
254
+ meta.append("⚠ Docker unreachable — retrying…", style=f"bold {THEME['crit']}")
255
+ else:
256
+ meta.append(f"containers:{total} ", style=f"bold {THEME['text']}")
257
+ meta.append(f"sort:{sort_by} ", style=f"bold {THEME['accent3']}")
258
+ meta.append(f"refresh:{refresh}s ", style=THEME["muted"])
259
+ if total > visible_rows:
260
+ end = min(scroll_offset + visible_rows, total)
261
+ meta.append(f"rows:{scroll_offset+1}-{end}/{total} ",
262
+ style=THEME["accent3"])
263
+ if alert_count:
264
+ meta.append(f"⚠ ALERTS:{alert_count}", style=f"bold {THEME['crit']}")
265
+ else:
266
+ meta.append("alerts:0", style=THEME["muted"])
267
+
268
+ return Panel(
269
+ Align.center(Text.assemble(title, "\n", meta)),
270
+ border_style=THEME["accent"] if docker_ok else THEME["crit"],
271
+ style=f"on {THEME['header_bg']}",
272
+ padding=(0, 2),
273
+ )
274
+
275
+ def make_table(data: list[dict], sort_by: str, scroll_offset: int,
276
+ visible_rows: int, selected: int) -> Table:
277
+ t = Table(
278
+ box=box.SIMPLE_HEAD,
279
+ border_style=THEME["border"],
280
+ header_style=f"bold {THEME['accent']}",
281
+ style=THEME["text"],
282
+ show_edge=True,
283
+ expand=True,
284
+ padding=(0, 1),
285
+ )
286
+ t.add_column("●", width=12)
287
+ t.add_column("CONTAINER", min_width=16, style=f"bold {THEME['accent3']}")
288
+ t.add_column("ID", width=10, style=THEME["muted"])
289
+ t.add_column("IMAGE", min_width=14, style=THEME["muted"], overflow="fold")
290
+ t.add_column("CPU %", min_width=20)
291
+ t.add_column("MEM %", min_width=20)
292
+ t.add_column("MEM USED/LIMIT", min_width=18, justify="right")
293
+ t.add_column("NET ↓/↑", min_width=20, justify="right")
294
+ t.add_column("BLK R/W", min_width=18, justify="right")
295
+
296
+ if not data:
297
+ t.add_row(
298
+ Text("", style=THEME["muted"]),
299
+ Text("no containers running", style=THEME["muted"]),
300
+ *[""] * 7,
301
+ )
302
+ return t
303
+
304
+ visible = data[scroll_offset: scroll_offset + visible_rows]
305
+
306
+ for i, row in enumerate(visible):
307
+ abs_idx = scroll_offset + i
308
+ is_sel = abs_idx == selected
309
+ row_style = f"on {THEME['sel_bg']}" if is_sel else ""
310
+
311
+ sel_prefix = Text()
312
+ if is_sel:
313
+ sel_prefix.append("▶ ", style=f"bold {THEME['accent']}")
314
+ sel_prefix.append_text(status_dot(row["status"]))
315
+
316
+ mem_label = Text()
317
+ mem_label.append(bytes_to_human(row["mem_used"]), style=THEME["text"])
318
+ mem_label.append(" / ", style=THEME["muted"])
319
+ mem_label.append(bytes_to_human(row["mem_lim"]), style=THEME["muted"])
320
+
321
+ net_label = Text()
322
+ net_label.append(f"↓{bytes_to_human(row['net_rx'])}", style=THEME["ok"])
323
+ net_label.append(" / ", style=THEME["muted"])
324
+ net_label.append(f"↑{bytes_to_human(row['net_tx'])}", style=THEME["warn"])
325
+
326
+ blk_label = Text()
327
+ blk_label.append(f"R:{bytes_to_human(row['blk_r'])}", style=THEME["accent3"])
328
+ blk_label.append(" / ", style=THEME["muted"])
329
+ blk_label.append(f"W:{bytes_to_human(row['blk_w'])}", style=THEME["warn"])
330
+
331
+ img = row["image"]
332
+ if len(img) > 26:
333
+ img = img[:23] + "…"
334
+
335
+ t.add_row(
336
+ sel_prefix,
337
+ Text(row["name"], style=f"bold {THEME['accent3']}"),
338
+ Text(row["id"], style=THEME["muted"]),
339
+ Text(img, style=THEME["muted"]),
340
+ pct_bar(row["cpu_pct"], THEME["cpu_bar"]),
341
+ pct_bar(row["mem_pct"], THEME["mem_bar"]),
342
+ mem_label,
343
+ net_label,
344
+ blk_label,
345
+ style=row_style,
346
+ )
347
+
348
+ # scrollbar
349
+ if len(data) > visible_rows:
350
+ pct = scroll_offset / max(len(data) - visible_rows, 1)
351
+ bar_len = 24
352
+ pos = int(pct * (bar_len - 1))
353
+ sc = Text()
354
+ sc.append("─" * pos, style=THEME["muted"])
355
+ sc.append("◆", style=THEME["accent"])
356
+ sc.append("─" * (bar_len - pos), style=THEME["muted"])
357
+ t.caption = sc
358
+
359
+ return t
360
+
361
+ def make_alerts_panel() -> Panel:
362
+ with _alerts_lock:
363
+ recent = list(_alerts[-6:])
364
+ if not recent:
365
+ body = Text(" no alerts", style=THEME["muted"])
366
+ else:
367
+ body = Text()
368
+ for a in reversed(recent):
369
+ body.append(f" {a['ts']} ", style=THEME["muted"])
370
+ body.append(f"[{a['kind']}] ", style=f"bold {THEME['crit']}")
371
+ body.append(f"{a['container']} ", style=f"bold {THEME['accent3']}")
372
+ body.append(f"> {a['value']:.1f}%\n", style=THEME["warn"])
373
+ return Panel(body, title=f"[bold {THEME['crit']}]⚠ ALERTS[/]",
374
+ border_style=THEME["accent2"], padding=(0, 1))
375
+
376
+ def make_help_bar(export_msg: str = "", export_timer: float = 0.0) -> Panel:
377
+ if export_msg and (time.time() - export_timer < 3):
378
+ t = Text(f" ✔ {export_msg}", style=f"bold {THEME['ok']}", justify="center")
379
+ return Panel(t, border_style=THEME["ok"], padding=(0, 0))
380
+ t = Text(justify="center")
381
+ for key, label in [
382
+ ("↑↓", "scroll"), ("c", "CPU"), ("m", "MEM"), ("n", "NET"),
383
+ ("b", "BLK"), ("e", "export"), ("a", "clr alerts"), ("q", "quit"),
384
+ ]:
385
+ t.append(f" [{key}] ", style=f"bold {THEME['accent']}")
386
+ t.append(f"{label} ", style=THEME["muted"])
387
+ return Panel(t, border_style=THEME["border"], padding=(0, 0))
388
+
389
+ # ─────────────────────────────────────────────
390
+ # EXPORT
391
+ # ─────────────────────────────────────────────
392
+
393
+ def export_csv(data: list[dict], path: str = "topdock_export.csv") -> str | None:
394
+ if not data:
395
+ return None
396
+ keys = ["ts","name","id","image","status","cpu_pct","mem_pct",
397
+ "mem_used","mem_lim","net_rx","net_tx","blk_r","blk_w"]
398
+ try:
399
+ with open(path, "w", newline="") as f:
400
+ w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore")
401
+ w.writeheader()
402
+ w.writerows(data)
403
+ return path
404
+ except OSError as e:
405
+ return None
406
+
407
+ def export_json(data: list[dict], path: str = "topdock_export.json") -> str | None:
408
+ try:
409
+ with open(path, "w") as f:
410
+ json.dump(data, f, indent=2, default=str)
411
+ return path
412
+ except OSError:
413
+ return None
414
+
415
+ # ─────────────────────────────────────────────
416
+ # LIVE DASHBOARD
417
+ # ─────────────────────────────────────────────
418
+
419
+ # approximate fixed UI rows: header(4) + alerts(9) + help(3) + table borders(2)
420
+ _FIXED_UI_ROWS = 18
421
+
422
+ def run_dashboard(client: docker.DockerClient, refresh: float,
423
+ sort_by: str, alert_threshold: float) -> None:
424
+ global ALERT_THRESHOLD
425
+ ALERT_THRESHOLD = alert_threshold
426
+
427
+ # ── shared state (all access must hold `state_lock`) ─────────────────
428
+ state_lock = threading.Lock()
429
+ current_sort = sort_by
430
+ last_data: list[dict] = []
431
+ scroll_offset = 0
432
+ selected = 0
433
+ export_msg = ""
434
+ export_timer = 0.0
435
+ docker_ok = True
436
+ quit_event = threading.Event()
437
+
438
+ def _vis_rows() -> int:
439
+ return max(1, console.size.height - _FIXED_UI_ROWS)
440
+
441
+ def _clamp() -> None:
442
+ """Clamp selected/scroll_offset to valid range. Must hold state_lock."""
443
+ nonlocal scroll_offset, selected
444
+ total = len(last_data)
445
+ if total == 0:
446
+ selected = 0
447
+ scroll_offset = 0
448
+ return
449
+ selected = max(0, min(selected, total - 1))
450
+ vis = _vis_rows()
451
+ if selected < scroll_offset:
452
+ scroll_offset = selected
453
+ elif selected >= scroll_offset + vis:
454
+ scroll_offset = selected - vis + 1
455
+ scroll_offset = max(0, min(scroll_offset, max(0, total - vis)))
456
+
457
+ # ── input thread ──────────────────────────────────────────────────────
458
+ def input_loop() -> None:
459
+ nonlocal current_sort, last_data, scroll_offset, selected
460
+ nonlocal export_msg, export_timer
461
+
462
+ if sys.platform == "win32":
463
+ _input_loop_windows()
464
+ return
465
+ _input_loop_unix()
466
+
467
+ def _scroll(delta: int) -> None:
468
+ """Must be called while holding state_lock."""
469
+ nonlocal selected
470
+ selected = max(0, min(selected + delta, len(last_data) - 1))
471
+ _clamp()
472
+
473
+ def _handle_key(ch: str) -> None:
474
+ """Must be called while holding state_lock."""
475
+ nonlocal current_sort, last_data, export_msg, export_timer
476
+ if ch == "q":
477
+ quit_event.set()
478
+ elif ch == "c":
479
+ current_sort = "cpu"
480
+ last_data = sort_stats(last_data, current_sort)
481
+ elif ch == "m":
482
+ current_sort = "mem"
483
+ last_data = sort_stats(last_data, current_sort)
484
+ elif ch == "n":
485
+ current_sort = "net"
486
+ last_data = sort_stats(last_data, current_sort)
487
+ elif ch == "b":
488
+ current_sort = "blk"
489
+ last_data = sort_stats(last_data, current_sort)
490
+ elif ch == "e":
491
+ snap = list(last_data)
492
+ # release lock during IO
493
+ state_lock.release()
494
+ try:
495
+ p = export_csv(snap)
496
+ export_json(snap)
497
+ msg = f"Exported → {p} + .json" if p else "Export failed (no data)"
498
+ finally:
499
+ state_lock.acquire()
500
+ export_msg = msg
501
+ export_timer = time.time()
502
+ elif ch == "a":
503
+ with _alerts_lock:
504
+ _alerts.clear()
505
+
506
+ def _input_loop_unix() -> None:
507
+ import termios, tty, select as sel_mod
508
+ fd = sys.stdin.fileno()
509
+ old = termios.tcgetattr(fd)
510
+ try:
511
+ tty.setcbreak(fd)
512
+ while not quit_event.is_set():
513
+ ready = sel_mod.select([sys.stdin], [], [], 0.1)[0]
514
+ if not ready:
515
+ continue
516
+ ch = sys.stdin.read(1)
517
+ if ch == "\x1b":
518
+ # FIX: read up to 4 bytes for full escape sequence (e.g. ESC[5~)
519
+ seq = ""
520
+ deadline = time.time() + 0.05
521
+ while time.time() < deadline:
522
+ if sel_mod.select([sys.stdin], [], [], 0.01)[0]:
523
+ seq += sys.stdin.read(1)
524
+ if seq and seq[-1].isalpha() or seq.endswith("~"):
525
+ break
526
+ else:
527
+ break
528
+ with state_lock:
529
+ if seq in ("[A", "OA"): _scroll(-1)
530
+ elif seq in ("[B", "OB"): _scroll(1)
531
+ elif seq in ("[5~", "[5"): _scroll(-10)
532
+ elif seq in ("[6~", "[6"): _scroll(10)
533
+ continue
534
+ with state_lock:
535
+ _handle_key(ch.lower())
536
+ finally:
537
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
538
+
539
+ def _input_loop_windows() -> None:
540
+ import msvcrt
541
+ while not quit_event.is_set():
542
+ if msvcrt.kbhit():
543
+ ch = msvcrt.getwch()
544
+ if ch in ("\x00", "\xe0"):
545
+ ch2 = msvcrt.getwch()
546
+ with state_lock:
547
+ if ch2 == "H": _scroll(-1)
548
+ elif ch2 == "P": _scroll(1)
549
+ elif ch2 == "I": _scroll(-10) # PgUp
550
+ elif ch2 == "Q": _scroll(10) # PgDn
551
+ else:
552
+ with state_lock:
553
+ _handle_key(ch.lower())
554
+ else:
555
+ time.sleep(0.05)
556
+
557
+ # ── layout builder ────────────────────────────────────────────────────
558
+ def build() -> Layout:
559
+ with state_lock:
560
+ data = list(last_data)
561
+ sel = selected
562
+ offset = scroll_offset
563
+ sort = current_sort
564
+ dok = docker_ok
565
+ emsg = export_msg
566
+ etimer = export_timer
567
+ _clamp()
568
+
569
+ vis = _vis_rows()
570
+ nalerts = len(_alerts)
571
+ # FIX: alert panel size is dynamic, not hardcoded
572
+ alert_size = min(nalerts + 3, 9) if nalerts > 0 else 3
573
+
574
+ layout = Layout()
575
+ layout.split_column(
576
+ Layout(make_header(sort, nalerts, len(data), refresh,
577
+ offset, vis, dok), size=4),
578
+ Layout(Panel(make_table(data, sort, offset, vis, sel),
579
+ border_style=THEME["border"],
580
+ style=f"on {THEME['bg']}",
581
+ padding=(0, 0)), name="main"),
582
+ Layout(make_alerts_panel(), size=alert_size),
583
+ Layout(make_help_bar(emsg, etimer), size=3),
584
+ )
585
+ return layout
586
+
587
+ # ── fetch loop (runs on main thread between Live updates) ─────────────
588
+ inp_thread = threading.Thread(target=input_loop, daemon=True)
589
+ inp_thread.start()
590
+
591
+ with Live(build(), refresh_per_second=4, screen=True, console=console) as live:
592
+ next_fetch = 0.0
593
+ while not quit_event.is_set():
594
+ now = time.time()
595
+ if now >= next_fetch:
596
+ try:
597
+ new_data = sort_stats(get_all_stats(client), sort_by)
598
+ with state_lock:
599
+ docker_ok = True
600
+ last_data = new_data
601
+ # preserve sort that may have changed interactively
602
+ sort_by = current_sort
603
+ _clamp()
604
+ except Exception:
605
+ with state_lock:
606
+ docker_ok = False
607
+ next_fetch = now + refresh
608
+ live.update(build())
609
+ time.sleep(0.1)
610
+
611
+ quit_event.set()
612
+ inp_thread.join(timeout=2)
613
+
614
+ # ─────────────────────────────────────────────
615
+ # SNAPSHOT
616
+ # ─────────────────────────────────────────────
617
+
618
+ def run_snapshot(client: docker.DockerClient, sort_by: str, fmt: str) -> None:
619
+ console.print(f"[{THEME['muted']}]Fetching stats…[/]")
620
+ data = sort_stats(get_all_stats(client), sort_by)
621
+ if not data:
622
+ console.print(f"[bold {THEME['warn']}]No running containers found.[/]")
623
+ return
624
+ if fmt == "json":
625
+ console.print_json(json.dumps(data, indent=2, default=str))
626
+ elif fmt == "csv":
627
+ path = export_csv(data)
628
+ if path:
629
+ console.print(f"[bold {THEME['ok']}]✔ Exported to {path}[/]")
630
+ else:
631
+ console.print(f"[bold {THEME['crit']}]✗ Export failed.[/]")
632
+ else:
633
+ table = make_table(data, sort_by, 0, len(data), -1)
634
+ console.print(Panel(table,
635
+ title=f"[bold {THEME['accent']}]⚡ TOPDOCK SNAPSHOT[/]",
636
+ border_style=THEME["accent"]))
637
+
638
+ # ─────────────────────────────────────────────
639
+ # CLI
640
+ # ─────────────────────────────────────────────
641
+
642
+ def parse_args() -> argparse.Namespace:
643
+ p = argparse.ArgumentParser(
644
+ prog="topdock",
645
+ description="⚡ TopDock — Cyberpunk Docker Stats Dashboard",
646
+ formatter_class=argparse.RawDescriptionHelpFormatter,
647
+ epilog="""
648
+ examples:
649
+ topdock # live dashboard, sort by CPU
650
+ topdock --sort mem # sort by memory
651
+ topdock --refresh 5 # refresh every 5 seconds
652
+ topdock --alert 90 # alert threshold 90%%
653
+ topdock --snapshot # one-shot table output
654
+ topdock --snapshot --format json
655
+ topdock --host tcp://192.168.1.10:2375 # remote docker host
656
+ """,
657
+ )
658
+ p.add_argument("--version", "-V", action="version", version=f"topdock {__version__}")
659
+ p.add_argument("--sort", "-s", choices=["cpu","mem","net","blk","name"], default="cpu",
660
+ metavar="FIELD", help="Sort column: cpu|mem|net|blk|name (default: cpu)")
661
+ p.add_argument("--refresh", "-r", type=float, default=2.0,
662
+ metavar="SEC", help="Stats refresh interval in seconds (default: 2)")
663
+ p.add_argument("--alert", "-a", type=float, default=80.0,
664
+ metavar="PCT", help="Alert threshold %% (default: 80)")
665
+ p.add_argument("--snapshot", action="store_true",
666
+ help="Print stats once and exit (no live UI)")
667
+ p.add_argument("--format", "-f", choices=["table","json","csv"], default="table",
668
+ help="Output format for --snapshot (default: table)")
669
+ p.add_argument("--host", default=None,
670
+ metavar="URL", help="Docker host URL (default: local socket)")
671
+ return p.parse_args()
672
+
673
+ def main() -> None:
674
+ args = parse_args()
675
+
676
+ # validate ranges
677
+ if args.refresh < 0.5:
678
+ console.print(f"[bold {THEME['warn']}]--refresh must be >= 0.5s[/]")
679
+ sys.exit(1)
680
+ if not (0 < args.alert <= 100):
681
+ console.print(f"[bold {THEME['warn']}]--alert must be between 1 and 100[/]")
682
+ sys.exit(1)
683
+
684
+ try:
685
+ client = (docker.DockerClient(base_url=args.host)
686
+ if args.host else docker.from_env())
687
+ client.ping()
688
+ except docker.errors.DockerException as e:
689
+ console.print(f"[bold {THEME['crit']}]✗ Cannot connect to Docker:[/] {e}")
690
+ console.print(f"[{THEME['muted']}]Is Docker running? Try: sudo systemctl start docker[/]")
691
+ sys.exit(1)
692
+
693
+ if args.snapshot:
694
+ run_snapshot(client, args.sort, args.format)
695
+ else:
696
+ try:
697
+ run_dashboard(client, args.refresh, args.sort, args.alert)
698
+ except KeyboardInterrupt:
699
+ pass
700
+ finally:
701
+ console.print(f"\n[bold {THEME['accent']}]⚡ TopDock terminated.[/]")
702
+
703
+ if __name__ == "__main__":
704
+ main()