dns ui: compact tab + benchmark dialog and api endpoint
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +69,334 @@ func handleDNSStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, makeDNSStatusResponse(mode))
|
writeJSON(w, http.StatusOK, makeDNSStatusResponse(mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dnsBenchmarkDefaultDomains = []string{
|
||||||
|
"cloudflare.com",
|
||||||
|
"google.com",
|
||||||
|
"telegram.org",
|
||||||
|
"github.com",
|
||||||
|
"youtube.com",
|
||||||
|
"twitter.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDNSBenchmark(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req DNSBenchmarkRequest
|
||||||
|
if r.Body != nil {
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && err != io.EOF {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreams := normalizeBenchmarkUpstreams(req.Upstreams)
|
||||||
|
if len(upstreams) == 0 {
|
||||||
|
cfg := loadDNSUpstreamsConf()
|
||||||
|
upstreams = normalizeBenchmarkUpstreamStrings([]string{
|
||||||
|
cfg.Default1,
|
||||||
|
cfg.Default2,
|
||||||
|
cfg.Meta1,
|
||||||
|
cfg.Meta2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(upstreams) == 0 {
|
||||||
|
http.Error(w, "no upstreams", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domains := normalizeBenchmarkDomains(req.Domains)
|
||||||
|
if len(domains) == 0 {
|
||||||
|
domains = append(domains, dnsBenchmarkDefaultDomains...)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutMS := req.TimeoutMS
|
||||||
|
if timeoutMS <= 0 {
|
||||||
|
timeoutMS = 1800
|
||||||
|
}
|
||||||
|
if timeoutMS < 300 {
|
||||||
|
timeoutMS = 300
|
||||||
|
}
|
||||||
|
if timeoutMS > 5000 {
|
||||||
|
timeoutMS = 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts := req.Attempts
|
||||||
|
if attempts <= 0 {
|
||||||
|
attempts = 1
|
||||||
|
}
|
||||||
|
if attempts > 3 {
|
||||||
|
attempts = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
concurrency := req.Concurrency
|
||||||
|
if concurrency <= 0 {
|
||||||
|
concurrency = 6
|
||||||
|
}
|
||||||
|
if concurrency < 1 {
|
||||||
|
concurrency = 1
|
||||||
|
}
|
||||||
|
if concurrency > 32 {
|
||||||
|
concurrency = 32
|
||||||
|
}
|
||||||
|
if concurrency > len(upstreams) {
|
||||||
|
concurrency = len(upstreams)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]DNSBenchmarkResult, 0, len(upstreams))
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
sem := make(chan struct{}, concurrency)
|
||||||
|
timeout := time.Duration(timeoutMS) * time.Millisecond
|
||||||
|
|
||||||
|
for _, upstream := range upstreams {
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(upstream string) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
result := benchmarkDNSUpstream(upstream, domains, timeout, attempts)
|
||||||
|
mu.Lock()
|
||||||
|
results = append(results, result)
|
||||||
|
mu.Unlock()
|
||||||
|
}(upstream)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
if results[i].Score == results[j].Score {
|
||||||
|
if results[i].AvgMS == results[j].AvgMS {
|
||||||
|
if results[i].OK == results[j].OK {
|
||||||
|
return results[i].Upstream < results[j].Upstream
|
||||||
|
}
|
||||||
|
return results[i].OK > results[j].OK
|
||||||
|
}
|
||||||
|
return results[i].AvgMS < results[j].AvgMS
|
||||||
|
}
|
||||||
|
return results[i].Score > results[j].Score
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := DNSBenchmarkResponse{
|
||||||
|
Results: results,
|
||||||
|
DomainsUsed: domains,
|
||||||
|
TimeoutMS: timeoutMS,
|
||||||
|
AttemptsPerDomain: attempts,
|
||||||
|
}
|
||||||
|
resp.RecommendedDefault = benchmarkTopN(results, 2, upstreams)
|
||||||
|
resp.RecommendedMeta = benchmarkTopN(results, 2, upstreams)
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBenchmarkUpstreams(in []DNSBenchmarkUpstream) []string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, item := range in {
|
||||||
|
if !item.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := normalizeDNSUpstream(item.Addr, "53")
|
||||||
|
if n == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[n]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[n] = struct{}{}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBenchmarkUpstreamStrings(in []string) []string {
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, raw := range in {
|
||||||
|
n := normalizeDNSUpstream(raw, "53")
|
||||||
|
if n == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[n]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[n] = struct{}{}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBenchmarkDomains(in []string) []string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, raw := range in {
|
||||||
|
d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")
|
||||||
|
if d == "" || strings.HasPrefix(d, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[d]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[d] = struct{}{}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
if len(out) > 100 {
|
||||||
|
out = out[:100]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchmarkDNSUpstream(upstream string, domains []string, timeout time.Duration, attempts int) DNSBenchmarkResult {
|
||||||
|
res := DNSBenchmarkResult{Upstream: upstream}
|
||||||
|
durations := make([]int, 0, len(domains)*attempts)
|
||||||
|
|
||||||
|
for _, host := range domains {
|
||||||
|
for i := 0; i < attempts; i++ {
|
||||||
|
start := time.Now()
|
||||||
|
_, err := dnsLookupAOnce(host, upstream, timeout)
|
||||||
|
elapsed := int(time.Since(start).Milliseconds())
|
||||||
|
if elapsed < 1 {
|
||||||
|
elapsed = 1
|
||||||
|
}
|
||||||
|
res.Attempts++
|
||||||
|
if err != nil {
|
||||||
|
res.Fail++
|
||||||
|
switch classifyDNSError(err) {
|
||||||
|
case dnsErrorNXDomain:
|
||||||
|
res.NXDomain++
|
||||||
|
case dnsErrorTimeout:
|
||||||
|
res.Timeout++
|
||||||
|
case dnsErrorTemporary:
|
||||||
|
res.Temporary++
|
||||||
|
default:
|
||||||
|
res.Other++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.OK++
|
||||||
|
durations = append(durations, elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durations) > 0 {
|
||||||
|
sort.Ints(durations)
|
||||||
|
sum := 0
|
||||||
|
for _, d := range durations {
|
||||||
|
sum += d
|
||||||
|
}
|
||||||
|
res.AvgMS = sum / len(durations)
|
||||||
|
idx := int(float64(len(durations)-1) * 0.95)
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
res.P95MS = durations[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
total := res.Attempts
|
||||||
|
if total > 0 {
|
||||||
|
okRate := float64(res.OK) / float64(total)
|
||||||
|
timeoutRate := float64(res.Timeout) / float64(total)
|
||||||
|
nxRate := float64(res.NXDomain) / float64(total)
|
||||||
|
avg := float64(res.AvgMS)
|
||||||
|
if avg <= 0 {
|
||||||
|
avg = float64(timeout.Milliseconds())
|
||||||
|
}
|
||||||
|
res.Score = okRate*100.0 - timeoutRate*45.0 - nxRate*12.0 - (avg / 30.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRate := 0.0
|
||||||
|
if res.Attempts > 0 {
|
||||||
|
timeoutRate = float64(res.Timeout) / float64(res.Attempts)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case res.OK == 0 || timeoutRate >= 0.15 || res.AvgMS > 400:
|
||||||
|
res.Color = "red"
|
||||||
|
case res.AvgMS < 200 && timeoutRate == 0:
|
||||||
|
res.Color = "green"
|
||||||
|
default:
|
||||||
|
res.Color = "yellow"
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func dnsLookupAOnce(host string, upstream string, timeout time.Duration) ([]string, error) {
|
||||||
|
server, port := splitDNS(upstream)
|
||||||
|
if server == "" {
|
||||||
|
return nil, fmt.Errorf("upstream empty")
|
||||||
|
}
|
||||||
|
if port == "" {
|
||||||
|
port = "53"
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(server, port)
|
||||||
|
resolver := &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
d := net.Dialer{}
|
||||||
|
return d.DialContext(ctx, "udp", addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
records, err := resolver.LookupHost(ctx, host)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, len(records))
|
||||||
|
for _, ip := range records {
|
||||||
|
if isPrivateIPv4(ip) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[ip]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[ip] = struct{}{}
|
||||||
|
out = append(out, ip)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("no public ips")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func benchmarkTopN(results []DNSBenchmarkResult, n int, fallback []string) []string {
|
||||||
|
out := make([]string, 0, n)
|
||||||
|
for _, item := range results {
|
||||||
|
if item.OK <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, item.Upstream)
|
||||||
|
if len(out) >= n {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range fallback {
|
||||||
|
if len(out) >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dup := false
|
||||||
|
for _, e := range out {
|
||||||
|
if e == item {
|
||||||
|
dup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dup {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// EN: `handleDNSModeSet` is an HTTP handler for dns mode set.
|
// EN: `handleDNSModeSet` is an HTTP handler for dns mode set.
|
||||||
// RU: `handleDNSModeSet` - HTTP-обработчик для dns mode set.
|
// RU: `handleDNSModeSet` - HTTP-обработчик для dns mode set.
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ func Run() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
ensureSeeds()
|
ensureSeeds()
|
||||||
|
if err := ensureAppMarksNft(); err != nil {
|
||||||
|
log.Printf("traffic appmarks nft init warning: %v", err)
|
||||||
|
}
|
||||||
|
if err := restoreAppMarksFromState(); err != nil {
|
||||||
|
log.Printf("traffic appmarks restore warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
@@ -144,6 +150,7 @@ func Run() {
|
|||||||
mux.HandleFunc("/api/v1/routes/fix-policy", handleFixPolicyRoute)
|
mux.HandleFunc("/api/v1/routes/fix-policy", handleFixPolicyRoute)
|
||||||
mux.HandleFunc("/api/v1/traffic/mode", handleTrafficMode)
|
mux.HandleFunc("/api/v1/traffic/mode", handleTrafficMode)
|
||||||
mux.HandleFunc("/api/v1/traffic/mode/test", handleTrafficModeTest)
|
mux.HandleFunc("/api/v1/traffic/mode/test", handleTrafficModeTest)
|
||||||
|
mux.HandleFunc("/api/v1/traffic/advanced/reset", handleTrafficAdvancedReset)
|
||||||
mux.HandleFunc("/api/v1/traffic/interfaces", handleTrafficInterfaces)
|
mux.HandleFunc("/api/v1/traffic/interfaces", handleTrafficInterfaces)
|
||||||
mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates)
|
mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates)
|
||||||
// per-app runtime marks (systemd scope / cgroup -> fwmark)
|
// per-app runtime marks (systemd scope / cgroup -> fwmark)
|
||||||
@@ -164,6 +171,7 @@ func Run() {
|
|||||||
mux.HandleFunc("/api/v1/dns-upstreams", handleDNSUpstreams)
|
mux.HandleFunc("/api/v1/dns-upstreams", handleDNSUpstreams)
|
||||||
mux.HandleFunc("/api/v1/dns/status", handleDNSStatus)
|
mux.HandleFunc("/api/v1/dns/status", handleDNSStatus)
|
||||||
mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet)
|
mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet)
|
||||||
|
mux.HandleFunc("/api/v1/dns/benchmark", handleDNSBenchmark)
|
||||||
mux.HandleFunc("/api/v1/dns/smartdns-service", handleDNSSmartdnsService)
|
mux.HandleFunc("/api/v1/dns/smartdns-service", handleDNSSmartdnsService)
|
||||||
|
|
||||||
// SmartDNS service
|
// SmartDNS service
|
||||||
|
|||||||
@@ -77,6 +77,43 @@ type DNSModeRequest struct {
|
|||||||
Mode DNSResolverMode `json:"mode"`
|
Mode DNSResolverMode `json:"mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DNSBenchmarkUpstream struct {
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSBenchmarkRequest struct {
|
||||||
|
Upstreams []DNSBenchmarkUpstream `json:"upstreams"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
TimeoutMS int `json:"timeout_ms"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSBenchmarkResult struct {
|
||||||
|
Upstream string `json:"upstream"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
OK int `json:"ok"`
|
||||||
|
Fail int `json:"fail"`
|
||||||
|
NXDomain int `json:"nxdomain"`
|
||||||
|
Timeout int `json:"timeout"`
|
||||||
|
Temporary int `json:"temporary"`
|
||||||
|
Other int `json:"other"`
|
||||||
|
AvgMS int `json:"avg_ms"`
|
||||||
|
P95MS int `json:"p95_ms"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DNSBenchmarkResponse struct {
|
||||||
|
Results []DNSBenchmarkResult `json:"results"`
|
||||||
|
DomainsUsed []string `json:"domains_used"`
|
||||||
|
TimeoutMS int `json:"timeout_ms"`
|
||||||
|
AttemptsPerDomain int `json:"attempts_per_domain"`
|
||||||
|
RecommendedDefault []string `json:"recommended_default"`
|
||||||
|
RecommendedMeta []string `json:"recommended_meta"`
|
||||||
|
}
|
||||||
|
|
||||||
type SmartDNSRuntimeStatusResponse struct {
|
type SmartDNSRuntimeStatusResponse struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
AppliedEnable bool `json:"applied_enabled"`
|
AppliedEnable bool `json:"applied_enabled"`
|
||||||
@@ -105,6 +142,7 @@ type TrafficModeState struct {
|
|||||||
Mode TrafficMode `json:"mode"`
|
Mode TrafficMode `json:"mode"`
|
||||||
PreferredIface string `json:"preferred_iface,omitempty"`
|
PreferredIface string `json:"preferred_iface,omitempty"`
|
||||||
AutoLocalBypass bool `json:"auto_local_bypass"`
|
AutoLocalBypass bool `json:"auto_local_bypass"`
|
||||||
|
IngressReplyBypass bool `json:"ingress_reply_bypass"`
|
||||||
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
||||||
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
||||||
ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"`
|
ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"`
|
||||||
@@ -118,6 +156,7 @@ type TrafficModeRequest struct {
|
|||||||
Mode TrafficMode `json:"mode"`
|
Mode TrafficMode `json:"mode"`
|
||||||
PreferredIface *string `json:"preferred_iface,omitempty"`
|
PreferredIface *string `json:"preferred_iface,omitempty"`
|
||||||
AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"`
|
AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"`
|
||||||
|
IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"`
|
||||||
ForceVPNSubnets *[]string `json:"force_vpn_subnets,omitempty"`
|
ForceVPNSubnets *[]string `json:"force_vpn_subnets,omitempty"`
|
||||||
ForceVPNUIDs *[]string `json:"force_vpn_uids,omitempty"`
|
ForceVPNUIDs *[]string `json:"force_vpn_uids,omitempty"`
|
||||||
ForceVPNCGroups *[]string `json:"force_vpn_cgroups,omitempty"`
|
ForceVPNCGroups *[]string `json:"force_vpn_cgroups,omitempty"`
|
||||||
@@ -131,7 +170,11 @@ type TrafficModeStatusResponse struct {
|
|||||||
DesiredMode TrafficMode `json:"desired_mode"`
|
DesiredMode TrafficMode `json:"desired_mode"`
|
||||||
AppliedMode TrafficMode `json:"applied_mode"`
|
AppliedMode TrafficMode `json:"applied_mode"`
|
||||||
PreferredIface string `json:"preferred_iface,omitempty"`
|
PreferredIface string `json:"preferred_iface,omitempty"`
|
||||||
|
AdvancedActive bool `json:"advanced_active"`
|
||||||
AutoLocalBypass bool `json:"auto_local_bypass"`
|
AutoLocalBypass bool `json:"auto_local_bypass"`
|
||||||
|
AutoLocalActive bool `json:"auto_local_active"`
|
||||||
|
IngressReplyBypass bool `json:"ingress_reply_bypass"`
|
||||||
|
IngressReplyActive bool `json:"ingress_reply_active"`
|
||||||
BypassCandidates int `json:"bypass_candidates"`
|
BypassCandidates int `json:"bypass_candidates"`
|
||||||
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
||||||
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
||||||
@@ -146,6 +189,8 @@ type TrafficModeStatusResponse struct {
|
|||||||
IfaceReason string `json:"iface_reason,omitempty"`
|
IfaceReason string `json:"iface_reason,omitempty"`
|
||||||
RuleMark bool `json:"rule_mark"`
|
RuleMark bool `json:"rule_mark"`
|
||||||
RuleFull bool `json:"rule_full"`
|
RuleFull bool `json:"rule_full"`
|
||||||
|
IngressRulePresent bool `json:"ingress_rule_present"`
|
||||||
|
IngressNftActive bool `json:"ingress_nft_active"`
|
||||||
TableDefault bool `json:"table_default"`
|
TableDefault bool `json:"table_default"`
|
||||||
ProbeOK bool `json:"probe_ok"`
|
ProbeOK bool `json:"probe_ok"`
|
||||||
ProbeMessage string `json:"probe_message,omitempty"`
|
ProbeMessage string `json:"probe_message,omitempty"`
|
||||||
@@ -208,7 +253,7 @@ type TrafficAppMarksRequest struct {
|
|||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
Command string `json:"command,omitempty"`
|
Command string `json:"command,omitempty"`
|
||||||
AppKey string `json:"app_key,omitempty"`
|
AppKey string `json:"app_key,omitempty"`
|
||||||
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add
|
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add; 0 = persistent
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrafficAppMarksResponse struct {
|
type TrafficAppMarksResponse struct {
|
||||||
@@ -240,7 +285,7 @@ type TrafficAppMarkItemView struct {
|
|||||||
AppKey string `json:"app_key,omitempty"`
|
AppKey string `json:"app_key,omitempty"`
|
||||||
AddedAt string `json:"added_at,omitempty"`
|
AddedAt string `json:"added_at,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
RemainingSec int `json:"remaining_sec,omitempty"`
|
RemainingSec int `json:"remaining_sec,omitempty"` // -1 = persistent
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrafficAppMarksItemsResponse struct {
|
type TrafficAppMarksItemsResponse struct {
|
||||||
@@ -259,8 +304,8 @@ type TrafficAppProfile struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
AppKey string `json:"app_key,omitempty"`
|
AppKey string `json:"app_key,omitempty"`
|
||||||
Command string `json:"command,omitempty"`
|
Command string `json:"command,omitempty"`
|
||||||
Target string `json:"target,omitempty"` // vpn|direct
|
Target string `json:"target,omitempty"` // vpn|direct
|
||||||
TTLSec int `json:"ttl_sec,omitempty"`
|
TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent
|
||||||
VPNProfile string `json:"vpn_profile,omitempty"`
|
VPNProfile string `json:"vpn_profile,omitempty"`
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
UpdatedAt string `json:"updated_at,omitempty"`
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
@@ -276,8 +321,8 @@ type TrafficAppProfileUpsertRequest struct {
|
|||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
AppKey string `json:"app_key,omitempty"`
|
AppKey string `json:"app_key,omitempty"`
|
||||||
Command string `json:"command,omitempty"`
|
Command string `json:"command,omitempty"`
|
||||||
Target string `json:"target,omitempty"` // vpn|direct
|
Target string `json:"target,omitempty"` // vpn|direct
|
||||||
TTLSec int `json:"ttl_sec,omitempty"`
|
TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent
|
||||||
VPNProfile string `json:"vpn_profile,omitempty"`
|
VPNProfile string `json:"vpn_profile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,11 @@ class TrafficModeStatus:
|
|||||||
desired_mode: str
|
desired_mode: str
|
||||||
applied_mode: str
|
applied_mode: str
|
||||||
preferred_iface: str
|
preferred_iface: str
|
||||||
|
advanced_active: bool
|
||||||
auto_local_bypass: bool
|
auto_local_bypass: bool
|
||||||
|
auto_local_active: bool
|
||||||
|
ingress_reply_bypass: bool
|
||||||
|
ingress_reply_active: bool
|
||||||
bypass_candidates: int
|
bypass_candidates: int
|
||||||
force_vpn_subnets: List[str]
|
force_vpn_subnets: List[str]
|
||||||
force_vpn_uids: List[str]
|
force_vpn_uids: List[str]
|
||||||
@@ -105,6 +109,8 @@ class TrafficModeStatus:
|
|||||||
iface_reason: str
|
iface_reason: str
|
||||||
rule_mark: bool
|
rule_mark: bool
|
||||||
rule_full: bool
|
rule_full: bool
|
||||||
|
ingress_rule_present: bool
|
||||||
|
ingress_nft_active: bool
|
||||||
table_default: bool
|
table_default: bool
|
||||||
probe_ok: bool
|
probe_ok: bool
|
||||||
probe_message: str
|
probe_message: str
|
||||||
@@ -221,6 +227,38 @@ class DnsUpstreams:
|
|||||||
meta2: str
|
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)
|
@dataclass(frozen=True)
|
||||||
class SmartdnsServiceState:
|
class SmartdnsServiceState:
|
||||||
state: str
|
state: str
|
||||||
@@ -654,7 +692,11 @@ class ApiClient:
|
|||||||
desired_mode=str(data.get("desired_mode") or 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"),
|
applied_mode=str(data.get("applied_mode") or "direct"),
|
||||||
preferred_iface=str(data.get("preferred_iface") or ""),
|
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_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),
|
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_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_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 ""),
|
iface_reason=str(data.get("iface_reason") or ""),
|
||||||
rule_mark=bool(data.get("rule_mark", False)),
|
rule_mark=bool(data.get("rule_mark", False)),
|
||||||
rule_full=bool(data.get("rule_full", 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)),
|
table_default=bool(data.get("table_default", False)),
|
||||||
probe_ok=bool(data.get("probe_ok", False)),
|
probe_ok=bool(data.get("probe_ok", False)),
|
||||||
probe_message=str(data.get("probe_message") or ""),
|
probe_message=str(data.get("probe_message") or ""),
|
||||||
@@ -681,6 +725,7 @@ class ApiClient:
|
|||||||
mode: str,
|
mode: str,
|
||||||
preferred_iface: Optional[str] = None,
|
preferred_iface: Optional[str] = None,
|
||||||
auto_local_bypass: Optional[bool] = None,
|
auto_local_bypass: Optional[bool] = None,
|
||||||
|
ingress_reply_bypass: Optional[bool] = None,
|
||||||
force_vpn_subnets: Optional[List[str]] = None,
|
force_vpn_subnets: Optional[List[str]] = None,
|
||||||
force_vpn_uids: Optional[List[str]] = None,
|
force_vpn_uids: Optional[List[str]] = None,
|
||||||
force_vpn_cgroups: Optional[List[str]] = None,
|
force_vpn_cgroups: Optional[List[str]] = None,
|
||||||
@@ -696,6 +741,8 @@ class ApiClient:
|
|||||||
payload["preferred_iface"] = str(preferred_iface).strip()
|
payload["preferred_iface"] = str(preferred_iface).strip()
|
||||||
if auto_local_bypass is not None:
|
if auto_local_bypass is not None:
|
||||||
payload["auto_local_bypass"] = bool(auto_local_bypass)
|
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:
|
if force_vpn_subnets is not None:
|
||||||
payload["force_vpn_subnets"] = [str(x) for x in force_vpn_subnets]
|
payload["force_vpn_subnets"] = [str(x) for x in force_vpn_subnets]
|
||||||
if force_vpn_uids is not None:
|
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),
|
desired_mode=str(data.get("desired_mode") or data.get("mode") or m),
|
||||||
applied_mode=str(data.get("applied_mode") or "direct"),
|
applied_mode=str(data.get("applied_mode") or "direct"),
|
||||||
preferred_iface=str(data.get("preferred_iface") or ""),
|
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_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),
|
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_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_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 ""),
|
iface_reason=str(data.get("iface_reason") or ""),
|
||||||
rule_mark=bool(data.get("rule_mark", False)),
|
rule_mark=bool(data.get("rule_mark", False)),
|
||||||
rule_full=bool(data.get("rule_full", 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)),
|
table_default=bool(data.get("table_default", False)),
|
||||||
probe_ok=bool(data.get("probe_ok", False)),
|
probe_ok=bool(data.get("probe_ok", False)),
|
||||||
probe_message=str(data.get("probe_message") or ""),
|
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"),
|
desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"),
|
||||||
applied_mode=str(data.get("applied_mode") or "direct"),
|
applied_mode=str(data.get("applied_mode") or "direct"),
|
||||||
preferred_iface=str(data.get("preferred_iface") or ""),
|
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_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),
|
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_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_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 ""),
|
iface_reason=str(data.get("iface_reason") or ""),
|
||||||
rule_mark=bool(data.get("rule_mark", False)),
|
rule_mark=bool(data.get("rule_mark", False)),
|
||||||
rule_full=bool(data.get("rule_full", 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)),
|
table_default=bool(data.get("table_default", False)),
|
||||||
probe_ok=bool(data.get("probe_ok", False)),
|
probe_ok=bool(data.get("probe_ok", False)),
|
||||||
probe_message=str(data.get("probe_message") or ""),
|
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:
|
def dns_status_get(self) -> DNSStatus:
|
||||||
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/status")) or {})
|
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/status")) or {})
|
||||||
return self._parse_dns_status(data)
|
return self._parse_dns_status(data)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ _NEXT_CHECK_RE = re.compile(
|
|||||||
from api_client import (
|
from api_client import (
|
||||||
ApiClient,
|
ApiClient,
|
||||||
CmdResult,
|
CmdResult,
|
||||||
|
DNSBenchmarkResponse,
|
||||||
|
DNSBenchmarkUpstream,
|
||||||
DNSStatus,
|
DNSStatus,
|
||||||
DnsUpstreams,
|
DnsUpstreams,
|
||||||
DomainsFile,
|
DomainsFile,
|
||||||
@@ -128,7 +130,11 @@ class TrafficModeView:
|
|||||||
desired_mode: str
|
desired_mode: str
|
||||||
applied_mode: str
|
applied_mode: str
|
||||||
preferred_iface: str
|
preferred_iface: str
|
||||||
|
advanced_active: bool
|
||||||
auto_local_bypass: bool
|
auto_local_bypass: bool
|
||||||
|
auto_local_active: bool
|
||||||
|
ingress_reply_bypass: bool
|
||||||
|
ingress_reply_active: bool
|
||||||
bypass_candidates: int
|
bypass_candidates: int
|
||||||
force_vpn_subnets: List[str]
|
force_vpn_subnets: List[str]
|
||||||
force_vpn_uids: List[str]
|
force_vpn_uids: List[str]
|
||||||
@@ -141,6 +147,8 @@ class TrafficModeView:
|
|||||||
cgroup_warning: str
|
cgroup_warning: str
|
||||||
active_iface: str
|
active_iface: str
|
||||||
iface_reason: str
|
iface_reason: str
|
||||||
|
ingress_rule_present: bool
|
||||||
|
ingress_nft_active: bool
|
||||||
probe_ok: bool
|
probe_ok: bool
|
||||||
probe_message: str
|
probe_message: str
|
||||||
healthy: bool
|
healthy: bool
|
||||||
@@ -614,7 +622,11 @@ class DashboardController:
|
|||||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||||
applied_mode=(st.applied_mode or "direct"),
|
applied_mode=(st.applied_mode or "direct"),
|
||||||
preferred_iface=st.preferred_iface or "",
|
preferred_iface=st.preferred_iface or "",
|
||||||
|
advanced_active=bool(st.advanced_active),
|
||||||
auto_local_bypass=bool(st.auto_local_bypass),
|
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),
|
bypass_candidates=int(st.bypass_candidates),
|
||||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||||
@@ -627,6 +639,8 @@ class DashboardController:
|
|||||||
cgroup_warning=st.cgroup_warning or "",
|
cgroup_warning=st.cgroup_warning or "",
|
||||||
active_iface=st.active_iface or "",
|
active_iface=st.active_iface or "",
|
||||||
iface_reason=st.iface_reason 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_ok=bool(st.probe_ok),
|
||||||
probe_message=st.probe_message or "",
|
probe_message=st.probe_message or "",
|
||||||
healthy=bool(st.healthy),
|
healthy=bool(st.healthy),
|
||||||
@@ -638,6 +652,7 @@ class DashboardController:
|
|||||||
mode: str,
|
mode: str,
|
||||||
preferred_iface: Optional[str] = None,
|
preferred_iface: Optional[str] = None,
|
||||||
auto_local_bypass: Optional[bool] = None,
|
auto_local_bypass: Optional[bool] = None,
|
||||||
|
ingress_reply_bypass: Optional[bool] = None,
|
||||||
force_vpn_subnets: Optional[List[str]] = None,
|
force_vpn_subnets: Optional[List[str]] = None,
|
||||||
force_vpn_uids: Optional[List[str]] = None,
|
force_vpn_uids: Optional[List[str]] = None,
|
||||||
force_vpn_cgroups: Optional[List[str]] = None,
|
force_vpn_cgroups: Optional[List[str]] = None,
|
||||||
@@ -649,6 +664,7 @@ class DashboardController:
|
|||||||
mode,
|
mode,
|
||||||
preferred_iface,
|
preferred_iface,
|
||||||
auto_local_bypass,
|
auto_local_bypass,
|
||||||
|
ingress_reply_bypass,
|
||||||
force_vpn_subnets,
|
force_vpn_subnets,
|
||||||
force_vpn_uids,
|
force_vpn_uids,
|
||||||
force_vpn_cgroups,
|
force_vpn_cgroups,
|
||||||
@@ -660,7 +676,11 @@ class DashboardController:
|
|||||||
desired_mode=(st.desired_mode or st.mode or mode),
|
desired_mode=(st.desired_mode or st.mode or mode),
|
||||||
applied_mode=(st.applied_mode or "direct"),
|
applied_mode=(st.applied_mode or "direct"),
|
||||||
preferred_iface=st.preferred_iface or "",
|
preferred_iface=st.preferred_iface or "",
|
||||||
|
advanced_active=bool(st.advanced_active),
|
||||||
auto_local_bypass=bool(st.auto_local_bypass),
|
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),
|
bypass_candidates=int(st.bypass_candidates),
|
||||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||||
@@ -673,6 +693,8 @@ class DashboardController:
|
|||||||
cgroup_warning=st.cgroup_warning or "",
|
cgroup_warning=st.cgroup_warning or "",
|
||||||
active_iface=st.active_iface or "",
|
active_iface=st.active_iface or "",
|
||||||
iface_reason=st.iface_reason 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_ok=bool(st.probe_ok),
|
||||||
probe_message=st.probe_message or "",
|
probe_message=st.probe_message or "",
|
||||||
healthy=bool(st.healthy),
|
healthy=bool(st.healthy),
|
||||||
@@ -685,7 +707,11 @@ class DashboardController:
|
|||||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||||
applied_mode=(st.applied_mode or "direct"),
|
applied_mode=(st.applied_mode or "direct"),
|
||||||
preferred_iface=st.preferred_iface or "",
|
preferred_iface=st.preferred_iface or "",
|
||||||
|
advanced_active=bool(st.advanced_active),
|
||||||
auto_local_bypass=bool(st.auto_local_bypass),
|
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),
|
bypass_candidates=int(st.bypass_candidates),
|
||||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||||
@@ -698,6 +724,39 @@ class DashboardController:
|
|||||||
cgroup_warning=st.cgroup_warning or "",
|
cgroup_warning=st.cgroup_warning or "",
|
||||||
active_iface=st.active_iface or "",
|
active_iface=st.active_iface or "",
|
||||||
iface_reason=st.iface_reason 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_ok=bool(st.probe_ok),
|
||||||
probe_message=st.probe_message or "",
|
probe_message=st.probe_message or "",
|
||||||
healthy=bool(st.healthy),
|
healthy=bool(st.healthy),
|
||||||
@@ -811,6 +870,22 @@ class DashboardController:
|
|||||||
def dns_upstreams_save(self, cfg: DnsUpstreams) -> None:
|
def dns_upstreams_save(self, cfg: DnsUpstreams) -> None:
|
||||||
self.client.dns_upstreams_set(cfg)
|
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:
|
def dns_status_view(self) -> DNSStatus:
|
||||||
return self.client.dns_status_get()
|
return self.client.dns_status_get()
|
||||||
|
|
||||||
|
|||||||
376
selective-vpn-gui/dns_benchmark_dialog.py
Normal file
376
selective-vpn-gui/dns_benchmark_dialog.py
Normal 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")
|
||||||
@@ -36,6 +36,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from api_client import ApiClient, DnsUpstreams
|
from api_client import ApiClient, DnsUpstreams
|
||||||
from dashboard_controller import DashboardController, TraceMode
|
from dashboard_controller import DashboardController, TraceMode
|
||||||
|
from dns_benchmark_dialog import DNSBenchmarkDialog
|
||||||
from traffic_mode_dialog import TrafficModeDialog
|
from traffic_mode_dialog import TrafficModeDialog
|
||||||
|
|
||||||
_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s")
|
_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s")
|
||||||
@@ -420,31 +421,27 @@ RU: Агрессивный режим дополнительно дергает
|
|||||||
tip.setStyleSheet("color: gray;")
|
tip.setStyleSheet("color: gray;")
|
||||||
main_layout.addWidget(tip)
|
main_layout.addWidget(tip)
|
||||||
|
|
||||||
ups_group = QGroupBox("Upstreams (auto-save)")
|
resolver_group = QGroupBox("Resolver DNS")
|
||||||
ups_group.setToolTip("""EN: DNS upstreams for direct resolver mode (and non-wildcard lists in hybrid mode).
|
resolver_group.setToolTip("""EN: Compact resolver DNS status. Open benchmark to test/apply upstreams.
|
||||||
RU: DNS апстримы для direct-резолвера (и для не-wildcard списков в hybrid режиме).""")
|
RU: Компактный статус DNS резолвера. Открой benchmark для проверки/применения апстримов.""")
|
||||||
ups_form = QFormLayout(ups_group)
|
resolver_layout = QVBoxLayout(resolver_group)
|
||||||
self.ent_def1 = QLineEdit()
|
|
||||||
self.ent_def1.setToolTip("""EN: Upstream default1. You can set an IP (port 53 is assumed).
|
row = QHBoxLayout()
|
||||||
RU: Апстрим default1. Можно указать IP (порт 53 по умолчанию).""")
|
self.btn_dns_benchmark = QPushButton("Open DNS benchmark")
|
||||||
self.ent_def2 = QLineEdit()
|
self.btn_dns_benchmark.clicked.connect(self.on_open_dns_benchmark)
|
||||||
self.ent_def2.setToolTip("""EN: Upstream default2. You can set an IP (port 53 is assumed).
|
row.addWidget(self.btn_dns_benchmark)
|
||||||
RU: Апстрим default2. Можно указать IP (порт 53 по умолчанию).""")
|
row.addStretch(1)
|
||||||
self.ent_meta1 = QLineEdit()
|
resolver_layout.addLayout(row)
|
||||||
self.ent_meta1.setToolTip("""EN: Upstream meta1. You can set an IP (port 53 is assumed).
|
|
||||||
RU: Апстрим meta1. Можно указать IP (порт 53 по умолчанию).""")
|
self.lbl_dns_resolver_upstreams = QLabel("Resolver upstreams: default[—, —] meta[—, —]")
|
||||||
self.ent_meta2 = QLineEdit()
|
self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;")
|
||||||
self.ent_meta2.setToolTip("""EN: Upstream meta2. You can set an IP (port 53 is assumed).
|
resolver_layout.addWidget(self.lbl_dns_resolver_upstreams)
|
||||||
RU: Апстрим meta2. Можно указать IP (порт 53 по умолчанию).""")
|
|
||||||
self.ent_def1.textEdited.connect(self._schedule_dns_autosave)
|
self.lbl_dns_resolver_health = QLabel("Resolver health: —")
|
||||||
self.ent_def2.textEdited.connect(self._schedule_dns_autosave)
|
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
|
||||||
self.ent_meta1.textEdited.connect(self._schedule_dns_autosave)
|
resolver_layout.addWidget(self.lbl_dns_resolver_health)
|
||||||
self.ent_meta2.textEdited.connect(self._schedule_dns_autosave)
|
|
||||||
ups_form.addRow("default1", self.ent_def1)
|
main_layout.addWidget(resolver_group)
|
||||||
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)
|
|
||||||
|
|
||||||
smart_group = QGroupBox("SmartDNS")
|
smart_group = QGroupBox("SmartDNS")
|
||||||
smart_group.setToolTip("""EN: SmartDNS is used for wildcard domains in hybrid mode.
|
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.setText(txt)
|
||||||
self.lbl_dns_mode_state.setStyleSheet(f"color: {color};")
|
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(
|
def _set_traffic_mode_state(
|
||||||
self,
|
self,
|
||||||
desired_mode: str,
|
desired_mode: str,
|
||||||
applied_mode: str,
|
applied_mode: str,
|
||||||
preferred_iface: str,
|
preferred_iface: str,
|
||||||
|
advanced_active: bool,
|
||||||
auto_local_bypass: bool,
|
auto_local_bypass: bool,
|
||||||
|
auto_local_active: bool,
|
||||||
|
ingress_reply_bypass: bool,
|
||||||
|
ingress_reply_active: bool,
|
||||||
bypass_candidates: int,
|
bypass_candidates: int,
|
||||||
overrides_applied: int,
|
overrides_applied: int,
|
||||||
cgroup_resolved_uids: int,
|
cgroup_resolved_uids: int,
|
||||||
cgroup_warning: str,
|
cgroup_warning: str,
|
||||||
healthy: bool,
|
healthy: bool,
|
||||||
|
ingress_rule_present: bool,
|
||||||
|
ingress_nft_active: bool,
|
||||||
probe_ok: bool,
|
probe_ok: bool,
|
||||||
probe_message: str,
|
probe_message: str,
|
||||||
active_iface: str,
|
active_iface: str,
|
||||||
@@ -763,9 +801,17 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
|||||||
diag_parts = []
|
diag_parts = []
|
||||||
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
|
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
|
||||||
diag_parts.append(
|
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"bypass_routes={bypass_candidates}")
|
||||||
diag_parts.append(f"overrides={overrides_applied}")
|
diag_parts.append(f"overrides={overrides_applied}")
|
||||||
if cgroup_resolved_uids > 0:
|
if cgroup_resolved_uids > 0:
|
||||||
@@ -776,6 +822,10 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
|||||||
diag_parts.append(f"iface={active_iface}")
|
diag_parts.append(f"iface={active_iface}")
|
||||||
if iface_reason:
|
if iface_reason:
|
||||||
diag_parts.append(f"source={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'}")
|
diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}")
|
||||||
if probe_message:
|
if probe_message:
|
||||||
diag_parts.append(probe_message)
|
diag_parts.append(probe_message)
|
||||||
@@ -998,12 +1048,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
|||||||
t.desired_mode,
|
t.desired_mode,
|
||||||
t.applied_mode,
|
t.applied_mode,
|
||||||
t.preferred_iface,
|
t.preferred_iface,
|
||||||
|
bool(t.advanced_active),
|
||||||
bool(t.auto_local_bypass),
|
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.bypass_candidates),
|
||||||
int(t.overrides_applied),
|
int(t.overrides_applied),
|
||||||
int(t.cgroup_resolved_uids),
|
int(t.cgroup_resolved_uids),
|
||||||
t.cgroup_warning,
|
t.cgroup_warning,
|
||||||
bool(t.healthy),
|
bool(t.healthy),
|
||||||
|
bool(t.ingress_rule_present),
|
||||||
|
bool(t.ingress_nft_active),
|
||||||
bool(t.probe_ok),
|
bool(t.probe_ok),
|
||||||
t.probe_message,
|
t.probe_message,
|
||||||
t.active_iface,
|
t.active_iface,
|
||||||
@@ -1017,10 +1073,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
|||||||
self._dns_ui_refresh = True
|
self._dns_ui_refresh = True
|
||||||
try:
|
try:
|
||||||
ups = self.ctrl.dns_upstreams_view()
|
ups = self.ctrl.dns_upstreams_view()
|
||||||
self.ent_def1.setText(ups.default1 or "")
|
self._set_dns_resolver_summary(ups)
|
||||||
self.ent_def2.setText(ups.default2 or "")
|
|
||||||
self.ent_meta1.setText(ups.meta1 or "")
|
|
||||||
self.ent_meta2.setText(ups.meta2 or "")
|
|
||||||
|
|
||||||
st = self.ctrl.dns_status_view()
|
st = self.ctrl.dns_status_view()
|
||||||
self.ent_smartdns_addr.setText(st.smartdns_addr or "")
|
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.setChecked(hybrid_enabled)
|
||||||
self.chk_dns_via_smartdns.blockSignals(False)
|
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_state = (st.unit_state or "unknown").strip().lower()
|
||||||
unit_active = unit_state == "active"
|
unit_active = unit_state == "active"
|
||||||
self.chk_dns_unit_relay.blockSignals(True)
|
self.chk_dns_unit_relay.blockSignals(True)
|
||||||
@@ -1386,13 +1433,6 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
|||||||
def work():
|
def work():
|
||||||
if self._dns_ui_refresh:
|
if self._dns_ui_refresh:
|
||||||
return
|
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.ctrl.dns_mode_set(
|
||||||
self.chk_dns_via_smartdns.isChecked(),
|
self.chk_dns_via_smartdns.isChecked(),
|
||||||
self.ent_smartdns_addr.text().strip(),
|
self.ent_smartdns_addr.text().strip(),
|
||||||
@@ -1400,6 +1440,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
|||||||
self.ctrl.log_gui("DNS settings autosaved")
|
self.ctrl.log_gui("DNS settings autosaved")
|
||||||
self._safe(work, title="DNS save error")
|
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 on_dns_mode_toggle(self) -> None:
|
||||||
def work():
|
def work():
|
||||||
via = self.chk_dns_via_smartdns.isChecked()
|
via = self.chk_dns_via_smartdns.isChecked()
|
||||||
|
|||||||
Reference in New Issue
Block a user