devmedic 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.
- devmedic/__init__.py +1 -0
- devmedic/__main__.py +5 -0
- devmedic/caches.py +173 -0
- devmedic/cli.py +152 -0
- devmedic/dashboard.py +645 -0
- devmedic/junk.py +185 -0
- devmedic/menu.py +295 -0
- devmedic/ports.py +126 -0
- devmedic/procs.py +113 -0
- devmedic/repos.py +129 -0
- devmedic/util.py +109 -0
- devmedic-0.2.0.dist-info/METADATA +133 -0
- devmedic-0.2.0.dist-info/RECORD +17 -0
- devmedic-0.2.0.dist-info/WHEEL +5 -0
- devmedic-0.2.0.dist-info/entry_points.txt +2 -0
- devmedic-0.2.0.dist-info/licenses/LICENSE +21 -0
- devmedic-0.2.0.dist-info/top_level.txt +1 -0
devmedic/dashboard.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""The big colourful overview (`devmedic`) and the live view (`devmedic watch`)."""
|
|
2
|
+
import getpass
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from collections import defaultdict, deque
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
|
|
12
|
+
import psutil
|
|
13
|
+
from rich.align import Align
|
|
14
|
+
from rich.console import Group
|
|
15
|
+
from rich.live import Live
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from . import caches, junk, ports, procs, repos
|
|
21
|
+
from .util import (HOME, console, dir_size, have, human_age, human_size, run,
|
|
22
|
+
short_path)
|
|
23
|
+
|
|
24
|
+
# ───────────────────────── visuals ─────────────────────────
|
|
25
|
+
|
|
26
|
+
GLYPHS = {
|
|
27
|
+
"d": [" _ ", " __| |", " / _` |", "| (_| |", " \\__,_|"],
|
|
28
|
+
"e": [" ", " ___ ", " / _ \\", "| __/", " \\___|"],
|
|
29
|
+
"v": [" ", "__ __", "\\ \\ / /", " \\ V / ", " \\_/ "],
|
|
30
|
+
"o": [" ", " ___ ", " / _ \\ ", "| (_) |", " \\___/ "],
|
|
31
|
+
"c": [" ", " ___ ", " / __|", "| (__ ", " \\___|"],
|
|
32
|
+
"t": [" _ ", "| |_ ", "| __|", "| |_ ", " \\__|"],
|
|
33
|
+
"r": [" ", " _ __ ", "| '__|", "| | ", "|_| "],
|
|
34
|
+
"m": [" ", " _ __ ___ ", "| '_ ` _ \\ ", "| | | | | |", "|_| |_| |_|"],
|
|
35
|
+
"i": [" _ ", "(_)", "| |", "| |", "|_|"],
|
|
36
|
+
}
|
|
37
|
+
GRADIENT = [(0, 215, 255), (95, 135, 255), (175, 95, 255), (255, 95, 215)]
|
|
38
|
+
SPARK = "▁▂▃▄▅▆▇█"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def gradient_color(frac):
|
|
42
|
+
frac = min(max(frac, 0), 1) * (len(GRADIENT) - 1)
|
|
43
|
+
i = min(int(frac), len(GRADIENT) - 2)
|
|
44
|
+
f = frac - i
|
|
45
|
+
a, b = GRADIENT[i], GRADIENT[i + 1]
|
|
46
|
+
r, g, bl = (int(a[k] + (b[k] - a[k]) * f) for k in range(3))
|
|
47
|
+
return f"#{r:02x}{g:02x}{bl:02x}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def banner(word="devmedic"):
|
|
51
|
+
rows = ["".join(GLYPHS[ch][i] for ch in word) for i in range(5)]
|
|
52
|
+
width = max(len(r) for r in rows)
|
|
53
|
+
text = Text()
|
|
54
|
+
for row in rows:
|
|
55
|
+
for x, ch in enumerate(row):
|
|
56
|
+
text.append(ch, style=f"bold {gradient_color(x / width)}")
|
|
57
|
+
text.append("\n")
|
|
58
|
+
text.rstrip()
|
|
59
|
+
return text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def level_color(pct, warn=75, bad=90):
|
|
63
|
+
return "red" if pct >= bad else "yellow" if pct >= warn else "green"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def bar(pct, width=22, warn=75, bad=90):
|
|
67
|
+
filled = round(pct / 100 * width)
|
|
68
|
+
color = level_color(pct, warn, bad)
|
|
69
|
+
return Text.assemble(("█" * filled, color), ("░" * (width - filled), "grey35"),
|
|
70
|
+
(f" {pct:3.0f}%", f"bold {color}"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def spark(values, maximum=100):
|
|
74
|
+
return "".join(SPARK[min(int(v / maximum * (len(SPARK) - 1) + 0.5), len(SPARK) - 1)]
|
|
75
|
+
for v in values)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def plural(n, word, many=None):
|
|
79
|
+
return f"{n} {word if n == 1 else many or word + 's'}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def kv_table():
|
|
83
|
+
t = Table.grid(padding=(0, 1))
|
|
84
|
+
t.add_column(style="bold grey70", no_wrap=True)
|
|
85
|
+
t.add_column()
|
|
86
|
+
return t
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def panel(body, title, color):
|
|
90
|
+
return Panel(body, title=f"[bold {color}]{title}[/]", title_align="left",
|
|
91
|
+
border_style=color, padding=(0, 1))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ───────────────────────── collectors ─────────────────────────
|
|
95
|
+
|
|
96
|
+
def os_name():
|
|
97
|
+
try:
|
|
98
|
+
with open("/etc/os-release") as f:
|
|
99
|
+
for line in f:
|
|
100
|
+
if line.startswith("PRETTY_NAME="):
|
|
101
|
+
return line.split("=", 1)[1].strip().strip('"')
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
return platform.system()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cpu_model():
|
|
108
|
+
try:
|
|
109
|
+
with open("/proc/cpuinfo") as f:
|
|
110
|
+
for line in f:
|
|
111
|
+
if line.startswith("model name"):
|
|
112
|
+
name = line.split(":", 1)[1].strip()
|
|
113
|
+
name = re.sub(r"\((R|TM)\)|CPU|Processor|\s@.*", "", name)
|
|
114
|
+
return " ".join(name.split())
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
return platform.processor() or "unknown CPU"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def temperature():
|
|
121
|
+
try:
|
|
122
|
+
temps = psutil.sensors_temperatures()
|
|
123
|
+
except (AttributeError, OSError):
|
|
124
|
+
return None
|
|
125
|
+
for key in ("coretemp", "k10temp", "zenpower", "cpu_thermal", "acpitz"):
|
|
126
|
+
readings = [t.current for t in temps.get(key, []) if t.current]
|
|
127
|
+
if readings:
|
|
128
|
+
return max(readings)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def battery():
|
|
133
|
+
try:
|
|
134
|
+
return psutil.sensors_battery()
|
|
135
|
+
except (AttributeError, OSError, RuntimeError):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def system_info():
|
|
140
|
+
return {
|
|
141
|
+
"user": getpass.getuser(), "host": socket.gethostname(), "os": os_name(),
|
|
142
|
+
"kernel": platform.release(), "cpu": cpu_model(),
|
|
143
|
+
"cores": psutil.cpu_count(logical=True),
|
|
144
|
+
"phys": psutil.cpu_count(logical=False),
|
|
145
|
+
"boot": psutil.boot_time(), "load": os.getloadavg(),
|
|
146
|
+
"temp": temperature(), "battery": battery(),
|
|
147
|
+
"shell": os.path.basename(os.environ.get("SHELL", "")),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def memory_hogs(limit=5):
|
|
152
|
+
by_name = defaultdict(lambda: [0, 0])
|
|
153
|
+
for p in psutil.process_iter(["name", "memory_info"]):
|
|
154
|
+
mi = p.info["memory_info"]
|
|
155
|
+
if mi and p.info["name"]:
|
|
156
|
+
name = p.info["name"]
|
|
157
|
+
# Group chrome/chromium helpers, "Web Content", etc. under the app.
|
|
158
|
+
name = re.sub(r"^(chrome|chromium|firefox|code|electron)\b.*", r"\1", name,
|
|
159
|
+
flags=re.I)
|
|
160
|
+
by_name[name][0] += mi.rss
|
|
161
|
+
by_name[name][1] += 1
|
|
162
|
+
return sorted(((n, rss, c) for n, (rss, c) in by_name.items()),
|
|
163
|
+
key=lambda x: x[1], reverse=True)[:limit]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def disks():
|
|
167
|
+
seen, out = set(), []
|
|
168
|
+
for p in psutil.disk_partitions(all=False):
|
|
169
|
+
if p.fstype in ("squashfs", "tmpfs", "overlay") or p.device in seen:
|
|
170
|
+
continue
|
|
171
|
+
if p.mountpoint.startswith(("/snap", "/var/snap", "/run")):
|
|
172
|
+
continue
|
|
173
|
+
seen.add(p.device)
|
|
174
|
+
try:
|
|
175
|
+
u = psutil.disk_usage(p.mountpoint)
|
|
176
|
+
except OSError:
|
|
177
|
+
continue
|
|
178
|
+
out.append({"mount": p.mountpoint, "device": p.device, "fs": p.fstype,
|
|
179
|
+
"used": u.used, "total": u.total, "pct": u.percent})
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
TOOLS = [ # label, version command
|
|
184
|
+
("git", ["git", "--version"]), ("python", ["python3", "--version"]),
|
|
185
|
+
("pip", ["pip3", "--version"]), ("node", ["node", "--version"]),
|
|
186
|
+
("npm", ["npm", "--version"]), ("pnpm", ["pnpm", "--version"]),
|
|
187
|
+
("yarn", ["yarn", "--version"]), ("bun", ["bun", "--version"]),
|
|
188
|
+
("deno", ["deno", "--version"]), ("go", ["go", "version"]),
|
|
189
|
+
("rust", ["rustc", "--version"]), ("java", ["java", "-version"]),
|
|
190
|
+
("gcc", ["gcc", "--version"]), ("make", ["make", "--version"]),
|
|
191
|
+
("docker", ["docker", "--version"]), ("kubectl", ["kubectl", "version", "--client"]),
|
|
192
|
+
("gh", ["gh", "--version"]), ("code", ["code", "--version"]),
|
|
193
|
+
("nvim", ["nvim", "--version"]), ("vim", ["vim", "--version"]),
|
|
194
|
+
]
|
|
195
|
+
VERSION_RE = re.compile(r"(\d+\.\d+(?:\.\d+)?)")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def tool_version(cmd):
|
|
199
|
+
if not have(cmd[0]):
|
|
200
|
+
return None
|
|
201
|
+
try:
|
|
202
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=4)
|
|
203
|
+
except (OSError, subprocess.SubprocessError):
|
|
204
|
+
return "?"
|
|
205
|
+
m = VERSION_RE.search(r.stdout + r.stderr)
|
|
206
|
+
return m.group(1) if m else "?"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def dev_tools():
|
|
210
|
+
with ThreadPoolExecutor(max_workers=10) as pool:
|
|
211
|
+
versions = list(pool.map(tool_version, [c for _, c in TOOLS]))
|
|
212
|
+
return [(label, v) for (label, _), v in zip(TOOLS, versions)]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def docker_status():
|
|
216
|
+
if not have("docker"):
|
|
217
|
+
return {"state": "missing"}
|
|
218
|
+
try:
|
|
219
|
+
r = subprocess.run(["docker", "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}"],
|
|
220
|
+
capture_output=True, text=True, timeout=5)
|
|
221
|
+
except (OSError, subprocess.SubprocessError):
|
|
222
|
+
return {"state": "down"}
|
|
223
|
+
if "permission denied" in r.stderr.lower():
|
|
224
|
+
return {"state": "denied"}
|
|
225
|
+
if r.returncode != 0:
|
|
226
|
+
return {"state": "down"}
|
|
227
|
+
rows = [line.split("\t") for line in r.stdout.splitlines() if line.strip()]
|
|
228
|
+
return {"state": "ok", "containers": rows}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def network():
|
|
232
|
+
ips = []
|
|
233
|
+
stats = psutil.net_if_stats()
|
|
234
|
+
for name, addrs in psutil.net_if_addrs().items():
|
|
235
|
+
if name == "lo" or name.startswith(("docker", "br-", "veth", "virbr")):
|
|
236
|
+
continue
|
|
237
|
+
if name in stats and not stats[name].isup:
|
|
238
|
+
continue
|
|
239
|
+
for a in addrs:
|
|
240
|
+
if a.family == socket.AF_INET:
|
|
241
|
+
ips.append((name, a.address))
|
|
242
|
+
wifi = None
|
|
243
|
+
if have("nmcli"):
|
|
244
|
+
for line in run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], timeout=3).splitlines():
|
|
245
|
+
if line.startswith("yes:"):
|
|
246
|
+
wifi = line[4:] or None
|
|
247
|
+
break
|
|
248
|
+
io = psutil.net_io_counters()
|
|
249
|
+
return {"ips": ips, "wifi": wifi, "sent": io.bytes_sent, "recv": io.bytes_recv}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def git_summary():
|
|
253
|
+
infos = [repos.inspect(r) for r in repos.find(HOME)]
|
|
254
|
+
return {"total": len(infos), "infos": infos,
|
|
255
|
+
"dirty": [i for i in infos if i["changed"] or i["untracked"]],
|
|
256
|
+
"unpushed": [i for i in infos if i["ahead"] or i["unpushed_branches"]],
|
|
257
|
+
"noremote": [i for i in infos if i["no_remote"]],
|
|
258
|
+
"stash": [i for i in infos if i["stashes"]],
|
|
259
|
+
"flagged": [i for i in infos if repos.needs_attention(i)]}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def junk_summary():
|
|
263
|
+
found = list(junk.find(HOME))
|
|
264
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
265
|
+
sizes = list(pool.map(dir_size, [p for p, _ in found]))
|
|
266
|
+
items = sorted(zip(sizes, (p for p, _ in found)), reverse=True)
|
|
267
|
+
return {"total": sum(sizes), "count": len(items), "top": items[:3]}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def collect_all():
|
|
271
|
+
with ThreadPoolExecutor(max_workers=12) as pool:
|
|
272
|
+
f = {
|
|
273
|
+
"cpu": pool.submit(psutil.cpu_percent, 0.5, True),
|
|
274
|
+
"sys": pool.submit(system_info),
|
|
275
|
+
"hogs": pool.submit(memory_hogs, 12),
|
|
276
|
+
"disks": pool.submit(disks),
|
|
277
|
+
"tools": pool.submit(dev_tools),
|
|
278
|
+
"docker": pool.submit(docker_status),
|
|
279
|
+
"net": pool.submit(network),
|
|
280
|
+
"ports": pool.submit(ports.listening),
|
|
281
|
+
"procs": pool.submit(procs.collect),
|
|
282
|
+
"caches": pool.submit(caches.collect),
|
|
283
|
+
"git": pool.submit(git_summary),
|
|
284
|
+
"junk": pool.submit(junk_summary),
|
|
285
|
+
}
|
|
286
|
+
data = {k: v.result() for k, v in f.items()}
|
|
287
|
+
data["mem"] = psutil.virtual_memory()
|
|
288
|
+
data["swap"] = psutil.swap_memory()
|
|
289
|
+
return data
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ───────────────────────── health score ─────────────────────────
|
|
293
|
+
|
|
294
|
+
def health(d):
|
|
295
|
+
score, tips = 100, []
|
|
296
|
+
|
|
297
|
+
def hit(points, tip):
|
|
298
|
+
nonlocal score
|
|
299
|
+
score -= points
|
|
300
|
+
tips.append(tip)
|
|
301
|
+
|
|
302
|
+
worst_disk = max((x["pct"] for x in d["disks"]), default=0)
|
|
303
|
+
if worst_disk >= 90:
|
|
304
|
+
hit(25, "[red]Disk almost full[/] → [bold]devmedic junk --clean[/], [bold]devmedic caches[/]")
|
|
305
|
+
elif worst_disk >= 75:
|
|
306
|
+
hit(10, "Disk filling up → [bold]devmedic junk[/] / [bold]devmedic caches[/]")
|
|
307
|
+
if d["mem"].percent >= 90:
|
|
308
|
+
hit(15, "[red]RAM nearly exhausted[/] → check [bold]devmedic procs[/]")
|
|
309
|
+
elif d["mem"].percent >= 80:
|
|
310
|
+
hit(7, "RAM usage is high → check the Memory panel")
|
|
311
|
+
if d["swap"].total and d["swap"].percent >= 50:
|
|
312
|
+
hit(5, "Heavy swap use — machine will feel slow")
|
|
313
|
+
temp = d["sys"]["temp"]
|
|
314
|
+
if temp and temp >= 85:
|
|
315
|
+
hit(10, f"[red]CPU is hot ({temp:.0f}°C)[/] — check fans / heavy processes")
|
|
316
|
+
elif temp and temp >= 75:
|
|
317
|
+
hit(4, f"CPU is warm ({temp:.0f}°C)")
|
|
318
|
+
user_c, sys_c = d["caches"]
|
|
319
|
+
reclaim = sum(c["size"] for c in user_c) + sum(s["size"] or 0 for s in sys_c) + d["junk"]["total"]
|
|
320
|
+
if reclaim >= 5 * 1024**3:
|
|
321
|
+
hit(10, f"{human_size(reclaim)} reclaimable → [bold]devmedic caches[/] + [bold]devmedic junk[/]")
|
|
322
|
+
elif reclaim >= 300 * 1024**2:
|
|
323
|
+
hit(3, f"{human_size(reclaim)} reclaimable → [bold]devmedic caches[/]")
|
|
324
|
+
g = d["git"]
|
|
325
|
+
risky = len(g["noremote"]) + len(g["unpushed"])
|
|
326
|
+
if risky:
|
|
327
|
+
hit(min(10, 3 * risky), f"{plural(risky, 'repo')} with work that isn't backed up → [bold]devmedic repos[/]")
|
|
328
|
+
stale = [x for x in procs.group(d["procs"]) if x["ports"] and time.time() - x["oldest"] > 86400]
|
|
329
|
+
if stale:
|
|
330
|
+
hit(3 * len(stale), f"{len(stale)} dev server(s) running > 1 day → [bold]devmedic ports[/]")
|
|
331
|
+
if d["docker"]["state"] == "denied":
|
|
332
|
+
hit(2, "Docker needs sudo → [bold]sudo usermod -aG docker $USER[/] then log out/in")
|
|
333
|
+
bat = d["sys"]["battery"]
|
|
334
|
+
if bat and not bat.power_plugged and bat.percent < 20:
|
|
335
|
+
tips.append(f"[yellow]Battery low ({bat.percent:.0f}%)[/] — plug in before long builds")
|
|
336
|
+
return max(score, 0), tips
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def grade(score):
|
|
340
|
+
for limit, g, color in ((90, "A", "green"), (75, "B", "cyan"), (60, "C", "yellow")):
|
|
341
|
+
if score >= limit:
|
|
342
|
+
return g, color
|
|
343
|
+
return "D", "red"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ───────────────────────── panels ─────────────────────────
|
|
347
|
+
|
|
348
|
+
def header(d, score):
|
|
349
|
+
s = d["sys"]
|
|
350
|
+
g, color = grade(score)
|
|
351
|
+
info = Text.assemble(
|
|
352
|
+
(f"{s['user']}", "bold cyan"), ("@", "grey50"), (f"{s['host']}\n", "bold magenta"),
|
|
353
|
+
(f"{s['os']}", "bold"), (f" · kernel {s['kernel']}\n", "grey62"),
|
|
354
|
+
(f"up {human_age(s['boot']).replace(' ago', '')}", "green"),
|
|
355
|
+
(f" · {s['shell'] or 'shell ?'} · {time.strftime('%a %d %b %H:%M')}", "grey62"),
|
|
356
|
+
)
|
|
357
|
+
score_txt = Text.assemble(("HEALTH ", "bold grey70"), (f"{score}", f"bold {color}"),
|
|
358
|
+
("/100 ", "grey50"), (f" {g} ", f"bold black on {color}"))
|
|
359
|
+
right = Group(info, Text(""), score_txt, score_bar(score))
|
|
360
|
+
if console.width < 100:
|
|
361
|
+
return Group(banner(), Text(""), right)
|
|
362
|
+
grid = Table.grid(expand=True, padding=(0, 3))
|
|
363
|
+
grid.add_column(no_wrap=True)
|
|
364
|
+
grid.add_column(ratio=1)
|
|
365
|
+
grid.add_row(banner(), right)
|
|
366
|
+
return grid
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def score_bar(score, width=26):
|
|
370
|
+
filled = round(score / 100 * width)
|
|
371
|
+
t = Text()
|
|
372
|
+
for i in range(width):
|
|
373
|
+
t.append("█" if i < filled else "░",
|
|
374
|
+
style=gradient_color(i / width) if i < filled else "grey35")
|
|
375
|
+
return t
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def system_panel(d):
|
|
379
|
+
s, cores = d["sys"], d["cpu"]
|
|
380
|
+
t = kv_table()
|
|
381
|
+
t.add_row("CPU", f"{s['cpu']} [grey62]({s['phys']}c/{s['cores']}t)[/]")
|
|
382
|
+
total = sum(cores) / len(cores) if cores else 0
|
|
383
|
+
t.add_row("Usage", bar(total))
|
|
384
|
+
t.add_row("Cores", Text(spark(cores), style="bold " + level_color(total)))
|
|
385
|
+
l1, l5, l15 = s["load"]
|
|
386
|
+
t.add_row("Load", f"{l1:.2f} {l5:.2f} {l15:.2f} "
|
|
387
|
+
f"[grey62](per core {l1 / s['cores']:.2f})[/]")
|
|
388
|
+
if s["temp"]:
|
|
389
|
+
t.add_row("Temp", Text(f"{s['temp']:.0f}°C", style="bold " + level_color(s["temp"], 70, 85)))
|
|
390
|
+
b = s["battery"]
|
|
391
|
+
if b:
|
|
392
|
+
state = "⚡ charging" if b.power_plugged else "🔋 on battery"
|
|
393
|
+
left = ""
|
|
394
|
+
if not b.power_plugged and b.secsleft not in (psutil.POWER_TIME_UNKNOWN,
|
|
395
|
+
psutil.POWER_TIME_UNLIMITED):
|
|
396
|
+
left = f", {b.secsleft // 3600}h{b.secsleft % 3600 // 60:02d}m left"
|
|
397
|
+
t.add_row("Battery", Text.assemble(
|
|
398
|
+
(f"{b.percent:.0f}% ", "bold " + level_color(100 - b.percent, 70, 85)),
|
|
399
|
+
(state + left, "grey62")))
|
|
400
|
+
return panel(t, "⚡ System", "cyan")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def memory_panel(d, limit=5):
|
|
404
|
+
m, sw = d["mem"], d["swap"]
|
|
405
|
+
t = kv_table()
|
|
406
|
+
t.add_row("RAM", bar(m.percent))
|
|
407
|
+
t.add_row("", f"[grey62]{human_size(m.total - m.available)} used · "
|
|
408
|
+
f"{human_size(m.available)} free of {human_size(m.total)}[/]")
|
|
409
|
+
if sw.total:
|
|
410
|
+
t.add_row("Swap", bar(sw.percent, warn=40, bad=70))
|
|
411
|
+
hogs = Table.grid(padding=(0, 1))
|
|
412
|
+
hogs.add_column(style="bold")
|
|
413
|
+
hogs.add_column(justify="right", style="grey62")
|
|
414
|
+
hogs.add_column(justify="right")
|
|
415
|
+
for name, rss, count in d["hogs"][:limit]:
|
|
416
|
+
hogs.add_row(name[:22], f"×{count}" if count > 1 else "",
|
|
417
|
+
Text(human_size(rss), style=level_color(rss / m.total * 100, 15, 30)))
|
|
418
|
+
t.add_row("Top", hogs)
|
|
419
|
+
return panel(t, "🧠 Memory", "magenta")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def storage_panel(d):
|
|
423
|
+
t = kv_table()
|
|
424
|
+
for x in d["disks"]:
|
|
425
|
+
t.add_row(x["mount"], bar(x["pct"]))
|
|
426
|
+
t.add_row("", f"[grey62]{human_size(x['total'] - x['used'])} free of "
|
|
427
|
+
f"{human_size(x['total'])} · {x['fs']} · {x['device']}[/]")
|
|
428
|
+
return panel(t, "💾 Storage", "blue")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def tools_panel(d):
|
|
432
|
+
installed = [(n, v) for n, v in d["tools"] if v]
|
|
433
|
+
missing = [n for n, v in d["tools"] if not v]
|
|
434
|
+
t = Table.grid(padding=(0, 2))
|
|
435
|
+
cols = 2
|
|
436
|
+
for _ in range(cols):
|
|
437
|
+
t.add_column(no_wrap=True)
|
|
438
|
+
cells = [Text.assemble(("✔ ", "green"), (f"{n:<7}", "bold"), (v, "cyan")) for n, v in installed]
|
|
439
|
+
for i in range(0, len(cells), cols):
|
|
440
|
+
t.add_row(*cells[i:i + cols], *[""] * (cols - len(cells[i:i + cols])))
|
|
441
|
+
body = [t]
|
|
442
|
+
dk = d["docker"]
|
|
443
|
+
if dk["state"] == "ok":
|
|
444
|
+
n = len(dk["containers"])
|
|
445
|
+
body.append(Text(f"🐳 {n} container{'s' if n != 1 else ''} running",
|
|
446
|
+
style="green" if n else "grey62"))
|
|
447
|
+
elif dk["state"] == "denied":
|
|
448
|
+
body.append(Text("🐳 docker: permission denied (not in docker group)", style="yellow"))
|
|
449
|
+
elif dk["state"] == "down":
|
|
450
|
+
body.append(Text("🐳 docker daemon not running", style="grey62"))
|
|
451
|
+
if missing:
|
|
452
|
+
shown = ", ".join(missing[:6]) + (" …" if len(missing) > 6 else "")
|
|
453
|
+
body.append(Text(f"{len(missing)} not installed: {shown}", style="grey50"))
|
|
454
|
+
return panel(Group(*body), "🛠 Dev tools", "green")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def network_panel(d):
|
|
458
|
+
n = d["net"]
|
|
459
|
+
t = kv_table()
|
|
460
|
+
if n["wifi"]:
|
|
461
|
+
t.add_row("Wi-Fi", f"[bold]{n['wifi']}[/]")
|
|
462
|
+
for name, ip in n["ips"][:3]:
|
|
463
|
+
t.add_row(name, f"[cyan]{ip}[/]")
|
|
464
|
+
if not n["ips"]:
|
|
465
|
+
t.add_row("IP", "[yellow]offline[/]")
|
|
466
|
+
t.add_row("Traffic", f"↑ {human_size(n['sent'])} ↓ {human_size(n['recv'])} "
|
|
467
|
+
f"[grey62]since boot[/]")
|
|
468
|
+
listen = d["ports"]
|
|
469
|
+
if listen:
|
|
470
|
+
t.add_row("Ports", ", ".join(f"[bold cyan]{p['port']}[/] {p['name']}" for p in listen[:6]))
|
|
471
|
+
else:
|
|
472
|
+
t.add_row("Ports", "[grey62]no dev servers listening[/]")
|
|
473
|
+
return panel(t, "🌐 Network", "yellow")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def dev_panel(d):
|
|
477
|
+
g, groups = d["git"], procs.group(d["procs"])
|
|
478
|
+
t = kv_table()
|
|
479
|
+
run_ram = sum(x["rss"] for x in groups)
|
|
480
|
+
t.add_row("Procs", f"{plural(len(d['procs']), 'dev process', 'dev processes')} in "
|
|
481
|
+
f"{plural(len(groups), 'project')} · {human_size(run_ram)}")
|
|
482
|
+
for x in groups[:3]:
|
|
483
|
+
where = "~" if x["cwd"] == HOME else short_path(x["cwd"])
|
|
484
|
+
names = ", ".join(sorted({p["name"] for p in x["procs"]}))
|
|
485
|
+
t.add_row("", f"[grey62]{where[-38:]}[/] {names[:20]} {human_size(x['rss'])}")
|
|
486
|
+
status = []
|
|
487
|
+
if g["dirty"]:
|
|
488
|
+
status.append(f"[yellow]{len(g['dirty'])} dirty[/]")
|
|
489
|
+
if g["unpushed"]:
|
|
490
|
+
status.append(f"[red]{len(g['unpushed'])} unpushed[/]")
|
|
491
|
+
if g["noremote"]:
|
|
492
|
+
status.append(f"[red]{len(g['noremote'])} no remote[/]")
|
|
493
|
+
if g["stash"]:
|
|
494
|
+
status.append(f"[magenta]{len(g['stash'])} stashed[/]")
|
|
495
|
+
t.add_row("Git", f"{g['total']} repos " + (" · ".join(status) or "[green]all clean ✔[/]"))
|
|
496
|
+
for i in g["flagged"][:3]:
|
|
497
|
+
t.add_row("", f"[grey62]{short_path(i['path'])[-40:]}[/] [cyan]{i['branch']}[/]")
|
|
498
|
+
return panel(t, "📂 Projects", "bright_magenta")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def cleanup_panel(d):
|
|
502
|
+
user_c, sys_c = d["caches"]
|
|
503
|
+
j = d["junk"]
|
|
504
|
+
t = kv_table()
|
|
505
|
+
total = 0
|
|
506
|
+
rows = [(c["label"], c["size"]) for c in user_c]
|
|
507
|
+
rows += [(s["label"], s["size"]) for s in sys_c if s["size"]]
|
|
508
|
+
rows.append((f"Junk folders ({j['count']})", j["total"]))
|
|
509
|
+
rows = [r for r in rows if r[1]]
|
|
510
|
+
rows.sort(key=lambda r: r[1], reverse=True)
|
|
511
|
+
biggest = max((r[1] for r in rows), default=1)
|
|
512
|
+
for label, size in rows[:6]:
|
|
513
|
+
total += size
|
|
514
|
+
w = max(1, round(size / biggest * 14))
|
|
515
|
+
t.add_row(label[:24], Text.assemble(("▇" * w, "bright_red" if size > 1024**3 else "orange3"),
|
|
516
|
+
(f" {human_size(size)}", "bold")))
|
|
517
|
+
if not rows:
|
|
518
|
+
t.add_row("", "[green]Nothing to clean ✨[/]")
|
|
519
|
+
else:
|
|
520
|
+
total = sum(r[1] for r in rows)
|
|
521
|
+
t.add_row("Total", f"[bold green]{human_size(total)}[/] [grey62]reclaimable[/]")
|
|
522
|
+
return panel(t, "🧹 Cleanup", "red")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def tips_panel(tips):
|
|
526
|
+
if not tips:
|
|
527
|
+
return panel(Text("Everything looks great — go build something! 🚀", style="bold green"),
|
|
528
|
+
"💡 Doctor's notes", "green")
|
|
529
|
+
body = Text.from_markup("\n".join(f"• {tip}" for tip in tips))
|
|
530
|
+
return panel(body, "💡 Doctor's notes", "yellow")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def two_columns(pairs, width):
|
|
534
|
+
if width < 100:
|
|
535
|
+
return Group(*[p for pair in pairs for p in pair if p])
|
|
536
|
+
grid = Table.grid(expand=True, padding=(0, 1))
|
|
537
|
+
grid.add_column(ratio=1)
|
|
538
|
+
grid.add_column(ratio=1)
|
|
539
|
+
for left, right in pairs:
|
|
540
|
+
grid.add_row(left, right or "")
|
|
541
|
+
return grid
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def render(d):
|
|
545
|
+
score, tips = health(d)
|
|
546
|
+
pairs = [(system_panel(d), memory_panel(d)),
|
|
547
|
+
(storage_panel(d), network_panel(d)),
|
|
548
|
+
(tools_panel(d), dev_panel(d)),
|
|
549
|
+
(cleanup_panel(d), tips_panel(tips))]
|
|
550
|
+
return Group(header(d, score), Text(""), two_columns(pairs, console.width),
|
|
551
|
+
Align.center(Text("devmedic ports · kill · junk · caches · repos · procs · "
|
|
552
|
+
"watch — --help for more", style="grey50")))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def gather():
|
|
556
|
+
with console.status("[bold cyan]Examining your machine…[/]", spinner="dots12"):
|
|
557
|
+
return collect_all()
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def show(d=None):
|
|
561
|
+
console.print(render(d or gather()))
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ───────────────────────── live mode ─────────────────────────
|
|
565
|
+
|
|
566
|
+
def watch(interval=1.0):
|
|
567
|
+
"""Live, full-screen dashboard: CPU/RAM/net history, ports and dev procs."""
|
|
568
|
+
cpu_hist = deque(maxlen=48)
|
|
569
|
+
mem_hist = deque(maxlen=48)
|
|
570
|
+
net_prev = psutil.net_io_counters()
|
|
571
|
+
t_prev = time.time()
|
|
572
|
+
s = system_info()
|
|
573
|
+
psutil.cpu_percent(percpu=True)
|
|
574
|
+
|
|
575
|
+
def frame():
|
|
576
|
+
nonlocal net_prev, t_prev
|
|
577
|
+
cores = psutil.cpu_percent(percpu=True)
|
|
578
|
+
total = sum(cores) / len(cores)
|
|
579
|
+
mem = psutil.virtual_memory()
|
|
580
|
+
cpu_hist.append(total)
|
|
581
|
+
mem_hist.append(mem.percent)
|
|
582
|
+
io, now = psutil.net_io_counters(), time.time()
|
|
583
|
+
dt = max(now - t_prev, 1e-3)
|
|
584
|
+
up = (io.bytes_sent - net_prev.bytes_sent) / dt
|
|
585
|
+
down = (io.bytes_recv - net_prev.bytes_recv) / dt
|
|
586
|
+
net_prev, t_prev = io, now
|
|
587
|
+
|
|
588
|
+
top = Table.grid(expand=True, padding=(0, 2))
|
|
589
|
+
top.add_column(no_wrap=True)
|
|
590
|
+
top.add_column(ratio=1)
|
|
591
|
+
top.add_row(banner(), Text.assemble(
|
|
592
|
+
(f"{s['user']}@{s['host']}\n", "bold cyan"), (f"{s['os']}\n", "bold"),
|
|
593
|
+
(f"{s['cpu']}\n", "grey62"),
|
|
594
|
+
(f"LIVE ● {time.strftime('%H:%M:%S')}", "bold red"), (" Ctrl+C to exit", "grey50")))
|
|
595
|
+
|
|
596
|
+
vitals = kv_table()
|
|
597
|
+
vitals.add_row("CPU", bar(total))
|
|
598
|
+
vitals.add_row("", Text(spark(cpu_hist).rjust(48, " "), style="cyan"))
|
|
599
|
+
vitals.add_row("Cores", Text(" ".join(f"{c:3.0f}" for c in cores), style=level_color(total)))
|
|
600
|
+
vitals.add_row("RAM", bar(mem.percent))
|
|
601
|
+
vitals.add_row("", Text(spark(mem_hist).rjust(48, " "), style="magenta"))
|
|
602
|
+
temp = temperature()
|
|
603
|
+
if temp:
|
|
604
|
+
vitals.add_row("Temp", Text(f"{temp:.0f}°C", style="bold " + level_color(temp, 70, 85)))
|
|
605
|
+
vitals.add_row("Net", f"↑ [green]{human_size(up)}/s[/] ↓ [cyan]{human_size(down)}/s[/]")
|
|
606
|
+
|
|
607
|
+
pt = Table(expand=True, box=None, header_style="bold grey70", padding=(0, 1))
|
|
608
|
+
pt.add_column("Port", style="bold cyan", justify="right")
|
|
609
|
+
pt.add_column("Process")
|
|
610
|
+
pt.add_column("Project", style="grey62", overflow="ellipsis", no_wrap=True)
|
|
611
|
+
for r in ports.listening()[:8]:
|
|
612
|
+
pt.add_row(str(r["port"]), r["name"], short_path(r["cwd"]) if r["cwd"] else "")
|
|
613
|
+
if not pt.row_count:
|
|
614
|
+
pt.add_row("", "[grey62]none[/]", "")
|
|
615
|
+
|
|
616
|
+
ht = Table(expand=True, box=None, header_style="bold grey70", padding=(0, 1))
|
|
617
|
+
ht.add_column("Top processes")
|
|
618
|
+
ht.add_column("Count", justify="right", style="grey62")
|
|
619
|
+
ht.add_column("RAM", justify="right")
|
|
620
|
+
for name, rss, count in memory_hogs(8):
|
|
621
|
+
ht.add_row(name[:28], f"×{count}" if count > 1 else "",
|
|
622
|
+
Text(human_size(rss), style=level_color(rss / mem.total * 100, 15, 30)))
|
|
623
|
+
|
|
624
|
+
grid = Table.grid(expand=True, padding=(0, 1))
|
|
625
|
+
grid.add_column(ratio=1)
|
|
626
|
+
grid.add_column(ratio=1)
|
|
627
|
+
grid.add_row(panel(vitals, "⚡ Vitals", "cyan"), panel(ht, "🧠 Memory hogs", "magenta"))
|
|
628
|
+
grid.add_row(panel(pt, "🔌 Listening ports", "yellow"),
|
|
629
|
+
panel(disk_mini(), "💾 Storage", "blue"))
|
|
630
|
+
return Group(top, Text(""), grid)
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
with Live(frame(), console=console, screen=True, refresh_per_second=4) as live:
|
|
634
|
+
while True:
|
|
635
|
+
time.sleep(interval)
|
|
636
|
+
live.update(frame())
|
|
637
|
+
except KeyboardInterrupt:
|
|
638
|
+
pass
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def disk_mini():
|
|
642
|
+
t = kv_table()
|
|
643
|
+
for x in disks():
|
|
644
|
+
t.add_row(x["mount"], bar(x["pct"]))
|
|
645
|
+
return t
|