topdock 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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
|
+

|
|
16
|
+

|
|
17
|
+

|
|
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,6 @@
|
|
|
1
|
+
topdock.py,sha256=Qs49Qsue17zI0vJ_jul67iKEcn5HF0FAhXr94zUY7AU,27761
|
|
2
|
+
topdock-0.2.0.dist-info/METADATA,sha256=jeDkmU7T2yBhONKOhvWN1D33JECo9OaZSEOmttjPrPE,1838
|
|
3
|
+
topdock-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
4
|
+
topdock-0.2.0.dist-info/entry_points.txt,sha256=JLMrWMhdXFjHrk8380qES1gNuX-fNZ7GRmgIy8nawZA,41
|
|
5
|
+
topdock-0.2.0.dist-info/top_level.txt,sha256=XfBKdcoBfO8exyGxiCNQCDuYa4cE6_DtC7R35zqbMvc,8
|
|
6
|
+
topdock-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
topdock
|
topdock.py
ADDED
|
@@ -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()
|