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/tui.py
ADDED
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
"""Интерактивный TUI syscheck — dash-дизайн в стиле WinMon.
|
|
2
|
+
|
|
3
|
+
Запуск без аргументов открывает экран:
|
|
4
|
+
- дашборд: CPU, MEMORY (донут), GPU, STORAGE, NETWORK, BATTERY
|
|
5
|
+
и на всю ширину — интерактивная таблица PROCESSES
|
|
6
|
+
- терминал команд со строкой ввода ❯ и Command Palette (Ctrl+P)
|
|
7
|
+
|
|
8
|
+
Toggle-панели (набери цифру + Enter):
|
|
9
|
+
1 CPU · 2 GPU · 3 NETWORK · 4 BATTERY · 5 PROCESSES
|
|
10
|
+
по умолчанию видны: MEMORY, STORAGE, NETWORK, PROCESSES
|
|
11
|
+
|
|
12
|
+
Процессы прямо в таблице:
|
|
13
|
+
↑↓ — выбор, Enter — детали, K — kill (с подтверждением),
|
|
14
|
+
S — suspend, R — restart (с подтверждением), / — поиск.
|
|
15
|
+
|
|
16
|
+
!shell по умолчанию ВЫКЛЮЧЕН и включается только флагом --enable-shell.
|
|
17
|
+
Перед первой активацией показывается предупреждение, на каждую команду
|
|
18
|
+
требуется подтверждение, все выполнения пишутся в audit-лог.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import os
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
|
|
26
|
+
from rich.markup import escape
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
|
|
29
|
+
from textual.app import App, ComposeResult
|
|
30
|
+
from textual.binding import Binding
|
|
31
|
+
from textual.containers import Container, Grid
|
|
32
|
+
from textual.widgets import DataTable, Static, Input, RichLog
|
|
33
|
+
|
|
34
|
+
from syscheck import cmdlang as cmdmod
|
|
35
|
+
from syscheck import providers
|
|
36
|
+
from syscheck import shellguard
|
|
37
|
+
from syscheck import __version__
|
|
38
|
+
from syscheck.palette import (
|
|
39
|
+
ACCENT, BG, BORDER, DANGER, DIM, MUTED, PANEL, TEXT, TRACK, WARN,
|
|
40
|
+
bar_pct, color_for,
|
|
41
|
+
)
|
|
42
|
+
from syscheck.screens import PaletteScreen, ProcessDetailsScreen
|
|
43
|
+
from syscheck.utils import bytes_to_human, seconds_to_human, get_color_for_percent, make_bar
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rate(v: float) -> str:
|
|
47
|
+
if v <= 0:
|
|
48
|
+
return "[#7d858e]—[/]"
|
|
49
|
+
return f"[#e6e9ec]{bytes_to_human(v)}/s[/]"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _trunc(s: str, n: int) -> str:
|
|
53
|
+
if len(s) <= n:
|
|
54
|
+
return s
|
|
55
|
+
return s[: n - 1] + "…"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _trunc_markup(s: str, n: int) -> str:
|
|
59
|
+
t = Text.from_markup(s)
|
|
60
|
+
if len(t.plain) <= n:
|
|
61
|
+
return s
|
|
62
|
+
cut = t.copy()
|
|
63
|
+
cut.truncate(n)
|
|
64
|
+
return str(cut)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ProcsTable(DataTable):
|
|
68
|
+
"""Интерактивная таблица процессов: Enter/K/S/R//."""
|
|
69
|
+
|
|
70
|
+
BINDINGS = [
|
|
71
|
+
Binding("enter", "details", "Details", show=False),
|
|
72
|
+
Binding("k", "kill_p", "Kill", show=False),
|
|
73
|
+
Binding("s", "stop_p", "Stop", show=False),
|
|
74
|
+
Binding("r", "restart_p", "Restart", show=False),
|
|
75
|
+
Binding("/", "search_p", "Search", show=False),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
def __init__(self, tui, **kwargs):
|
|
79
|
+
super().__init__(**kwargs)
|
|
80
|
+
self._tui = tui
|
|
81
|
+
|
|
82
|
+
def _pid(self) -> int | None:
|
|
83
|
+
if not self.row_count:
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
row = self.cursor_coordinate.row
|
|
87
|
+
key = self.get_row_at(row)
|
|
88
|
+
return int(key)
|
|
89
|
+
except Exception:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def action_details(self) -> None:
|
|
93
|
+
self._tui.show_proc_details(self._pid())
|
|
94
|
+
|
|
95
|
+
def action_kill_p(self) -> None:
|
|
96
|
+
self._tui.prompt_proc_control("kill", self._pid())
|
|
97
|
+
|
|
98
|
+
def action_stop_p(self) -> None:
|
|
99
|
+
self._tui.proc_control("stop", self._pid())
|
|
100
|
+
|
|
101
|
+
def action_restart_p(self) -> None:
|
|
102
|
+
self._tui.prompt_proc_control("restart", self._pid())
|
|
103
|
+
|
|
104
|
+
def action_search_p(self) -> None:
|
|
105
|
+
self._tui.focus_process_search()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
class SysCheckTUI(App):
|
|
110
|
+
"""Дашборд-оболочка в стиле WinMon."""
|
|
111
|
+
|
|
112
|
+
TITLE = "syscheck"
|
|
113
|
+
SUB_TITLE = "system diagnostics"
|
|
114
|
+
ENABLE_COMMAND_PALETTE = False
|
|
115
|
+
|
|
116
|
+
CSS = """
|
|
117
|
+
Screen {
|
|
118
|
+
padding: 0 1;
|
|
119
|
+
background: #0b0d0f;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#dash {
|
|
123
|
+
height: 1fr;
|
|
124
|
+
layout: grid;
|
|
125
|
+
grid-size: 2 1;
|
|
126
|
+
grid-columns: 1fr 1.4fr;
|
|
127
|
+
grid-rows: 1fr;
|
|
128
|
+
grid-gutter: 1;
|
|
129
|
+
padding: 1 0;
|
|
130
|
+
}
|
|
131
|
+
#dash.procs-hidden {
|
|
132
|
+
grid-size: 1 1;
|
|
133
|
+
grid-columns: 1fr;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#left-col {
|
|
137
|
+
height: 100%;
|
|
138
|
+
layout: vertical;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.panel {
|
|
142
|
+
border: solid #252a2f;
|
|
143
|
+
background: #101316;
|
|
144
|
+
}
|
|
145
|
+
.panel.watched {
|
|
146
|
+
border: solid #8bd450;
|
|
147
|
+
}
|
|
148
|
+
.panel > .ph {
|
|
149
|
+
height: 1;
|
|
150
|
+
padding: 0 1;
|
|
151
|
+
color: #555d66;
|
|
152
|
+
border-bottom: solid #252a2f;
|
|
153
|
+
}
|
|
154
|
+
.panel > .pb {
|
|
155
|
+
height: 1fr;
|
|
156
|
+
padding: 0 1 1 1;
|
|
157
|
+
}
|
|
158
|
+
#panel-procs {
|
|
159
|
+
width: 100%;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#dt-procs {
|
|
163
|
+
height: 1fr;
|
|
164
|
+
padding: 0 1;
|
|
165
|
+
border: none;
|
|
166
|
+
}
|
|
167
|
+
#dt-procs:focus {
|
|
168
|
+
border: solid #353b42;
|
|
169
|
+
}
|
|
170
|
+
#dt-procs > .datatable--cursor {
|
|
171
|
+
background: #14181c;
|
|
172
|
+
color: #e6e9ec;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#log {
|
|
176
|
+
height: 8;
|
|
177
|
+
border: solid #252a2f;
|
|
178
|
+
background: #080a0c;
|
|
179
|
+
padding: 0 1;
|
|
180
|
+
}
|
|
181
|
+
#log .scrollbar {
|
|
182
|
+
color: #252a2f;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#cmdline {
|
|
186
|
+
height: 3;
|
|
187
|
+
layout: horizontal;
|
|
188
|
+
border: solid #252a2f;
|
|
189
|
+
background: #080a0c;
|
|
190
|
+
padding: 0 1;
|
|
191
|
+
margin-bottom: 1;
|
|
192
|
+
}
|
|
193
|
+
.prompt {
|
|
194
|
+
width: 2;
|
|
195
|
+
content-align: center middle;
|
|
196
|
+
color: #8bd450;
|
|
197
|
+
}
|
|
198
|
+
#cmd {
|
|
199
|
+
border: none;
|
|
200
|
+
background: transparent;
|
|
201
|
+
color: #e6e9ec;
|
|
202
|
+
}
|
|
203
|
+
#cmd:focus {
|
|
204
|
+
border: none;
|
|
205
|
+
}
|
|
206
|
+
.hints {
|
|
207
|
+
dock: right;
|
|
208
|
+
width: auto;
|
|
209
|
+
content-align: right middle;
|
|
210
|
+
color: #555d66;
|
|
211
|
+
}
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
BINDINGS = [
|
|
215
|
+
Binding("ctrl+c", "quit", "Quit"),
|
|
216
|
+
Binding("ctrl+l", "clear_log", "Clear"),
|
|
217
|
+
Binding("ctrl+p", "palette", "Palette"),
|
|
218
|
+
Binding("up", "history_up", "Prev", show=False),
|
|
219
|
+
Binding("down", "history_down", "Next", show=False),
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
history: list = []
|
|
223
|
+
|
|
224
|
+
WATCH_PANELS = {"cpu": "#panel-cpu", "network": "#panel-net", "process": "#panel-procs"}
|
|
225
|
+
|
|
226
|
+
_TOGGLE_MAP = {"1": "cpu", "2": "gpu", "3": "net", "4": "batt", "5": "procs"}
|
|
227
|
+
_PANEL_NAMES = {
|
|
228
|
+
"cpu": "CPU", "gpu": "GPU", "net": "NETWORK",
|
|
229
|
+
"batt": "BATTERY", "procs": "PROCESSES",
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
def __init__(self, enable_shell: bool = False, **kwargs):
|
|
233
|
+
super().__init__(**kwargs)
|
|
234
|
+
self._enable_shell_requested = enable_shell
|
|
235
|
+
self._pending_shell_cmd: str | None = None
|
|
236
|
+
self._first_confirm_waiting = False
|
|
237
|
+
self._collecting = False
|
|
238
|
+
self._dash: dict | None = None
|
|
239
|
+
self._hist_index = 0
|
|
240
|
+
self._watch: str | None = None
|
|
241
|
+
self._proc_sort = "cpu"
|
|
242
|
+
self._proc_filter: str | None = None
|
|
243
|
+
self._pending_proc: tuple | None = None
|
|
244
|
+
self._net_peak = {"down": 1.0, "up": 1.0}
|
|
245
|
+
|
|
246
|
+
def compose(self) -> ComposeResult:
|
|
247
|
+
table = ProcsTable(self, id="dt-procs", cursor_type="row", zebra_stripes=False)
|
|
248
|
+
table.add_columns("PID", "PROCESS", "CPU", "MEMORY", "STATUS")
|
|
249
|
+
self._dt = table
|
|
250
|
+
yield Grid(
|
|
251
|
+
Container(
|
|
252
|
+
self._panel("cpu", "CPU"),
|
|
253
|
+
self._panel("ram", "MEMORY"),
|
|
254
|
+
self._panel("gpu", "GPU"),
|
|
255
|
+
self._panel("disk", "STORAGE"),
|
|
256
|
+
self._panel("net", "NETWORK"),
|
|
257
|
+
self._panel("batt", "BATTERY"),
|
|
258
|
+
id="left-col",
|
|
259
|
+
),
|
|
260
|
+
Container(
|
|
261
|
+
Static("PROCESSES", id="ph-procs", classes="ph"),
|
|
262
|
+
table,
|
|
263
|
+
id="panel-procs",
|
|
264
|
+
classes="panel",
|
|
265
|
+
),
|
|
266
|
+
id="dash",
|
|
267
|
+
)
|
|
268
|
+
yield RichLog(id="log", markup=True, wrap=False, min_width=40, max_lines=300)
|
|
269
|
+
with Container(id="cmdline"):
|
|
270
|
+
yield Static("❯", classes="prompt")
|
|
271
|
+
yield Input(placeholder="ram, disk, process list, network connections, watch network, help …", id="cmd")
|
|
272
|
+
yield Static("", classes="hints", id="hints")
|
|
273
|
+
|
|
274
|
+
def _panel(self, pid: str, title: str) -> Container:
|
|
275
|
+
return Container(
|
|
276
|
+
Static(title, id=f"ph-{pid}", classes="ph"),
|
|
277
|
+
Static("…", id=f"body-{pid}", classes="pb"),
|
|
278
|
+
id=f"panel-{pid}",
|
|
279
|
+
classes="panel",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def on_mount(self) -> None:
|
|
283
|
+
self._log = self.query_one("#log", RichLog)
|
|
284
|
+
self._write("[#8bd450][+] syscheck[/] [dim]v{0} — interactive diagnostics[/]".format(__version__))
|
|
285
|
+
self._write("[dim]type 'help' for commands · ctrl+p palette · ctrl+c to exit[/]")
|
|
286
|
+
self._write("[dim]1-5 toggle panels (1cpu 2gpu 3net 4batt 5proc) · ctrl+p palette[/]")
|
|
287
|
+
try:
|
|
288
|
+
dt = self._dt
|
|
289
|
+
widths = {"PID": 6, "PROCESS": 26, "CPU": 9, "MEMORY": 10, "STATUS": 9}
|
|
290
|
+
for name, w in widths.items():
|
|
291
|
+
try:
|
|
292
|
+
dt.columns[name].width = w
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
for name in ("cpu", "gpu", "batt"):
|
|
298
|
+
try:
|
|
299
|
+
self.query_one(f"#panel-{name}").display = False
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
self._apply_watch()
|
|
303
|
+
self.set_interval(2.0, self._refresh_dashboard)
|
|
304
|
+
self._refresh_dashboard()
|
|
305
|
+
if self._enable_shell_requested:
|
|
306
|
+
self._activate_shell()
|
|
307
|
+
self.query_one("#cmd", Input).focus()
|
|
308
|
+
|
|
309
|
+
# --- toggle панелей ---
|
|
310
|
+
def _toggle_panel(self, name: str) -> None:
|
|
311
|
+
try:
|
|
312
|
+
w = self.query_one(f"#panel-{name}")
|
|
313
|
+
except Exception:
|
|
314
|
+
return
|
|
315
|
+
w.display = not w.display
|
|
316
|
+
label = self._PANEL_NAMES.get(name, name)
|
|
317
|
+
state = "shown" if w.display else "hidden"
|
|
318
|
+
self._write(f"[dim]{label}: {state}[/]")
|
|
319
|
+
if name == "procs":
|
|
320
|
+
dash = self.query_one("#dash")
|
|
321
|
+
dash.set_class(not w.display, "procs-hidden")
|
|
322
|
+
self._apply_watch()
|
|
323
|
+
|
|
324
|
+
# --- лог команд ---
|
|
325
|
+
def _write(self, text: str) -> None:
|
|
326
|
+
for line in text.split("\n"):
|
|
327
|
+
if line.strip():
|
|
328
|
+
self._log.write(f"[#555d66]›[/] {line}")
|
|
329
|
+
else:
|
|
330
|
+
self._log.write("")
|
|
331
|
+
|
|
332
|
+
def _clear_log(self) -> None:
|
|
333
|
+
self._log.clear()
|
|
334
|
+
|
|
335
|
+
# --- дашборд: фоновый сбор в потоке, рендер из кэша ---
|
|
336
|
+
def _refresh_dashboard(self) -> None:
|
|
337
|
+
if self._collecting:
|
|
338
|
+
return
|
|
339
|
+
self._collecting = True
|
|
340
|
+
try:
|
|
341
|
+
asyncio.create_task(self._tick())
|
|
342
|
+
except RuntimeError:
|
|
343
|
+
self._collecting = False
|
|
344
|
+
|
|
345
|
+
async def _tick(self) -> None:
|
|
346
|
+
try:
|
|
347
|
+
data = await asyncio.to_thread(self._collect)
|
|
348
|
+
except Exception:
|
|
349
|
+
data = None
|
|
350
|
+
finally:
|
|
351
|
+
self._collecting = False
|
|
352
|
+
if data is None:
|
|
353
|
+
return
|
|
354
|
+
self._dash = data
|
|
355
|
+
try:
|
|
356
|
+
self.query_one("#hints", Static).update("1\u20115 panels \u00b7 ctrl+p palette")
|
|
357
|
+
self.query_one("#body-cpu", Static).update(self._cpu_body(data["cpu"], data["temp"]))
|
|
358
|
+
self.query_one("#body-ram", Static).update(self._mem_body(data["ram"]))
|
|
359
|
+
self.query_one("#body-gpu", Static).update(self._gpu_body(data["gpu"]))
|
|
360
|
+
self.query_one("#body-disk", Static).update(self._disk_body(data["disk"], data["io"]))
|
|
361
|
+
self.query_one("#body-net", Static).update(self._net_body(data["net_rates"], data["conn"], data["net"]))
|
|
362
|
+
self.query_one("#body-batt", Static).update(self._batt_body(data["batt"]))
|
|
363
|
+
self.query_one("#ph-procs", Static).update(self._procs_header(data))
|
|
364
|
+
self._update_procs_table(self._render_procs(data))
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
self._apply_watch()
|
|
368
|
+
|
|
369
|
+
def _collect(self) -> dict:
|
|
370
|
+
delta = providers.cpu_delta()
|
|
371
|
+
base = providers.cpu_info(interval=None)
|
|
372
|
+
cpu = dict(base)
|
|
373
|
+
if delta is not None:
|
|
374
|
+
cpu["percent"], cpu["per_cpu"] = delta
|
|
375
|
+
return {
|
|
376
|
+
"cpu": cpu,
|
|
377
|
+
"ram": providers.ram_info(top=False),
|
|
378
|
+
"net": providers.net_info(),
|
|
379
|
+
"net_rates": providers.network_speeds(),
|
|
380
|
+
"disk": providers.disk_info(),
|
|
381
|
+
"sys": providers.system_info(),
|
|
382
|
+
"procs": providers.list_processes(sort=self._proc_sort, limit=80),
|
|
383
|
+
"proc_count": providers.process_count(),
|
|
384
|
+
"gpu": providers.gpu_info(),
|
|
385
|
+
"io": providers.disk_io_speeds(),
|
|
386
|
+
"conn": providers.connections_by_process(),
|
|
387
|
+
"batt": providers.battery_info(),
|
|
388
|
+
"temp": providers.cpu_temp_celsius(),
|
|
389
|
+
"clock": datetime.now().strftime("%H:%M:%S"),
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
# --- рендеры панелей ---
|
|
393
|
+
def _metric_line(self, big: str, label: str) -> str:
|
|
394
|
+
return f"{big} [#7d858e]{label}[/]"
|
|
395
|
+
|
|
396
|
+
def _cpu_body(self, d: dict, temp: float | None) -> str:
|
|
397
|
+
freq = d.get("freq_current") or 0
|
|
398
|
+
ghz = f"{freq / 1000:.2f} GHz" if freq else "\u2014"
|
|
399
|
+
t = f"{temp:.0f}\u00b0C" if temp is not None else "\u2014"
|
|
400
|
+
return "\n".join([
|
|
401
|
+
self._metric_line(f"[{color_for(d['percent'])}]{d['percent']:3.0f}[/][#7d858e] %[/]", "TOTAL USAGE"),
|
|
402
|
+
bar_pct(d["percent"], 22),
|
|
403
|
+
f"[#555d66]FREQ[/] {ghz}"
|
|
404
|
+
f" [#555d66]CORES[/] [#e6e9ec]{d['count_physical']}p/{d['count_logical']}l[/]"
|
|
405
|
+
f" [#555d66]TEMP[/] [#e6e9ec]{t}[/]",
|
|
406
|
+
])
|
|
407
|
+
|
|
408
|
+
def _mem_body(self, d: dict) -> str:
|
|
409
|
+
def row(label: str, val: str) -> str:
|
|
410
|
+
return f"[#555d66]{label:<9}[/] [#e6e9ec]{val}[/]"
|
|
411
|
+
|
|
412
|
+
lines = [
|
|
413
|
+
self._metric_line(
|
|
414
|
+
f"[{color_for(d['percent'])}]{d['percent']:3.0f}[/][#7d858e] %[/]",
|
|
415
|
+
"TOTAL USAGE",
|
|
416
|
+
),
|
|
417
|
+
bar_pct(d["percent"], 22),
|
|
418
|
+
row("TOTAL", f"{bytes_to_human(d['total']):>9}"),
|
|
419
|
+
row("USED", f"{bytes_to_human(d['used']):>9}"),
|
|
420
|
+
row("AVAILABLE", f"{bytes_to_human(d['available']):>9}"),
|
|
421
|
+
row("CACHED", f"{bytes_to_human(d['cached']):>9}"),
|
|
422
|
+
]
|
|
423
|
+
if d["swap_total"] > 0:
|
|
424
|
+
lines.append(row("SWAP", f"{d['swap_percent']:.0f}% {bytes_to_human(d['swap_total']):>7}"))
|
|
425
|
+
return "\n".join(lines)
|
|
426
|
+
|
|
427
|
+
def _gpu_body(self, g: dict) -> str:
|
|
428
|
+
name = g.get("name")
|
|
429
|
+
if not name:
|
|
430
|
+
return "[#7d858e]no GPU info detected[/]"
|
|
431
|
+
name = _trunc(name, 34)
|
|
432
|
+
vram = g.get("vram_gb")
|
|
433
|
+
vram_txt = f"{vram:.1f} / {vram:.1f} GB" if vram else "\u2014"
|
|
434
|
+
util, temp = g.get("util"), g.get("temp")
|
|
435
|
+
lines = [
|
|
436
|
+
self._metric_line(
|
|
437
|
+
f"[#e6e9ec]{util:.0f}[/][#7d858e] %[/]" if util is not None else "[#e6e9ec]N/A[/]",
|
|
438
|
+
"UTILIZATION",
|
|
439
|
+
),
|
|
440
|
+
]
|
|
441
|
+
if util is not None:
|
|
442
|
+
lines.append(bar_pct(util, 22))
|
|
443
|
+
else:
|
|
444
|
+
lines.append(bar_pct(0, 22))
|
|
445
|
+
lines.append(f"[#555d66]GPU[/] [#7d858e]{name}[/]")
|
|
446
|
+
t_txt = f"{temp}\u00b0C" if temp is not None else "\u2014"
|
|
447
|
+
lines.append(f"[#555d66]TEMP[/] [#7d858e]{t_txt}[/] [#555d66]VRAM[/] [#e6e9ec]{vram_txt}[/]")
|
|
448
|
+
return "\n".join(lines)
|
|
449
|
+
|
|
450
|
+
def _disk_body(self, d: dict, io: dict) -> str:
|
|
451
|
+
disks = d.get("disks") or []
|
|
452
|
+
if not disks:
|
|
453
|
+
return "[#7d858e]no disks[/]"
|
|
454
|
+
lines = []
|
|
455
|
+
for disk in disks[:2]:
|
|
456
|
+
dev = escape(disk["device"].rstrip("\\"))
|
|
457
|
+
c = color_for(disk["percent"])
|
|
458
|
+
lines.append(
|
|
459
|
+
f"[#e6e9ec]{dev}[/] {bar_pct(disk['percent'], 16)}"
|
|
460
|
+
f" [{c}]{disk['percent']:3.0f}%[/]"
|
|
461
|
+
)
|
|
462
|
+
lines.append(
|
|
463
|
+
f"[#555d66]READ[/] {_rate(io.get('read', 0))}"
|
|
464
|
+
f" [#555d66]WRITE[/] {_rate(io.get('write', 0))}"
|
|
465
|
+
)
|
|
466
|
+
return "\n".join(lines)
|
|
467
|
+
|
|
468
|
+
def _net_body(self, rates: dict, conn: dict, net: dict) -> str:
|
|
469
|
+
up_ifaces = [i for i in net.get("interfaces", []) if i["up"] and i["ipv4"]]
|
|
470
|
+
if up_ifaces:
|
|
471
|
+
itf = up_ifaces[0]
|
|
472
|
+
top = f"[#7d858e]INTERFACE[/] [#e6e9ec]{_trunc(itf['name'], 12)}[/]"
|
|
473
|
+
top += f" [#7d858e]IP[/] [#e6e9ec]{itf['ipv4'][0]}[/]"
|
|
474
|
+
else:
|
|
475
|
+
top = "[#7d858e]no interfaces up[/]"
|
|
476
|
+
down = rates.get("down") or 0
|
|
477
|
+
up = rates.get("up") or 0
|
|
478
|
+
self._net_peak["down"] = max(self._net_peak["down"], down)
|
|
479
|
+
self._net_peak["up"] = max(self._net_peak["up"], up)
|
|
480
|
+
dw = int(20 * min(down / self._net_peak["down"], 1))
|
|
481
|
+
uw = int(20 * min(up / self._net_peak["up"], 1))
|
|
482
|
+
total = conn.get("total", 0)
|
|
483
|
+
return "\n".join([
|
|
484
|
+
top,
|
|
485
|
+
("[#555d66]DOWNLOAD[/] " + _rate(down) + f" [{ACCENT}]{'█' * dw}[/][{TRACK}]{'░' * (20 - dw)}[/]"),
|
|
486
|
+
("[#555d66]UPLOAD[/] " + _rate(up) + f" [{ACCENT}]{'█' * uw}[/][{TRACK}]{'░' * (20 - uw)}[/]"),
|
|
487
|
+
f"[#555d66]CONNECTIONS[/] [#e6e9ec]{total}[/]",
|
|
488
|
+
])
|
|
489
|
+
|
|
490
|
+
def _batt_body(self, b: dict | None) -> str:
|
|
491
|
+
if not b:
|
|
492
|
+
return "[#7d858e]no battery detected[/]"
|
|
493
|
+
lines = [
|
|
494
|
+
self._metric_line(f"[{color_for(b['percent'])}]{b['percent']:3.0f}[/][#7d858e] %[/]", "CHARGE"),
|
|
495
|
+
bar_pct(b["percent"], 22),
|
|
496
|
+
]
|
|
497
|
+
state = "PLUGGED IN" if b.get("plugged") else "DISCHARGING"
|
|
498
|
+
left = ""
|
|
499
|
+
if b.get("seconds_left"):
|
|
500
|
+
left = f" \u00b7 {int(b['seconds_left'] / 60)}m left"
|
|
501
|
+
lines.append(f"[#555d66]{state}[/]{left}")
|
|
502
|
+
return "\n".join(lines)
|
|
503
|
+
|
|
504
|
+
# --- процессы ---
|
|
505
|
+
def _procs_header(self, d: dict) -> str:
|
|
506
|
+
head = f"PROCESSES [#7d858e]{d['proc_count']} RUNNING[/]"
|
|
507
|
+
if self._proc_filter:
|
|
508
|
+
head += f" [#555d66]filter[/] [{ACCENT}]{_trunc(self._proc_filter, 12)}[/]"
|
|
509
|
+
head += f" [#555d66]\u00b7 sort {self._proc_sort}[/]"
|
|
510
|
+
if self._watch == "process":
|
|
511
|
+
head += f" [{ACCENT}]● watching[/]"
|
|
512
|
+
return head
|
|
513
|
+
|
|
514
|
+
def _render_procs(self, d: dict) -> list:
|
|
515
|
+
data = d.get("procs") or []
|
|
516
|
+
if self._proc_filter:
|
|
517
|
+
q = self._proc_filter.lower()
|
|
518
|
+
data = [p for p in data if q in (p.get("name") or "").lower()
|
|
519
|
+
or q == str(p.get("pid"))]
|
|
520
|
+
sort_key = {"cpu": "cpu_percent", "ram": "memory_percent", "name": "name"}.get(
|
|
521
|
+
self._proc_sort, "cpu_percent")
|
|
522
|
+
if sort_key == "name":
|
|
523
|
+
data = sorted(data, key=lambda x: (x.get(sort_key) or "") or "")
|
|
524
|
+
else:
|
|
525
|
+
data = sorted(data, key=lambda x: x.get(sort_key, 0) or 0, reverse=True)
|
|
526
|
+
return data[:15]
|
|
527
|
+
|
|
528
|
+
def _update_procs_table(self, rows: list) -> None:
|
|
529
|
+
dt = self._dt
|
|
530
|
+
prev_key: str | None = None
|
|
531
|
+
if dt.row_count:
|
|
532
|
+
try:
|
|
533
|
+
prev_key = dt.get_row_at(dt.cursor_coordinate.row)
|
|
534
|
+
except Exception:
|
|
535
|
+
prev_key = None
|
|
536
|
+
dt.clear()
|
|
537
|
+
for p in rows:
|
|
538
|
+
pid = p.get("pid") or 0
|
|
539
|
+
name = _trunc(p.get("name") or "?", 26)
|
|
540
|
+
cpu = min(p.get("cpu_percent") or 0, 9999)
|
|
541
|
+
rss = p.get("rss") or 0
|
|
542
|
+
st = (p.get("status") or "running").lower()[:9]
|
|
543
|
+
sc = {"running": ACCENT, "stopped": DANGER, "sleeping": MUTED}.get(st, MUTED)
|
|
544
|
+
dt.add_row(
|
|
545
|
+
Text(f"{pid}", style=TEXT),
|
|
546
|
+
Text(name, style=TEXT),
|
|
547
|
+
Text(f"{cpu:5.1f}%", style=color_for(cpu)),
|
|
548
|
+
Text(bytes_to_human(rss), style=TEXT),
|
|
549
|
+
Text(st.upper(), style=sc),
|
|
550
|
+
key=str(pid),
|
|
551
|
+
)
|
|
552
|
+
if rows and dt.row_count:
|
|
553
|
+
if prev_key:
|
|
554
|
+
try:
|
|
555
|
+
dt.move_cursor(row=dt.get_row_index(prev_key), column=0)
|
|
556
|
+
return
|
|
557
|
+
except Exception:
|
|
558
|
+
pass
|
|
559
|
+
dt.move_cursor(row=0, column=0)
|
|
560
|
+
|
|
561
|
+
def show_proc_details(self, pid: int | None) -> None:
|
|
562
|
+
if pid is None:
|
|
563
|
+
self._write("[dim]no process selected[/]")
|
|
564
|
+
return
|
|
565
|
+
try:
|
|
566
|
+
asyncio.create_task(self._collect_and_show(pid))
|
|
567
|
+
except RuntimeError:
|
|
568
|
+
pass
|
|
569
|
+
|
|
570
|
+
def prompt_proc_control(self, kind: str, pid: int | None) -> None:
|
|
571
|
+
if pid is None:
|
|
572
|
+
self._write("[dim]no process selected[/]")
|
|
573
|
+
return
|
|
574
|
+
self._pending_proc = (kind, pid)
|
|
575
|
+
guard = "self" if pid == os.getpid() else None
|
|
576
|
+
self._write(f"[yellow]confirm {kind} of process {pid}? type 'confirm'[/]" +
|
|
577
|
+
(f" [red](refusing self!)[/] " if guard else ""))
|
|
578
|
+
|
|
579
|
+
def proc_control(self, kind: str, pid: int | None) -> None:
|
|
580
|
+
if pid is None:
|
|
581
|
+
self._write("[dim]no process selected[/]")
|
|
582
|
+
return
|
|
583
|
+
if kind in ("stop", "resume"):
|
|
584
|
+
self._run_in_thread(lambda: self._proc_ctl_op(kind, pid))
|
|
585
|
+
|
|
586
|
+
def focus_process_search(self) -> None:
|
|
587
|
+
inp = self.query_one("#cmd", Input)
|
|
588
|
+
inp.value = "process find "
|
|
589
|
+
inp.focus()
|
|
590
|
+
inp.cursor_position = len(inp.value)
|
|
591
|
+
|
|
592
|
+
def _proc_ctl_op(self, kind: str, pid: int) -> str:
|
|
593
|
+
fn = {"kill": providers.kill_process, "restart": providers.restart_process,
|
|
594
|
+
"stop": providers.suspend_process, "resume": providers.resume_process}[kind]
|
|
595
|
+
try:
|
|
596
|
+
if pid == os.getpid():
|
|
597
|
+
return "[red]refusing to act on self[/]"
|
|
598
|
+
r = fn(pid)
|
|
599
|
+
return f"[#8bd450][+] {r} (pid {pid})[/]"
|
|
600
|
+
except Exception as e:
|
|
601
|
+
return f"[red]error: {e}[/]"
|
|
602
|
+
|
|
603
|
+
async def _collect_and_show(self, pid: int) -> None:
|
|
604
|
+
data = await asyncio.to_thread(providers.process_detail, pid)
|
|
605
|
+
self.push_screen(ProcessDetailsScreen(
|
|
606
|
+
data, error=None if data else f"process {pid} not found"))
|
|
607
|
+
|
|
608
|
+
# --- фокус-watch ---
|
|
609
|
+
def _apply_watch(self) -> None:
|
|
610
|
+
for key, sel in self.WATCH_PANELS.items():
|
|
611
|
+
try:
|
|
612
|
+
w = self.query_one(sel)
|
|
613
|
+
w.set_class(self._watch == key, "watched")
|
|
614
|
+
except Exception:
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
# --- Command Palette ---
|
|
618
|
+
def action_palette(self) -> None:
|
|
619
|
+
self.push_screen(PaletteScreen(cmdmod.command_list()), callback=self._palette_picked)
|
|
620
|
+
|
|
621
|
+
def _palette_picked(self, cmd: str | None) -> None:
|
|
622
|
+
if not cmd:
|
|
623
|
+
return
|
|
624
|
+
self._run_command(cmd)
|
|
625
|
+
|
|
626
|
+
# --- активация !shell ---
|
|
627
|
+
def _activate_shell(self) -> None:
|
|
628
|
+
if shellguard.needs_first_confirmation():
|
|
629
|
+
self._first_confirm_waiting = True
|
|
630
|
+
self._write("[bold red][!] !shell is DISABLED by default[/]")
|
|
631
|
+
self._write(shellguard.WARNING_TEXT.format(audit=shellguard.AUDIT_LOG))
|
|
632
|
+
self._write("[yellow]type 'yes' to enable !shell, or anything else to keep it disabled[/]")
|
|
633
|
+
else:
|
|
634
|
+
shellguard.set_enabled(True)
|
|
635
|
+
self._mark_shell_enabled()
|
|
636
|
+
|
|
637
|
+
def _mark_shell_enabled(self) -> None:
|
|
638
|
+
self._write("[#8bd450][+] !shell enabled[/]")
|
|
639
|
+
self._write(f"[dim]every command is confirmed and logged to {shellguard.AUDIT_LOG}[/]")
|
|
640
|
+
|
|
641
|
+
# --- обработка ввода ---
|
|
642
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
643
|
+
raw = event.value.strip()
|
|
644
|
+
event.input.value = ""
|
|
645
|
+
if not raw:
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
if self._first_confirm_waiting:
|
|
649
|
+
self._handle_first_confirm(raw)
|
|
650
|
+
return
|
|
651
|
+
if self._pending_shell_cmd is not None:
|
|
652
|
+
self._handle_shell_confirm(raw)
|
|
653
|
+
return
|
|
654
|
+
if self._pending_proc is not None:
|
|
655
|
+
self._handle_proc_confirm(raw)
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
if raw in self._TOGGLE_MAP:
|
|
659
|
+
self._toggle_panel(self._TOGGLE_MAP[raw])
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
self.history.append(raw)
|
|
663
|
+
self._hist_index = len(self.history)
|
|
664
|
+
self._write(f"[#7d858e]❯[/] [bold]{raw}[/]")
|
|
665
|
+
self._run_command(raw)
|
|
666
|
+
|
|
667
|
+
def _run_command(self, raw: str) -> None:
|
|
668
|
+
words = raw.split()
|
|
669
|
+
cmd0 = words[0].lower() if words else ""
|
|
670
|
+
if cmd0 in ("exit", "quit"):
|
|
671
|
+
self.exit()
|
|
672
|
+
return
|
|
673
|
+
if cmd0 == "clear":
|
|
674
|
+
self._clear_log()
|
|
675
|
+
return
|
|
676
|
+
if cmd0 == "help":
|
|
677
|
+
self._write(cmdmod.help_text())
|
|
678
|
+
return
|
|
679
|
+
if cmd0 == "watch":
|
|
680
|
+
self._write(self._cmd_watch(words[1:]))
|
|
681
|
+
return
|
|
682
|
+
if cmd0 == "refresh":
|
|
683
|
+
self._refresh_dashboard()
|
|
684
|
+
return
|
|
685
|
+
if cmd0 == "settings":
|
|
686
|
+
self._write(self._cmd_settings())
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
entry = cmdmod.lookup(words)
|
|
690
|
+
if entry is None:
|
|
691
|
+
self._write(f"[dim]unknown command:[/] {cmd0}")
|
|
692
|
+
self._write("[dim]type 'help' for a list[/]")
|
|
693
|
+
return
|
|
694
|
+
fn = self._handler_for(entry["cmd"])
|
|
695
|
+
if fn is None:
|
|
696
|
+
self._write(f"[dim]unknown command:[/] {cmd0}")
|
|
697
|
+
return
|
|
698
|
+
if entry["thread"]:
|
|
699
|
+
self._run_in_thread(lambda: fn(words[1:]))
|
|
700
|
+
else:
|
|
701
|
+
out = fn(words[1:])
|
|
702
|
+
if out:
|
|
703
|
+
self._write(out)
|
|
704
|
+
|
|
705
|
+
def _handler_for(self, cmd: str):
|
|
706
|
+
return {
|
|
707
|
+
"cpu": self._cmd_cpu, "ram": self._cmd_ram, "gpu": self._cmd_gpu,
|
|
708
|
+
"battery": self._cmd_battery, "temp": self._cmd_temp,
|
|
709
|
+
"disk": self._cmd_disk, "network": self._cmd_network,
|
|
710
|
+
"net": self._cmd_net, "process": self._cmd_process, "proc": self._cmd_proc,
|
|
711
|
+
"system": self._cmd_system, "all": self._cmd_all,
|
|
712
|
+
}.get(cmd)
|
|
713
|
+
|
|
714
|
+
def _run_in_thread(self, fn, note: str | None = None) -> None:
|
|
715
|
+
async def run() -> None:
|
|
716
|
+
if note:
|
|
717
|
+
self._write(note)
|
|
718
|
+
try:
|
|
719
|
+
result = await asyncio.to_thread(fn)
|
|
720
|
+
except Exception as e:
|
|
721
|
+
self._write(f"[red]error: {e}[/]")
|
|
722
|
+
return
|
|
723
|
+
if isinstance(result, tuple) and result and result[0] == "@details":
|
|
724
|
+
pid = result[1]
|
|
725
|
+
data = await asyncio.to_thread(providers.process_detail, None if pid is None else int(pid))
|
|
726
|
+
self.push_screen(ProcessDetailsScreen(
|
|
727
|
+
data, error=None if data else f"process {pid} not found"))
|
|
728
|
+
return
|
|
729
|
+
self._write(result)
|
|
730
|
+
|
|
731
|
+
try:
|
|
732
|
+
asyncio.create_task(run())
|
|
733
|
+
except RuntimeError:
|
|
734
|
+
self._write("[red]error: app loop not running[/]")
|
|
735
|
+
|
|
736
|
+
def _handle_shell_cmd(self, args) -> None:
|
|
737
|
+
shell_cmd = " ".join(args)
|
|
738
|
+
if not shell_cmd:
|
|
739
|
+
self._write("[dim]usage: !shell <command>[/]")
|
|
740
|
+
return
|
|
741
|
+
if not shellguard.is_enabled():
|
|
742
|
+
self._write("[yellow][!] !shell is disabled[/]")
|
|
743
|
+
self._write("[dim]run syscheck with --enable-shell to turn it on[/]")
|
|
744
|
+
return
|
|
745
|
+
self._pending_shell_cmd = shell_cmd
|
|
746
|
+
self._write(shellguard.resolve_confirmation(shell_cmd))
|
|
747
|
+
|
|
748
|
+
def _handle_shell_confirm(self, raw: str) -> None:
|
|
749
|
+
cmd = self._pending_shell_cmd
|
|
750
|
+
self._pending_shell_cmd = None
|
|
751
|
+
if raw == cmd or raw.lower() == "confirm":
|
|
752
|
+
self._write(f"[dim]> !shell {cmd}[/]")
|
|
753
|
+
self._run_in_thread(lambda: shellguard.run_shell(cmd))
|
|
754
|
+
else:
|
|
755
|
+
self._write(f"[dim]cancelled ({raw})[/]")
|
|
756
|
+
|
|
757
|
+
def _handle_first_confirm(self, raw: str) -> None:
|
|
758
|
+
self._first_confirm_waiting = False
|
|
759
|
+
if raw.lower() == "yes":
|
|
760
|
+
shellguard.set_enabled(True)
|
|
761
|
+
shellguard.mark_warned()
|
|
762
|
+
self._mark_shell_enabled()
|
|
763
|
+
else:
|
|
764
|
+
self._write(f"[dim]!shell stays disabled ({raw})[/]")
|
|
765
|
+
|
|
766
|
+
def _handle_proc_confirm(self, raw: str) -> None:
|
|
767
|
+
kind, pid = self._pending_proc
|
|
768
|
+
self._pending_proc = None
|
|
769
|
+
if raw == "confirm":
|
|
770
|
+
self._write(f"[dim]> process {kind} {pid}[/]")
|
|
771
|
+
self._run_in_thread(lambda: self._proc_ctl_op(kind, pid))
|
|
772
|
+
else:
|
|
773
|
+
self._write(f"[dim]cancelled ({raw})[/]")
|
|
774
|
+
|
|
775
|
+
# --- команды: Info ---
|
|
776
|
+
def _cmd_cpu(self, args) -> str:
|
|
777
|
+
d = providers.cpu_detail()
|
|
778
|
+
t = f"{d['temp']:.0f}\u00b0C" if d.get("temp") is not None else "\u2014"
|
|
779
|
+
ghz = f"{d['freq_current'] / 1000:.2f} GHz" if d.get("freq_current") else "\u2014"
|
|
780
|
+
lines = [
|
|
781
|
+
"[dim]CPU DETAILS[/]",
|
|
782
|
+
f"[#555d66]{'Model':<9}[/] [#e6e9ec]{_trunc(d['model'], 52)}[/]",
|
|
783
|
+
f"[#555d66]{'Cores':<9}[/] [#e6e9ec]{d['count_physical']} / {d['count_logical']}[/]",
|
|
784
|
+
f"[#555d66]{'Frequency':<9}[/] [#e6e9ec]{ghz}[/]",
|
|
785
|
+
f"[#555d66]{'Temperature':<9}[/] [#e6e9ec]{t}[/]",
|
|
786
|
+
"[dim]per-core[/]",
|
|
787
|
+
]
|
|
788
|
+
for i, p in enumerate(d.get("per_cpu") or []):
|
|
789
|
+
lines.append(f" Core {i:<2} {bar_pct(p, 12)} [{color_for(p)}]{p:3.0f}%[/]")
|
|
790
|
+
return "\n".join(lines)
|
|
791
|
+
|
|
792
|
+
def _cmd_ram(self, args) -> str:
|
|
793
|
+
d = providers.ram_info()
|
|
794
|
+
lines = [
|
|
795
|
+
"[dim]MEMORY[/]",
|
|
796
|
+
f"[#555d66]{'Used':<9}[/] [{color_for(d['percent'])}]{bytes_to_human(d['used'])}[/] / {bytes_to_human(d['total'])} ({d['percent']:.0f}%)",
|
|
797
|
+
f"[#555d66]{'Available':<9}[/] [#e6e9ec]{bytes_to_human(d['available'])}[/]",
|
|
798
|
+
f"[#555d66]{'Cached':<9}[/] [#e6e9ec]{bytes_to_human(d['cached'])}[/]",
|
|
799
|
+
]
|
|
800
|
+
if d["swap_total"] > 0:
|
|
801
|
+
lines.append(f"[#555d66]{'Swap':<9}[/] {d['swap_percent']:.0f}% ({bytes_to_human(d['swap_total'])})")
|
|
802
|
+
lines.append(bar_pct(d["percent"], 20))
|
|
803
|
+
return "\n".join(lines)
|
|
804
|
+
|
|
805
|
+
def _cmd_gpu(self, args) -> str:
|
|
806
|
+
g = providers.gpu_info()
|
|
807
|
+
if not g.get("name"):
|
|
808
|
+
return "[dim]GPU[/]\n[#7d858e]no GPU info detected[/]"
|
|
809
|
+
lines = ["[dim]GPU[/]", f"[#e6e9ec]{g['name']}[/]"]
|
|
810
|
+
if g.get("vram_gb"):
|
|
811
|
+
lines.append(f"[#555d66]{'VRAM':<9}[/] [#e6e9ec]{g['vram_gb']:.1f} GB[/]")
|
|
812
|
+
if g.get("util") is not None:
|
|
813
|
+
lines.append(f"[#555d66]{'Util':<9}[/] {g['util']:.0f}% {bar_pct(g['util'], 16)}")
|
|
814
|
+
if g.get("temp") is not None:
|
|
815
|
+
lines.append(f"[#555d66]{'Temp':<9}[/] [#e6e9ec]{g['temp']}\u00b0C[/]")
|
|
816
|
+
if g.get("util") is None and g.get("temp") is None:
|
|
817
|
+
lines.append("[#7d858e]utilization/temp not exposed by this driver[/]")
|
|
818
|
+
return "\n".join(lines)
|
|
819
|
+
|
|
820
|
+
def _cmd_battery(self, args) -> str:
|
|
821
|
+
b = providers.battery_info()
|
|
822
|
+
if not b:
|
|
823
|
+
return "[dim]battery[/]\n[#7d858e]no battery detected[/]"
|
|
824
|
+
lines = ["[dim]battery[/]",
|
|
825
|
+
f"[#555d66]{'Charge':<9}[/] [{color_for(b['percent'])}]{b['percent']:.0f}%[/]"]
|
|
826
|
+
lines.append(bar_pct(b["percent"], 20))
|
|
827
|
+
lines.append(f"[#555d66]{'State':<9}[/] [{'#8bd450' if b.get('plugged') else '#e4b84c'}]{'CHARGING/PLUGGED' if b.get('plugged') else 'DISCHARGING'}[/]")
|
|
828
|
+
if b.get("seconds_left"):
|
|
829
|
+
lines.append(f"[#555d66]{'Remaining':<9}[/] {int(b['seconds_left'] / 60)}m")
|
|
830
|
+
return "\n".join(lines)
|
|
831
|
+
|
|
832
|
+
def _cmd_temp(self, args) -> str:
|
|
833
|
+
d = providers.temperatures()
|
|
834
|
+
if not d["available"]:
|
|
835
|
+
return "[#7d858e]temperatures not supported via psutil on this system[/]"
|
|
836
|
+
lines = ["[dim]temperatures[/]"]
|
|
837
|
+
for name, entries in d["temps"].items():
|
|
838
|
+
for e in entries:
|
|
839
|
+
lines.append(f" [#7d858e]{_trunc(name, 24)}[/] [#e6e9ec]{e.current}\u00b0C[/]")
|
|
840
|
+
return "\n".join(lines) if len(lines) > 1 else "no sensors"
|
|
841
|
+
|
|
842
|
+
# --- команды: Network ---
|
|
843
|
+
def _cmd_network(self, args) -> str:
|
|
844
|
+
sub = args[0].lower() if args else ""
|
|
845
|
+
if sub == "connections":
|
|
846
|
+
c = providers.connections_by_process()
|
|
847
|
+
lines = ["[dim]TCP CONNECTIONS[/]",
|
|
848
|
+
f" [#555d66]total[/] [#e6e9ec]{c['total']}[/]"]
|
|
849
|
+
for name, n in c["top"]:
|
|
850
|
+
lines.append(f" {_trunc(name, 24):<26}[#e6e9ec]{n:>4}[/]")
|
|
851
|
+
return "\n".join(lines)
|
|
852
|
+
if sub == "interfaces":
|
|
853
|
+
d = providers.net_info()
|
|
854
|
+
lines = ["[dim]NETWORK INTERFACES[/]"]
|
|
855
|
+
for i in d["interfaces"]:
|
|
856
|
+
st = "[#8bd450]up[/]" if i["up"] else "[#555d66]down[/]"
|
|
857
|
+
ip = ", ".join(i["ipv4"]) if i["ipv4"] else "[#555d66]\u2014[/]"
|
|
858
|
+
lines.append(f" {st} {_trunc(i['name'], 20):<22}{ip:<18}{i['speed']} Mbps")
|
|
859
|
+
return "\n".join(lines)
|
|
860
|
+
if sub == "speeds":
|
|
861
|
+
r = providers.network_speeds()
|
|
862
|
+
return ("[dim]NETWORK SPEEDS[/]\n"
|
|
863
|
+
f" [#555d66]download[/] {_rate(r['down'])}\n"
|
|
864
|
+
f" [#555d66]upload[/] {_rate(r['up'])}")
|
|
865
|
+
if sub in ("ping", "icmp"):
|
|
866
|
+
host = args[1] if len(args) > 1 else "8.8.8.8"
|
|
867
|
+
return self._net([host])
|
|
868
|
+
if sub == "help":
|
|
869
|
+
return self._net([])
|
|
870
|
+
return self._net([])
|
|
871
|
+
|
|
872
|
+
def _cmd_net(self, args) -> str:
|
|
873
|
+
return self._net(args)
|
|
874
|
+
|
|
875
|
+
def _net(self, args) -> str:
|
|
876
|
+
host = args[0] if args else "8.8.8.8"
|
|
877
|
+
d = providers.net_info()
|
|
878
|
+
lines = []
|
|
879
|
+
for i in d["interfaces"]:
|
|
880
|
+
st = "[#8bd450]up[/]" if i["up"] else "[#555d66]down[/]"
|
|
881
|
+
ip = ", ".join(i["ipv4"]) if i["ipv4"] else "[#555d66]N/A[/]"
|
|
882
|
+
lines.append(f" {st} {_trunc(i['name'], 20).ljust(20)} {ip} {i['speed']} Mbps")
|
|
883
|
+
if d["io"]:
|
|
884
|
+
lines.append(f"[#555d66]traffic[/] [#8bd450]{bytes_to_human(d['io']['bytes_sent'])}[/] / {bytes_to_human(d['io']['bytes_recv'])}")
|
|
885
|
+
p = providers.ping(host)
|
|
886
|
+
if p["success"] and p["avg_ms"] is not None:
|
|
887
|
+
lines.append(f"[#8bd450]ping {host}: {p['avg_ms']:.1f} ms[/]")
|
|
888
|
+
elif p["success"]:
|
|
889
|
+
lines.append(f"[#8bd450]ping {host}: ok[/]")
|
|
890
|
+
else:
|
|
891
|
+
lines.append(f"[#e05b5b]ping {host}: unreachable[/]")
|
|
892
|
+
return "\n".join(lines)
|
|
893
|
+
|
|
894
|
+
# --- команды: Processes ---
|
|
895
|
+
def _cmd_process(self, args) -> str:
|
|
896
|
+
sub = args[0].lower() if args else "list"
|
|
897
|
+
rest = args[1:]
|
|
898
|
+
if sub == "list":
|
|
899
|
+
return self._proc_table_text(rest)
|
|
900
|
+
if sub == "find":
|
|
901
|
+
if not rest:
|
|
902
|
+
return "[dim]usage: process find <name|pid>[/]"
|
|
903
|
+
p = providers.find_process(rest[0])
|
|
904
|
+
if not p:
|
|
905
|
+
return f"[#7d858e]no process matching '{rest[0]}'[/]"
|
|
906
|
+
return (f"[#8bd450][+] {_trunc(p['name'], 20)}[/] pid {p['pid']}"
|
|
907
|
+
f" cpu {p['cpu']:.1f}% mem {p['memory_percent']:.1f}%"
|
|
908
|
+
f"\n[dim] \u2192 process details {p['pid']} or find it in the table[/]")
|
|
909
|
+
if sub == "sort":
|
|
910
|
+
key = rest[0].lower() if rest else "cpu"
|
|
911
|
+
if key not in ("cpu", "ram", "name"):
|
|
912
|
+
return f"[dim]sort by cpu|ram|name, not '{key}'[/]"
|
|
913
|
+
self._proc_sort = key
|
|
914
|
+
return f"processes sorted by {key}"
|
|
915
|
+
if sub in ("kill", "killall", "stop", "suspend", "resume", "restart"):
|
|
916
|
+
if not rest:
|
|
917
|
+
return f"[dim]usage: process {sub} <name|pid>[/]"
|
|
918
|
+
pid = providers.resolve_pid(rest[0])
|
|
919
|
+
if pid is None:
|
|
920
|
+
return f"[#7d858e]no process matching '{rest[0]}'[/]"
|
|
921
|
+
if sub == "kill" or sub == "restart":
|
|
922
|
+
self._pending_proc = (sub, pid)
|
|
923
|
+
return f"[yellow]confirm {sub} of process {pid}? type 'confirm'[/]"
|
|
924
|
+
return self._proc_ctl_op("stop" if sub in ("stop", "suspend") else "resume", pid)
|
|
925
|
+
if sub == "details":
|
|
926
|
+
if not rest:
|
|
927
|
+
return "[dim]usage: process details <name|pid>[/]"
|
|
928
|
+
pid = providers.resolve_pid(rest[0])
|
|
929
|
+
if pid is None:
|
|
930
|
+
return f"[#7d858e]no process matching '{rest[0]}'[/]"
|
|
931
|
+
return ("@details", pid)
|
|
932
|
+
return self._proc_table_text([])
|
|
933
|
+
|
|
934
|
+
def _proc_table_text(self, args, sort: str | None = None) -> str:
|
|
935
|
+
q = args[0] if args else None
|
|
936
|
+
key = sort or self._proc_sort
|
|
937
|
+
rows = providers.list_processes(query=q, sort=key, limit=20)
|
|
938
|
+
lines = [f"[dim]processes \u00b7 sort {key}" + (f" \u00b7 filter '{q}'" if q else "") + "[/]",
|
|
939
|
+
f"[#555d66]{'PID':<7}{'PROCESS':<26}{'CPU':>8}{'MEM':>9}{'STATUS':>9}[/]"]
|
|
940
|
+
for p in rows:
|
|
941
|
+
cpu = min(p.get("cpu_percent") or 0, 9999)
|
|
942
|
+
rss = p.get("rss") or 0
|
|
943
|
+
st = (p.get("status") or "running").lower()[:9]
|
|
944
|
+
sc = {"running": ACCENT, "stopped": DANGER, "sleeping": MUTED}.get(st, MUTED)
|
|
945
|
+
lines.append(
|
|
946
|
+
f"[#7d858e]{p.get('pid') or 0:<7}[/]{_trunc((p.get('name') or '?'), 26):<26}"
|
|
947
|
+
f"[{color_for(cpu)}]{cpu:6.1f}[/]"
|
|
948
|
+
f"[#7d858e]%[/]"
|
|
949
|
+
f"[#e6e9ec]{bytes_to_human(rss):>8}[/]"
|
|
950
|
+
f"[{sc}]{st.upper():>9}[/]"
|
|
951
|
+
)
|
|
952
|
+
if len(lines) == 2:
|
|
953
|
+
lines.append("[#7d858e]no processes[/]")
|
|
954
|
+
lines.append("[dim]\u2192 click the table or tab to it: Enter details \u00b7 K kill \u00b7 S stop \u00b7 R restart \u00b7 / search[/]")
|
|
955
|
+
return "\n".join(lines)
|
|
956
|
+
|
|
957
|
+
def _cmd_proc(self, args) -> str:
|
|
958
|
+
key = args[0] if args and args[0] in ("cpu", "ram", "name") else self._proc_sort
|
|
959
|
+
return self._proc_table_text([], sort=key)
|
|
960
|
+
|
|
961
|
+
# --- команды: Storage ---
|
|
962
|
+
def _cmd_disk(self, args) -> str:
|
|
963
|
+
sub = args[0].lower() if args else "list"
|
|
964
|
+
if sub == "io":
|
|
965
|
+
io = providers.disk_io_speeds()
|
|
966
|
+
return ("[dim]DISK IO[/]\n"
|
|
967
|
+
f" [#555d66]read[/] {_rate(io['read'])}\n"
|
|
968
|
+
f" [#555d66]write[/] {_rate(io['write'])}")
|
|
969
|
+
if sub == "info":
|
|
970
|
+
letter = args[1] if len(args) > 1 else ""
|
|
971
|
+
return self._disk_info_letter(letter.upper())
|
|
972
|
+
d = providers.disk_info()
|
|
973
|
+
lines = ["[dim]DISKS[/]"]
|
|
974
|
+
for disk in d["disks"]:
|
|
975
|
+
c = color_for(disk["percent"])
|
|
976
|
+
dev = escape(disk["device"].rstrip("\\"))
|
|
977
|
+
lines.append(f" [#e6e9ec]{dev}[/] [{c}]{disk['percent']:3.0f}%[/] {bar_pct(disk['percent'], 14)}")
|
|
978
|
+
lines.append(f" {bytes_to_human(disk['used'])} / {bytes_to_human(disk['total'])} (free {bytes_to_human(disk['free'])})")
|
|
979
|
+
return "\n".join(lines) if len(lines) > 1 else "no disks"
|
|
980
|
+
|
|
981
|
+
def _disk_info_letter(self, letter: str) -> str:
|
|
982
|
+
d = providers.disk_info()
|
|
983
|
+
for disk in d["disks"]:
|
|
984
|
+
dev = disk["device"].rstrip("\\")
|
|
985
|
+
if letter and dev.upper().startswith(letter):
|
|
986
|
+
c = color_for(disk["percent"])
|
|
987
|
+
return "\n".join([
|
|
988
|
+
f"[dim]DISK {dev}[/] [{c}]{disk['percent']:.0f}%[/]",
|
|
989
|
+
f" [#555d66]total[/] [#e6e9ec]{bytes_to_human(disk['total'])}[/]",
|
|
990
|
+
f" [#555d66]used[/] [#e6e9ec]{bytes_to_human(disk['used'])}[/]",
|
|
991
|
+
f" [#555d66]free[/] [#e6e9ec]{bytes_to_human(disk['free'])}[/]",
|
|
992
|
+
" " + bar_pct(disk["percent"], 18),
|
|
993
|
+
])
|
|
994
|
+
return f"[#7d858e]no disk starting with '{letter}'[/]"
|
|
995
|
+
|
|
996
|
+
# --- команды: System ---
|
|
997
|
+
def _cmd_system(self, args) -> str:
|
|
998
|
+
return self._sys(args)
|
|
999
|
+
|
|
1000
|
+
def _sys(self, args) -> str:
|
|
1001
|
+
d = providers.system_info()
|
|
1002
|
+
q = providers.system_quick()
|
|
1003
|
+
lines = [
|
|
1004
|
+
"[dim]SYSTEM[/]",
|
|
1005
|
+
f"[#555d66]{'OS':<9}[/] [#e6e9ec]{d['os']}[/] ({d['machine']})",
|
|
1006
|
+
f"[#555d66]{'Host':<9}[/] {d['node']}",
|
|
1007
|
+
f"[#555d66]{'CPU':<9}[/] {_trunc(d['processor'], 40)}",
|
|
1008
|
+
f"[#555d66]{'Uptime':<9}[/] {seconds_to_human(d['uptime_seconds'])} (since {d['boot_datetime']})",
|
|
1009
|
+
f"[#555d66]{'Python':<9}[/] {d['python']}",
|
|
1010
|
+
]
|
|
1011
|
+
if d["users"]:
|
|
1012
|
+
lines.append(f"[#555d66]{'Users':<9}[/] {', '.join(d['users'])}")
|
|
1013
|
+
lines.append(f"[#555d66]{'Load':<9}[/] cpu {q['cpu_percent']}% "
|
|
1014
|
+
f"ram {q['ram_percent']}% (free {bytes_to_human(q['ram_available'])})")
|
|
1015
|
+
if q.get("main_percent") is not None:
|
|
1016
|
+
lines.append(f"[#555d66]{'Disk':<9}[/] root {q['main_percent']}% (free {bytes_to_human(q['main_free'])})")
|
|
1017
|
+
net = providers.net_info()
|
|
1018
|
+
if net.get("io"):
|
|
1019
|
+
lines.append(f"[#555d66]{'Net':<9}[/] sent {bytes_to_human(net['io']['bytes_sent'])} "
|
|
1020
|
+
f"recv {bytes_to_human(net['io']['bytes_recv'])}")
|
|
1021
|
+
return "\n".join(lines)
|
|
1022
|
+
|
|
1023
|
+
def _cmd_all(self, args) -> str:
|
|
1024
|
+
parts = [self._cmd_cpu([]), self._cmd_ram([]), self._cmd_disk([])]
|
|
1025
|
+
net_summary = self._net([]).splitlines()
|
|
1026
|
+
parts.append("\n".join(net_summary[:3]))
|
|
1027
|
+
return "\n\n".join(parts)
|
|
1028
|
+
|
|
1029
|
+
# --- команды: Display ---
|
|
1030
|
+
def _cmd_watch(self, args) -> str:
|
|
1031
|
+
if not args:
|
|
1032
|
+
return ("[dim]usage: watch cpu | network | process [name] | off[/]\n"
|
|
1033
|
+
f"[dim]currently: {self._watch or 'none'}[/]")
|
|
1034
|
+
target = args[0].lower()
|
|
1035
|
+
if target in ("off", "none", "clear"):
|
|
1036
|
+
self._watch = None
|
|
1037
|
+
self._proc_filter = None
|
|
1038
|
+
self._apply_watch()
|
|
1039
|
+
self._refresh_dashboard()
|
|
1040
|
+
return "watching: none"
|
|
1041
|
+
if target == "cpu":
|
|
1042
|
+
self._watch = "cpu"
|
|
1043
|
+
elif target == "network":
|
|
1044
|
+
self._watch = "network"
|
|
1045
|
+
elif target in ("process", "procs"):
|
|
1046
|
+
self._watch = "process"
|
|
1047
|
+
self._proc_filter = args[1] if len(args) > 1 else self._proc_filter
|
|
1048
|
+
else:
|
|
1049
|
+
return f"[dim]unknown watch target '{target}'[/]"
|
|
1050
|
+
self._apply_watch()
|
|
1051
|
+
self._refresh_dashboard()
|
|
1052
|
+
extra = f" filter '{self._proc_filter}'" if self._watch == "process" and self._proc_filter else ""
|
|
1053
|
+
return f"watching: {self._watch}{extra}"
|
|
1054
|
+
|
|
1055
|
+
def _cmd_settings(self) -> str:
|
|
1056
|
+
cfg = os.path.expanduser("~/.syscheck/config.json")
|
|
1057
|
+
audit = os.path.expanduser("~/.syscheck/shell_audit.log")
|
|
1058
|
+
shell = "enabled" if shellguard.is_enabled() else "disabled"
|
|
1059
|
+
watch = self._watch or "none"
|
|
1060
|
+
return "\n".join([
|
|
1061
|
+
"[dim]SETTINGS[/]",
|
|
1062
|
+
f"[#555d66]{'Version':<9}[/] {__version__}",
|
|
1063
|
+
f"[#555d66]{'Config':<9}[/] {cfg}",
|
|
1064
|
+
f"[#555d66]{'Audit':<9}[/] {audit}",
|
|
1065
|
+
f"[#555d66]{'!shell':<9}[/] [{WARN}]{shell}[/]",
|
|
1066
|
+
f"[#555d66]{'Watch':<9}[/] {watch}",
|
|
1067
|
+
])
|
|
1068
|
+
|
|
1069
|
+
# --- действия ---
|
|
1070
|
+
def action_clear_log(self) -> None:
|
|
1071
|
+
self._clear_log()
|
|
1072
|
+
|
|
1073
|
+
def action_history_up(self) -> None:
|
|
1074
|
+
if self.history:
|
|
1075
|
+
self._hist_index = max(0, self._hist_index - 1)
|
|
1076
|
+
self.query_one("#cmd", Input).value = self.history[self._hist_index]
|
|
1077
|
+
|
|
1078
|
+
def action_history_down(self) -> None:
|
|
1079
|
+
if self.history:
|
|
1080
|
+
self._hist_index = min(len(self.history), self._hist_index + 1)
|
|
1081
|
+
val = self.history[self._hist_index] if self._hist_index < len(self.history) else ""
|
|
1082
|
+
self.query_one("#cmd", Input).value = val
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def run(enable_shell: bool = False) -> None:
|
|
1086
|
+
SysCheckTUI(enable_shell=enable_shell).run()
|