syscheck-cli 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.
- syscheck/__init__.py +2 -0
- syscheck/__main__.py +4 -0
- syscheck/cli.py +134 -0
- syscheck/cmdlang.py +109 -0
- syscheck/commands/__init__.py +8 -0
- syscheck/commands/cpu.py +45 -0
- syscheck/commands/disk.py +61 -0
- syscheck/commands/net.py +59 -0
- syscheck/commands/proc.py +35 -0
- syscheck/commands/ram.py +49 -0
- syscheck/commands/sys.py +43 -0
- syscheck/commands/temp.py +50 -0
- syscheck/commands/watch.py +115 -0
- syscheck/palette.py +30 -0
- syscheck/plugin.py +89 -0
- syscheck/plugins/__init__.py +1 -0
- syscheck/plugins/example.py +38 -0
- syscheck/providers.py +705 -0
- syscheck/screens.py +265 -0
- syscheck/shellguard.py +126 -0
- syscheck/tui.py +1086 -0
- syscheck/utils.py +111 -0
- syscheck_cli-0.2.0.dist-info/METADATA +14 -0
- syscheck_cli-0.2.0.dist-info/RECORD +27 -0
- syscheck_cli-0.2.0.dist-info/WHEEL +5 -0
- syscheck_cli-0.2.0.dist-info/entry_points.txt +2 -0
- syscheck_cli-0.2.0.dist-info/top_level.txt +1 -0
syscheck/providers.py
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""Провайдеры данных — собирают метрики, не печатают.
|
|
2
|
+
|
|
3
|
+
Отделены от рендера, чтобы одни и те же данные можно было показывать
|
|
4
|
+
в CLI-выводе, в TUI-панели и в JSON.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
import psutil
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- CPU: неблокирующий расчёт дельт по собственным выборкам -------------
|
|
23
|
+
_cpu_prev: Optional[Tuple[float, Any, Any]] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cpu_percent_from_times(a, b) -> float:
|
|
27
|
+
"""Процент занятости CPU из двух выборок cpu_times() одного ядра."""
|
|
28
|
+
idle_d = max(b.idle - a.idle, 0)
|
|
29
|
+
total_d = max(sum(b) - sum(a), idle_d)
|
|
30
|
+
if total_d <= 0:
|
|
31
|
+
return 0.0
|
|
32
|
+
return max(0.0, min(100.0, (total_d - idle_d) / total_d * 100.0))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cpu_delta() -> Optional[Tuple[float, List[float]]]:
|
|
36
|
+
"""Неблокирующий (overall %, per-core %).
|
|
37
|
+
|
|
38
|
+
Считает прирост по сырым счётчикам cpu_times() с прошлого вызова —
|
|
39
|
+
без блокирующего interval и без сна. Первый вызов возвращает None
|
|
40
|
+
(нет базовой выборки).
|
|
41
|
+
"""
|
|
42
|
+
global _cpu_prev
|
|
43
|
+
now = time.time()
|
|
44
|
+
overall = psutil.cpu_times()
|
|
45
|
+
per = psutil.cpu_times(percpu=True)
|
|
46
|
+
if _cpu_prev is None:
|
|
47
|
+
_cpu_prev = (now, overall, per)
|
|
48
|
+
return None
|
|
49
|
+
t0, o0, p0 = _cpu_prev
|
|
50
|
+
_cpu_prev = (now, overall, per)
|
|
51
|
+
dt = now - t0
|
|
52
|
+
if dt <= 0.05:
|
|
53
|
+
return None
|
|
54
|
+
return _cpu_percent_from_times(o0, overall), [
|
|
55
|
+
_cpu_percent_from_times(x0, x) for x0, x in zip(p0, per)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cpu_info(interval: float = 0.3) -> Dict[str, Any]:
|
|
60
|
+
freq = psutil.cpu_freq()
|
|
61
|
+
return {
|
|
62
|
+
"percent": psutil.cpu_percent(interval=interval),
|
|
63
|
+
"count_logical": psutil.cpu_count(logical=True),
|
|
64
|
+
"count_physical": psutil.cpu_count(logical=False),
|
|
65
|
+
"freq_current": freq.current if freq else None,
|
|
66
|
+
"freq_max": freq.max if freq else None,
|
|
67
|
+
"per_cpu": psutil.cpu_percent(interval=0, percpu=True),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ram_info(top: bool = True) -> Dict[str, Any]:
|
|
72
|
+
mem = psutil.virtual_memory()
|
|
73
|
+
swap = _swap_slow()
|
|
74
|
+
procs = []
|
|
75
|
+
if top:
|
|
76
|
+
for p in psutil.process_iter(["pid", "name", "memory_percent"]):
|
|
77
|
+
try:
|
|
78
|
+
pinfo = p.info
|
|
79
|
+
if pinfo["memory_percent"] and pinfo["memory_percent"] > 0.1:
|
|
80
|
+
procs.append(pinfo)
|
|
81
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
82
|
+
pass
|
|
83
|
+
procs.sort(key=lambda x: x["memory_percent"] or 0, reverse=True)
|
|
84
|
+
return {
|
|
85
|
+
"total": mem.total,
|
|
86
|
+
"available": mem.available,
|
|
87
|
+
"used": mem.used,
|
|
88
|
+
"cached": getattr(mem, "cached", 0) or 0,
|
|
89
|
+
"percent": mem.percent,
|
|
90
|
+
"swap_total": swap.total,
|
|
91
|
+
"swap_used": swap.used,
|
|
92
|
+
"swap_percent": swap.percent,
|
|
93
|
+
"top_procs": procs[:10],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_swap_cache: dict = {"ts": 0.0, "v": None}
|
|
98
|
+
_SWAP_TTL = 5.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _swap_slow():
|
|
102
|
+
"""psutil.swap_memory() на Windows может занимать ~600 мс — кэшируем."""
|
|
103
|
+
now = time.time()
|
|
104
|
+
v = _swap_cache["v"]
|
|
105
|
+
if v is None or now - _swap_cache["ts"] > _SWAP_TTL:
|
|
106
|
+
_swap_cache["v"] = psutil.swap_memory()
|
|
107
|
+
_swap_cache["ts"] = now
|
|
108
|
+
return _swap_cache["v"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def disk_info() -> List[Dict[str, Any]]:
|
|
112
|
+
disks = []
|
|
113
|
+
for part in psutil.disk_partitions(all=False):
|
|
114
|
+
try:
|
|
115
|
+
usage = psutil.disk_usage(part.mountpoint)
|
|
116
|
+
disks.append({
|
|
117
|
+
"device": part.device,
|
|
118
|
+
"mountpoint": part.mountpoint,
|
|
119
|
+
"total": usage.total,
|
|
120
|
+
"used": usage.used,
|
|
121
|
+
"free": usage.free,
|
|
122
|
+
"percent": usage.percent,
|
|
123
|
+
})
|
|
124
|
+
except (PermissionError, OSError):
|
|
125
|
+
pass
|
|
126
|
+
io = None
|
|
127
|
+
try:
|
|
128
|
+
io = psutil.disk_io_counters()
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
return {"disks": disks, "io": io}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def net_info() -> Dict[str, Any]:
|
|
135
|
+
addrs = psutil.net_if_addrs()
|
|
136
|
+
stats = psutil.net_if_stats()
|
|
137
|
+
interfaces = []
|
|
138
|
+
for name, iface_addrs in addrs.items():
|
|
139
|
+
stat = stats.get(name)
|
|
140
|
+
ipv4 = [a.address for a in iface_addrs if a.family == socket.AF_INET]
|
|
141
|
+
mac_list = [a.address for a in iface_addrs if a.family == psutil.AF_LINK]
|
|
142
|
+
interfaces.append({
|
|
143
|
+
"name": name,
|
|
144
|
+
"up": bool(stat and stat.isup),
|
|
145
|
+
"speed": stat.speed if stat else 0,
|
|
146
|
+
"ipv4": ipv4,
|
|
147
|
+
"mac": mac_list[:1] or [],
|
|
148
|
+
})
|
|
149
|
+
try:
|
|
150
|
+
io = psutil.net_io_counters()
|
|
151
|
+
io_data = {
|
|
152
|
+
"bytes_sent": io.bytes_sent,
|
|
153
|
+
"bytes_recv": io.bytes_recv,
|
|
154
|
+
"packets_sent": io.packets_sent,
|
|
155
|
+
"packets_recv": io.packets_recv,
|
|
156
|
+
"errin": io.errin,
|
|
157
|
+
"errout": io.errout,
|
|
158
|
+
}
|
|
159
|
+
except Exception:
|
|
160
|
+
io_data = None
|
|
161
|
+
return {"interfaces": interfaces, "io": io_data}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _decode_ping_output(raw: bytes) -> str:
|
|
165
|
+
for enc in ("cp866", "cp1251", "utf-8", "latin-1"):
|
|
166
|
+
try:
|
|
167
|
+
return raw.decode(enc)
|
|
168
|
+
except (UnicodeDecodeError, LookupError):
|
|
169
|
+
continue
|
|
170
|
+
return raw.decode("latin-1", errors="replace")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def ping(host: str, count: int = 4) -> Dict[str, Any]:
|
|
174
|
+
"""Пингует хост и возвращает статистику."""
|
|
175
|
+
param = "-n" if sys.platform == "win32" else "-c"
|
|
176
|
+
try:
|
|
177
|
+
result = subprocess.run(
|
|
178
|
+
["ping", param, str(count), host],
|
|
179
|
+
capture_output=True, timeout=15,
|
|
180
|
+
)
|
|
181
|
+
output = _decode_ping_output(result.stdout)
|
|
182
|
+
pattern = re.compile(r"=\s*(\d+)\s*(?:\u043c\u0441|ms)", re.IGNORECASE)
|
|
183
|
+
matches = list(pattern.finditer(output))
|
|
184
|
+
times = []
|
|
185
|
+
for m in matches[:count]:
|
|
186
|
+
ctx = output[m.end():m.end() + 20]
|
|
187
|
+
if "TTL" in ctx or not re.search(r"\d+\s*(?:\u043c\u0441|ms)", ctx):
|
|
188
|
+
try:
|
|
189
|
+
times.append(float(m.group(1)))
|
|
190
|
+
except (ValueError, TypeError):
|
|
191
|
+
pass
|
|
192
|
+
avg = sum(times) / len(times) if times else None
|
|
193
|
+
return {
|
|
194
|
+
"host": host,
|
|
195
|
+
"success": result.returncode == 0,
|
|
196
|
+
"avg_ms": avg,
|
|
197
|
+
"min_ms": min(times) if times else None,
|
|
198
|
+
"max_ms": max(times) if times else None,
|
|
199
|
+
"lost": max(0, count - len(times)),
|
|
200
|
+
"total": count,
|
|
201
|
+
}
|
|
202
|
+
except (subprocess.TimeoutExpired, Exception) as e:
|
|
203
|
+
return {
|
|
204
|
+
"host": host,
|
|
205
|
+
"success": False,
|
|
206
|
+
"error": str(e),
|
|
207
|
+
"avg_ms": None,
|
|
208
|
+
"lost": count,
|
|
209
|
+
"total": count,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --- процессы: кэш объектов + неблокирующие дельты CPU ------------------
|
|
214
|
+
_proc_objs: List[Any] = []
|
|
215
|
+
_proc_list_time: float = 0.0
|
|
216
|
+
_proc_samples: Dict[int, Tuple[float, float, float]] = {}
|
|
217
|
+
_proc_raw: List[Dict[str, Any]] = []
|
|
218
|
+
_proc_raw_time: float = 0.0
|
|
219
|
+
_proc_lock = threading.Lock()
|
|
220
|
+
_PROC_REFRESH_EVERY = 8.0
|
|
221
|
+
_PROC_CPU_EVERY = 3.0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _refresh_proc_list() -> None:
|
|
225
|
+
"""Пересобирает список процессов не чаще раза в _PROC_REFRESH_EVERY сек."""
|
|
226
|
+
global _proc_objs, _proc_list_time
|
|
227
|
+
if time.time() - _proc_list_time < _PROC_REFRESH_EVERY:
|
|
228
|
+
return
|
|
229
|
+
with _proc_lock:
|
|
230
|
+
if time.time() - _proc_list_time < _PROC_REFRESH_EVERY:
|
|
231
|
+
return
|
|
232
|
+
listed = []
|
|
233
|
+
for p in psutil.process_iter(["pid", "name", "memory_percent", "status", "memory_info"]):
|
|
234
|
+
try:
|
|
235
|
+
# на Windows pid 0 — «System Idle Process», его cpu% равен общему
|
|
236
|
+
# простою всех ядер (может быть >1000%) и вводит в заблуждение.
|
|
237
|
+
if p.info["pid"] == 0:
|
|
238
|
+
continue
|
|
239
|
+
listed.append(p)
|
|
240
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
241
|
+
pass
|
|
242
|
+
_proc_objs = listed
|
|
243
|
+
_proc_list_time = time.time()
|
|
244
|
+
live = {p.pid for p in listed}
|
|
245
|
+
for pid in [k for k in _proc_samples if k not in live]:
|
|
246
|
+
_proc_samples.pop(pid, None)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _process_cpu_delta(p) -> float:
|
|
250
|
+
"""CPU% процесса по приросту cpu_times() между вызовами (без блокировок)."""
|
|
251
|
+
try:
|
|
252
|
+
t = p.cpu_times()
|
|
253
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
254
|
+
return 0.0
|
|
255
|
+
now = time.time()
|
|
256
|
+
pid = p.pid
|
|
257
|
+
prev = _proc_samples.get(pid)
|
|
258
|
+
_proc_samples[pid] = (now, t.user, t.system)
|
|
259
|
+
if prev is None:
|
|
260
|
+
return 0.0
|
|
261
|
+
dt = now - prev[0]
|
|
262
|
+
if dt <= 0.05:
|
|
263
|
+
return 0.0
|
|
264
|
+
du = max((t.user - prev[1]) + (t.system - prev[2]), 0.0)
|
|
265
|
+
return du / dt * 100.0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _refresh_proc_raw() -> List[Dict[str, Any]]:
|
|
269
|
+
"""Сырые данные процессов (с CPU-дельтой). Пересчёт CPU не чаще _PROC_CPU_EVERY."""
|
|
270
|
+
global _proc_raw, _proc_raw_time
|
|
271
|
+
now = time.time()
|
|
272
|
+
if now - _proc_raw_time < _PROC_CPU_EVERY and _proc_raw:
|
|
273
|
+
return _proc_raw
|
|
274
|
+
_refresh_proc_list()
|
|
275
|
+
raw = []
|
|
276
|
+
for p in _proc_objs:
|
|
277
|
+
try:
|
|
278
|
+
info = p.info # имя/pid/status кэшированы при сборке списка
|
|
279
|
+
info["cpu_percent"] = _process_cpu_delta(p)
|
|
280
|
+
mi = info.get("memory_info")
|
|
281
|
+
info["rss"] = getattr(mi, "rss", 0) if mi else 0
|
|
282
|
+
raw.append(info)
|
|
283
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
284
|
+
continue
|
|
285
|
+
_proc_raw = raw
|
|
286
|
+
_proc_raw_time = now
|
|
287
|
+
return raw
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def processes(sort_by: str = "cpu", limit: int = 15) -> List[Dict[str, Any]]:
|
|
291
|
+
data = list(_refresh_proc_raw())
|
|
292
|
+
key = {"cpu": "cpu_percent", "ram": "memory_percent", "name": "name"}.get(
|
|
293
|
+
sort_by, "cpu_percent"
|
|
294
|
+
)
|
|
295
|
+
if key == "name":
|
|
296
|
+
data.sort(key=lambda x: x.get(key, "") or "")
|
|
297
|
+
else:
|
|
298
|
+
data.sort(key=lambda x: x.get(key, 0) or 0, reverse=True)
|
|
299
|
+
return data[:limit]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def temperatures() -> Dict[str, Any]:
|
|
303
|
+
if not hasattr(psutil, "sensors_temperatures"):
|
|
304
|
+
return {"available": False, "temps": {}}
|
|
305
|
+
try:
|
|
306
|
+
temps = psutil.sensors_temperatures()
|
|
307
|
+
except AttributeError:
|
|
308
|
+
return {"available": False, "temps": {}}
|
|
309
|
+
return {"available": bool(temps), "temps": temps}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def system_info() -> Dict[str, Any]:
|
|
313
|
+
uname = platform.uname()
|
|
314
|
+
boot = psutil.boot_time()
|
|
315
|
+
users = []
|
|
316
|
+
try:
|
|
317
|
+
users = sorted(set(u.name for u in psutil.users()))
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
return {
|
|
321
|
+
"os": f"{uname.system} {uname.release}",
|
|
322
|
+
"version": uname.version,
|
|
323
|
+
"machine": uname.machine,
|
|
324
|
+
"node": uname.node,
|
|
325
|
+
"processor": uname.processor or "N/A",
|
|
326
|
+
"python": platform.python_version(),
|
|
327
|
+
"boot_time": boot,
|
|
328
|
+
"uptime_seconds": time.time() - boot,
|
|
329
|
+
"boot_datetime": datetime.fromtimestamp(boot).strftime("%Y-%m-%d %H:%M:%S"),
|
|
330
|
+
"users": users,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def system_quick() -> Dict[str, Any]:
|
|
335
|
+
data = {"cpu_percent": psutil.cpu_percent(interval=0.3)}
|
|
336
|
+
mem = psutil.virtual_memory()
|
|
337
|
+
data["ram_percent"] = mem.percent
|
|
338
|
+
data["ram_available"] = mem.available
|
|
339
|
+
data["disks"] = []
|
|
340
|
+
for d in disk_info()["disks"]:
|
|
341
|
+
data["disks"].append((d["device"], d["percent"], d["free"]))
|
|
342
|
+
if psutil.disk_usage("C:\\" if os.name == "nt" else "/"):
|
|
343
|
+
try:
|
|
344
|
+
root = psutil.disk_usage("C:\\" if os.name == "nt" else "/")
|
|
345
|
+
data["main_percent"], data["main_free"] = root.percent, root.free
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
try:
|
|
349
|
+
io = psutil.net_io_counters()
|
|
350
|
+
data["net_sent"] = io.bytes_sent
|
|
351
|
+
data["net_recv"] = io.bytes_recv
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
return data
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# --- дисковые скорости чтения/записи (MB/s по дельтам) ----------------
|
|
358
|
+
_io_prev: Optional[Tuple[float, int, int]] = None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def disk_io_speeds() -> Dict[str, float]:
|
|
362
|
+
"""Скорости диска за интервал между вызовами (bytes/s). Неблокирующе."""
|
|
363
|
+
global _io_prev
|
|
364
|
+
try:
|
|
365
|
+
io = psutil.disk_io_counters()
|
|
366
|
+
except Exception:
|
|
367
|
+
return {"read": 0.0, "write": 0.0}
|
|
368
|
+
now = time.time()
|
|
369
|
+
if _io_prev is None:
|
|
370
|
+
_io_prev = (now, io.read_bytes, io.write_bytes)
|
|
371
|
+
return {"read": 0.0, "write": 0.0}
|
|
372
|
+
t0, r0, w0 = _io_prev
|
|
373
|
+
_io_prev = (now, io.read_bytes, io.write_bytes)
|
|
374
|
+
dt = max(now - t0, 1e-6)
|
|
375
|
+
return {
|
|
376
|
+
"read": max(io.read_bytes - r0, 0) / dt,
|
|
377
|
+
"write": max(io.write_bytes - w0, 0) / dt,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# --- GPU (best-effort, кэш) -------------------------------------------
|
|
382
|
+
_gpu_cache: dict = {"ts": 0.0, "data": None}
|
|
383
|
+
_GPU_TTL = 30.0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def gpu_info() -> Dict[str, Any]:
|
|
387
|
+
"""Имя/VRAM видеокарты через WMI. Утилизация/temp обычно недоступны.
|
|
388
|
+
|
|
389
|
+
Возвращает {"name","vram_gb","util","temp"}; недоступные поля — None.
|
|
390
|
+
"""
|
|
391
|
+
now = time.time()
|
|
392
|
+
if _gpu_cache["data"] is not None and now - _gpu_cache["ts"] < _GPU_TTL:
|
|
393
|
+
return _gpu_cache["data"]
|
|
394
|
+
data: Dict[str, Any] = {"name": None, "vram_gb": None, "util": None, "temp": None}
|
|
395
|
+
try:
|
|
396
|
+
out = subprocess.run(
|
|
397
|
+
[
|
|
398
|
+
"powershell", "-NoProfile", "-Command",
|
|
399
|
+
"Get-CimInstance Win32_VideoController | Where-Object { $_.Name -notlike '*Virtual*' } |"
|
|
400
|
+
" Select-Object -First 1 -Property Name,AdapterRAM | ConvertTo-Json -Compress",
|
|
401
|
+
],
|
|
402
|
+
capture_output=True, text=True, timeout=10, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
403
|
+
)
|
|
404
|
+
raw = out.stdout.strip()
|
|
405
|
+
if raw:
|
|
406
|
+
import json as _json
|
|
407
|
+
info = _json.loads(raw)
|
|
408
|
+
vram = info.get("AdapterRAM")
|
|
409
|
+
if vram:
|
|
410
|
+
vram = float(vram) / (1024 ** 3)
|
|
411
|
+
data["name"] = info.get("Name")
|
|
412
|
+
data["vram_gb"] = round(vram, 1) if vram else None
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
if not data["name"]:
|
|
416
|
+
try:
|
|
417
|
+
out = subprocess.run(
|
|
418
|
+
["wmic", "path", "win32_VideoController", "get", "name"],
|
|
419
|
+
capture_output=True, text=True, timeout=8,
|
|
420
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
421
|
+
)
|
|
422
|
+
lines = [ln.strip() for ln in out.stdout.splitlines()
|
|
423
|
+
if ln.strip() and ln.strip().lower() != "name"]
|
|
424
|
+
lines = [ln for ln in lines if "virtual" not in ln.lower()]
|
|
425
|
+
data["name"] = lines[0] if lines else None
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
_gpu_cache["data"], _gpu_cache["ts"] = data, now
|
|
429
|
+
return data
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def process_count() -> int:
|
|
433
|
+
"""Количество процессов из кэша (после пересборки списка)."""
|
|
434
|
+
_refresh_proc_list()
|
|
435
|
+
return len(_proc_objs)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def cpu_temp_celsius() -> Optional[float]:
|
|
439
|
+
"""Температура CPU, если psutil её видит на данной ОС."""
|
|
440
|
+
try:
|
|
441
|
+
for entries in psutil.sensors_temperatures().values():
|
|
442
|
+
if entries:
|
|
443
|
+
return entries[0].current
|
|
444
|
+
except (AttributeError, Exception):
|
|
445
|
+
return None
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
# --- CPU: детальная карточка -------------------------------------------
|
|
449
|
+
def cpu_detail() -> Dict[str, Any]:
|
|
450
|
+
"""CPU с моделью и температурой — для команды/панели cpu."""
|
|
451
|
+
d = dict(cpu_info(interval=0.25))
|
|
452
|
+
d["model"] = platform.processor() or "N/A"
|
|
453
|
+
d["temp"] = cpu_temp_celsius()
|
|
454
|
+
return d
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# --- батарея ------------------------------------------------------------
|
|
458
|
+
def battery_info() -> Optional[Dict[str, Any]]:
|
|
459
|
+
"""Данные батареи или None, если в системе нет батареи."""
|
|
460
|
+
try:
|
|
461
|
+
b = psutil.sensors_battery()
|
|
462
|
+
if b is None:
|
|
463
|
+
return None
|
|
464
|
+
secs = b.secsleft
|
|
465
|
+
left = None
|
|
466
|
+
if secs not in (psutil.POWER_TIME_UNLIMITED, psutil.POWER_TIME_UNKNOWN) and secs > 0:
|
|
467
|
+
left = int(secs)
|
|
468
|
+
return {"percent": b.percent, "plugged": bool(b.power_plugged), "seconds_left": left}
|
|
469
|
+
except (AttributeError, Exception):
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# --- сеть: скорости по дельтам ------------------------------------------
|
|
474
|
+
_net_prev: Optional[Tuple[float, int, int]] = None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def network_speeds() -> Dict[str, float]:
|
|
478
|
+
"""Текущие скорости сети (bytes/s) по дельтам счётчиков."""
|
|
479
|
+
global _net_prev
|
|
480
|
+
try:
|
|
481
|
+
io = psutil.net_io_counters()
|
|
482
|
+
except Exception:
|
|
483
|
+
return {"down": 0.0, "up": 0.0}
|
|
484
|
+
now = time.time()
|
|
485
|
+
if _net_prev is None:
|
|
486
|
+
_net_prev = (now, io.bytes_recv, io.bytes_sent)
|
|
487
|
+
return {"down": 0.0, "up": 0.0}
|
|
488
|
+
t0, r0, s0 = _net_prev
|
|
489
|
+
_net_prev = (now, io.bytes_recv, io.bytes_sent)
|
|
490
|
+
dt = max(now - t0, 1e-6)
|
|
491
|
+
return {
|
|
492
|
+
"down": max(io.bytes_recv - r0, 0) / dt,
|
|
493
|
+
"up": max(io.bytes_sent - s0, 0) / dt,
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --- сеть: соединения по процессам (кэш) --------------------------------
|
|
498
|
+
_conn_cache: dict = {"ts": 0.0, "data": None}
|
|
499
|
+
_CONN_TTL = 4.0
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def connections_by_process(top_n: int = 10, kind: str = "tcp") -> Dict[str, Any]:
|
|
503
|
+
"""TCP-соединения, сгруппированные по процессам (имя -> число)."""
|
|
504
|
+
now = time.time()
|
|
505
|
+
if _conn_cache["data"] is not None and now - _conn_cache["ts"] < _CONN_TTL:
|
|
506
|
+
return _conn_cache["data"]
|
|
507
|
+
_refresh_proc_list()
|
|
508
|
+
pid2name = {(p.info.get("pid")): (p.info.get("name") or "?") for p in _proc_objs}
|
|
509
|
+
counts: Dict[str, int] = {}
|
|
510
|
+
total = 0
|
|
511
|
+
try:
|
|
512
|
+
conns = psutil.net_connections(kind=kind)
|
|
513
|
+
except (psutil.AccessDenied, psutil.PermissionError):
|
|
514
|
+
conns = []
|
|
515
|
+
except Exception:
|
|
516
|
+
conns = []
|
|
517
|
+
for c in conns:
|
|
518
|
+
total += 1
|
|
519
|
+
if c.pid in (0, 4):
|
|
520
|
+
name = "system"
|
|
521
|
+
else:
|
|
522
|
+
name = pid2name.get(c.pid) if c.pid is not None else None
|
|
523
|
+
if name is None:
|
|
524
|
+
name = "kernel" if c.pid is None else f"?({c.pid})"
|
|
525
|
+
counts[name] = counts.get(name, 0) + 1
|
|
526
|
+
top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:top_n]
|
|
527
|
+
data = {"total": total, "top": top}
|
|
528
|
+
_conn_cache["data"], _conn_cache["ts"] = data, now
|
|
529
|
+
return data
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# --- процессы: детали и управление --------------------------------------
|
|
533
|
+
def _window_handle_count(pid: int) -> Optional[int]:
|
|
534
|
+
"""Число открытых хэндлов (Windows) или None."""
|
|
535
|
+
if sys.platform != "win32":
|
|
536
|
+
return None
|
|
537
|
+
try:
|
|
538
|
+
from ctypes import byref, c_ulong, windll
|
|
539
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
540
|
+
h = windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
541
|
+
if not h:
|
|
542
|
+
return None
|
|
543
|
+
try:
|
|
544
|
+
cnt = c_ulong()
|
|
545
|
+
if windll.kernel32.GetProcessHandleCount(h, byref(cnt)):
|
|
546
|
+
return int(cnt.value)
|
|
547
|
+
return None
|
|
548
|
+
finally:
|
|
549
|
+
windll.kernel32.CloseHandle(h)
|
|
550
|
+
except Exception:
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def process_detail(pid: int) -> Optional[Dict[str, Any]]:
|
|
555
|
+
"""Расширенная карточка процесса или None, если процесс исчез."""
|
|
556
|
+
try:
|
|
557
|
+
p = psutil.Process(pid)
|
|
558
|
+
except psutil.NoSuchProcess:
|
|
559
|
+
return None
|
|
560
|
+
info: Dict[str, Any] = {
|
|
561
|
+
"pid": pid,
|
|
562
|
+
"name": "?", "cpu": 0.0, "memory_percent": 0.0, "rss": 0,
|
|
563
|
+
"threads": None, "status": "?", "cmdline": [], "exe": None,
|
|
564
|
+
"handles": None, "create_time": None, "uptime_seconds": 0.0,
|
|
565
|
+
}
|
|
566
|
+
try:
|
|
567
|
+
info["name"] = p.name() or "?"
|
|
568
|
+
except (psutil.AccessDenied, Exception):
|
|
569
|
+
pass
|
|
570
|
+
try:
|
|
571
|
+
info["cpu"] = _process_cpu_delta(p)
|
|
572
|
+
except Exception:
|
|
573
|
+
pass
|
|
574
|
+
try:
|
|
575
|
+
info["memory_percent"] = p.memory_percent()
|
|
576
|
+
except (psutil.AccessDenied, Exception):
|
|
577
|
+
pass
|
|
578
|
+
try:
|
|
579
|
+
info["rss"] = p.memory_info().rss
|
|
580
|
+
except (psutil.AccessDenied, Exception):
|
|
581
|
+
pass
|
|
582
|
+
try:
|
|
583
|
+
info["threads"] = p.num_threads()
|
|
584
|
+
except (psutil.AccessDenied, Exception):
|
|
585
|
+
pass
|
|
586
|
+
try:
|
|
587
|
+
info["status"] = p.status()
|
|
588
|
+
except (psutil.AccessDenied, Exception):
|
|
589
|
+
pass
|
|
590
|
+
try:
|
|
591
|
+
info["cmdline"] = list(p.cmdline() or [])
|
|
592
|
+
except (psutil.AccessDenied, Exception):
|
|
593
|
+
pass
|
|
594
|
+
try:
|
|
595
|
+
info["exe"] = p.exe()
|
|
596
|
+
except (psutil.AccessDenied, Exception):
|
|
597
|
+
pass
|
|
598
|
+
try:
|
|
599
|
+
info["handles"] = _window_handle_count(pid)
|
|
600
|
+
except Exception:
|
|
601
|
+
pass
|
|
602
|
+
try:
|
|
603
|
+
info["create_time"] = p.create_time()
|
|
604
|
+
info["uptime_seconds"] = max(time.time() - info["create_time"], 0)
|
|
605
|
+
except (psutil.AccessDenied, Exception):
|
|
606
|
+
pass
|
|
607
|
+
return info
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def find_process(query: str) -> Optional[Dict[str, Any]]:
|
|
611
|
+
"""Ищет процесс по pid или подстроке имени."""
|
|
612
|
+
_refresh_proc_list()
|
|
613
|
+
q = query.strip().lower()
|
|
614
|
+
if q.isdigit():
|
|
615
|
+
pid = int(q)
|
|
616
|
+
for p in _proc_objs:
|
|
617
|
+
if p.info["pid"] == pid:
|
|
618
|
+
return _proc_detail_from_cache(p)
|
|
619
|
+
return None
|
|
620
|
+
for p in _proc_objs:
|
|
621
|
+
nm = p.info.get("name") or ""
|
|
622
|
+
if nm.lower().find(q) != -1:
|
|
623
|
+
return _proc_detail_from_cache(p)
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _proc_detail_from_cache(p) -> Dict[str, Any]:
|
|
628
|
+
return {
|
|
629
|
+
"pid": p.info.get("pid"), "name": p.info.get("name") or "?",
|
|
630
|
+
"cpu": p.info.get("cpu_percent") or 0.0,
|
|
631
|
+
"memory_percent": p.info.get("memory_percent") or 0.0,
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def list_processes(query: Optional[str] = None, sort: str = "cpu",
|
|
636
|
+
limit: int = 25) -> List[Dict[str, Any]]:
|
|
637
|
+
"""Список процессов (имя+pid+лог), с фильтром и сортировкой."""
|
|
638
|
+
data = [dict(p) for p in _refresh_proc_raw()]
|
|
639
|
+
if query:
|
|
640
|
+
q = query.lower()
|
|
641
|
+
data = [d for d in data if q in (d.get("name") or "").lower()
|
|
642
|
+
or q in " ".join([str(d.get("pid"))])]
|
|
643
|
+
key = {"cpu": "cpu_percent", "ram": "memory_percent", "name": "name"}.get(sort, "cpu_percent")
|
|
644
|
+
if key == "name":
|
|
645
|
+
data.sort(key=lambda x: (x.get(key) or "") or "")
|
|
646
|
+
else:
|
|
647
|
+
data.sort(key=lambda x: x.get(key, 0) or 0, reverse=True)
|
|
648
|
+
return data[:limit]
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def resolve_pid(query: str) -> Optional[int]:
|
|
652
|
+
"""Возвращает pid по числу или подстроке имени."""
|
|
653
|
+
p = find_process(query)
|
|
654
|
+
return p["pid"] if p else None
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def kill_process(pid: int) -> str:
|
|
658
|
+
"""Завершает процесс; отказывает для собственного pid."""
|
|
659
|
+
if pid == os.getpid():
|
|
660
|
+
raise ValueError("refusing to kill self")
|
|
661
|
+
try:
|
|
662
|
+
p = psutil.Process(pid)
|
|
663
|
+
except psutil.NoSuchProcess:
|
|
664
|
+
raise ValueError(f"no such process: {pid}")
|
|
665
|
+
p.terminate()
|
|
666
|
+
try:
|
|
667
|
+
p.wait(timeout=5)
|
|
668
|
+
except psutil.TimeoutExpired:
|
|
669
|
+
p.kill()
|
|
670
|
+
p.wait(timeout=3)
|
|
671
|
+
return "killed"
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def suspend_process(pid: int) -> str:
|
|
675
|
+
if pid == os.getpid():
|
|
676
|
+
raise ValueError("refusing to suspend self")
|
|
677
|
+
psutil.Process(pid).suspend()
|
|
678
|
+
return "suspended"
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def resume_process(pid: int) -> str:
|
|
682
|
+
psutil.Process(pid).resume()
|
|
683
|
+
return "resumed"
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def restart_process(pid: int) -> str:
|
|
687
|
+
"""Перезапуск: завершить и поднять exe заново (best-effort)."""
|
|
688
|
+
if pid == os.getpid():
|
|
689
|
+
raise ValueError("refusing to restart self")
|
|
690
|
+
pid = int(pid)
|
|
691
|
+
exe = None
|
|
692
|
+
try:
|
|
693
|
+
exe = psutil.Process(pid).exe()
|
|
694
|
+
except (psutil.AccessDenied, psutil.NoSuchProcess, Exception):
|
|
695
|
+
pass
|
|
696
|
+
kill_process(pid)
|
|
697
|
+
if exe and os.path.isfile(exe):
|
|
698
|
+
cwd = os.path.dirname(exe) or None
|
|
699
|
+
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
700
|
+
try:
|
|
701
|
+
subprocess.Popen([exe], cwd=cwd, close_fds=True, creationflags=flags)
|
|
702
|
+
return f"restarted ({exe})"
|
|
703
|
+
except OSError:
|
|
704
|
+
return f"killed, failed to relaunch ({exe})"
|
|
705
|
+
return "killed, nothing to relaunch"
|