#!/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 ( 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." ) 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_reload = QPushButton("Reload active set") self.btn_reload.clicked.connect(self.on_reload_pool) row_btns.addWidget(self.btn_reload) self.btn_save_pool = QPushButton("Save active set") self.btn_save_pool.clicked.connect(self.on_save_pool) 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.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]] = [] 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 = [] 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] 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: 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_reload_pool(self) -> None: self._safe(self._load_sources, "Reload DNS active set error") def on_save_pool(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) self._save_settings() self.lbl_summary.setText(f"Saved active DNS set: active={active}/{total}") self.lbl_summary.setStyleSheet("color: green;") if self.refresh_cb: self.refresh_cb() self._safe(work, "Save DNS active set 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()), ) 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" ) 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)