399 lines
15 KiB
Python
399 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Callable, List
|
|
|
|
from PySide6.QtCore import Qt, QSettings
|
|
from PySide6.QtGui import QColor
|
|
from PySide6.QtWidgets import (
|
|
QCheckBox,
|
|
QDialog,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QPlainTextEdit,
|
|
QSpinBox,
|
|
QTableWidget,
|
|
QTableWidgetItem,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from api_client import DNSBenchmarkUpstream
|
|
from dashboard_controller import DashboardController
|
|
|
|
|
|
DEFAULT_UPSTREAMS = [
|
|
"94.140.14.14",
|
|
"94.140.14.15",
|
|
"94.140.15.15",
|
|
"94.140.15.16",
|
|
"1.1.1.1",
|
|
"1.0.0.1",
|
|
"8.8.8.8",
|
|
"8.8.4.4",
|
|
"208.67.222.222",
|
|
"208.67.220.220",
|
|
"76.76.2.0",
|
|
"76.76.10.0",
|
|
]
|
|
|
|
DEFAULT_DOMAINS = [
|
|
"cloudflare.com",
|
|
"google.com",
|
|
"github.com",
|
|
"telegram.org",
|
|
"youtube.com",
|
|
"twitter.com",
|
|
]
|
|
|
|
|
|
class DNSBenchmarkDialog(QDialog):
|
|
def __init__(
|
|
self,
|
|
ctrl: DashboardController,
|
|
settings: QSettings,
|
|
refresh_cb: Callable[[], None] | None = None,
|
|
parent: QWidget | None = None,
|
|
) -> None:
|
|
super().__init__(parent)
|
|
self.ctrl = ctrl
|
|
self.settings = settings
|
|
self.refresh_cb = refresh_cb
|
|
|
|
self.setWindowTitle("DNS benchmark")
|
|
self.resize(980, 660)
|
|
|
|
root = QVBoxLayout(self)
|
|
|
|
hint = QLabel(
|
|
"One DNS per row. Checkbox means ACTIVE for resolver wave mode. "
|
|
"Benchmark checks all rows and shows health. Resolver applies up to 12 active DNS."
|
|
)
|
|
hint.setWordWrap(True)
|
|
hint.setStyleSheet("color: gray;")
|
|
root.addWidget(hint)
|
|
|
|
self.tbl_sources = QTableWidget(0, 2)
|
|
self.tbl_sources.setHorizontalHeaderLabels(["Active", "DNS upstream"])
|
|
self.tbl_sources.horizontalHeader().setStretchLastSection(True)
|
|
self.tbl_sources.setSelectionBehavior(QTableWidget.SelectRows)
|
|
self.tbl_sources.setSelectionMode(QTableWidget.SingleSelection)
|
|
self.tbl_sources.itemChanged.connect(self._on_sources_changed)
|
|
root.addWidget(self.tbl_sources, stretch=2)
|
|
|
|
row_btns = QHBoxLayout()
|
|
self.btn_add = QPushButton("Add DNS")
|
|
self.btn_add.clicked.connect(self.on_add_dns)
|
|
row_btns.addWidget(self.btn_add)
|
|
self.btn_remove = QPushButton("Remove selected")
|
|
self.btn_remove.clicked.connect(self.on_remove_selected)
|
|
row_btns.addWidget(self.btn_remove)
|
|
self.btn_reset = QPushButton("Reset defaults")
|
|
self.btn_reset.clicked.connect(self.on_reset_defaults)
|
|
row_btns.addWidget(self.btn_reset)
|
|
self.btn_save_pool = QPushButton("Apply resolver DNS")
|
|
self.btn_save_pool.clicked.connect(self.on_apply_resolver_dns)
|
|
row_btns.addWidget(self.btn_save_pool)
|
|
row_btns.addStretch(1)
|
|
root.addLayout(row_btns)
|
|
|
|
self.txt_domains = QPlainTextEdit()
|
|
self.txt_domains.setPlaceholderText(
|
|
"Test domains (one per line)\n"
|
|
"Example:\n"
|
|
"cloudflare.com\n"
|
|
"google.com\n"
|
|
"telegram.org"
|
|
)
|
|
self.txt_domains.textChanged.connect(self._on_domains_changed)
|
|
self.txt_domains.setFixedHeight(120)
|
|
root.addWidget(self.txt_domains)
|
|
|
|
opts = QHBoxLayout()
|
|
self.spin_timeout = QSpinBox()
|
|
self.spin_timeout.setRange(300, 5000)
|
|
self.spin_timeout.setValue(1800)
|
|
self.spin_timeout.setSuffix(" ms")
|
|
opts.addWidget(QLabel("Timeout:"))
|
|
opts.addWidget(self.spin_timeout)
|
|
|
|
self.spin_attempts = QSpinBox()
|
|
self.spin_attempts.setRange(1, 3)
|
|
self.spin_attempts.setValue(1)
|
|
opts.addWidget(QLabel("Attempts/domain:"))
|
|
opts.addWidget(self.spin_attempts)
|
|
|
|
self.spin_concurrency = QSpinBox()
|
|
self.spin_concurrency.setRange(1, 32)
|
|
self.spin_concurrency.setValue(6)
|
|
opts.addWidget(QLabel("Parallel DNS checks:"))
|
|
opts.addWidget(self.spin_concurrency)
|
|
|
|
self.chk_load_profile = QCheckBox("Load profile (realistic)")
|
|
self.chk_load_profile.setChecked(True)
|
|
self.chk_load_profile.setToolTip(
|
|
"EN: Load profile adds synthetic NX probes and burst rounds to simulate resolver pressure.\n"
|
|
"RU: Load-профиль добавляет synthetic NX-пробы и burst-раунды, чтобы симулировать нагрузку резолвера."
|
|
)
|
|
opts.addWidget(self.chk_load_profile)
|
|
|
|
self.btn_run = QPushButton("Run benchmark")
|
|
self.btn_run.clicked.connect(self.on_run_benchmark)
|
|
opts.addWidget(self.btn_run)
|
|
opts.addStretch(1)
|
|
root.addLayout(opts)
|
|
|
|
self.lbl_summary = QLabel("No benchmark yet")
|
|
self.lbl_summary.setStyleSheet("color: gray;")
|
|
root.addWidget(self.lbl_summary)
|
|
|
|
self.tbl_results = QTableWidget(0, 7)
|
|
self.tbl_results.setHorizontalHeaderLabels(
|
|
["DNS", "OK/Fail", "Avg/P95", "Timeout", "NX", "Score", "Status"]
|
|
)
|
|
self.tbl_results.horizontalHeader().setStretchLastSection(True)
|
|
self.tbl_results.setEditTriggers(QTableWidget.NoEditTriggers)
|
|
self.tbl_results.setSelectionBehavior(QTableWidget.SelectRows)
|
|
self.tbl_results.setSelectionMode(QTableWidget.SingleSelection)
|
|
root.addWidget(self.tbl_results, stretch=3)
|
|
|
|
close_row = QHBoxLayout()
|
|
close_row.addStretch(1)
|
|
self.btn_close = QPushButton("Close")
|
|
self.btn_close.clicked.connect(self.accept)
|
|
close_row.addWidget(self.btn_close)
|
|
root.addLayout(close_row)
|
|
|
|
self._load_sources()
|
|
self._load_domains()
|
|
|
|
def _safe(self, fn, title: str) -> None:
|
|
try:
|
|
fn()
|
|
except Exception as e:
|
|
QMessageBox.critical(self, title, str(e))
|
|
|
|
def _load_sources(self) -> None:
|
|
rows: List[tuple[bool, str]] = []
|
|
|
|
# Priority 1: backend upstream pool
|
|
try:
|
|
st = self.ctrl.dns_upstream_pool_view()
|
|
for item in st.items:
|
|
addr = str(item.addr or "").strip()
|
|
if not addr:
|
|
continue
|
|
rows.append((bool(item.enabled), addr))
|
|
except Exception:
|
|
rows = []
|
|
|
|
# Priority 2: local settings cache (when backend is not available)
|
|
if not rows:
|
|
raw = str(self.settings.value("dns_benchmark/upstreams", "") or "").strip()
|
|
if raw:
|
|
try:
|
|
data = json.loads(raw)
|
|
if isinstance(data, list):
|
|
for item in data:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
addr = str(item.get("addr") or "").strip()
|
|
if not addr:
|
|
continue
|
|
rows.append((bool(item.get("enabled", True)), addr))
|
|
except Exception:
|
|
rows = []
|
|
|
|
if not rows:
|
|
rows = [(True, item) for item in DEFAULT_UPSTREAMS]
|
|
|
|
# Keep developer defaults always visible in list.
|
|
merged: List[tuple[bool, str]] = []
|
|
seen = set()
|
|
by_addr = {addr: enabled for enabled, addr in rows}
|
|
for addr in DEFAULT_UPSTREAMS:
|
|
merged.append((bool(by_addr.get(addr, False)), addr))
|
|
seen.add(addr)
|
|
for enabled, addr in rows:
|
|
if addr in seen:
|
|
continue
|
|
merged.append((enabled, addr))
|
|
rows = merged
|
|
|
|
self.tbl_sources.blockSignals(True)
|
|
self.tbl_sources.setRowCount(0)
|
|
for enabled, addr in rows:
|
|
self._append_source_row(enabled, addr)
|
|
self.tbl_sources.blockSignals(False)
|
|
self._save_settings()
|
|
|
|
def _load_domains(self) -> None:
|
|
raw = str(self.settings.value("dns_benchmark/domains", "") or "").strip()
|
|
if not raw:
|
|
raw = "\n".join(DEFAULT_DOMAINS)
|
|
self.txt_domains.blockSignals(True)
|
|
self.txt_domains.setPlainText(raw)
|
|
self.txt_domains.blockSignals(False)
|
|
|
|
def _save_settings(self) -> None:
|
|
items = []
|
|
for i in range(self.tbl_sources.rowCount()):
|
|
ck = self.tbl_sources.item(i, 0)
|
|
addr = self.tbl_sources.item(i, 1)
|
|
if not ck or not addr:
|
|
continue
|
|
val = str(addr.text() or "").strip()
|
|
if not val:
|
|
continue
|
|
items.append({"enabled": ck.checkState() == Qt.Checked, "addr": val})
|
|
self.settings.setValue("dns_benchmark/upstreams", json.dumps(items, ensure_ascii=True))
|
|
self.settings.setValue("dns_benchmark/domains", self.txt_domains.toPlainText().strip())
|
|
|
|
def _on_sources_changed(self, _item: QTableWidgetItem) -> None:
|
|
self._save_settings()
|
|
|
|
def _on_domains_changed(self) -> None:
|
|
self._save_settings()
|
|
|
|
def _append_source_row(self, enabled: bool, addr: str) -> None:
|
|
row = self.tbl_sources.rowCount()
|
|
self.tbl_sources.insertRow(row)
|
|
ck = QTableWidgetItem("")
|
|
ck.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
|
ck.setCheckState(Qt.Checked if enabled else Qt.Unchecked)
|
|
self.tbl_sources.setItem(row, 0, ck)
|
|
it = QTableWidgetItem(addr)
|
|
it.setFlags(it.flags() | Qt.ItemIsEditable)
|
|
self.tbl_sources.setItem(row, 1, it)
|
|
|
|
def _source_payload(self) -> List[DNSBenchmarkUpstream]:
|
|
out: List[DNSBenchmarkUpstream] = []
|
|
for i in range(self.tbl_sources.rowCount()):
|
|
ck = self.tbl_sources.item(i, 0)
|
|
addr = self.tbl_sources.item(i, 1)
|
|
if not ck or not addr:
|
|
continue
|
|
val = str(addr.text() or "").strip()
|
|
if not val:
|
|
continue
|
|
out.append(DNSBenchmarkUpstream(addr=val, enabled=(ck.checkState() == Qt.Checked)))
|
|
return out
|
|
|
|
def _domains_payload(self) -> List[str]:
|
|
out: List[str] = []
|
|
seen = set()
|
|
for ln in self.txt_domains.toPlainText().splitlines():
|
|
d = str(ln or "").strip().lower().rstrip(".")
|
|
if not d or d.startswith("#") or d in seen:
|
|
continue
|
|
seen.add(d)
|
|
out.append(d)
|
|
return out
|
|
|
|
def on_add_dns(self) -> None:
|
|
self._append_source_row(True, "")
|
|
self._save_settings()
|
|
|
|
def on_remove_selected(self) -> None:
|
|
row = self.tbl_sources.currentRow()
|
|
if row >= 0:
|
|
self.tbl_sources.removeRow(row)
|
|
self._save_settings()
|
|
|
|
def on_reset_defaults(self) -> None:
|
|
btn = QMessageBox.question(
|
|
self,
|
|
"Reset DNS defaults",
|
|
"Reset list to 12 developer default DNS entries?\n"
|
|
"This does not apply to resolver until you click 'Apply resolver DNS'.",
|
|
QMessageBox.Yes | QMessageBox.No,
|
|
QMessageBox.No,
|
|
)
|
|
if btn != QMessageBox.Yes:
|
|
return
|
|
|
|
self.tbl_sources.blockSignals(True)
|
|
self.tbl_sources.setRowCount(0)
|
|
for item in DEFAULT_UPSTREAMS:
|
|
self._append_source_row(True, item)
|
|
self.tbl_sources.blockSignals(False)
|
|
self._save_settings()
|
|
|
|
def on_apply_resolver_dns(self) -> None:
|
|
def work() -> None:
|
|
payload = self._source_payload()
|
|
st = self.ctrl.dns_upstream_pool_save(payload)
|
|
active = sum(1 for x in st.items if x.enabled)
|
|
total = len(st.items)
|
|
applied = active if active < 12 else 12
|
|
self._save_settings()
|
|
self.lbl_summary.setText(f"Applied resolver DNS: active={active}/{total}, applied={applied}/12")
|
|
self.lbl_summary.setStyleSheet("color: green;")
|
|
if self.refresh_cb:
|
|
self.refresh_cb()
|
|
|
|
self._safe(work, "Apply resolver DNS error")
|
|
|
|
def on_run_benchmark(self) -> None:
|
|
def work() -> None:
|
|
self._save_settings()
|
|
payload = self._source_payload()
|
|
domains = self._domains_payload()
|
|
resp = self.ctrl.dns_benchmark(
|
|
upstreams=payload,
|
|
domains=domains,
|
|
timeout_ms=int(self.spin_timeout.value()),
|
|
attempts=int(self.spin_attempts.value()),
|
|
concurrency=int(self.spin_concurrency.value()),
|
|
profile="load" if self.chk_load_profile.isChecked() else "quick",
|
|
)
|
|
self._render_results(resp)
|
|
if self.refresh_cb:
|
|
self.refresh_cb()
|
|
|
|
self._safe(work, "DNS benchmark error")
|
|
|
|
def _render_results(self, resp) -> None:
|
|
self.tbl_results.setRowCount(0)
|
|
ok_total = 0
|
|
fail_total = 0
|
|
timeout_total = 0
|
|
for row_data in (resp.results or []):
|
|
ok_total += int(row_data.ok or 0)
|
|
fail_total += int(row_data.fail or 0)
|
|
timeout_total += int(row_data.timeout or 0)
|
|
row = self.tbl_results.rowCount()
|
|
self.tbl_results.insertRow(row)
|
|
self.tbl_results.setItem(row, 0, QTableWidgetItem(row_data.upstream))
|
|
self.tbl_results.setItem(row, 1, QTableWidgetItem(f"{row_data.ok}/{row_data.fail}"))
|
|
self.tbl_results.setItem(row, 2, QTableWidgetItem(f"{row_data.avg_ms} / {row_data.p95_ms} ms"))
|
|
self.tbl_results.setItem(row, 3, QTableWidgetItem(str(row_data.timeout)))
|
|
self.tbl_results.setItem(row, 4, QTableWidgetItem(str(row_data.nxdomain)))
|
|
self.tbl_results.setItem(row, 5, QTableWidgetItem(f"{row_data.score:.1f}"))
|
|
status = row_data.color or "unknown"
|
|
st_item = QTableWidgetItem(status)
|
|
low = status.lower()
|
|
if low == "green":
|
|
st_item.setForeground(QColor("green"))
|
|
elif low in ("yellow", "orange"):
|
|
st_item.setForeground(QColor("#b58900"))
|
|
else:
|
|
st_item.setForeground(QColor("red"))
|
|
self.tbl_results.setItem(row, 6, st_item)
|
|
|
|
self.lbl_summary.setText(
|
|
f"Checked: {len(resp.results)} DNS | domains={len(resp.domains_used)} "
|
|
f"| timeout={resp.timeout_ms}ms | profile={str(getattr(resp, 'profile', '') or 'load')}"
|
|
)
|
|
self.lbl_summary.setStyleSheet("color: gray;")
|
|
|
|
avg_values = [int(r.avg_ms or 0) for r in (resp.results or []) if int(r.ok or 0) > 0 and int(r.avg_ms or 0) > 0]
|
|
avg_all = int(sum(avg_values) / len(avg_values)) if avg_values else 0
|
|
self.settings.setValue("dns_benchmark/last_avg_ms", avg_all)
|
|
self.settings.setValue("dns_benchmark/last_ok", ok_total)
|
|
self.settings.setValue("dns_benchmark/last_fail", fail_total)
|
|
self.settings.setValue("dns_benchmark/last_timeout", timeout_total)
|