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/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()