ndVpi 1.0.0b0__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.
ndvpi/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """ndVpi — фоновая оптимизация сети и сканер Wi-Fi."""
2
+ from .version import __version__
3
+ from .wifi import WiFiScanner
4
+ from .network_info import NetworkInfo
5
+ from .polidium import Polidium
6
+ from .critchers import Critchers
7
+ from .network_config import NetworkConfig
8
+ from .monitor import NetworkMonitor
9
+ from .diagnostics import NetworkDiagnostics
10
+ from .history import SessionHistory
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "WiFiScanner",
15
+ "NetworkInfo",
16
+ "Polidium",
17
+ "Critchers",
18
+ "NetworkConfig",
19
+ "NetworkMonitor",
20
+ "NetworkDiagnostics",
21
+ "SessionHistory",
22
+ ]
23
+
24
+ try:
25
+ from . import collquan_ext # noqa: F401
26
+ except Exception:
27
+ pass
ndvpi/charm_enhance.py ADDED
@@ -0,0 +1,136 @@
1
+ """Мягкая интеграция с CharmPkk: красиво, если есть; обычно, если нет."""
2
+ import sys
3
+
4
+ _charm = None
5
+ _c = None
6
+ _available = False
7
+
8
+ try:
9
+ from charmpkk import console as _c
10
+ _charm = __import__("charmpkk")
11
+ _available = True
12
+ except Exception:
13
+ _available = False
14
+
15
+ def has_charm(): return _available
16
+ def console(): return _c
17
+ def theme(name="tokyo_night"): return _c.get_theme(name) if _available else None
18
+
19
+ def _fg(color):
20
+ if not _available or not color: return None
21
+ if isinstance(color, str) and color.startswith("#"): return _c.fg_hex(color)
22
+ return color
23
+
24
+ def print_msg(text, color=None, style=None):
25
+ print(_c.colorize(text, fg=_fg(color), style=style)) if _available else print(text)
26
+
27
+ def banner(title, subtitle=None, color="#7aa2ff"):
28
+ if _available: return _c.banner(title, subtitle, fg=_fg(color))
29
+ return f"\n=== {title} ===\n{subtitle}\n" if subtitle else f"\n=== {title} ===\n"
30
+
31
+ def box(content, title=None, color="#7aa2ff"):
32
+ if _available: return _c.box(content, title=title, border_fg=_fg(color))
33
+ lines = content if isinstance(content, list) else [content]
34
+ return "\n".join([f"--- {title} ---" if title else "-"*20] + [str(l) for l in lines] + ["-"*20])
35
+
36
+ def table(headers, rows, header_color="#7aa2ff"):
37
+ if _available: return _c.Table(headers=headers, rows=rows, header_fg=_fg(header_color)).render()
38
+ all_rows = [list(headers)] + [list(r) for r in rows]
39
+ n_cols = len(headers)
40
+ widths = [max(len(str(r[i])) if i < len(r) else 0 for r in all_rows) for i in range(n_cols)]
41
+ lines = []
42
+ for i, row in enumerate(all_rows):
43
+ cells = [str(row[j]) if j < len(row) else "" for j in range(n_cols)]
44
+ lines.append(" | ".join(c.ljust(widths[j]) for j, c in enumerate(cells)))
45
+ if i == 0: lines.append("-+-".join("-" * w for w in widths))
46
+ return "\n".join(lines)
47
+
48
+ def sparkline(values, fg="#7aa2ff"):
49
+ if _available: return _c.sparkline(values, fg=_fg(fg))
50
+ return "".join("▁▂▃▄▅▆▇█"[int(v/max(values or [1])*(7))] for v in values)
51
+
52
+ def key_value(data, key_color="#8b93a7"):
53
+ if _available: return _c.key_value(data, key_fg=_fg(key_color))
54
+ max_k = max((len(str(k)) for k in data), default=0)
55
+ return "\n".join(f" {str(k).ljust(max_k)}: {v}" for k, v in data.items())
56
+
57
+ def info(msg): print(_c.info(msg)) if _available else print(f"[i] {msg}")
58
+ def success(msg): print(_c.success(msg)) if _available else print(f"[+] {msg}")
59
+ def warning(msg): print(_c.warning(msg)) if _available else print(f"[!] {msg}")
60
+ def error(msg): print(_c.error(msg)) if _available else print(f"[x] {msg}")
61
+
62
+ def render_info(msg): return _c.info(msg) if _available else f"[i] {msg}"
63
+ def render_success(msg): return _c.success(msg) if _available else f"[+] {msg}"
64
+ def render_warning(msg): return _c.warning(msg) if _available else f"[!] {msg}"
65
+ def render_error(msg): return _c.error(msg) if _available else f"[x] {msg}"
66
+
67
+ def ask(prompt, default=None):
68
+ if _available: return _c.ask(prompt, default=default)
69
+ hint = f" [{default}]" if default is not None else ""
70
+ try: return input(f"? {prompt}{hint}: ").strip() or default
71
+ except (EOFError, KeyboardInterrupt): return default
72
+
73
+ def confirm(prompt, default=False):
74
+ if _available: return _c.confirm(prompt, default=default)
75
+ d = "Y/n" if default else "y/N"
76
+ try: v = input(f"? {prompt} ({d}): ").strip().lower()
77
+ except (EOFError, KeyboardInterrupt): return default
78
+ return v.startswith("y") if v else default
79
+
80
+ def select(title, options, color="#7aa2ff"):
81
+ if not options: return None
82
+ if _available: return _c.select(title, options, fg_cursor=_fg(color), fg_title=_fg(color))
83
+ print(title)
84
+ for i, opt in enumerate(options, 1): print(f" {i}. {opt}")
85
+ try:
86
+ idx = int(input("> ").strip()) - 1
87
+ if 0 <= idx < len(options): return options[idx]
88
+ except Exception: return None
89
+
90
+ class _NullProgress:
91
+ def __enter__(self): return self
92
+ def __exit__(self, *exc): print()
93
+ def update(self, current): sys.stdout.write(f"\r {current}%"); sys.stdout.flush()
94
+ def finish(self, msg=None): print(msg) if msg else None
95
+
96
+ def progress(total=100, label="", color="#7aa2ff"):
97
+ return _c.FancyProgressBar(total=total, label=label, fg=_fg(color)) if _available else _NullProgress()
98
+
99
+ class _NullSpinner:
100
+ def __enter__(self): return self
101
+ def __exit__(self, *exc): pass
102
+ def stop(self, final=None): print(final) if final else None
103
+
104
+ def spinner(text="Loading", color="#7aa2ff"):
105
+ if _available: return _c.Spinner(text, fg=_fg(color))
106
+ print(text + "...")
107
+ return _NullSpinner()
108
+
109
+ class LiveDashboard:
110
+ def __init__(self):
111
+ self._live = None
112
+ self._running = False
113
+ self._last_lines = 0
114
+ def __enter__(self):
115
+ if _available: self._live = _c.Live(); self._live.__enter__()
116
+ self._running = True
117
+ return self
118
+ def update(self, content):
119
+ if not self._running: return
120
+ if _available and self._live: self._live.update(content)
121
+ else:
122
+ lines = content.split("\n")
123
+ if self._last_lines > 0: sys.stdout.write(f"\033[{self._last_lines}A")
124
+ max_lines = max(self._last_lines, len(lines))
125
+ for i in range(max_lines):
126
+ sys.stdout.write("\033[2K")
127
+ if i < len(lines): sys.stdout.write(lines[i])
128
+ sys.stdout.write("\n")
129
+ if max_lines > len(lines): sys.stdout.write(f"\033[{max_lines - len(lines)}A")
130
+ self._last_lines = len(lines)
131
+ sys.stdout.flush()
132
+ def __exit__(self, *exc):
133
+ self._running = False
134
+ if _available and self._live: self._live.__exit__(*exc)
135
+ else: sys.stdout.write("\n")
136
+ return False
ndvpi/cli.py ADDED
@@ -0,0 +1,13 @@
1
+ """CLI для ndVpi."""
2
+ import sys
3
+
4
+ def main():
5
+ from .menu import run
6
+ try:
7
+ return run()
8
+ except KeyboardInterrupt:
9
+ print()
10
+ return 130
11
+
12
+ if __name__ == "__main__":
13
+ sys.exit(main())
ndvpi/collquan_ext.py ADDED
@@ -0,0 +1,111 @@
1
+ """Интеграция ndVpi с Collquan (CharmPkk)."""
2
+ try:
3
+ from charmpkk.terminals import CollquanShell
4
+ except Exception:
5
+ CollquanShell = None
6
+
7
+ from .wifi import WiFiScanner
8
+ from .network_info import NetworkInfo
9
+ from .polidium import Polidium
10
+ from .critchers import Critchers
11
+ from .network_config import NetworkConfig, _is_admin
12
+
13
+ _polidium = Polidium()
14
+ _critchers = Critchers()
15
+ _config = NetworkConfig()
16
+
17
+ def _cmd_scan(args):
18
+ data = WiFiScanner().current_network()
19
+ if not data: print("Нет данных о Wi-Fi."); return
20
+ for k, v in data.items(): print(f"{k}: {v}")
21
+
22
+ def _cmd_info(args):
23
+ info = NetworkInfo()
24
+ print(f"IP: {info.local_ip()}")
25
+ print(f"Шлюз: {info.gateway()}")
26
+ print(f"DNS: {', '.join(info.dns_servers()) or '—'}")
27
+ print(f"Ping: {info.ping() or '—'} ms")
28
+
29
+ def _cmd_poli(args):
30
+ if args and args[0] == "dns":
31
+ _polidium.flush_dns()
32
+ print("[+] DNS кэш очищен")
33
+ elif args and args[0] == "arp":
34
+ _polidium.flush_arp()
35
+ print("[+] ARP кэш очищен")
36
+ else:
37
+ _polidium.flush_dns()
38
+ _polidium.flush_arp()
39
+ print("[+] Polidium: кэши очищены")
40
+
41
+ def _cmd_crit(args):
42
+ if args and args[0] == "ram":
43
+ ok, msg = _critchers.clean_standby_list()
44
+ print(f"[{'x' if not ok else '+'}] {msg}")
45
+ elif args and args[0] == "tcp":
46
+ ok, msg = _critchers.apply_tcp_tweaks()
47
+ print(f"[{'x' if not ok else '+'}] {msg}")
48
+ elif args and args[0] == "restore":
49
+ ok, msg = _critchers.restore()
50
+ print(f"[{'x' if not ok else '+'}] {msg}")
51
+ else:
52
+ print("Использование: ndcrit <ram|tcp|restore>")
53
+
54
+ def _cmd_dns(args):
55
+ if not args or args[0] == "show":
56
+ servers = _config.get_dns()
57
+ print("DNS:", ", ".join(servers) if servers else "автоматически")
58
+ return
59
+ if args[0] == "set":
60
+ if len(args) < 2: print("Использование: nddns set <ip1> [ip2]..."); return
61
+ ok, msg = _config.set_dns(args[1:])
62
+ print(("[+] " if ok else "[x] ") + msg)
63
+ return
64
+ if args[0] == "reset":
65
+ ok, msg = _config.reset_dns()
66
+ print(("[+] " if ok else "[x] ") + msg)
67
+ return
68
+ print("Использование: nddns [show|set <ip>...|reset]")
69
+
70
+ def _cmd_pass(args):
71
+ if len(args) < 2: print("Использование: ndpass <ssid> <new_password>"); return
72
+ ssid, pwd = args[0], args[1]
73
+ print(f"[!] Внимание: смена пароля для '{ssid}' может разорвать соединение.")
74
+ if input("Продолжить? [y/N] ").strip().lower() != "y":
75
+ print("Отменено."); return
76
+ ok, msg = _config.change_wifi_password(ssid, pwd)
77
+ print(("[+] " if ok else "[x] ") + msg)
78
+
79
+ def _cmd_adapters(args):
80
+ if not args or args[0] == "list":
81
+ for a in _config.list_adapters(): print(f" {a.get('name', '?'):<30} {a}")
82
+ return
83
+ if args[0] in ("enable", "disable") and len(args) >= 2:
84
+ ok, msg = _config.set_adapter_state(args[1], enabled=(args[0] == "enable"))
85
+ print(("[+] " if ok else "[x] ") + msg)
86
+ return
87
+ print("Использование: ndadapters [list|enable|disable <name>]")
88
+
89
+ def _cmd_mtu(args):
90
+ if not args: print("Использование: ndmtu <interface> [new_mtu]"); return
91
+ iface = args[0]
92
+ if len(args) == 1:
93
+ mtu = _config.get_mtu(iface)
94
+ print(f"MTU {iface} = {mtu if mtu is not None else '—'}")
95
+ return
96
+ ok, msg = _config.set_mtu(iface, args[1])
97
+ print(("[+] " if ok else "[x] ") + msg)
98
+
99
+ def _cmd_admin(args):
100
+ print("Администратор:", "да" if _is_admin() else "нет")
101
+
102
+ if CollquanShell is not None:
103
+ CollquanShell.register("ndscan", "Сканировать Wi-Fi")(_cmd_scan)
104
+ CollquanShell.register("ndinfo", "Информация о сети")(_cmd_info)
105
+ CollquanShell.register("ndpoli", "Polidium: очистка DNS/ARP")(_cmd_poli)
106
+ CollquanShell.register("ndcrit", "Critchers: RAM/TCP буст")(_cmd_crit)
107
+ CollquanShell.register("nddns", "DNS: show/set/reset")(_cmd_dns)
108
+ CollquanShell.register("ndpass", "Сменить пароль Wi-Fi: <ssid> <pass>")(_cmd_pass)
109
+ CollquanShell.register("ndadapters", "Адаптеры: list/enable/disable")(_cmd_adapters)
110
+ CollquanShell.register("ndmtu", "MTU: <iface> [new_mtu]")(_cmd_mtu)
111
+ CollquanShell.register("ndadmin", "Проверить права администратора")(_cmd_admin)
ndvpi/critchers.py ADDED
@@ -0,0 +1,184 @@
1
+ """Critchers: Агрессивный сетевой бустер и очистка ресурсов (Без psutil)."""
2
+ import os
3
+ import sys
4
+ import json
5
+ import ctypes
6
+ import platform
7
+ import subprocess
8
+ import atexit
9
+ from pathlib import Path
10
+
11
+ BACKUP_FILE = Path.home() / ".ndvpi" / "critchers_backup.json"
12
+
13
+ def _is_windows():
14
+ return platform.system() == "Windows"
15
+
16
+ def _is_admin():
17
+ if not _is_windows(): return False
18
+ try:
19
+ return ctypes.windll.shell32.IsUserAnAdmin() != 0
20
+ except Exception:
21
+ return False
22
+
23
+ def _get_pids(process_name):
24
+ try:
25
+ cmd = f'tasklist /FI "IMAGENAME eq {process_name}" /FO CSV /NH'
26
+ out = subprocess.run(cmd, shell=True, capture_output=True, text=True, encoding="cp866").stdout
27
+ pids = []
28
+ for line in out.splitlines():
29
+ if process_name.lower() in line.lower():
30
+ parts = line.split('","')
31
+ if len(parts) > 1:
32
+ try: pids.append(int(parts[1]))
33
+ except ValueError: pass
34
+ return pids
35
+ except Exception:
36
+ return []
37
+
38
+ def _suspend_process(pid):
39
+ try:
40
+ PROCESS_SUSPEND_RESUME = 0x0800
41
+ handle = ctypes.windll.kernel32.OpenProcess(PROCESS_SUSPEND_RESUME, False, pid)
42
+ if handle:
43
+ ctypes.windll.ntdll.NtSuspendProcess(handle)
44
+ ctypes.windll.kernel32.CloseHandle(handle)
45
+ return True
46
+ except Exception:
47
+ pass
48
+ return False
49
+
50
+ def _resume_process(pid):
51
+ try:
52
+ PROCESS_SUSPEND_RESUME = 0x0800
53
+ handle = ctypes.windll.kernel32.OpenProcess(PROCESS_SUSPEND_RESUME, False, pid)
54
+ if handle:
55
+ ctypes.windll.ntdll.NtResumeProcess(handle)
56
+ ctypes.windll.kernel32.CloseHandle(handle)
57
+ return True
58
+ except Exception:
59
+ pass
60
+ return False
61
+
62
+ class Critchers:
63
+ def __init__(self):
64
+ self.backup_data = {}
65
+ self.suspended_pids = []
66
+ atexit.register(self.resume_background_downloads)
67
+
68
+ if BACKUP_FILE.exists():
69
+ try:
70
+ self.backup_data = json.loads(BACKUP_FILE.read_text("utf-8"))
71
+ except Exception:
72
+ pass
73
+
74
+ def _save_backup(self):
75
+ BACKUP_FILE.parent.mkdir(parents=True, exist_ok=True)
76
+ BACKUP_FILE.write_text(json.dumps(self.backup_data, indent=2), "utf-8")
77
+
78
+ def clean_standby_list(self):
79
+ if not _is_windows() or not _is_admin():
80
+ return False, "Требуются права администратора Windows"
81
+ try:
82
+ ctypes.windll.ntdll.NtSetSystemInformation(-1073744922, ctypes.byref(ctypes.c_int(4)), 4)
83
+ return True, "RAM Standby List очищен"
84
+ except Exception as e:
85
+ return False, f"Ошибка RAM: {e}"
86
+
87
+ def apply_tcp_tweaks(self, dry_run=False):
88
+ if not _is_windows(): return False, "Только для Windows"
89
+ import winreg
90
+ tweaks = {
91
+ r"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters": {
92
+ "NetworkThrottlingIndex": 0xffffffff,
93
+ "TcpAckFrequency": 1,
94
+ "TCPNoDelay": 1,
95
+ "TcpDelAckTicks": 0
96
+ }
97
+ }
98
+ if dry_run:
99
+ return True, "DRY-RUN: Будет изменен реестр (TCP Acknowledgment, Throttling)"
100
+ if not _is_admin():
101
+ return False, "Требуются права администратора"
102
+ for path, values in tweaks.items():
103
+ try:
104
+ key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_ALL_ACCESS)
105
+ for name, val in values.items():
106
+ try:
107
+ orig_val, _ = winreg.QueryValueEx(key, name)
108
+ self.backup_data[f"{path}\\{name}"] = orig_val
109
+ except FileNotFoundError:
110
+ self.backup_data[f"{path}\\{name}"] = None
111
+ winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, val)
112
+ winreg.CloseKey(key)
113
+ except Exception as e:
114
+ return False, f"Ошибка реестра: {e}"
115
+ self._save_backup()
116
+ return True, "Твики TCP успешно применены"
117
+
118
+ def disable_wifi_power_saving(self, dry_run=False):
119
+ if not _is_windows(): return False, "Только для Windows"
120
+ if dry_run: return True, "DRY-RUN: Отключение энергосбережения Wi-Fi адаптера"
121
+ if not _is_admin(): return False, "Требуются права администратора"
122
+ ps_cmd = (
123
+ 'Get-NetAdapter | Where-Object {$_.PhysicalMediaType -match "Native 802.11"} '
124
+ '| Disable-NetAdapterPowerManagement -ErrorAction SilentlyContinue'
125
+ )
126
+ try:
127
+ subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True)
128
+ return True, "Энергосбережение Wi-Fi отключено"
129
+ except Exception as e:
130
+ return False, f"Ошибка: {e}"
131
+
132
+ def pause_background_downloads(self):
133
+ if not _is_windows() or not _is_admin():
134
+ return False, "Требуются права администратора"
135
+ targets = ["OneDrive.exe", "wuauclt.exe", "SysMain.exe"]
136
+ paused = []
137
+ for proc_name in targets:
138
+ pids = _get_pids(proc_name)
139
+ for pid in pids:
140
+ if _suspend_process(pid):
141
+ self.suspended_pids.append(pid)
142
+ paused.append(f"{proc_name} (PID: {pid})")
143
+ if paused:
144
+ return True, f"Приостановлены процессы: {', '.join(paused)}"
145
+ return True, "Фоновые качалки не найдены (или уже спят)"
146
+
147
+ def resume_background_downloads(self):
148
+ if not self.suspended_pids: return
149
+ resumed = []
150
+ for pid in self.suspended_pids:
151
+ if _resume_process(pid):
152
+ resumed.append(str(pid))
153
+ self.suspended_pids = []
154
+ if resumed:
155
+ print(f"[Critchers] Возобновлены процессы: {', '.join(resumed)}")
156
+
157
+ def restore(self):
158
+ if not _is_windows() or not _is_admin():
159
+ return False, "Требуются права администратора"
160
+ import winreg
161
+ restored = 0
162
+ for full_key, orig_val in self.backup_data.items():
163
+ path, name = full_key.rsplit("\\", 1)
164
+ try:
165
+ key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_ALL_ACCESS)
166
+ if orig_val is None:
167
+ try: winreg.DeleteValue(key, name)
168
+ except FileNotFoundError: pass
169
+ else:
170
+ winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, orig_val)
171
+ winreg.CloseKey(key)
172
+ restored += 1
173
+ except Exception:
174
+ pass
175
+ self.backup_data = {}
176
+ if BACKUP_FILE.exists():
177
+ BACKUP_FILE.unlink()
178
+ ps_cmd = (
179
+ 'Get-NetAdapter | Where-Object {$_.PhysicalMediaType -match "Native 802.11"} '
180
+ '| Enable-NetAdapterPowerManagement -ErrorAction SilentlyContinue'
181
+ )
182
+ subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True)
183
+ self.resume_background_downloads()
184
+ return True, f"Восстановлено настроек: {restored}. Wi-Fi питание и процессы возвращены."
ndvpi/diagnostics.py ADDED
@@ -0,0 +1,75 @@
1
+ """Диагностика сети: выявление проблем и рекомендации."""
2
+ import socket
3
+ from .network_info import NetworkInfo
4
+ from .wifi import WiFiScanner
5
+ from .network_config import NetworkConfig
6
+ from . import charm_enhance as ui
7
+
8
+ class CheckResult:
9
+ def __init__(self, name, ok, details, recommendation=""):
10
+ self.name, self.ok, self.details, self.recommendation = name, ok, details, recommendation
11
+
12
+ class NetworkDiagnostics:
13
+ def __init__(self):
14
+ self.info = NetworkInfo()
15
+ self.wifi = WiFiScanner()
16
+ self.config = NetworkConfig()
17
+
18
+ def run_checks(self):
19
+ results = []
20
+ adapters = self.config.list_adapters()
21
+ enabled = [a for a in adapters if a.get("state") in ("Connected", "Подключено", "up", "Подключен")]
22
+ if not adapters:
23
+ results.append(CheckResult("Адаптеры", False, "Не найдены сетевые адаптеры", "Проверьте драйверы"))
24
+ elif not enabled:
25
+ results.append(CheckResult("Адаптеры", False, "Все отключены", "Включите сетевой адаптер"))
26
+ else:
27
+ results.append(CheckResult("Адаптеры", True, f"Активен: {enabled[0].get('name', 'Неизвестно')}"))
28
+
29
+ wifi_data = self.wifi.current_network()
30
+ if wifi_data:
31
+ results.append(CheckResult("Wi-Fi", True, f"Подключено к {wifi_data.get('ssid', '?')}"))
32
+ else:
33
+ results.append(CheckResult("Wi-Fi", True, "Не активен (возможно Ethernet)"))
34
+
35
+ gw = self.info.gateway()
36
+ if not gw:
37
+ results.append(CheckResult("Шлюз", False, "Не найден", "Проверьте DHCP"))
38
+ else:
39
+ gw_ping = self.info.ping(gw, timeout=1)
40
+ if gw_ping is not None:
41
+ results.append(CheckResult("Шлюз", True, f"{gw} ({gw_ping:.0f} ms)"))
42
+ else:
43
+ results.append(CheckResult("Шлюз", False, f"{gw} не отвечает", "Перезагрузите роутер"))
44
+
45
+ try:
46
+ socket.gethostbyname("google.com")
47
+ results.append(CheckResult("DNS", True, "Разрешение имён работает"))
48
+ except socket.gaierror:
49
+ results.append(CheckResult("DNS", False, "Домены не резолвятся", "Смените DNS на 1.1.1.1"))
50
+
51
+ ping_stats = self.info.ping_series("8.8.8.8", count=3, timeout=1)
52
+ if not ping_stats:
53
+ results.append(CheckResult("Интернет", False, "Нет доступа", "Проверьте кабель/провайдера"))
54
+ else:
55
+ if ping_stats["loss"] > 0:
56
+ results.append(CheckResult("Потери", False, f"Потери: {ping_stats['loss']:.1f}%", "Помехи или перегрузка канала"))
57
+ elif ping_stats["avg"] > 150:
58
+ results.append(CheckResult("Пинг", False, f"Высокий: {ping_stats['avg']:.0f} ms", "Попробуйте Critchers или Polidium"))
59
+ else:
60
+ results.append(CheckResult("Интернет", True, f"Стабильно ({ping_stats['avg']:.0f} ms)"))
61
+ return results
62
+
63
+ def get_report(self):
64
+ results = self.run_checks()
65
+ fails = [r for r in results if not r.ok]
66
+ if ui.has_charm():
67
+ rows = [[("✔ OK" if r.ok else "✖ ОШИБКА"), r.name, r.details or "", r.recommendation or ""] for r in results]
68
+ out = [ui.table(["Статус", "Проверка", "Детали", "Рекомендация"], rows), ""]
69
+ out.append(ui.render_warning("Обнаружены проблемы.") if fails else ui.render_success("Сеть работает стабильно."))
70
+ return "\n".join(out)
71
+ out = ["=== Диагностика сети ==="]
72
+ for r in results:
73
+ out.append(f"{'[+]' if r.ok else '[x]'} {r.name}: {r.details}")
74
+ if not r.ok and r.recommendation: out.append(f" -> {r.recommendation}")
75
+ return "\n".join(out)
ndvpi/history.py ADDED
@@ -0,0 +1,58 @@
1
+ """История метрик сети за сессию и экспорт в JSON/CSV."""
2
+ import json
3
+ import csv
4
+ import time
5
+ from pathlib import Path
6
+
7
+ class SessionHistory:
8
+ def __init__(self):
9
+ self.records = []
10
+
11
+ def add_ping(self, host, stats):
12
+ if stats:
13
+ self.records.append({"ts": time.time(), "type": "ping", "host": host, "data": stats})
14
+
15
+ def add_speed(self, download_mbps, upload_mbps):
16
+ self.records.append({"ts": time.time(), "type": "speed", "data": {"download": download_mbps, "upload": upload_mbps}})
17
+
18
+ def clear(self):
19
+ self.records.clear()
20
+
21
+ def summary(self):
22
+ pings = [r for r in self.records if r["type"] == "ping"]
23
+ speeds = [r for r in self.records if r["type"] == "speed"]
24
+ result = {"samples": len(self.records)}
25
+ if pings:
26
+ result.update({
27
+ "avg_ping_ms": round(sum(p["data"]["avg"] for p in pings) / len(pings), 2),
28
+ "avg_loss_pct": round(sum(p["data"]["loss"] for p in pings) / len(pings), 2),
29
+ "avg_jitter_ms": round(sum(p["data"]["jitter"] for p in pings) / len(pings), 2)
30
+ })
31
+ if speeds:
32
+ result.update({
33
+ "avg_download_mbps": round(sum(s["data"]["download"] for s in speeds) / len(speeds), 2),
34
+ "avg_upload_mbps": round(sum(s["data"]["upload"] for s in speeds) / len(speeds), 2)
35
+ })
36
+ return result
37
+
38
+ def export_json(self, filepath):
39
+ path = Path(filepath)
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ with open(path, "w", encoding="utf-8") as f:
42
+ json.dump(self.records, f, indent=2, ensure_ascii=False)
43
+ return str(path)
44
+
45
+ def export_csv(self, filepath):
46
+ path = Path(filepath)
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ with open(path, "w", encoding="utf-8", newline="") as f:
49
+ writer = csv.writer(f)
50
+ writer.writerow(["Timestamp", "Type", "Min/Avg/Download", "Max/Upload", "Loss/Jitter"])
51
+ for r in self.records:
52
+ if r["type"] == "ping":
53
+ d = r["data"]
54
+ writer.writerow([time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["ts"])), "Ping", f"{d['avg']:.1f} ms", f"{d['max']:.1f} ms", f"{d['loss']:.1f}% / {d['jitter']:.1f} ms"])
55
+ elif r["type"] == "speed":
56
+ d = r["data"]
57
+ writer.writerow([time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["ts"])), "Speed", f"{d['download']:.1f} Mbps", f"{d['upload']:.1f} Mbps", "-"])
58
+ return str(path)