# Custom Best-Exit Probe Target Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let each tunnel define the TCP host/port used for exit-side quality probing and `best` exit scoring, defaulting to `www.bing.com:443` when unset. **Architecture:** Add small backend probe-target normalization helpers, persist configured target fields on `tunnel`, and thread the effective target through tunnel quality probing and best-exit scoring. Frontend adds compact tunnel-form inputs and replaces Bing-specific monitoring labels with target-aware text while keeping existing API fields compatible. **Tech Stack:** Go `net/http` handlers + GORM models/repository, SQLite/PostgreSQL auto-migration, Vite/React/TypeScript frontend with shadcn bridge components. --- ## File Structure - Create `go-backend/internal/http/handler/tunnel_probe_target.go`: target constants, value type, validation, request parsing, formatting, and effective default helpers. - Create `go-backend/internal/http/handler/tunnel_probe_target_test.go`: TDD coverage for normalization, defaulting, invalid inputs, and formatting. - Modify `go-backend/internal/store/model/model.go`: add `probe_target_host` and `probe_target_port` columns to `model.Tunnel`. - Modify `go-backend/internal/store/repo/repository.go`: include configured target fields in `ListTunnels()`. - Modify `go-backend/internal/store/repo/repository_mutations.go`: persist target fields in tunnel create/update repository helpers. - Modify `go-backend/internal/http/handler/mutations.go`: validate request target and save it during `tunnelCreate` / `tunnelUpdate`. - Create `go-backend/internal/http/handler/tunnel_probe_target_api_test.go`: handler-level persistence/list/get tests. - Modify `go-backend/internal/http/handler/tunnel_best_exit.go` and `tunnel_best_exit_test.go`: make exit-to-public scoring use the configured target. - Modify `go-backend/internal/http/handler/tunnel_quality_prober.go` and add tests in `tunnel_quality_prober_test.go`: make quality snapshots use and expose the effective target. - Modify `go-backend/internal/http/handler/monitoring.go`: include effective target metadata in DB fallback quality responses. - Modify `vite-frontend/src/pages/tunnel.tsx`: tunnel types, edit/create payload, form inputs, list display for target. - Modify `vite-frontend/src/pages/tunnel/form.ts`: frontend validation/defaults for target fields. - Modify `vite-frontend/src/api/types.ts`: tunnel quality target metadata. - Modify `vite-frontend/src/pages/node/tunnel-monitor-view.tsx`: target-aware monitoring labels and chart names. ## Task 1: Backend Probe Target Helpers **Files:** - Create: `go-backend/internal/http/handler/tunnel_probe_target.go` - Create: `go-backend/internal/http/handler/tunnel_probe_target_test.go` - [ ] **Step 1: Write failing helper tests** Create `go-backend/internal/http/handler/tunnel_probe_target_test.go`: ```go package handler import "testing" func TestNormalizeTunnelProbeTargetDefaultsWhenEmpty(t *testing.T) { target, configured, err := normalizeTunnelProbeTarget("", 0) if err != nil { t.Fatalf("unexpected error: %v", err) } if configured { t.Fatalf("expected empty input to be default, not configured") } if target.Host != defaultTunnelProbeTargetHost || target.Port != defaultTunnelProbeTargetPort { t.Fatalf("unexpected default target: %+v", target) } } func TestNormalizeTunnelProbeTargetAcceptsHostPortAndIPv6(t *testing.T) { target, configured, err := normalizeTunnelProbeTarget(" [2001:db8::1] ", 8443) if err != nil { t.Fatalf("unexpected error: %v", err) } if !configured { t.Fatalf("expected explicit target") } if target.Host != "2001:db8::1" || target.Port != 8443 { t.Fatalf("unexpected normalized target: %+v", target) } if got := formatTunnelProbeTarget(target); got != "[2001:db8::1]:8443" { t.Fatalf("unexpected formatted target: %s", got) } } func TestNormalizeTunnelProbeTargetRejectsPartialAndInvalidInputs(t *testing.T) { tests := []struct { name string host string port int }{ {name: "missing host", host: "", port: 443}, {name: "missing port", host: "example.com", port: 0}, {name: "port too high", host: "example.com", port: 70000}, {name: "scheme", host: "https://example.com", port: 443}, {name: "path", host: "example.com/ping", port: 443}, {name: "space", host: "example .com", port: 443}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if _, _, err := normalizeTunnelProbeTarget(tt.host, tt.port); err == nil { t.Fatalf("expected validation error") } }) } } func TestParseTunnelProbeTargetFromRequest(t *testing.T) { req := map[string]interface{}{ "probeTargetHost": "speed.example.com", "probeTargetPort": float64(1443), } target, configured, err := parseTunnelProbeTargetFromRequest(req) if err != nil { t.Fatalf("unexpected error: %v", err) } if !configured || target.Host != "speed.example.com" || target.Port != 1443 { t.Fatalf("unexpected request target: %+v configured=%v", target, configured) } } ``` - [ ] **Step 2: Run tests to verify RED** Run from `go-backend`: ```bash go test ./internal/http/handler -run 'TestNormalizeTunnelProbeTarget|TestParseTunnelProbeTarget' -count=1 ``` Expected: FAIL with undefined symbols such as `normalizeTunnelProbeTarget`, `defaultTunnelProbeTargetHost`, and `parseTunnelProbeTargetFromRequest`. - [ ] **Step 3: Implement helper** Create `go-backend/internal/http/handler/tunnel_probe_target.go`: ```go package handler import ( "errors" "fmt" "net/netip" "strings" "go-backend/internal/store/model" ) const ( defaultTunnelProbeTargetHost = "www.bing.com" defaultTunnelProbeTargetPort = 443 ) type tunnelProbeTarget struct { Host string Port int } func defaultTunnelProbeTarget() tunnelProbeTarget { return tunnelProbeTarget{Host: defaultTunnelProbeTargetHost, Port: defaultTunnelProbeTargetPort} } func normalizeTunnelProbeTarget(host string, port int) (tunnelProbeTarget, bool, error) { host = strings.TrimSpace(host) if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") } if host == "" && port == 0 { return defaultTunnelProbeTarget(), false, nil } if host == "" { return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能为空") } if port <= 0 || port > 65535 { return tunnelProbeTarget{}, false, errors.New("测试目标端口必须是 1-65535") } if strings.Contains(host, "://") || strings.ContainsAny(host, "/?#") || strings.ContainsAny(host, " \t\r\n") { return tunnelProbeTarget{}, false, errors.New("测试目标 Host 不能包含协议或路径") } return tunnelProbeTarget{Host: host, Port: port}, true, nil } func parseTunnelProbeTargetFromRequest(req map[string]interface{}) (tunnelProbeTarget, bool, error) { if req == nil { return defaultTunnelProbeTarget(), false, nil } return normalizeTunnelProbeTarget(asString(req["probeTargetHost"]), asInt(req["probeTargetPort"], 0)) } func effectiveTunnelProbeTarget(tunnel *model.Tunnel) tunnelProbeTarget { if tunnel == nil { return defaultTunnelProbeTarget() } return effectiveTunnelProbeTargetValues(tunnel.ProbeTargetHost, tunnel.ProbeTargetPort) } func effectiveTunnelProbeTargetValues(host string, port int) tunnelProbeTarget { target, configured, err := normalizeTunnelProbeTarget(host, port) if err != nil || !configured { return defaultTunnelProbeTarget() } return target } func formatTunnelProbeTarget(target tunnelProbeTarget) string { if addr, err := netip.ParseAddr(target.Host); err == nil && addr.Is6() { return fmt.Sprintf("[%s]:%d", target.Host, target.Port) } return fmt.Sprintf("%s:%d", target.Host, target.Port) } ``` - [ ] **Step 4: Run helper tests to verify GREEN** Run from `go-backend`: ```bash go test ./internal/http/handler -run 'TestNormalizeTunnelProbeTarget|TestParseTunnelProbeTarget' -count=1 ``` Expected: PASS. - [ ] **Step 5: Format and commit** ```bash gofmt -w internal/http/handler/tunnel_probe_target.go internal/http/handler/tunnel_probe_target_test.go git add internal/http/handler/tunnel_probe_target.go internal/http/handler/tunnel_probe_target_test.go git commit -m "feat: add tunnel probe target normalization" ``` ## Task 2: Persist Probe Target On Tunnels **Files:** - Modify: `go-backend/internal/store/model/model.go` - Modify: `go-backend/internal/store/repo/repository.go` - Modify: `go-backend/internal/store/repo/repository_mutations.go` - Modify: `go-backend/internal/http/handler/mutations.go` - Create: `go-backend/internal/http/handler/tunnel_probe_target_api_test.go` - [ ] **Step 1: Write failing API persistence tests** Create `go-backend/internal/http/handler/tunnel_probe_target_api_test.go`: ```go package handler import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "go-backend/internal/store/repo" ) func TestTunnelCreatePersistsProbeTargetAndListReturnsConfiguredValue(t *testing.T) { h := setupProbeTargetTunnelHandler(t) body := bytes.NewReader([]byte(`{ "name":"custom-target", "type":1, "flow":1, "trafficRatio":1, "status":1, "inNodeId":[{"nodeId":10,"protocol":"tls"}], "probeTargetHost":"speed.example.com", "probeTargetPort":8443 }`)) res := httptest.NewRecorder() h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body)) assertProbeTargetSuccess(t, res) listRes := httptest.NewRecorder() h.tunnelList(listRes, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil)) var payload struct { Code int `json:"code"` Data []map[string]any `json:"data"` } decodeProbeTargetResponse(t, listRes, &payload) if payload.Code != 0 { t.Fatalf("expected success, got code %d", payload.Code) } item := payload.Data[0] if item["probeTargetHost"] != "speed.example.com" || item["probeTargetPort"] != float64(8443) { t.Fatalf("unexpected probe target in list response: %+v", item) } } func TestTunnelUpdatePersistsDefaultProbeTargetAsEmpty(t *testing.T) { h := setupProbeTargetTunnelHandler(t) seedProbeTargetTunnel(t, h, 77, "existing", "old.example.com", 9443) body := bytes.NewReader([]byte(`{ "id":77, "name":"existing", "type":1, "flow":1, "trafficRatio":1, "status":1, "inNodeId":[{"nodeId":10,"protocol":"tls"}], "probeTargetHost":"", "probeTargetPort":0 }`)) res := httptest.NewRecorder() h.tunnelUpdate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/update", body)) assertProbeTargetSuccess(t, res) items, err := h.repo.ListTunnels() if err != nil { t.Fatalf("list tunnels: %v", err) } item := findProbeTargetTunnelItem(t, items, 77) if item["probeTargetHost"] != "" || item["probeTargetPort"] != 0 { t.Fatalf("expected default target to round-trip as empty/0, got %+v", item) } } func TestTunnelCreateRejectsInvalidProbeTarget(t *testing.T) { h := setupProbeTargetTunnelHandler(t) body := bytes.NewReader([]byte(`{ "name":"bad-target", "type":1, "flow":1, "trafficRatio":1, "status":1, "inNodeId":[{"nodeId":10,"protocol":"tls"}], "probeTargetHost":"https://example.com", "probeTargetPort":443 }`)) res := httptest.NewRecorder() h.tunnelCreate(res, httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", body)) var payload struct { Code int `json:"code"` Msg string `json:"msg"` } decodeProbeTargetResponse(t, res, &payload) if payload.Code == 0 || payload.Msg == "" { t.Fatalf("expected validation failure, got %+v", payload) } } func setupProbeTargetTunnelHandler(t *testing.T) *Handler { t.Helper() r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db")) if err != nil { t.Fatalf("open sqlite: %v", err) } t.Cleanup(func() { _ = r.Close() }) h := New(r, "secret") now := time.Now().UnixMilli() if err := r.DB().Exec(` INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx) VALUES(10, 'entry-a', 'entry-secret', '10.0.0.1', '10.0.0.1', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0) `, now, now).Error; err != nil { t.Fatalf("insert node: %v", err) } return h } func seedProbeTargetTunnel(t *testing.T, h *Handler, id int64, name string, host string, port int) { t.Helper() now := time.Now().UnixMilli() if err := h.repo.DB().Exec(` INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference, probe_target_host, probe_target_port) VALUES(?, ?, 1, 1, 'tls', 1, ?, ?, 1, ?, '', ?, ?) `, id, name, now, now, id, host, port).Error; err != nil { t.Fatalf("insert tunnel: %v", err) } if err := h.repo.DB().Exec(` INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '1', 10, 30001, 'round', 1, 'tls') `, id).Error; err != nil { t.Fatalf("insert chain: %v", err) } } func assertProbeTargetSuccess(t *testing.T, res *httptest.ResponseRecorder) { t.Helper() var payload struct { Code int `json:"code"` Msg string `json:"msg"` } decodeProbeTargetResponse(t, res, &payload) if payload.Code != 0 { t.Fatalf("expected success, got %+v", payload) } } func decodeProbeTargetResponse(t *testing.T, res *httptest.ResponseRecorder, v any) { t.Helper() if res.Code != http.StatusOK { t.Fatalf("expected HTTP %d, got %d", http.StatusOK, res.Code) } if err := json.NewDecoder(res.Body).Decode(v); err != nil { t.Fatalf("decode response: %v", err) } } func findProbeTargetTunnelItem(t *testing.T, items []map[string]interface{}, id int64) map[string]interface{} { t.Helper() for _, item := range items { if asInt64(item["id"], 0) == id { return item } } t.Fatalf("tunnel %d not found: %+v", id, items) return nil } ``` - [ ] **Step 2: Run tests to verify RED** Run from `go-backend`: ```bash go test ./internal/http/handler -run 'TestTunnel(Create|Update).*ProbeTarget' -count=1 ``` Expected: FAIL because `probe_target_host` / `probe_target_port` columns and response fields do not exist yet. - [ ] **Step 3: Add model and repository fields** Modify `go-backend/internal/store/model/model.go` in `type Tunnel`: ```go IPPreference string `gorm:"column:ip_preference;type:varchar(10);not null;default:''"` ProbeTargetHost string `gorm:"column:probe_target_host;type:text;not null;default:''"` ProbeTargetPort int `gorm:"column:probe_target_port;not null;default:0"` ``` Modify `go-backend/internal/store/repo/repository.go` in `ListTunnels()` tunnel map: ```go "ipPreference": t.IPPreference, "probeTargetHost": t.ProbeTargetHost, "probeTargetPort": t.ProbeTargetPort, ``` Modify `go-backend/internal/store/repo/repository_mutations.go` signatures and fields: ```go func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, protocol string, probeTargetHost string, probeTargetPort int, now int64) error { ``` Add to the `Updates` map: ```go "probe_target_host": probeTargetHost, "probe_target_port": probeTargetPort, ``` Update `CreateTunnelTx` for consistency: ```go func (r *Repository) CreateTunnelTx(tx *gorm.DB, name string, trafficRatio float64, typeVal int, flow int64, now int64, status int, inIP interface{}, inx int, ipPreference string, probeTargetHost string, probeTargetPort int) (int64, error) { ``` Set fields in `model.Tunnel`: ```go ProbeTargetHost: probeTargetHost, ProbeTargetPort: probeTargetPort, ``` - [ ] **Step 4: Validate and save fields in handlers** In `tunnelCreate`, after `ipPreference := asString(req["ipPreference"])`, add: ```go probeTarget, probeTargetConfigured, err := parseTunnelProbeTargetFromRequest(req) if err != nil { response.WriteJSON(w, response.ErrDefault(err.Error())) return } probeTargetHost := "" probeTargetPort := 0 if probeTargetConfigured { probeTargetHost = probeTarget.Host probeTargetPort = probeTarget.Port } ``` Set fields on the `model.Tunnel` literal: ```go ProbeTargetHost: probeTargetHost, ProbeTargetPort: probeTargetPort, ``` In `tunnelUpdate`, after `ipPreference := asString(req["ipPreference"])`, add the same validation block and pass `probeTargetHost`, `probeTargetPort` into `h.repo.UpdateTunnelTx(...)` before `now`. - [ ] **Step 5: Run API persistence tests to verify GREEN** Run from `go-backend`: ```bash go test ./internal/http/handler -run 'TestTunnel(Create|Update).*ProbeTarget' -count=1 ``` Expected: PASS. - [ ] **Step 6: Run broader backend compile check and commit** ```bash gofmt -w internal/store/model/model.go internal/store/repo/repository.go internal/store/repo/repository_mutations.go internal/http/handler/mutations.go internal/http/handler/tunnel_probe_target_api_test.go go test ./internal/http/handler -run 'TestTunnel(Create|Update).*ProbeTarget|TestNormalizeTunnelProbeTarget' -count=1 git add internal/store/model/model.go internal/store/repo/repository.go internal/store/repo/repository_mutations.go internal/http/handler/mutations.go internal/http/handler/tunnel_probe_target_api_test.go git commit -m "feat: persist tunnel probe targets" ``` ## Task 3: Use Probe Target In Best-Exit Scoring **Files:** - Modify: `go-backend/internal/http/handler/tunnel_best_exit.go` - Modify: `go-backend/internal/http/handler/tunnel_best_exit_test.go` - Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go` - [ ] **Step 1: Write failing best-exit scoring test** Append to `go-backend/internal/http/handler/tunnel_best_exit_test.go`: ```go func TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget(t *testing.T) { owner := chainNodeRecord{NodeID: 10, NodeName: "entry-a"} exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30001}} nodes := map[int64]*nodeRecord{ 10: {ID: 10, Name: "entry-a", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"}, 30: {ID: 30, Name: "exit-a", ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"}, } target := tunnelProbeTarget{Host: "speed.example.com", Port: 8443} var calls []string ping := func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) { calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port)) return 10, 0, nil } scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, target, ping) if len(scores) != 1 || !scores[0].Success { t.Fatalf("expected successful score, got %+v", scores) } if !slices.Contains(calls, "30|speed.example.com|8443") { t.Fatalf("expected exit public probe to use configured target, calls=%+v", calls) } for _, call := range calls { if strings.Contains(call, defaultTunnelProbeTargetHost) { t.Fatalf("did not expect default target call when custom target configured: %+v", calls) } } } ``` Ensure the `tunnel_best_exit_test.go` import block contains these imports after adding the test: ```go import ( "fmt" "slices" "strings" "testing" "time" ) ``` - [ ] **Step 2: Run test to verify RED** Run from `go-backend`: ```bash go test ./internal/http/handler -run TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget -count=1 ``` Expected: FAIL because `evaluateBestExitOwner` does not accept a target and still uses the hardcoded default. - [ ] **Step 3: Thread target into best-exit scoring** Modify `evaluateBestExitOwner` signature in `tunnel_best_exit.go`: ```go func evaluateBestExitOwner(owner chainNodeRecord, exits []chainNodeRecord, nodes map[int64]*nodeRecord, ipPreference string, options diagnosisExecOptions, target tunnelProbeTarget, ping bestExitProbeFunc) []bestExitCandidateScore { ``` Replace the hardcoded public probe call: ```go publicLatency, publicLoss, publicErr := ping(exit.NodeID, target.Host, target.Port, options) ``` Update existing tests and callers to pass `defaultTunnelProbeTarget()` unless they are testing custom targets. In `tunnel_quality_prober.go`, compute and pass the target from `probeTunnel`: ```go probeTarget := effectiveTunnelProbeTarget(tunnel) p.probeBestExitOwners(tunnelID, inNodes, midNodesGrouped, outNodes, ipPreference, options, probeTarget) ``` Change `probeBestExitOwners` signature: ```go func (p *tunnelQualityProber) probeBestExitOwners(tunnelID int64, inNodes []chainNodeRecord, chainHops [][]chainNodeRecord, outNodes []chainNodeRecord, ipPreference string, options diagnosisExecOptions, probeTarget tunnelProbeTarget) { ``` Pass it into scoring: ```go scores := evaluateBestExitOwner(owner, outNodes, nodeMap, ipPreference, options, probeTarget, roundPinger) ``` - [ ] **Step 4: Make round pinger cache target-safe** In `tunnel_best_exit.go`, replace `newBestExitRoundPinger` cache key with node+host+port: ```go type bestExitProbeCacheKey struct { NodeID int64 Host string Port int } func newBestExitRoundPinger(base bestExitProbeFunc) bestExitProbeFunc { cache := make(map[bestExitProbeCacheKey]bestExitProbeResult) return func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) { key := bestExitProbeCacheKey{NodeID: nodeID, Host: ip, Port: port} if cached, ok := cache[key]; ok { return cached.latency, cached.loss, cached.err } lat, loss, err := base(nodeID, ip, port, options) cache[key] = bestExitProbeResult{latency: lat, loss: loss, err: err} return lat, loss, err } } ``` - [ ] **Step 5: Run best-exit tests to verify GREEN** Run from `go-backend`: ```bash go test ./internal/http/handler -run 'TestEvaluateBestExitOwner|TestBestExit|TestBuildTunnelChainConfigUsesBestRuntimeStrategy' -count=1 ``` Expected: PASS. - [ ] **Step 6: Format and commit** ```bash gofmt -w internal/http/handler/tunnel_best_exit.go internal/http/handler/tunnel_best_exit_test.go internal/http/handler/tunnel_quality_prober.go git add internal/http/handler/tunnel_best_exit.go internal/http/handler/tunnel_best_exit_test.go internal/http/handler/tunnel_quality_prober.go git commit -m "feat: use probe target for best exit scoring" ``` ## Task 4: Use Probe Target In Tunnel Quality Monitoring **Files:** - Modify: `go-backend/internal/http/handler/tunnel_quality_prober.go` - Modify: `go-backend/internal/http/handler/monitoring.go` - Create: `go-backend/internal/http/handler/tunnel_quality_prober_test.go` - [ ] **Step 1: Write failing quality prober test** Create `go-backend/internal/http/handler/tunnel_quality_prober_test.go`: ```go func TestTunnelQualityProberUsesConfiguredProbeTarget(t *testing.T) { h := setupProbeTargetTunnelHandler(t) seedProbeTargetTunnel(t, h, 77, "quality-target", "speed.example.com", 8443) if err := h.repo.DB().Exec(` INSERT INTO node(id, name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx) VALUES(30, 'exit-a', 'exit-secret', '10.0.0.30', '10.0.0.30', '', '30000-30010', '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0) `, time.Now().UnixMilli(), time.Now().UnixMilli()).Error; err != nil { t.Fatalf("insert exit node: %v", err) } if err := h.repo.DB().Exec(` INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(77, '3', 30, 30001, 'round', 1, 'tls') `).Error; err != nil { t.Fatalf("insert exit chain: %v", err) } p := newTunnelQualityProber(h) var calls []string p.probeNode = func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) { calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port)) return 10, 0, nil } p.probeTunnel(77) if !slices.Contains(calls, "30|speed.example.com|8443") { t.Fatalf("expected exit probe to configured target, calls=%+v", calls) } snaps := p.GetAll() if len(snaps) != 1 { t.Fatalf("expected one quality snapshot, got %+v", snaps) } if snaps[0].ProbeTargetHost != "speed.example.com" || snaps[0].ProbeTargetPort != 8443 { t.Fatalf("unexpected snapshot target metadata: %+v", snaps[0]) } } ``` Use this import block in the new `tunnel_quality_prober_test.go` file: ```go import ( "fmt" "slices" "testing" "time" ) ``` - [ ] **Step 2: Run test to verify RED** Run from `go-backend`: ```bash go test ./internal/http/handler -run TestTunnelQualityProberUsesConfiguredProbeTarget -count=1 ``` Expected: FAIL because `tunnelQualityProber` has no injectable `probeNode` and snapshots do not include target metadata. - [ ] **Step 3: Add injectable probe function and target metadata** Modify `tunnelQualitySnapshot` in `tunnel_quality_prober.go`: ```go ProbeTargetHost string `json:"probeTargetHost,omitempty"` ProbeTargetPort int `json:"probeTargetPort,omitempty"` ``` Modify `tunnelQualityProber` struct: ```go probeNode bestExitProbeFunc ``` Add method: ```go func (p *tunnelQualityProber) pingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) { if p != nil && p.probeNode != nil { return p.probeNode(nodeID, ip, port, options) } return p.tcpPingNode(nodeID, ip, port, options) } ``` Replace existing `p.tcpPingNode(...)` calls in `probeTunnel` and `probeBestExitOwners` setup with `p.pingNode(...)` / `newBestExitRoundPinger(p.pingNode)`. - [ ] **Step 4: Use effective target in quality probes** In `probeTunnel`, after loading `tunnel` and before the switch: ```go probeTarget := effectiveTunnelProbeTarget(tunnel) snap.ProbeTargetHost = probeTarget.Host snap.ProbeTargetPort = probeTarget.Port ``` Replace hardcoded Bing probes: ```go lat, loss, err := p.pingNode(inNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options) ``` and: ```go lat, loss, err := p.pingNode(outNodes[0].NodeID, probeTarget.Host, probeTarget.Port, options) ``` Use the same replacement in the default case. Keep persisted DB columns unchanged. - [ ] **Step 5: Include target metadata in DB fallback monitoring response** In `monitoring.go`, when converting DB `TunnelQuality` rows to `tunnelQualitySnapshot`, fetch tunnel list once and build an effective-target map: ```go targetsByTunnelID := map[int64]tunnelProbeTarget{} if tunnels, listErr := h.repo.ListTunnels(); listErr == nil { for _, item := range tunnels { id := asInt64(item["id"], 0) if id > 0 { targetsByTunnelID[id] = effectiveTunnelProbeTargetValues(asString(item["probeTargetHost"]), asInt(item["probeTargetPort"], 0)) } } } ``` Set snapshot metadata: ```go target := targetsByTunnelID[q.TunnelID] if target.Host == "" { target = defaultTunnelProbeTarget() } ``` Then include: ```go ProbeTargetHost: target.Host, ProbeTargetPort: target.Port, ``` - [ ] **Step 6: Run quality prober tests to verify GREEN** Run from `go-backend`: ```bash go test ./internal/http/handler -run 'TestTunnelQualityProberUsesConfiguredProbeTarget|TestTunnelCreatePersistsProbeTarget' -count=1 ``` Expected: PASS. - [ ] **Step 7: Format and commit** ```bash gofmt -w internal/http/handler/tunnel_quality_prober.go internal/http/handler/tunnel_quality_prober_test.go internal/http/handler/monitoring.go git add internal/http/handler/tunnel_quality_prober.go internal/http/handler/tunnel_quality_prober_test.go internal/http/handler/monitoring.go git commit -m "feat: use probe target for tunnel quality checks" ``` ## Task 5: Frontend Form And Monitoring Display **Files:** - Modify: `vite-frontend/src/pages/tunnel.tsx` - Modify: `vite-frontend/src/pages/tunnel/form.ts` - Modify: `vite-frontend/src/api/types.ts` - Modify: `vite-frontend/src/pages/node/tunnel-monitor-view.tsx` - [ ] **Step 1: Extend frontend tunnel and quality types** In `vite-frontend/src/pages/tunnel.tsx`, add fields to `Tunnel` and `TunnelForm`: ```ts probeTargetHost?: string; probeTargetPort?: number; ``` In `vite-frontend/src/api/types.ts`, extend `TunnelQualityApiItem`: ```ts probeTargetHost?: string; probeTargetPort?: number; ``` In `vite-frontend/src/pages/tunnel/form.ts`, extend `TunnelFormInput`: ```ts probeTargetHost?: string; probeTargetPort?: number; ``` - [ ] **Step 2: Add frontend defaults and validation** In `createTunnelFormDefaults()` add: ```ts probeTargetHost: "", probeTargetPort: 0, ``` In `validateTunnelForm`, add after traffic ratio validation: ```ts const probeHost = (form.probeTargetHost || "").trim(); const probePort = Number(form.probeTargetPort || 0); if (probeHost || probePort > 0) { if (!probeHost) { errors.probeTargetHost = "请输入测试目标 Host"; } else if ( probeHost.includes("://") || /[\s/?#]/.test(probeHost) ) { errors.probeTargetHost = "Host 不能包含协议、空格或路径"; } if (!Number.isInteger(probePort) || probePort < 1 || probePort > 65535) { errors.probeTargetPort = "端口必须是 1-65535"; } } ``` - [ ] **Step 3: Round-trip form values** In `handleEdit`, add: ```ts probeTargetHost: tunnel.probeTargetHost || "", probeTargetPort: tunnel.probeTargetPort || 0, ``` Before building submit `data`, normalize: ```ts const probeTargetHost = (form.probeTargetHost || "").trim(); const probeTargetPort = probeTargetHost ? Number(form.probeTargetPort || 0) : 0; ``` Set in payload: ```ts probeTargetHost, probeTargetPort, ``` - [ ] **Step 4: Add form inputs** In the tunnel modal body near `ipPreference` and before `入口配置`, add: ```tsx
用于实时隧道质量检测和 best 最优出口评分,留空使用 www.bing.com:443