evo-cli 0.5.1__tar.gz → 0.7.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.
- {evo_cli-0.5.1 → evo_cli-0.7.0}/PKG-INFO +1 -1
- evo_cli-0.7.0/evo_cli/VERSION +1 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/cli.py +4 -0
- evo_cli-0.7.0/evo_cli/commands/sysmon.py +545 -0
- evo_cli-0.7.0/evo_cli/commands/wifi.py +837 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/SOURCES.txt +5 -1
- evo_cli-0.7.0/tests/test_sysmon.py +91 -0
- evo_cli-0.7.0/tests/test_wifi.py +154 -0
- evo_cli-0.5.1/evo_cli/VERSION +0 -1
- {evo_cli-0.5.1 → evo_cli-0.7.0}/Containerfile +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/HISTORY.md +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/LICENSE +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/MANIFEST.in +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/README.md +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/base.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli/console.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/pyproject.toml +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/setup.cfg +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/tests/__init__.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/tests/test_cli.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.5.1 → evo_cli-0.7.0}/tests/test_plantuml.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.7.0
|
|
@@ -10,6 +10,8 @@ from evo_cli.commands.netcheck import netcheck
|
|
|
10
10
|
from evo_cli.commands.plantuml import plantuml
|
|
11
11
|
from evo_cli.commands.site2s import site2s
|
|
12
12
|
from evo_cli.commands.ssh import setupssh
|
|
13
|
+
from evo_cli.commands.sysmon import sysmon
|
|
14
|
+
from evo_cli.commands.wifi import wifi
|
|
13
15
|
|
|
14
16
|
click.rich_click.USE_MARKDOWN = True
|
|
15
17
|
click.rich_click.SHOW_ARGUMENTS = True
|
|
@@ -41,6 +43,8 @@ cli.add_command(site2s)
|
|
|
41
43
|
cli.add_command(localproxy)
|
|
42
44
|
cli.add_command(netcheck)
|
|
43
45
|
cli.add_command(plantuml)
|
|
46
|
+
cli.add_command(sysmon)
|
|
47
|
+
cli.add_command(wifi)
|
|
44
48
|
|
|
45
49
|
|
|
46
50
|
def main():
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import json as jsonlib
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import rich_click as click
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from evo_cli.console import console, error, info, step, success, warning
|
|
15
|
+
|
|
16
|
+
# Thresholds used to turn raw numbers into a plain-language verdict.
|
|
17
|
+
TEMP_WARM = 70.0 # °C - normal under load, worth noting
|
|
18
|
+
TEMP_HOT = 90.0 # °C - heavy load / poor cooling, may throttle soon
|
|
19
|
+
CPU_BUSY = 85.0 # % overall CPU usage
|
|
20
|
+
MEM_HIGH = 85.0 # % memory in use
|
|
21
|
+
NOMINAL_PRESSURE = {"nominal", ""}
|
|
22
|
+
|
|
23
|
+
EPILOG = Text.from_markup(
|
|
24
|
+
"[bold]Examples[/bold]\n\n"
|
|
25
|
+
" [cyan]evo sysmon[/cyan] one-shot temperature + performance snapshot\n"
|
|
26
|
+
" [cyan]evo sysmon -w 2[/cyan] live dashboard, refresh every 2s (Ctrl-C to stop)\n"
|
|
27
|
+
" [cyan]evo sysmon -n 10[/cyan] show the top 10 CPU-hungry processes\n"
|
|
28
|
+
" [cyan]evo sysmon --json[/cyan] machine-readable output\n"
|
|
29
|
+
" [cyan]evo sysmon --install-deps[/cyan] install the smctemp sensor reader (macOS)\n\n"
|
|
30
|
+
"[dim]On Apple Silicon, real °C values come from the SMC via 'smctemp'\n"
|
|
31
|
+
"(brew install narugit/tap/smctemp). Without it, only battery temperature\n"
|
|
32
|
+
"and macOS thermal-pressure level are available. On Linux it reads\n"
|
|
33
|
+
"/sys/class/thermal sensors. Everything degrades gracefully.[/dim]"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- low-level helpers -------------------------------------------------------
|
|
38
|
+
def _run(cmd, timeout=6):
|
|
39
|
+
"""Run a command, return its stdout on success, else None (never raises)."""
|
|
40
|
+
try:
|
|
41
|
+
out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
42
|
+
except (OSError, subprocess.SubprocessError):
|
|
43
|
+
return None
|
|
44
|
+
return out.stdout if out.returncode == 0 else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fmt_bytes(n):
|
|
48
|
+
if n is None:
|
|
49
|
+
return "-"
|
|
50
|
+
value = float(n)
|
|
51
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
52
|
+
if value < 1024 or unit == "TB":
|
|
53
|
+
return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
|
|
54
|
+
value /= 1024
|
|
55
|
+
return f"{value:.1f}TB"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- temperature: macOS ------------------------------------------------------
|
|
59
|
+
def _mac_smc_temp(flag):
|
|
60
|
+
"""Read CPU (-c) or GPU (-g) temperature via smctemp; °C float or None."""
|
|
61
|
+
if not shutil.which("smctemp"):
|
|
62
|
+
return None
|
|
63
|
+
out = _run(["smctemp", flag, "-f"]) # -f -> decimal output
|
|
64
|
+
if not out:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
value = float(out.strip().splitlines()[0])
|
|
68
|
+
except (ValueError, IndexError):
|
|
69
|
+
return None
|
|
70
|
+
return value if value > 0 else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mac_battery_temp():
|
|
74
|
+
"""Battery temperature from ioreg (AppleSmartBattery), reported in 0.01 °C."""
|
|
75
|
+
out = _run(["ioreg", "-r", "-n", "AppleSmartBattery"])
|
|
76
|
+
if not out:
|
|
77
|
+
return None
|
|
78
|
+
match = re.search(r'"Temperature"\s*=\s*(\d+)', out)
|
|
79
|
+
return int(match.group(1)) / 100.0 if match else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _mac_thermal_pressure():
|
|
83
|
+
"""macOS thermal-pressure level (Nominal/Moderate/Heavy/...) via powermetrics.
|
|
84
|
+
|
|
85
|
+
Needs root; we use ``sudo -n`` so it stays silent (returns None) when sudo
|
|
86
|
+
credentials are not already cached - never prompts for a password.
|
|
87
|
+
"""
|
|
88
|
+
out = _run(
|
|
89
|
+
["sudo", "-n", "powermetrics", "--samplers", "thermal", "-n", "1", "-i", "200"],
|
|
90
|
+
timeout=10,
|
|
91
|
+
)
|
|
92
|
+
if not out:
|
|
93
|
+
return None
|
|
94
|
+
match = re.search(r"pressure level:\s*(\w+)", out)
|
|
95
|
+
return match.group(1) if match else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --- temperature: Linux ------------------------------------------------------
|
|
99
|
+
def _linux_temps():
|
|
100
|
+
"""Read every /sys/class/thermal zone; return list of {label, value}."""
|
|
101
|
+
entries = []
|
|
102
|
+
base = "/sys/class/thermal"
|
|
103
|
+
if not os.path.isdir(base):
|
|
104
|
+
return entries
|
|
105
|
+
for zone in sorted(os.listdir(base)):
|
|
106
|
+
if not zone.startswith("thermal_zone"):
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
raw = open(f"{base}/{zone}/temp").read().strip()
|
|
110
|
+
label = open(f"{base}/{zone}/type").read().strip()
|
|
111
|
+
entries.append({"label": label, "value": int(raw) / 1000.0})
|
|
112
|
+
except (OSError, ValueError):
|
|
113
|
+
continue
|
|
114
|
+
return entries
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def collect_temps():
|
|
118
|
+
system = platform.system()
|
|
119
|
+
data = {
|
|
120
|
+
"platform": system,
|
|
121
|
+
"entries": [],
|
|
122
|
+
"cpu": None,
|
|
123
|
+
"pressure": None,
|
|
124
|
+
"source": None,
|
|
125
|
+
"smctemp_available": None,
|
|
126
|
+
}
|
|
127
|
+
if system == "Darwin":
|
|
128
|
+
cpu = _mac_smc_temp("-c")
|
|
129
|
+
gpu = _mac_smc_temp("-g")
|
|
130
|
+
battery = _mac_battery_temp()
|
|
131
|
+
data["smctemp_available"] = bool(shutil.which("smctemp"))
|
|
132
|
+
data["source"] = "smctemp" if data["smctemp_available"] else "ioreg"
|
|
133
|
+
data["pressure"] = _mac_thermal_pressure()
|
|
134
|
+
for label, value in (("CPU", cpu), ("GPU", gpu), ("Battery", battery)):
|
|
135
|
+
if value is not None:
|
|
136
|
+
data["entries"].append({"label": label, "value": value})
|
|
137
|
+
data["cpu"] = cpu
|
|
138
|
+
elif system == "Linux":
|
|
139
|
+
data["source"] = "sysfs"
|
|
140
|
+
data["entries"] = _linux_temps()
|
|
141
|
+
for entry in data["entries"]:
|
|
142
|
+
low = entry["label"].lower()
|
|
143
|
+
if any(k in low for k in ("cpu", "package", "core", "tctl", "x86_pkg", "soc")):
|
|
144
|
+
data["cpu"] = entry["value"]
|
|
145
|
+
break
|
|
146
|
+
if data["cpu"] is None and data["entries"]:
|
|
147
|
+
data["cpu"] = max(e["value"] for e in data["entries"])
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- performance: CPU usage --------------------------------------------------
|
|
152
|
+
def _mac_cpu_usage():
|
|
153
|
+
"""Overall CPU usage % from two top samples (the first is since boot)."""
|
|
154
|
+
out = _run(["top", "-l", "2", "-n", "0"], timeout=8)
|
|
155
|
+
if not out:
|
|
156
|
+
return None
|
|
157
|
+
samples = re.findall(r"CPU usage:.*?([\d.]+)% idle", out)
|
|
158
|
+
if not samples:
|
|
159
|
+
return None
|
|
160
|
+
return round(100.0 - float(samples[-1]), 1)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _linux_cpu_usage():
|
|
164
|
+
def snapshot():
|
|
165
|
+
with open("/proc/stat") as handle:
|
|
166
|
+
fields = list(map(int, handle.readline().split()[1:]))
|
|
167
|
+
idle = fields[3] + (fields[4] if len(fields) > 4 else 0)
|
|
168
|
+
return idle, sum(fields)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
idle1, total1 = snapshot()
|
|
172
|
+
time.sleep(0.3)
|
|
173
|
+
idle2, total2 = snapshot()
|
|
174
|
+
except (OSError, ValueError):
|
|
175
|
+
return None
|
|
176
|
+
delta = total2 - total1
|
|
177
|
+
if delta <= 0:
|
|
178
|
+
return None
|
|
179
|
+
return round((1 - (idle2 - idle1) / delta) * 100, 1)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cpu_usage():
|
|
183
|
+
system = platform.system()
|
|
184
|
+
if system == "Darwin":
|
|
185
|
+
return _mac_cpu_usage()
|
|
186
|
+
if system == "Linux":
|
|
187
|
+
return _linux_cpu_usage()
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --- performance: memory -----------------------------------------------------
|
|
192
|
+
def _mac_memory():
|
|
193
|
+
total_out = _run(["sysctl", "-n", "hw.memsize"])
|
|
194
|
+
vm = _run(["vm_stat"])
|
|
195
|
+
if not total_out or not vm:
|
|
196
|
+
return None
|
|
197
|
+
try:
|
|
198
|
+
total = int(total_out.strip())
|
|
199
|
+
except ValueError:
|
|
200
|
+
return None
|
|
201
|
+
page = 4096
|
|
202
|
+
page_match = re.search(r"page size of (\d+) bytes", vm)
|
|
203
|
+
if page_match:
|
|
204
|
+
page = int(page_match.group(1))
|
|
205
|
+
|
|
206
|
+
def pages(label):
|
|
207
|
+
match = re.search(rf"{label}:\s+(\d+)", vm)
|
|
208
|
+
return int(match.group(1)) if match else 0
|
|
209
|
+
|
|
210
|
+
# Mirrors Activity Monitor's "Memory Used": app + wired + compressed.
|
|
211
|
+
used = (pages("Pages active") + pages("Pages wired down") + pages("Pages occupied by compressor")) * page
|
|
212
|
+
return {"total": total, "used": used, "percent": round(used / total * 100, 1)}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _linux_memory():
|
|
216
|
+
info_map = {}
|
|
217
|
+
try:
|
|
218
|
+
with open("/proc/meminfo") as handle:
|
|
219
|
+
for line in handle:
|
|
220
|
+
key, _, value = line.partition(":")
|
|
221
|
+
info_map[key.strip()] = int(value.split()[0]) * 1024
|
|
222
|
+
except (OSError, ValueError):
|
|
223
|
+
return None
|
|
224
|
+
total = info_map.get("MemTotal")
|
|
225
|
+
if not total:
|
|
226
|
+
return None
|
|
227
|
+
available = info_map.get("MemAvailable", info_map.get("MemFree", 0))
|
|
228
|
+
used = total - available
|
|
229
|
+
return {"total": total, "used": used, "percent": round(used / total * 100, 1)}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def memory():
|
|
233
|
+
system = platform.system()
|
|
234
|
+
if system == "Darwin":
|
|
235
|
+
return _mac_memory()
|
|
236
|
+
if system == "Linux":
|
|
237
|
+
return _linux_memory()
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --- performance: misc -------------------------------------------------------
|
|
242
|
+
def load_average():
|
|
243
|
+
try:
|
|
244
|
+
return list(os.getloadavg())
|
|
245
|
+
except (OSError, AttributeError):
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def uptime_seconds():
|
|
250
|
+
system = platform.system()
|
|
251
|
+
if system == "Linux":
|
|
252
|
+
try:
|
|
253
|
+
return float(open("/proc/uptime").read().split()[0])
|
|
254
|
+
except (OSError, ValueError):
|
|
255
|
+
return None
|
|
256
|
+
if system == "Darwin":
|
|
257
|
+
out = _run(["sysctl", "-n", "kern.boottime"])
|
|
258
|
+
if out:
|
|
259
|
+
match = re.search(r"sec\s*=\s*(\d+)", out)
|
|
260
|
+
if match:
|
|
261
|
+
return time.time() - int(match.group(1))
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cpu_model():
|
|
266
|
+
system = platform.system()
|
|
267
|
+
if system == "Darwin":
|
|
268
|
+
return (_run(["sysctl", "-n", "machdep.cpu.brand_string"]) or "").strip() or None
|
|
269
|
+
if system == "Linux":
|
|
270
|
+
try:
|
|
271
|
+
for line in open("/proc/cpuinfo"):
|
|
272
|
+
if line.lower().startswith("model name"):
|
|
273
|
+
return line.split(":", 1)[1].strip()
|
|
274
|
+
except OSError:
|
|
275
|
+
pass
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def top_processes(count):
|
|
280
|
+
system = platform.system()
|
|
281
|
+
if system == "Darwin":
|
|
282
|
+
out = _run(["ps", "-Ao", "pid,%cpu,%mem,comm", "-r"])
|
|
283
|
+
else:
|
|
284
|
+
out = _run(["ps", "-Ao", "pid,%cpu,%mem,comm", "--sort=-%cpu"])
|
|
285
|
+
if not out:
|
|
286
|
+
return []
|
|
287
|
+
rows = []
|
|
288
|
+
for line in out.strip().splitlines()[1 : count + 1]:
|
|
289
|
+
parts = line.split(None, 3)
|
|
290
|
+
if len(parts) < 4:
|
|
291
|
+
continue
|
|
292
|
+
pid, cpu, mem, comm = parts
|
|
293
|
+
try:
|
|
294
|
+
rows.append({"pid": pid, "cpu": float(cpu), "mem": float(mem), "command": comm.split("/")[-1]})
|
|
295
|
+
except ValueError:
|
|
296
|
+
continue
|
|
297
|
+
return rows
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def collect(top_n):
|
|
301
|
+
return {
|
|
302
|
+
"host": platform.node(),
|
|
303
|
+
"system": f"{platform.system()} {platform.release()}",
|
|
304
|
+
"cpu_model": cpu_model(),
|
|
305
|
+
"cpu_count": os.cpu_count(),
|
|
306
|
+
"temps": collect_temps(),
|
|
307
|
+
"cpu_percent": cpu_usage(),
|
|
308
|
+
"memory": memory(),
|
|
309
|
+
"load": load_average(),
|
|
310
|
+
"uptime": uptime_seconds(),
|
|
311
|
+
"processes": top_processes(top_n),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# --- rendering ---------------------------------------------------------------
|
|
316
|
+
def _temp_cell(value):
|
|
317
|
+
if value is None:
|
|
318
|
+
return "[dim]-[/dim]"
|
|
319
|
+
style = "success"
|
|
320
|
+
if value >= TEMP_HOT:
|
|
321
|
+
style = "error"
|
|
322
|
+
elif value >= TEMP_WARM:
|
|
323
|
+
style = "warning"
|
|
324
|
+
return f"[{style}]{value:.1f}°C[/{style}]"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _pct_cell(value, threshold):
|
|
328
|
+
if value is None:
|
|
329
|
+
return "[dim]-[/dim]"
|
|
330
|
+
style = "error" if value >= threshold else "info"
|
|
331
|
+
return f"[{style}]{value:.0f}%[/{style}]"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _fmt_uptime(seconds):
|
|
335
|
+
if seconds is None:
|
|
336
|
+
return "-"
|
|
337
|
+
seconds = int(seconds)
|
|
338
|
+
days, rem = divmod(seconds, 86400)
|
|
339
|
+
hours, rem = divmod(rem, 3600)
|
|
340
|
+
minutes = rem // 60
|
|
341
|
+
if days:
|
|
342
|
+
return f"{days}d {hours}h {minutes}m"
|
|
343
|
+
if hours:
|
|
344
|
+
return f"{hours}h {minutes}m"
|
|
345
|
+
return f"{minutes}m"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def render_temps(temps):
|
|
349
|
+
table = Table(title="Temperature", title_style="accent", show_header=True, header_style="accent")
|
|
350
|
+
table.add_column("Sensor", style="info", no_wrap=True)
|
|
351
|
+
table.add_column("Temp", justify="right")
|
|
352
|
+
table.add_column("Status", justify="left")
|
|
353
|
+
if not temps["entries"]:
|
|
354
|
+
table.add_row("[dim]no sensors[/dim]", "-", "-")
|
|
355
|
+
for entry in temps["entries"]:
|
|
356
|
+
value = entry["value"]
|
|
357
|
+
if value >= TEMP_HOT:
|
|
358
|
+
status = "[error]hot[/error]"
|
|
359
|
+
elif value >= TEMP_WARM:
|
|
360
|
+
status = "[warning]warm[/warning]"
|
|
361
|
+
else:
|
|
362
|
+
status = "[success]ok[/success]"
|
|
363
|
+
table.add_row(entry["label"], _temp_cell(value), status)
|
|
364
|
+
if temps.get("pressure"):
|
|
365
|
+
pressure = temps["pressure"]
|
|
366
|
+
hot = pressure.lower() not in NOMINAL_PRESSURE
|
|
367
|
+
cell = f"[{'error' if hot else 'success'}]{pressure}[/]"
|
|
368
|
+
table.add_row("Thermal pressure", cell, "[dim]macOS[/dim]")
|
|
369
|
+
console.print(table)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def render_perf(data):
|
|
373
|
+
table = Table(title="Performance", title_style="accent", show_header=True, header_style="accent")
|
|
374
|
+
table.add_column("Metric", style="info", no_wrap=True)
|
|
375
|
+
table.add_column("Value", justify="right")
|
|
376
|
+
table.add_column("Detail", justify="left")
|
|
377
|
+
|
|
378
|
+
ncpu = data["cpu_count"] or 0
|
|
379
|
+
table.add_row("CPU usage", _pct_cell(data["cpu_percent"], CPU_BUSY), f"{ncpu} logical cores")
|
|
380
|
+
|
|
381
|
+
load = data["load"]
|
|
382
|
+
if load:
|
|
383
|
+
over = ncpu and load[0] > ncpu
|
|
384
|
+
load_style = "error" if over else "success"
|
|
385
|
+
load_text = f"[{load_style}]{load[0]:.2f}[/] {load[1]:.2f} {load[2]:.2f}"
|
|
386
|
+
table.add_row("Load avg", load_text, "1m 5m 15m")
|
|
387
|
+
|
|
388
|
+
mem = data["memory"]
|
|
389
|
+
if mem:
|
|
390
|
+
detail = f"{fmt_bytes(mem['used'])} / {fmt_bytes(mem['total'])}"
|
|
391
|
+
table.add_row("Memory", _pct_cell(mem["percent"], MEM_HIGH), detail)
|
|
392
|
+
|
|
393
|
+
table.add_row("Uptime", _fmt_uptime(data["uptime"]), data["cpu_model"] or "")
|
|
394
|
+
console.print(table)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def render_processes(processes):
|
|
398
|
+
if not processes:
|
|
399
|
+
return
|
|
400
|
+
table = Table(title="Top processes (by CPU)", title_style="accent", show_header=True, header_style="accent")
|
|
401
|
+
table.add_column("PID", style="dim", justify="right", no_wrap=True)
|
|
402
|
+
table.add_column("CPU%", justify="right")
|
|
403
|
+
table.add_column("MEM%", justify="right")
|
|
404
|
+
table.add_column("Command", style="info")
|
|
405
|
+
for proc in processes:
|
|
406
|
+
cpu_style = "error" if proc["cpu"] >= 100 else ("warning" if proc["cpu"] >= 25 else "")
|
|
407
|
+
cpu_text = f"[{cpu_style}]{proc['cpu']:.1f}[/]" if cpu_style else f"{proc['cpu']:.1f}"
|
|
408
|
+
table.add_row(proc["pid"], cpu_text, f"{proc['mem']:.1f}", proc["command"])
|
|
409
|
+
console.print(table)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def build_notes(data):
|
|
413
|
+
notes = []
|
|
414
|
+
temps = data["temps"]
|
|
415
|
+
cpu_t = temps.get("cpu")
|
|
416
|
+
if cpu_t is not None:
|
|
417
|
+
if cpu_t >= TEMP_HOT:
|
|
418
|
+
notes.append(("warning", f"CPU is hot ({cpu_t:.0f}°C) - heavy load or limited cooling"))
|
|
419
|
+
elif cpu_t >= TEMP_WARM:
|
|
420
|
+
notes.append(("info", f"CPU is warm ({cpu_t:.0f}°C) - normal while busy"))
|
|
421
|
+
pressure = temps.get("pressure")
|
|
422
|
+
if pressure and pressure.lower() not in NOMINAL_PRESSURE:
|
|
423
|
+
notes.append(("warning", f"Thermal pressure is {pressure} - the system may be throttling"))
|
|
424
|
+
|
|
425
|
+
cpu_u = data["cpu_percent"]
|
|
426
|
+
if cpu_u is not None and cpu_u >= CPU_BUSY:
|
|
427
|
+
notes.append(("warning", f"CPU usage is high ({cpu_u:.0f}%)"))
|
|
428
|
+
|
|
429
|
+
load = data["load"]
|
|
430
|
+
ncpu = data["cpu_count"]
|
|
431
|
+
if load and ncpu and load[0] > ncpu:
|
|
432
|
+
notes.append(("warning", f"Load average {load[0]:.2f} exceeds {ncpu} cores - tasks are queuing"))
|
|
433
|
+
|
|
434
|
+
mem = data["memory"]
|
|
435
|
+
if mem and mem["percent"] >= MEM_HIGH:
|
|
436
|
+
notes.append(("warning", f"Memory usage is high ({mem['percent']:.0f}%)"))
|
|
437
|
+
|
|
438
|
+
procs = data["processes"]
|
|
439
|
+
if procs and procs[0]["cpu"] >= 100 and (cpu_u is None or cpu_u >= CPU_BUSY or (cpu_t and cpu_t >= TEMP_WARM)):
|
|
440
|
+
top = procs[0]
|
|
441
|
+
notes.append(("info", f"Biggest CPU consumer: {top['command']} ({top['cpu']:.0f}%, pid {top['pid']})"))
|
|
442
|
+
|
|
443
|
+
if temps["platform"] == "Darwin" and temps.get("smctemp_available") is False:
|
|
444
|
+
notes.append(
|
|
445
|
+
(
|
|
446
|
+
"info",
|
|
447
|
+
"Install 'smctemp' for real CPU/GPU °C readings: "
|
|
448
|
+
"evo sysmon --install-deps (or brew install narugit/tap/smctemp)",
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return notes
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def render(data, show_procs):
|
|
455
|
+
render_temps(data["temps"])
|
|
456
|
+
console.print()
|
|
457
|
+
render_perf(data)
|
|
458
|
+
if show_procs:
|
|
459
|
+
console.print()
|
|
460
|
+
render_processes(data["processes"])
|
|
461
|
+
|
|
462
|
+
notes = build_notes(data)
|
|
463
|
+
step("Verdict")
|
|
464
|
+
if not notes:
|
|
465
|
+
success("All good - temperatures, CPU, memory and load are within normal range.")
|
|
466
|
+
else:
|
|
467
|
+
dispatch = {"warning": warning, "error": error, "info": info}
|
|
468
|
+
for level, message in notes:
|
|
469
|
+
dispatch.get(level, info)(message)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def install_deps():
|
|
473
|
+
if platform.system() != "Darwin":
|
|
474
|
+
info("Dependency install is only needed/supported on macOS (Apple Silicon).")
|
|
475
|
+
return
|
|
476
|
+
if shutil.which("smctemp"):
|
|
477
|
+
success("smctemp is already installed.")
|
|
478
|
+
return
|
|
479
|
+
if not shutil.which("brew"):
|
|
480
|
+
error("Homebrew not found. Install it from https://brew.sh first.")
|
|
481
|
+
sys.exit(1)
|
|
482
|
+
info("Installing smctemp via Homebrew (narugit/tap/smctemp)...")
|
|
483
|
+
result = subprocess.run(["brew", "install", "narugit/tap/smctemp"])
|
|
484
|
+
if result.returncode == 0 and shutil.which("smctemp"):
|
|
485
|
+
success("smctemp installed - real CPU/GPU temperatures are now available.")
|
|
486
|
+
else:
|
|
487
|
+
error("Install failed. Try manually: brew install narugit/tap/smctemp")
|
|
488
|
+
sys.exit(1)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@click.command("sysmon", epilog=EPILOG)
|
|
492
|
+
@click.option("-n", "--top", "top_n", default=5, show_default=True, help="Number of top CPU processes to show.")
|
|
493
|
+
@click.option("-w", "--watch", "watch", default=0.0, help="Refresh continuously every N seconds (Ctrl-C to stop).")
|
|
494
|
+
@click.option("--no-procs", "no_procs", is_flag=True, help="Hide the top-processes table.")
|
|
495
|
+
@click.option("--json", "as_json", is_flag=True, help="Print result as JSON.")
|
|
496
|
+
@click.option("--install-deps", "do_install", is_flag=True, help="Install the smctemp sensor reader (macOS) and exit.")
|
|
497
|
+
def sysmon(top_n, watch, no_procs, as_json, do_install):
|
|
498
|
+
"""Show machine temperature and performance at a glance.
|
|
499
|
+
|
|
500
|
+
Reports **CPU / GPU / battery temperature**, overall **CPU usage**,
|
|
501
|
+
**memory** pressure, **load average**, uptime and the **top CPU-hungry
|
|
502
|
+
processes**, then prints a plain-language verdict that flags overheating,
|
|
503
|
+
throttling, an oversubscribed CPU or memory pressure.
|
|
504
|
+
|
|
505
|
+
On Apple Silicon real °C values are read from the SMC via `smctemp`
|
|
506
|
+
(`evo sysmon --install-deps` to set it up); without it you still get battery
|
|
507
|
+
temperature and the macOS thermal-pressure level. On Linux it reads
|
|
508
|
+
`/sys/class/thermal`. With `-w/--watch` it becomes a live dashboard.
|
|
509
|
+
"""
|
|
510
|
+
if do_install:
|
|
511
|
+
step("evo sysmon - install deps")
|
|
512
|
+
install_deps()
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
show_procs = not no_procs
|
|
516
|
+
|
|
517
|
+
if as_json:
|
|
518
|
+
console.print_json(jsonlib.dumps(collect(top_n), ensure_ascii=False, default=str))
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
if watch and watch > 0:
|
|
522
|
+
try:
|
|
523
|
+
while True:
|
|
524
|
+
console.clear()
|
|
525
|
+
step("evo sysmon")
|
|
526
|
+
with console.status("[info]sampling...[/info]", spinner="dots"):
|
|
527
|
+
data = collect(top_n)
|
|
528
|
+
render(data, show_procs)
|
|
529
|
+
console.print(f"[dim]refreshing every {watch:g}s - Ctrl-C to stop[/dim]")
|
|
530
|
+
time.sleep(watch)
|
|
531
|
+
except KeyboardInterrupt:
|
|
532
|
+
console.print()
|
|
533
|
+
info("stopped")
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
step("evo sysmon")
|
|
537
|
+
try:
|
|
538
|
+
with console.status("[info]sampling...[/info]", spinner="dots"):
|
|
539
|
+
data = collect(top_n)
|
|
540
|
+
render(data, show_procs)
|
|
541
|
+
except click.ClickException:
|
|
542
|
+
raise
|
|
543
|
+
except Exception as exc:
|
|
544
|
+
error(str(exc))
|
|
545
|
+
sys.exit(1)
|