dns ui: compact tab + benchmark dialog and api endpoint

This commit is contained in:
beckline
2026-02-22 14:40:40 +03:00
parent 0b28586f31
commit a7ec4fe801
7 changed files with 1089 additions and 50 deletions

View File

@@ -90,7 +90,11 @@ class TrafficModeStatus:
desired_mode: str
applied_mode: str
preferred_iface: str
advanced_active: bool
auto_local_bypass: bool
auto_local_active: bool
ingress_reply_bypass: bool
ingress_reply_active: bool
bypass_candidates: int
force_vpn_subnets: List[str]
force_vpn_uids: List[str]
@@ -105,6 +109,8 @@ class TrafficModeStatus:
iface_reason: str
rule_mark: bool
rule_full: bool
ingress_rule_present: bool
ingress_nft_active: bool
table_default: bool
probe_ok: bool
probe_message: str
@@ -221,6 +227,38 @@ class DnsUpstreams:
meta2: str
@dataclass(frozen=True)
class DNSBenchmarkUpstream:
addr: str
enabled: bool = True
@dataclass(frozen=True)
class DNSBenchmarkResult:
upstream: str
attempts: int
ok: int
fail: int
nxdomain: int
timeout: int
temporary: int
other: int
avg_ms: int
p95_ms: int
score: float
color: str
@dataclass(frozen=True)
class DNSBenchmarkResponse:
results: List[DNSBenchmarkResult]
domains_used: List[str]
timeout_ms: int
attempts_per_domain: int
recommended_default: List[str]
recommended_meta: List[str]
@dataclass(frozen=True)
class SmartdnsServiceState:
state: str
@@ -654,7 +692,11 @@ class ApiClient:
desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"),
applied_mode=str(data.get("applied_mode") or "direct"),
preferred_iface=str(data.get("preferred_iface") or ""),
advanced_active=bool(data.get("advanced_active", False)),
auto_local_bypass=bool(data.get("auto_local_bypass", True)),
auto_local_active=bool(data.get("auto_local_active", False)),
ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)),
ingress_reply_active=bool(data.get("ingress_reply_active", False)),
bypass_candidates=int(data.get("bypass_candidates", 0) or 0),
force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()],
force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()],
@@ -669,6 +711,8 @@ class ApiClient:
iface_reason=str(data.get("iface_reason") or ""),
rule_mark=bool(data.get("rule_mark", False)),
rule_full=bool(data.get("rule_full", False)),
ingress_rule_present=bool(data.get("ingress_rule_present", False)),
ingress_nft_active=bool(data.get("ingress_nft_active", False)),
table_default=bool(data.get("table_default", False)),
probe_ok=bool(data.get("probe_ok", False)),
probe_message=str(data.get("probe_message") or ""),
@@ -681,6 +725,7 @@ class ApiClient:
mode: str,
preferred_iface: Optional[str] = None,
auto_local_bypass: Optional[bool] = None,
ingress_reply_bypass: Optional[bool] = None,
force_vpn_subnets: Optional[List[str]] = None,
force_vpn_uids: Optional[List[str]] = None,
force_vpn_cgroups: Optional[List[str]] = None,
@@ -696,6 +741,8 @@ class ApiClient:
payload["preferred_iface"] = str(preferred_iface).strip()
if auto_local_bypass is not None:
payload["auto_local_bypass"] = bool(auto_local_bypass)
if ingress_reply_bypass is not None:
payload["ingress_reply_bypass"] = bool(ingress_reply_bypass)
if force_vpn_subnets is not None:
payload["force_vpn_subnets"] = [str(x) for x in force_vpn_subnets]
if force_vpn_uids is not None:
@@ -724,7 +771,11 @@ class ApiClient:
desired_mode=str(data.get("desired_mode") or data.get("mode") or m),
applied_mode=str(data.get("applied_mode") or "direct"),
preferred_iface=str(data.get("preferred_iface") or ""),
advanced_active=bool(data.get("advanced_active", False)),
auto_local_bypass=bool(data.get("auto_local_bypass", True)),
auto_local_active=bool(data.get("auto_local_active", False)),
ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)),
ingress_reply_active=bool(data.get("ingress_reply_active", False)),
bypass_candidates=int(data.get("bypass_candidates", 0) or 0),
force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()],
force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()],
@@ -739,6 +790,8 @@ class ApiClient:
iface_reason=str(data.get("iface_reason") or ""),
rule_mark=bool(data.get("rule_mark", False)),
rule_full=bool(data.get("rule_full", False)),
ingress_rule_present=bool(data.get("ingress_rule_present", False)),
ingress_nft_active=bool(data.get("ingress_nft_active", False)),
table_default=bool(data.get("table_default", False)),
probe_ok=bool(data.get("probe_ok", False)),
probe_message=str(data.get("probe_message") or ""),
@@ -756,7 +809,11 @@ class ApiClient:
desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"),
applied_mode=str(data.get("applied_mode") or "direct"),
preferred_iface=str(data.get("preferred_iface") or ""),
advanced_active=bool(data.get("advanced_active", False)),
auto_local_bypass=bool(data.get("auto_local_bypass", True)),
auto_local_active=bool(data.get("auto_local_active", False)),
ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)),
ingress_reply_active=bool(data.get("ingress_reply_active", False)),
bypass_candidates=int(data.get("bypass_candidates", 0) or 0),
force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()],
force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()],
@@ -771,6 +828,46 @@ class ApiClient:
iface_reason=str(data.get("iface_reason") or ""),
rule_mark=bool(data.get("rule_mark", False)),
rule_full=bool(data.get("rule_full", False)),
ingress_rule_present=bool(data.get("ingress_rule_present", False)),
ingress_nft_active=bool(data.get("ingress_nft_active", False)),
table_default=bool(data.get("table_default", False)),
probe_ok=bool(data.get("probe_ok", False)),
probe_message=str(data.get("probe_message") or ""),
healthy=bool(data.get("healthy", False)),
message=str(data.get("message") or ""),
)
def traffic_advanced_reset(self) -> TrafficModeStatus:
data = cast(
Dict[str, Any],
self._json(self._request("POST", "/api/v1/traffic/advanced/reset")) or {},
)
return TrafficModeStatus(
mode=str(data.get("mode") or "selective"),
desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"),
applied_mode=str(data.get("applied_mode") or "direct"),
preferred_iface=str(data.get("preferred_iface") or ""),
advanced_active=bool(data.get("advanced_active", False)),
auto_local_bypass=bool(data.get("auto_local_bypass", True)),
auto_local_active=bool(data.get("auto_local_active", False)),
ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)),
ingress_reply_active=bool(data.get("ingress_reply_active", False)),
bypass_candidates=int(data.get("bypass_candidates", 0) or 0),
force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()],
force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()],
force_vpn_cgroups=[str(x) for x in (data.get("force_vpn_cgroups") or []) if str(x).strip()],
force_direct_subnets=[str(x) for x in (data.get("force_direct_subnets") or []) if str(x).strip()],
force_direct_uids=[str(x) for x in (data.get("force_direct_uids") or []) if str(x).strip()],
force_direct_cgroups=[str(x) for x in (data.get("force_direct_cgroups") or []) if str(x).strip()],
overrides_applied=int(data.get("overrides_applied", 0) or 0),
cgroup_resolved_uids=int(data.get("cgroup_resolved_uids", 0) or 0),
cgroup_warning=str(data.get("cgroup_warning") or ""),
active_iface=str(data.get("active_iface") or ""),
iface_reason=str(data.get("iface_reason") or ""),
rule_mark=bool(data.get("rule_mark", False)),
rule_full=bool(data.get("rule_full", False)),
ingress_rule_present=bool(data.get("ingress_rule_present", False)),
ingress_nft_active=bool(data.get("ingress_nft_active", False)),
table_default=bool(data.get("table_default", False)),
probe_ok=bool(data.get("probe_ok", False)),
probe_message=str(data.get("probe_message") or ""),
@@ -1088,6 +1185,63 @@ class ApiClient:
},
)
def dns_benchmark(
self,
upstreams: List[DNSBenchmarkUpstream],
domains: List[str],
timeout_ms: int = 1800,
attempts: int = 1,
concurrency: int = 6,
) -> DNSBenchmarkResponse:
data = cast(
Dict[str, Any],
self._json(
self._request(
"POST",
"/api/v1/dns/benchmark",
json_body={
"upstreams": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (upstreams or [])],
"domains": [str(d or "").strip() for d in (domains or []) if str(d or "").strip()],
"timeout_ms": int(timeout_ms),
"attempts": int(attempts),
"concurrency": int(concurrency),
},
)
)
or {},
)
raw_results = data.get("results") or []
if not isinstance(raw_results, list):
raw_results = []
results: List[DNSBenchmarkResult] = []
for row in raw_results:
if not isinstance(row, dict):
continue
results.append(
DNSBenchmarkResult(
upstream=str(row.get("upstream") or "").strip(),
attempts=int(row.get("attempts", 0) or 0),
ok=int(row.get("ok", 0) or 0),
fail=int(row.get("fail", 0) or 0),
nxdomain=int(row.get("nxdomain", 0) or 0),
timeout=int(row.get("timeout", 0) or 0),
temporary=int(row.get("temporary", 0) or 0),
other=int(row.get("other", 0) or 0),
avg_ms=int(row.get("avg_ms", 0) or 0),
p95_ms=int(row.get("p95_ms", 0) or 0),
score=float(row.get("score", 0.0) or 0.0),
color=str(row.get("color") or "").strip().lower(),
)
)
return DNSBenchmarkResponse(
results=results,
domains_used=[str(d or "").strip() for d in (data.get("domains_used") or []) if str(d or "").strip()],
timeout_ms=int(data.get("timeout_ms", 0) or 0),
attempts_per_domain=int(data.get("attempts_per_domain", 0) or 0),
recommended_default=[str(d or "").strip() for d in (data.get("recommended_default") or []) if str(d or "").strip()],
recommended_meta=[str(d or "").strip() for d in (data.get("recommended_meta") or []) if str(d or "").strip()],
)
def dns_status_get(self) -> DNSStatus:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/status")) or {})
return self._parse_dns_status(data)

View File

@@ -24,6 +24,8 @@ _NEXT_CHECK_RE = re.compile(
from api_client import (
ApiClient,
CmdResult,
DNSBenchmarkResponse,
DNSBenchmarkUpstream,
DNSStatus,
DnsUpstreams,
DomainsFile,
@@ -128,7 +130,11 @@ class TrafficModeView:
desired_mode: str
applied_mode: str
preferred_iface: str
advanced_active: bool
auto_local_bypass: bool
auto_local_active: bool
ingress_reply_bypass: bool
ingress_reply_active: bool
bypass_candidates: int
force_vpn_subnets: List[str]
force_vpn_uids: List[str]
@@ -141,6 +147,8 @@ class TrafficModeView:
cgroup_warning: str
active_iface: str
iface_reason: str
ingress_rule_present: bool
ingress_nft_active: bool
probe_ok: bool
probe_message: str
healthy: bool
@@ -614,7 +622,11 @@ class DashboardController:
desired_mode=(st.desired_mode or st.mode or "selective"),
applied_mode=(st.applied_mode or "direct"),
preferred_iface=st.preferred_iface or "",
advanced_active=bool(st.advanced_active),
auto_local_bypass=bool(st.auto_local_bypass),
auto_local_active=bool(st.auto_local_active),
ingress_reply_bypass=bool(st.ingress_reply_bypass),
ingress_reply_active=bool(st.ingress_reply_active),
bypass_candidates=int(st.bypass_candidates),
force_vpn_subnets=list(st.force_vpn_subnets or []),
force_vpn_uids=list(st.force_vpn_uids or []),
@@ -627,6 +639,8 @@ class DashboardController:
cgroup_warning=st.cgroup_warning or "",
active_iface=st.active_iface or "",
iface_reason=st.iface_reason or "",
ingress_rule_present=bool(st.ingress_rule_present),
ingress_nft_active=bool(st.ingress_nft_active),
probe_ok=bool(st.probe_ok),
probe_message=st.probe_message or "",
healthy=bool(st.healthy),
@@ -638,6 +652,7 @@ class DashboardController:
mode: str,
preferred_iface: Optional[str] = None,
auto_local_bypass: Optional[bool] = None,
ingress_reply_bypass: Optional[bool] = None,
force_vpn_subnets: Optional[List[str]] = None,
force_vpn_uids: Optional[List[str]] = None,
force_vpn_cgroups: Optional[List[str]] = None,
@@ -649,6 +664,7 @@ class DashboardController:
mode,
preferred_iface,
auto_local_bypass,
ingress_reply_bypass,
force_vpn_subnets,
force_vpn_uids,
force_vpn_cgroups,
@@ -660,7 +676,11 @@ class DashboardController:
desired_mode=(st.desired_mode or st.mode or mode),
applied_mode=(st.applied_mode or "direct"),
preferred_iface=st.preferred_iface or "",
advanced_active=bool(st.advanced_active),
auto_local_bypass=bool(st.auto_local_bypass),
auto_local_active=bool(st.auto_local_active),
ingress_reply_bypass=bool(st.ingress_reply_bypass),
ingress_reply_active=bool(st.ingress_reply_active),
bypass_candidates=int(st.bypass_candidates),
force_vpn_subnets=list(st.force_vpn_subnets or []),
force_vpn_uids=list(st.force_vpn_uids or []),
@@ -673,6 +693,8 @@ class DashboardController:
cgroup_warning=st.cgroup_warning or "",
active_iface=st.active_iface or "",
iface_reason=st.iface_reason or "",
ingress_rule_present=bool(st.ingress_rule_present),
ingress_nft_active=bool(st.ingress_nft_active),
probe_ok=bool(st.probe_ok),
probe_message=st.probe_message or "",
healthy=bool(st.healthy),
@@ -685,7 +707,11 @@ class DashboardController:
desired_mode=(st.desired_mode or st.mode or "selective"),
applied_mode=(st.applied_mode or "direct"),
preferred_iface=st.preferred_iface or "",
advanced_active=bool(st.advanced_active),
auto_local_bypass=bool(st.auto_local_bypass),
auto_local_active=bool(st.auto_local_active),
ingress_reply_bypass=bool(st.ingress_reply_bypass),
ingress_reply_active=bool(st.ingress_reply_active),
bypass_candidates=int(st.bypass_candidates),
force_vpn_subnets=list(st.force_vpn_subnets or []),
force_vpn_uids=list(st.force_vpn_uids or []),
@@ -698,6 +724,39 @@ class DashboardController:
cgroup_warning=st.cgroup_warning or "",
active_iface=st.active_iface or "",
iface_reason=st.iface_reason or "",
ingress_rule_present=bool(st.ingress_rule_present),
ingress_nft_active=bool(st.ingress_nft_active),
probe_ok=bool(st.probe_ok),
probe_message=st.probe_message or "",
healthy=bool(st.healthy),
message=st.message or "",
)
def traffic_advanced_reset(self) -> TrafficModeView:
st: TrafficModeStatus = self.client.traffic_advanced_reset()
return TrafficModeView(
desired_mode=(st.desired_mode or st.mode or "selective"),
applied_mode=(st.applied_mode or "direct"),
preferred_iface=st.preferred_iface or "",
advanced_active=bool(st.advanced_active),
auto_local_bypass=bool(st.auto_local_bypass),
auto_local_active=bool(st.auto_local_active),
ingress_reply_bypass=bool(st.ingress_reply_bypass),
ingress_reply_active=bool(st.ingress_reply_active),
bypass_candidates=int(st.bypass_candidates),
force_vpn_subnets=list(st.force_vpn_subnets or []),
force_vpn_uids=list(st.force_vpn_uids or []),
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
force_direct_subnets=list(st.force_direct_subnets or []),
force_direct_uids=list(st.force_direct_uids or []),
force_direct_cgroups=list(st.force_direct_cgroups or []),
overrides_applied=int(st.overrides_applied),
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
cgroup_warning=st.cgroup_warning or "",
active_iface=st.active_iface or "",
iface_reason=st.iface_reason or "",
ingress_rule_present=bool(st.ingress_rule_present),
ingress_nft_active=bool(st.ingress_nft_active),
probe_ok=bool(st.probe_ok),
probe_message=st.probe_message or "",
healthy=bool(st.healthy),
@@ -811,6 +870,22 @@ class DashboardController:
def dns_upstreams_save(self, cfg: DnsUpstreams) -> None:
self.client.dns_upstreams_set(cfg)
def dns_benchmark(
self,
upstreams: List[DNSBenchmarkUpstream],
domains: List[str],
timeout_ms: int = 1800,
attempts: int = 1,
concurrency: int = 6,
) -> DNSBenchmarkResponse:
return self.client.dns_benchmark(
upstreams=upstreams,
domains=domains,
timeout_ms=timeout_ms,
attempts=attempts,
concurrency=concurrency,
)
def dns_status_view(self) -> DNSStatus:
return self.client.dns_status_get()

View File

@@ -0,0 +1,376 @@
#!/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, DnsUpstreams
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._last_recommended_default: List[str] = []
self._last_recommended_meta: List[str] = []
self.setWindowTitle("DNS benchmark")
self.resize(980, 650)
root = QVBoxLayout(self)
hint = QLabel(
"List format: one DNS per row. Toggle checkbox to include in test. "
"Then run benchmark and apply best DNS to resolver."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: gray;")
root.addWidget(hint)
self.tbl_sources = QTableWidget(0, 2)
self.tbl_sources.setHorizontalHeaderLabels(["Use", "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)
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)
apply_row = QHBoxLayout()
self.btn_apply_default = QPushButton("Apply top-2 to Default")
self.btn_apply_default.clicked.connect(self.on_apply_default)
apply_row.addWidget(self.btn_apply_default)
self.btn_apply_meta = QPushButton("Apply top-2 to Meta")
self.btn_apply_meta.clicked.connect(self.on_apply_meta)
apply_row.addWidget(self.btn_apply_meta)
apply_row.addStretch(1)
self.btn_close = QPushButton("Close")
self.btn_close.clicked.connect(self.accept)
apply_row.addWidget(self.btn_close)
root.addLayout(apply_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:
raw = str(self.settings.value("dns_benchmark/upstreams", "") or "").strip()
rows: List[tuple[bool, str]] = []
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.setRowCount(0)
for enabled, addr in rows:
self._append_source_row(enabled, addr)
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.setPlainText(raw)
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.setRowCount(0)
for item in DEFAULT_UPSTREAMS:
self._append_source_row(True, item)
self._save_settings()
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._last_recommended_default = list(resp.recommended_default or [])
self._last_recommended_meta = list(resp.recommended_meta or [])
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)
dflt = ", ".join(resp.recommended_default or []) or ""
meta = ", ".join(resp.recommended_meta or []) or ""
self.lbl_summary.setText(
f"Checked: {len(resp.results)} DNS | domains={len(resp.domains_used)} "
f"| timeout={resp.timeout_ms}ms | rec default: {dflt} | rec meta: {meta}"
)
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)
def on_apply_default(self) -> None:
def work() -> None:
picks = list(self._last_recommended_default or [])
if len(picks) < 2:
raise ValueError("run benchmark first (need at least 2 recommended DNS)")
cur = self.ctrl.dns_upstreams_view()
cfg = DnsUpstreams(
default1=picks[0],
default2=picks[1],
meta1=cur.meta1,
meta2=cur.meta2,
)
self.ctrl.dns_upstreams_save(cfg)
if self.refresh_cb:
self.refresh_cb()
self.lbl_summary.setText(f"Applied default DNS: {picks[0]}, {picks[1]}")
self.lbl_summary.setStyleSheet("color: green;")
self._safe(work, "Apply default DNS error")
def on_apply_meta(self) -> None:
def work() -> None:
picks = list(self._last_recommended_meta or [])
if len(picks) < 2:
raise ValueError("run benchmark first (need at least 2 recommended DNS)")
cur = self.ctrl.dns_upstreams_view()
cfg = DnsUpstreams(
default1=cur.default1,
default2=cur.default2,
meta1=picks[0],
meta2=picks[1],
)
self.ctrl.dns_upstreams_save(cfg)
if self.refresh_cb:
self.refresh_cb()
self.lbl_summary.setText(f"Applied meta DNS: {picks[0]}, {picks[1]}")
self.lbl_summary.setStyleSheet("color: green;")
self._safe(work, "Apply meta DNS error")

View File

@@ -36,6 +36,7 @@ from PySide6.QtWidgets import (
from api_client import ApiClient, DnsUpstreams
from dashboard_controller import DashboardController, TraceMode
from dns_benchmark_dialog import DNSBenchmarkDialog
from traffic_mode_dialog import TrafficModeDialog
_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s")
@@ -420,31 +421,27 @@ RU: Агрессивный режим дополнительно дергает
tip.setStyleSheet("color: gray;")
main_layout.addWidget(tip)
ups_group = QGroupBox("Upstreams (auto-save)")
ups_group.setToolTip("""EN: DNS upstreams for direct resolver mode (and non-wildcard lists in hybrid mode).
RU: DNS апстримы для direct-резолвера (и для не-wildcard списков в hybrid режиме).""")
ups_form = QFormLayout(ups_group)
self.ent_def1 = QLineEdit()
self.ent_def1.setToolTip("""EN: Upstream default1. You can set an IP (port 53 is assumed).
RU: Апстрим default1. Можно указать IP (порт 53 по умолчанию).""")
self.ent_def2 = QLineEdit()
self.ent_def2.setToolTip("""EN: Upstream default2. You can set an IP (port 53 is assumed).
RU: Апстрим default2. Можно указать IP (порт 53 по умолчанию).""")
self.ent_meta1 = QLineEdit()
self.ent_meta1.setToolTip("""EN: Upstream meta1. You can set an IP (port 53 is assumed).
RU: Апстрим meta1. Можно указать IP (порт 53 по умолчанию).""")
self.ent_meta2 = QLineEdit()
self.ent_meta2.setToolTip("""EN: Upstream meta2. You can set an IP (port 53 is assumed).
RU: Апстрим meta2. Можно указать IP (порт 53 по умолчанию).""")
self.ent_def1.textEdited.connect(self._schedule_dns_autosave)
self.ent_def2.textEdited.connect(self._schedule_dns_autosave)
self.ent_meta1.textEdited.connect(self._schedule_dns_autosave)
self.ent_meta2.textEdited.connect(self._schedule_dns_autosave)
ups_form.addRow("default1", self.ent_def1)
ups_form.addRow("default2", self.ent_def2)
ups_form.addRow("meta1", self.ent_meta1)
ups_form.addRow("meta2", self.ent_meta2)
main_layout.addWidget(ups_group)
resolver_group = QGroupBox("Resolver DNS")
resolver_group.setToolTip("""EN: Compact resolver DNS status. Open benchmark to test/apply upstreams.
RU: Компактный статус DNS резолвера. Открой benchmark для проверки/применения апстримов.""")
resolver_layout = QVBoxLayout(resolver_group)
row = QHBoxLayout()
self.btn_dns_benchmark = QPushButton("Open DNS benchmark")
self.btn_dns_benchmark.clicked.connect(self.on_open_dns_benchmark)
row.addWidget(self.btn_dns_benchmark)
row.addStretch(1)
resolver_layout.addLayout(row)
self.lbl_dns_resolver_upstreams = QLabel("Resolver upstreams: default[—, —] meta[—, —]")
self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;")
resolver_layout.addWidget(self.lbl_dns_resolver_upstreams)
self.lbl_dns_resolver_health = QLabel("Resolver health: —")
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
resolver_layout.addWidget(self.lbl_dns_resolver_health)
main_layout.addWidget(resolver_group)
smart_group = QGroupBox("SmartDNS")
smart_group.setToolTip("""EN: SmartDNS is used for wildcard domains in hybrid mode.
@@ -732,17 +729,58 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
self.lbl_dns_mode_state.setText(txt)
self.lbl_dns_mode_state.setStyleSheet(f"color: {color};")
def _set_dns_resolver_summary(self, ups: DnsUpstreams) -> None:
d1 = (ups.default1 or "").strip() or ""
d2 = (ups.default2 or "").strip() or ""
m1 = (ups.meta1 or "").strip() or ""
m2 = (ups.meta2 or "").strip() or ""
self.lbl_dns_resolver_upstreams.setText(
f"Resolver upstreams: default[{d1}, {d2}] meta[{m1}, {m2}]"
)
self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;")
avg_ms = self._ui_settings.value("dns_benchmark/last_avg_ms", None)
ok = self._ui_settings.value("dns_benchmark/last_ok", None)
fail = self._ui_settings.value("dns_benchmark/last_fail", None)
timeout = self._ui_settings.value("dns_benchmark/last_timeout", None)
if avg_ms is None or ok is None or fail is None:
self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet")
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
return
try:
avg = int(avg_ms)
ok_i = int(ok)
fail_i = int(fail)
timeout_i = int(timeout or 0)
except Exception:
self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet")
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
return
color = "green" if avg < 200 else ("#b58900" if avg <= 400 else "red")
if timeout_i > 0 and color != "red":
color = "#b58900"
self.lbl_dns_resolver_health.setText(
f"Resolver health: avg={avg}ms ok={ok_i} fail={fail_i} timeout={timeout_i}"
)
self.lbl_dns_resolver_health.setStyleSheet(f"color: {color};")
def _set_traffic_mode_state(
self,
desired_mode: str,
applied_mode: str,
preferred_iface: str,
advanced_active: bool,
auto_local_bypass: bool,
auto_local_active: bool,
ingress_reply_bypass: bool,
ingress_reply_active: bool,
bypass_candidates: int,
overrides_applied: int,
cgroup_resolved_uids: int,
cgroup_warning: str,
healthy: bool,
ingress_rule_present: bool,
ingress_nft_active: bool,
probe_ok: bool,
probe_message: str,
active_iface: str,
@@ -763,9 +801,17 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
diag_parts = []
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
diag_parts.append(
f"auto_local_bypass={'on' if auto_local_bypass else 'off'}"
f"advanced={'on' if advanced_active else 'off'}"
)
if bypass_candidates > 0:
diag_parts.append(
f"auto_local={'on' if auto_local_bypass else 'off'}"
f"({'active' if auto_local_active else 'saved'})"
)
diag_parts.append(
f"ingress_reply={'on' if ingress_reply_bypass else 'off'}"
f"({'active' if ingress_reply_active else 'saved'})"
)
if auto_local_active and bypass_candidates > 0:
diag_parts.append(f"bypass_routes={bypass_candidates}")
diag_parts.append(f"overrides={overrides_applied}")
if cgroup_resolved_uids > 0:
@@ -776,6 +822,10 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
diag_parts.append(f"iface={active_iface}")
if iface_reason:
diag_parts.append(f"source={iface_reason}")
diag_parts.append(
f"ingress_diag=rule:{'ok' if ingress_rule_present else 'off'}"
f"/nft:{'ok' if ingress_nft_active else 'off'}"
)
diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}")
if probe_message:
diag_parts.append(probe_message)
@@ -998,12 +1048,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
t.desired_mode,
t.applied_mode,
t.preferred_iface,
bool(t.advanced_active),
bool(t.auto_local_bypass),
bool(t.auto_local_active),
bool(t.ingress_reply_bypass),
bool(t.ingress_reply_active),
int(t.bypass_candidates),
int(t.overrides_applied),
int(t.cgroup_resolved_uids),
t.cgroup_warning,
bool(t.healthy),
bool(t.ingress_rule_present),
bool(t.ingress_nft_active),
bool(t.probe_ok),
t.probe_message,
t.active_iface,
@@ -1017,10 +1073,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
self._dns_ui_refresh = True
try:
ups = self.ctrl.dns_upstreams_view()
self.ent_def1.setText(ups.default1 or "")
self.ent_def2.setText(ups.default2 or "")
self.ent_meta1.setText(ups.meta1 or "")
self.ent_meta2.setText(ups.meta2 or "")
self._set_dns_resolver_summary(ups)
st = self.ctrl.dns_status_view()
self.ent_smartdns_addr.setText(st.smartdns_addr or "")
@@ -1037,12 +1090,6 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
self.chk_dns_via_smartdns.setChecked(hybrid_enabled)
self.chk_dns_via_smartdns.blockSignals(False)
# In direct + hybrid modes upstreams stay editable.
self.ent_def1.setEnabled(True)
self.ent_def2.setEnabled(True)
self.ent_meta1.setEnabled(True)
self.ent_meta2.setEnabled(True)
unit_state = (st.unit_state or "unknown").strip().lower()
unit_active = unit_state == "active"
self.chk_dns_unit_relay.blockSignals(True)
@@ -1386,13 +1433,6 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
def work():
if self._dns_ui_refresh:
return
ups = DnsUpstreams(
default1=self.ent_def1.text().strip(),
default2=self.ent_def2.text().strip(),
meta1=self.ent_meta1.text().strip(),
meta2=self.ent_meta2.text().strip(),
)
self.ctrl.dns_upstreams_save(ups)
self.ctrl.dns_mode_set(
self.chk_dns_via_smartdns.isChecked(),
self.ent_smartdns_addr.text().strip(),
@@ -1400,6 +1440,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
self.ctrl.log_gui("DNS settings autosaved")
self._safe(work, title="DNS save error")
def on_open_dns_benchmark(self) -> None:
def work():
dlg = DNSBenchmarkDialog(
self.ctrl,
settings=self._ui_settings,
refresh_cb=self.refresh_dns_tab,
parent=self,
)
dlg.exec()
self.refresh_dns_tab()
self._safe(work, title="DNS benchmark error")
def on_dns_mode_toggle(self) -> None:
def work():
via = self.chk_dns_via_smartdns.isChecked()