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/junk.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Find regenerable build/dependency folders across your projects."""
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
|
|
6
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from .util import (HOME, confirm, console, dir_size, display_path, human_age, human_size,
|
|
10
|
+
remove_tree, short_path, size_style)
|
|
11
|
+
|
|
12
|
+
# name -> (kind, check) ; check(parent_dir, junk_dir) says whether it's really junk
|
|
13
|
+
def _sibling(*names):
|
|
14
|
+
return lambda parent, _d: any(os.path.exists(os.path.join(parent, n)) for n in names)
|
|
15
|
+
|
|
16
|
+
def _always(_parent, _d):
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
def _is_venv(_parent, d):
|
|
20
|
+
return os.path.exists(os.path.join(d, "pyvenv.cfg"))
|
|
21
|
+
|
|
22
|
+
PATTERNS = {
|
|
23
|
+
"node_modules": ("Node deps", _always),
|
|
24
|
+
".venv": ("Python venv", _is_venv),
|
|
25
|
+
"venv": ("Python venv", _is_venv),
|
|
26
|
+
"env": ("Python venv", _is_venv),
|
|
27
|
+
"target": ("Rust/Maven", _sibling("Cargo.toml", "pom.xml")),
|
|
28
|
+
".next": ("Next.js build", _always),
|
|
29
|
+
".nuxt": ("Nuxt build", _always),
|
|
30
|
+
".svelte-kit": ("SvelteKit", _always),
|
|
31
|
+
".parcel-cache": ("Parcel cache", _always),
|
|
32
|
+
".turbo": ("Turbo cache", _always),
|
|
33
|
+
".angular": ("Angular cache", _always),
|
|
34
|
+
".gradle": ("Gradle cache", _sibling("build.gradle", "build.gradle.kts",
|
|
35
|
+
"settings.gradle", "settings.gradle.kts")),
|
|
36
|
+
".dart_tool": ("Dart/Flutter", _sibling("pubspec.yaml")),
|
|
37
|
+
".tox": ("tox envs", _always),
|
|
38
|
+
".pytest_cache": ("pytest cache", _always),
|
|
39
|
+
".mypy_cache": ("mypy cache", _always),
|
|
40
|
+
".ruff_cache": ("ruff cache", _always),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Never descend into these (huge, or not your projects).
|
|
44
|
+
SKIP_DIRS = {".git", ".cache", ".local", ".npm", ".cargo", ".rustup", "snap",
|
|
45
|
+
".mozilla", ".config", ".vscode", ".vscode-server", ".var",
|
|
46
|
+
".m2", ".gradle", ".nvm", ".pyenv", ".docker", ".Trash"}
|
|
47
|
+
# Skipped only directly under $HOME / filesystem root.
|
|
48
|
+
SKIP_AT_TOP = {"go", "proc", "sys", "dev", "run", "tmp", "boot", "lost+found"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find(root, max_depth=8):
|
|
52
|
+
"""Yield (junk_path, kind) — does not descend into junk it found."""
|
|
53
|
+
root = os.path.abspath(root)
|
|
54
|
+
stack = [(root, 0)]
|
|
55
|
+
while stack:
|
|
56
|
+
current, depth = stack.pop()
|
|
57
|
+
try:
|
|
58
|
+
entries = list(os.scandir(current))
|
|
59
|
+
except OSError:
|
|
60
|
+
continue
|
|
61
|
+
for e in entries:
|
|
62
|
+
if not e.is_dir(follow_symlinks=False):
|
|
63
|
+
continue
|
|
64
|
+
pattern = PATTERNS.get(e.name)
|
|
65
|
+
if pattern and pattern[1](current, e.path):
|
|
66
|
+
yield e.path, pattern[0]
|
|
67
|
+
continue
|
|
68
|
+
at_top = current in (HOME, "/")
|
|
69
|
+
if depth < max_depth and e.name not in SKIP_DIRS and not (
|
|
70
|
+
at_top and (e.name.startswith(".") or e.name in SKIP_AT_TOP)):
|
|
71
|
+
stack.append((e.path, depth + 1))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def project_last_touched(junk_path):
|
|
75
|
+
"""Newest mtime among the project's own top-level files (not the junk)."""
|
|
76
|
+
parent = os.path.dirname(junk_path)
|
|
77
|
+
newest = 0
|
|
78
|
+
try:
|
|
79
|
+
for e in os.scandir(parent):
|
|
80
|
+
if e.path == junk_path or e.name in PATTERNS:
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
newest = max(newest, e.stat(follow_symlinks=False).st_mtime)
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
return newest or os.stat(junk_path).st_mtime
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan(root, older_than_days=0):
|
|
92
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
93
|
+
console=console, transient=True) as prog:
|
|
94
|
+
task = prog.add_task(f"Scanning {short_path(os.path.abspath(root))} …")
|
|
95
|
+
found = []
|
|
96
|
+
for path, kind in find(root):
|
|
97
|
+
found.append((path, kind))
|
|
98
|
+
prog.update(task, description=f"Found {len(found)} — {short_path(path)}")
|
|
99
|
+
prog.update(task, description=f"Measuring {len(found)} folders …")
|
|
100
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
101
|
+
sizes = list(pool.map(dir_size, [p for p, _ in found]))
|
|
102
|
+
cutoff = time.time() - older_than_days * 86400
|
|
103
|
+
items = []
|
|
104
|
+
for (path, kind), size in zip(found, sizes):
|
|
105
|
+
touched = project_last_touched(path)
|
|
106
|
+
if older_than_days and touched > cutoff:
|
|
107
|
+
continue
|
|
108
|
+
items.append({"path": path, "kind": kind, "size": size, "touched": touched})
|
|
109
|
+
items.sort(key=lambda i: i["size"], reverse=True)
|
|
110
|
+
return items
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def show(items, limit=40, root=None):
|
|
114
|
+
if not items:
|
|
115
|
+
console.print("[green]No junk folders found. Clean machine! ✨[/]")
|
|
116
|
+
return
|
|
117
|
+
total = sum(i["size"] for i in items)
|
|
118
|
+
t = Table(title=f"Regenerable folders — {human_size(total)} total",
|
|
119
|
+
title_justify="left")
|
|
120
|
+
t.add_column("#", justify="right", style="dim")
|
|
121
|
+
t.add_column("Size", justify="right")
|
|
122
|
+
t.add_column("Type")
|
|
123
|
+
t.add_column("Project last touched", justify="right")
|
|
124
|
+
t.add_column("Path", overflow="fold")
|
|
125
|
+
for n, i in enumerate(items[:limit], 1):
|
|
126
|
+
t.add_row(str(n), f"[{size_style(i['size'])}]{human_size(i['size'])}[/]",
|
|
127
|
+
i["kind"], human_age(i["touched"]), display_path(i["path"], root))
|
|
128
|
+
console.print(t)
|
|
129
|
+
if len(items) > limit:
|
|
130
|
+
rest = items[limit:]
|
|
131
|
+
console.print(f"[dim]…and {len(rest)} more "
|
|
132
|
+
f"({human_size(sum(i['size'] for i in rest))}). Use --limit.[/]")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_selection(text, count):
|
|
136
|
+
"""'1,3,5-7' / 'all' -> sorted list of 0-based indices."""
|
|
137
|
+
text = text.strip().lower()
|
|
138
|
+
if text in ("a", "all"):
|
|
139
|
+
return list(range(count))
|
|
140
|
+
picked = set()
|
|
141
|
+
for part in text.replace(" ", ",").split(","):
|
|
142
|
+
if not part:
|
|
143
|
+
continue
|
|
144
|
+
if "-" in part:
|
|
145
|
+
lo, hi = part.split("-", 1)
|
|
146
|
+
picked.update(range(int(lo), int(hi) + 1))
|
|
147
|
+
else:
|
|
148
|
+
picked.add(int(part))
|
|
149
|
+
return sorted(i - 1 for i in picked if 1 <= i <= count)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def clean(items, assume_yes=False, limit=40):
|
|
153
|
+
if not items:
|
|
154
|
+
return
|
|
155
|
+
shown = items[:limit]
|
|
156
|
+
if assume_yes:
|
|
157
|
+
chosen = shown
|
|
158
|
+
else:
|
|
159
|
+
try:
|
|
160
|
+
answer = console.input("[bold]Delete which?[/] [dim](e.g. 1,3,5-8 / all / "
|
|
161
|
+
"Enter to cancel)[/] ")
|
|
162
|
+
chosen = [shown[i] for i in parse_selection(answer, len(shown))]
|
|
163
|
+
except ValueError:
|
|
164
|
+
console.print("[red]Couldn't parse that selection.[/]")
|
|
165
|
+
return
|
|
166
|
+
except (EOFError, KeyboardInterrupt):
|
|
167
|
+
console.print()
|
|
168
|
+
return
|
|
169
|
+
if not chosen:
|
|
170
|
+
console.print("Nothing deleted.")
|
|
171
|
+
return
|
|
172
|
+
total = sum(i["size"] for i in chosen)
|
|
173
|
+
console.print(f"About to delete [bold]{len(chosen)}[/] folders "
|
|
174
|
+
f"([bold]{human_size(total)}[/]). They can be regenerated "
|
|
175
|
+
f"(npm install, pip install, cargo build…).")
|
|
176
|
+
if not confirm("Proceed?", assume_yes):
|
|
177
|
+
return
|
|
178
|
+
freed = 0
|
|
179
|
+
for i in chosen:
|
|
180
|
+
if remove_tree(i["path"]):
|
|
181
|
+
freed += i["size"]
|
|
182
|
+
console.print(f"[green]✔[/] {short_path(i['path'])}")
|
|
183
|
+
else:
|
|
184
|
+
console.print(f"[red]✘ couldn't fully remove[/] {short_path(i['path'])}")
|
|
185
|
+
console.print(f"[bold green]Freed {human_size(freed)}[/]")
|
devmedic/menu.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Interactive section picker: `devmedic` with no arguments in a terminal."""
|
|
2
|
+
import os
|
|
3
|
+
import select
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from rich.console import Group
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from . import caches, dashboard as db, ports, procs, repos
|
|
12
|
+
from .util import console, human_size, short_path
|
|
13
|
+
|
|
14
|
+
# ───────────────────────── section details ─────────────────────────
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def detail_system(d):
|
|
18
|
+
console.print(db.system_panel(d))
|
|
19
|
+
t = Table(title="Per-core usage", title_justify="left", box=None, padding=(0, 1))
|
|
20
|
+
t.add_column("Core", style="bold grey70", justify="right")
|
|
21
|
+
t.add_column("Usage")
|
|
22
|
+
for i, pct in enumerate(d["cpu"]):
|
|
23
|
+
t.add_row(str(i), db.bar(pct, 30))
|
|
24
|
+
console.print(t)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detail_memory(d):
|
|
28
|
+
console.print(db.memory_panel(d, limit=12))
|
|
29
|
+
console.print("[dim]Processes are grouped by app (e.g. all browser tabs together).[/]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def detail_storage(d):
|
|
33
|
+
console.print(db.storage_panel(d))
|
|
34
|
+
console.print("[dim]Free space fast: devmedic junk --clean · devmedic caches[/]")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detail_network(d):
|
|
38
|
+
console.print(db.network_panel(d))
|
|
39
|
+
ports.show()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detail_tools(d):
|
|
43
|
+
console.print(db.tools_panel(d))
|
|
44
|
+
dk = d["docker"]
|
|
45
|
+
if dk["state"] == "ok" and dk["containers"]:
|
|
46
|
+
t = Table(title="Running containers", title_justify="left")
|
|
47
|
+
for col in ("Name", "Image", "Status"):
|
|
48
|
+
t.add_column(col)
|
|
49
|
+
for row in dk["containers"]:
|
|
50
|
+
t.add_row(*row[:3])
|
|
51
|
+
console.print(t)
|
|
52
|
+
elif dk["state"] == "denied":
|
|
53
|
+
console.print("Fix Docker permissions: [bold]sudo usermod -aG docker $USER[/] "
|
|
54
|
+
"then log out and back in.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def detail_projects(d):
|
|
58
|
+
procs.show(d["procs"])
|
|
59
|
+
console.print()
|
|
60
|
+
repos.show(d["git"]["infos"], show_all=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def detail_cleanup(d):
|
|
64
|
+
user_c, sys_c = d["caches"]
|
|
65
|
+
caches.show(user_c, sys_c)
|
|
66
|
+
j = d["junk"]
|
|
67
|
+
console.print()
|
|
68
|
+
if j["count"]:
|
|
69
|
+
console.print(f"[bold]{j['count']} junk folders[/] ({human_size(j['total'])}), biggest:")
|
|
70
|
+
for size, path in j["top"]:
|
|
71
|
+
console.print(f" {human_size(size):>9} {short_path(path)}")
|
|
72
|
+
console.print("[dim]Review & delete: devmedic junk --clean[/]")
|
|
73
|
+
else:
|
|
74
|
+
console.print("[green]No node_modules / .venv / target junk found in ~ ✨[/]")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def detail_notes(d):
|
|
78
|
+
_, tips = db.health(d)
|
|
79
|
+
console.print(db.tips_panel(tips))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ───────────────────────── menu summaries ─────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _c(text, color):
|
|
86
|
+
return f"[{color}]{text}[/]"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def sum_system(d):
|
|
90
|
+
s, cores = d["sys"], d["cpu"]
|
|
91
|
+
total = sum(cores) / len(cores) if cores else 0
|
|
92
|
+
parts = [_c(f"CPU {total:.0f}%", db.level_color(total))]
|
|
93
|
+
if s["temp"]:
|
|
94
|
+
parts.append(_c(f"{s['temp']:.0f}°C", db.level_color(s["temp"], 70, 85)))
|
|
95
|
+
if s["battery"]:
|
|
96
|
+
b = s["battery"]
|
|
97
|
+
parts.append(_c(f"battery {b.percent:.0f}%{' ⚡' if b.power_plugged else ''}",
|
|
98
|
+
db.level_color(100 - b.percent, 70, 85)))
|
|
99
|
+
return " · ".join(parts)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def sum_memory(d):
|
|
103
|
+
m = d["mem"]
|
|
104
|
+
top = d["hogs"][0] if d["hogs"] else None
|
|
105
|
+
return (_c(f"RAM {m.percent:.0f}%", db.level_color(m.percent, 75, 90))
|
|
106
|
+
+ (f" · {top[0]} {human_size(top[1])}" if top else ""))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sum_storage(d):
|
|
110
|
+
return " · ".join(_c(f"{x['mount']} {x['pct']:.0f}%", db.level_color(x["pct"]))
|
|
111
|
+
for x in d["disks"][:3])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def sum_network(d):
|
|
115
|
+
n = d["net"]
|
|
116
|
+
where = n["wifi"] or (n["ips"][0][0] if n["ips"] else None)
|
|
117
|
+
ip = n["ips"][0][1] if n["ips"] else None
|
|
118
|
+
head = f"{where} {ip}" if ip else _c("offline", "yellow")
|
|
119
|
+
return f"{head} · {db.plural(len(d['ports']), 'port')} listening"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sum_tools(d):
|
|
123
|
+
installed = sum(1 for _, v in d["tools"] if v)
|
|
124
|
+
dk = {"ok": _c("docker ✔", "green"), "denied": _c("docker needs permission", "yellow"),
|
|
125
|
+
"down": _c("docker stopped", "grey62"), "missing": ""}[d["docker"]["state"]]
|
|
126
|
+
return " · ".join(x for x in (f"{installed} tools installed", dk) if x)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def sum_projects(d):
|
|
130
|
+
g = d["git"]
|
|
131
|
+
risky = len(g["noremote"]) + len(g["unpushed"])
|
|
132
|
+
parts = [f"{db.plural(g['total'], 'repo')}"]
|
|
133
|
+
parts.append(_c(f"{risky} not backed up", "red") if risky else _c("all backed up", "green"))
|
|
134
|
+
parts.append(f"{db.plural(len(d['procs']), 'dev process', 'dev processes')}")
|
|
135
|
+
return " · ".join(parts)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def sum_cleanup(d):
|
|
139
|
+
user_c, sys_c = d["caches"]
|
|
140
|
+
total = (sum(c["size"] for c in user_c) + sum(s["size"] or 0 for s in sys_c)
|
|
141
|
+
+ d["junk"]["total"])
|
|
142
|
+
color = "red" if total > 5 * 1024**3 else "yellow" if total > 300 * 1024**2 else "green"
|
|
143
|
+
return _c(f"{human_size(total)} reclaimable", color)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def sum_notes(d):
|
|
147
|
+
_, tips = db.health(d)
|
|
148
|
+
return _c(f"{db.plural(len(tips), 'suggestion')}", "yellow") if tips else _c("all good ✨", "green")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# key, icon, title, summary(d), detail(d)
|
|
152
|
+
SECTIONS = [
|
|
153
|
+
("system", "⚡", "System", sum_system, detail_system),
|
|
154
|
+
("memory", "🧠", "Memory", sum_memory, detail_memory),
|
|
155
|
+
("storage", "💾", "Storage", sum_storage, detail_storage),
|
|
156
|
+
("network", "🌐", "Network", sum_network, detail_network),
|
|
157
|
+
("tools", "🛠 ", "Dev tools", sum_tools, detail_tools),
|
|
158
|
+
("projects", "📂", "Projects", sum_projects, detail_projects),
|
|
159
|
+
("cleanup", "🧹", "Cleanup", sum_cleanup, detail_cleanup),
|
|
160
|
+
("notes", "💡", "Doctor's notes", sum_notes, detail_notes),
|
|
161
|
+
]
|
|
162
|
+
EXTRAS = [ # key, icon, title, hint
|
|
163
|
+
("all", "📊", "Everything", "full dashboard on one screen"),
|
|
164
|
+
("watch", "📡", "Live monitor", "real-time CPU / RAM / network"),
|
|
165
|
+
("refresh", "🔄", "Refresh", "re-scan the machine"),
|
|
166
|
+
("quit", "🚪", "Quit", ""),
|
|
167
|
+
]
|
|
168
|
+
SECTION_KEYS = [s[0] for s in SECTIONS]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def show_section(key, d=None):
|
|
172
|
+
"""Print one section (used by `devmedic memory` etc.)."""
|
|
173
|
+
d = d or db.gather()
|
|
174
|
+
for k, icon, title, _, detail in SECTIONS:
|
|
175
|
+
if k == key:
|
|
176
|
+
if key in ("projects", "cleanup"): # the others open with their own panel
|
|
177
|
+
console.rule(f"[bold cyan]{icon} {title}[/]", align="left", style="cyan")
|
|
178
|
+
detail(d)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ───────────────────────── keyboard ─────────────────────────
|
|
183
|
+
|
|
184
|
+
KEYS = {b"\x1b[A": "up", b"\x1bOA": "up", b"k": "up",
|
|
185
|
+
b"\x1b[B": "down", b"\x1bOB": "down", b"j": "down",
|
|
186
|
+
b"\r": "enter", b"\n": "enter", b" ": "enter", b"\x1b[C": "enter", b"l": "enter",
|
|
187
|
+
b"q": "quit", b"Q": "quit", b"\x1b": "back", b"\x1b[D": "back", b"h": "back",
|
|
188
|
+
b"r": "refresh", b"a": "all", b"w": "watch", b"\x1b[H": "home", b"\x1b[F": "end"}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_key():
|
|
192
|
+
import termios # POSIX only — imported here so the package loads everywhere
|
|
193
|
+
import tty
|
|
194
|
+
|
|
195
|
+
fd = sys.stdin.fileno()
|
|
196
|
+
old = termios.tcgetattr(fd)
|
|
197
|
+
try:
|
|
198
|
+
tty.setcbreak(fd)
|
|
199
|
+
data = os.read(fd, 1)
|
|
200
|
+
# Escape sequences arrive as a burst; grab the rest if it's there.
|
|
201
|
+
while data.startswith(b"\x1b") and select.select([fd], [], [], 0.03)[0]:
|
|
202
|
+
data += os.read(fd, 8)
|
|
203
|
+
finally:
|
|
204
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
205
|
+
if data.isdigit():
|
|
206
|
+
return data.decode()
|
|
207
|
+
return KEYS.get(data, "other")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def wait_key(msg="Press any key to go back · q to quit"):
|
|
211
|
+
console.print()
|
|
212
|
+
console.print(Text(f" {msg}", style="grey50"))
|
|
213
|
+
return read_key()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ───────────────────────── menu screen ─────────────────────────
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def menu_body(d, cursor):
|
|
220
|
+
items = [(k, icon, title, Text.from_markup(summ(d))) for k, icon, title, summ, _ in SECTIONS]
|
|
221
|
+
items += [(k, icon, title, Text(hint, style="grey50")) for k, icon, title, hint in EXTRAS]
|
|
222
|
+
t = Table.grid(padding=(0, 1))
|
|
223
|
+
t.add_column(width=2) # pointer
|
|
224
|
+
t.add_column(width=3, justify="right", style="grey50") # shortcut
|
|
225
|
+
t.add_column(no_wrap=True) # icon + title
|
|
226
|
+
t.add_column() # summary
|
|
227
|
+
for i, (key, icon, title, summary) in enumerate(items):
|
|
228
|
+
if i == len(SECTIONS):
|
|
229
|
+
t.add_row("", "", Text("─" * 18, style="grey35"), "")
|
|
230
|
+
selected = i == cursor
|
|
231
|
+
shortcut = str(i + 1) if i < len(SECTIONS) else {"all": "a", "watch": "w",
|
|
232
|
+
"refresh": "r", "quit": "q"}[key]
|
|
233
|
+
label = Text(f"{icon} {title}", style="bold reverse cyan" if selected else "bold")
|
|
234
|
+
t.add_row(Text("❯", style="bold magenta") if selected else "", shortcut, label, summary)
|
|
235
|
+
return t, [it[0] for it in items]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def render_menu(d, cursor):
|
|
239
|
+
score, _ = db.health(d)
|
|
240
|
+
body, keys = menu_body(d, cursor)
|
|
241
|
+
footer = Text("↑↓ / j k move · Enter open · 1-8 jump · a all · w live · r refresh · q quit",
|
|
242
|
+
style="grey50")
|
|
243
|
+
menu = Panel(Group(body, Text(""), footer), title="[bold cyan]Choose a section[/]",
|
|
244
|
+
title_align="left", border_style="cyan", padding=(1, 2))
|
|
245
|
+
return Group(db.header(d, score), Text(""), menu), keys
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def run():
|
|
249
|
+
d = db.gather()
|
|
250
|
+
cursor = 0
|
|
251
|
+
while True:
|
|
252
|
+
console.clear()
|
|
253
|
+
screen, keys = render_menu(d, cursor)
|
|
254
|
+
console.print(screen)
|
|
255
|
+
key = read_key()
|
|
256
|
+
if key.isdigit():
|
|
257
|
+
n = int(key) - 1
|
|
258
|
+
if 0 <= n < len(keys):
|
|
259
|
+
cursor = n
|
|
260
|
+
key = "enter"
|
|
261
|
+
else:
|
|
262
|
+
continue
|
|
263
|
+
if key in ("all", "watch", "refresh"):
|
|
264
|
+
cursor = keys.index(key)
|
|
265
|
+
key = "enter"
|
|
266
|
+
if key == "up":
|
|
267
|
+
cursor = (cursor - 1) % len(keys)
|
|
268
|
+
elif key == "down":
|
|
269
|
+
cursor = (cursor + 1) % len(keys)
|
|
270
|
+
elif key == "home":
|
|
271
|
+
cursor = 0
|
|
272
|
+
elif key == "end":
|
|
273
|
+
cursor = len(keys) - 1
|
|
274
|
+
elif key in ("quit", "back"):
|
|
275
|
+
break
|
|
276
|
+
elif key == "enter":
|
|
277
|
+
choice = keys[cursor]
|
|
278
|
+
if choice == "quit":
|
|
279
|
+
break
|
|
280
|
+
if choice == "refresh":
|
|
281
|
+
console.clear()
|
|
282
|
+
d = db.gather()
|
|
283
|
+
continue
|
|
284
|
+
console.clear()
|
|
285
|
+
if choice == "watch":
|
|
286
|
+
db.watch()
|
|
287
|
+
continue
|
|
288
|
+
if choice == "all":
|
|
289
|
+
db.show(d)
|
|
290
|
+
else:
|
|
291
|
+
show_section(choice, d)
|
|
292
|
+
if wait_key() == "quit":
|
|
293
|
+
break
|
|
294
|
+
console.clear()
|
|
295
|
+
console.print("[bold cyan]🩺 devmedic[/] — stay healthy! 👋")
|
devmedic/ports.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Who is listening on which port, and killing them."""
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import psutil
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from .util import confirm, console, short_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Friendly names for common root-owned services we can't inspect.
|
|
13
|
+
WELL_KNOWN = {22: "sshd", 25: "smtp", 53: "DNS (systemd-resolved)", 80: "http",
|
|
14
|
+
443: "https", 631: "CUPS printing", 3306: "MySQL", 5432: "PostgreSQL",
|
|
15
|
+
6379: "Redis", 27017: "MongoDB", 5353: "mDNS (avahi)", 2375: "Docker",
|
|
16
|
+
11434: "Ollama"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def listening(include_system=False):
|
|
20
|
+
"""List of dicts describing TCP listeners (deduped across IPv4/IPv6)."""
|
|
21
|
+
rows = {}
|
|
22
|
+
try:
|
|
23
|
+
conns = psutil.net_connections(kind="inet")
|
|
24
|
+
except (psutil.AccessDenied, OSError):
|
|
25
|
+
conns = [] # locked-down containers / non-Linux without root
|
|
26
|
+
for c in conns:
|
|
27
|
+
if c.status != psutil.CONN_LISTEN or not c.laddr:
|
|
28
|
+
continue
|
|
29
|
+
port = c.laddr.port
|
|
30
|
+
key = (port, c.pid)
|
|
31
|
+
if key in rows:
|
|
32
|
+
rows[key]["addrs"].add(c.laddr.ip)
|
|
33
|
+
continue
|
|
34
|
+
info = {"port": port, "pid": c.pid, "addrs": {c.laddr.ip},
|
|
35
|
+
"name": WELL_KNOWN.get(port, "?"), "cmd": "", "cwd": "", "user": "", "mine": False}
|
|
36
|
+
if c.pid:
|
|
37
|
+
try:
|
|
38
|
+
p = psutil.Process(c.pid)
|
|
39
|
+
info["name"] = p.name()
|
|
40
|
+
info["cmd"] = " ".join(" ".join(p.cmdline()).split())
|
|
41
|
+
info["user"] = p.username()
|
|
42
|
+
info["mine"] = p.uids().real == os.getuid()
|
|
43
|
+
info["cwd"] = p.cwd()
|
|
44
|
+
except (psutil.AccessDenied, psutil.NoSuchProcess, psutil.ZombieProcess):
|
|
45
|
+
pass
|
|
46
|
+
rows[key] = info
|
|
47
|
+
result = sorted(rows.values(), key=lambda r: r["port"])
|
|
48
|
+
if not include_system:
|
|
49
|
+
result = [r for r in result if r["mine"]]
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def exposure(addrs):
|
|
54
|
+
if all(a.startswith("127.") or a == "::1" for a in addrs):
|
|
55
|
+
return "[green]local only[/]"
|
|
56
|
+
return "[yellow]network[/]"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def show(include_system=False):
|
|
60
|
+
rows = listening(include_system)
|
|
61
|
+
if not rows:
|
|
62
|
+
console.print("[green]No listening ports owned by you.[/] "
|
|
63
|
+
"[dim](use --all to include system services)[/]")
|
|
64
|
+
return
|
|
65
|
+
t = Table(title="Listening ports", title_justify="left")
|
|
66
|
+
t.add_column("Port", style="bold cyan", justify="right")
|
|
67
|
+
t.add_column("PID", justify="right")
|
|
68
|
+
t.add_column("Process")
|
|
69
|
+
t.add_column("Exposed")
|
|
70
|
+
t.add_column("Project dir", style="dim")
|
|
71
|
+
t.add_column("Command", overflow="ellipsis", max_width=60, no_wrap=True)
|
|
72
|
+
for r in rows:
|
|
73
|
+
t.add_row(str(r["port"]), str(r["pid"] or "?"), r["name"],
|
|
74
|
+
exposure(r["addrs"]), short_path(r["cwd"]) if r["cwd"] else "",
|
|
75
|
+
r["cmd"])
|
|
76
|
+
console.print(t)
|
|
77
|
+
if not include_system:
|
|
78
|
+
console.print("[dim]Showing your processes only. --all for system services.[/]")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def kill(port, force=False, assume_yes=False):
|
|
82
|
+
targets = [r for r in listening(include_system=True) if r["port"] == port]
|
|
83
|
+
if not targets:
|
|
84
|
+
console.print(f"[green]Nothing is listening on port {port}.[/]")
|
|
85
|
+
return 0
|
|
86
|
+
status = 0
|
|
87
|
+
for r in targets:
|
|
88
|
+
if not r["pid"]:
|
|
89
|
+
console.print(f"[red]Port {port} is held by a process you can't see "
|
|
90
|
+
f"(probably root).[/] Try: [bold]sudo ss -ltnp 'sport = :{port}'[/]")
|
|
91
|
+
status = 1
|
|
92
|
+
continue
|
|
93
|
+
console.print(f"Port [bold cyan]{port}[/] → PID {r['pid']} "
|
|
94
|
+
f"[bold]{r['name']}[/] [dim]{r['cmd'][:100]}[/]")
|
|
95
|
+
if not r["mine"]:
|
|
96
|
+
console.print(f"[yellow]Owned by {r['user'] or 'another user'}; "
|
|
97
|
+
f"you may need sudo.[/]")
|
|
98
|
+
if not confirm("Kill it?", assume_yes):
|
|
99
|
+
continue
|
|
100
|
+
status |= terminate(r["pid"], force)
|
|
101
|
+
return status
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def terminate(pid, force=False):
|
|
105
|
+
try:
|
|
106
|
+
p = psutil.Process(pid)
|
|
107
|
+
if force:
|
|
108
|
+
p.kill()
|
|
109
|
+
else:
|
|
110
|
+
p.terminate()
|
|
111
|
+
try:
|
|
112
|
+
p.wait(timeout=3)
|
|
113
|
+
except psutil.TimeoutExpired:
|
|
114
|
+
console.print("[yellow]Didn't exit after SIGTERM, sending SIGKILL…[/]")
|
|
115
|
+
p.kill()
|
|
116
|
+
p.wait(timeout=3)
|
|
117
|
+
except psutil.NoSuchProcess:
|
|
118
|
+
pass
|
|
119
|
+
except psutil.AccessDenied:
|
|
120
|
+
console.print(f"[red]Permission denied.[/] Try: [bold]sudo kill {pid}[/]")
|
|
121
|
+
return 1
|
|
122
|
+
except psutil.TimeoutExpired:
|
|
123
|
+
console.print(f"[red]PID {pid} is still alive.[/]")
|
|
124
|
+
return 1
|
|
125
|
+
console.print(f"[green]✔ Killed PID {pid}[/]")
|
|
126
|
+
return 0
|