From e5acc491866c213c2f37fc3caaf582b2bb44e725 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 1 May 2026 13:30:46 +0800 Subject: [PATCH] feat: show current best exit state --- go-backend/internal/http/handler/handler.go | 1 + go-backend/internal/http/handler/mutations.go | 1 + .../http/handler/tunnel_best_exit_display.go | 248 ++++++++++++ .../handler/tunnel_best_exit_display_test.go | 383 ++++++++++++++++++ vite-frontend/src/pages/tunnel.tsx | 203 ++++++++-- 5 files changed, 806 insertions(+), 30 deletions(-) create mode 100644 go-backend/internal/http/handler/tunnel_best_exit_display.go create mode 100644 go-backend/internal/http/handler/tunnel_best_exit_display_test.go diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 02ba927..1d54d60 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -475,6 +475,7 @@ func (h *Handler) tunnelList(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } + h.attachBestExitStates(items) response.WriteJSON(w, response.OK(items)) } diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index cefa58b..463d6b8 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -842,6 +842,7 @@ func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } + h.attachBestExitStates(items) for _, it := range items { if asInt64(it["id"], 0) == id { response.WriteJSON(w, response.OK(it)) diff --git a/go-backend/internal/http/handler/tunnel_best_exit_display.go b/go-backend/internal/http/handler/tunnel_best_exit_display.go new file mode 100644 index 0000000..1253ca4 --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_best_exit_display.go @@ -0,0 +1,248 @@ +package handler + +import ( + "strings" +) + +const ( + bestExitDisplayStatusApplied = "applied" + bestExitDisplayStatusWaiting = "waiting" + bestExitDisplaySummaryMulti = "多个出口" + bestExitDisplaySummaryWait = "等待探测" + bestExitUnknownExitName = "未知出口" + bestExitUnknownEntryName = "未知入口" + bestExitUnknownChainName = "未知中转" +) + +type bestExitDecisionSnapshot struct { + AppliedExitNodeID int64 + UpdatedAt int64 + Reason string + Scores []bestExitCandidateScore +} + +type bestExitDisplayState struct { + Enabled bool `json:"enabled"` + Summary string `json:"summary"` + Status string `json:"status"` + UpdatedAt int64 `json:"updatedAt,omitempty"` + Reason string `json:"reason,omitempty"` + Items []bestExitDisplayItem `json:"items"` +} + +type bestExitDisplayItem struct { + OwnerNodeID int64 `json:"ownerNodeId"` + OwnerNodeName string `json:"ownerNodeName"` + OwnerRole string `json:"ownerRole"` + ExitNodeID int64 `json:"exitNodeId,omitempty"` + ExitNodeName string `json:"exitNodeName"` + UpdatedAt int64 `json:"updatedAt,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type bestExitNodeNameLookup func(nodeID int64) (string, bool) + +func (m *bestExitManager) snapshot(key bestExitOwnerKey) (bestExitDecisionSnapshot, bool) { + if m == nil { + return bestExitDecisionSnapshot{}, false + } + m.mu.Lock() + defer m.mu.Unlock() + d := m.decisions[key] + if d == nil { + return bestExitDecisionSnapshot{}, false + } + updatedAt := int64(0) + if !d.LastSwitchAt.IsZero() { + updatedAt = d.LastSwitchAt.UnixMilli() + } + return bestExitDecisionSnapshot{ + AppliedExitNodeID: d.AppliedExitNodeID, + UpdatedAt: updatedAt, + Reason: d.LastReason, + Scores: cloneBestExitScores(d.Scores), + }, true +} + +func (h *Handler) attachBestExitStates(items []map[string]interface{}) { + if h == nil || len(items) == 0 { + return + } + lookup := h.bestExitNodeNameLookup() + for _, item := range items { + state, ok := buildBestExitDisplayState(item, h.bestExit, lookup) + if !ok { + delete(item, "bestExitState") + continue + } + item["bestExitState"] = state + } +} + +func (h *Handler) bestExitNodeNameLookup() bestExitNodeNameLookup { + cache := map[int64]string{} + return func(nodeID int64) (string, bool) { + if nodeID <= 0 || h == nil { + return "", false + } + if name, ok := cache[nodeID]; ok { + return name, name != "" + } + node, err := h.getNodeRecord(nodeID) + if err != nil || node == nil { + cache[nodeID] = "" + return "", false + } + name := strings.TrimSpace(node.Name) + cache[nodeID] = name + return name, name != "" + } +} + +func buildBestExitDisplayState(tunnel map[string]interface{}, manager *bestExitManager, lookup bestExitNodeNameLookup) (*bestExitDisplayState, bool) { + if tunnel == nil { + return nil, false + } + tunnelID := asInt64(tunnel["id"], 0) + outNodes := bestExitDisplayMapSlice(tunnel["outNodeId"]) + if tunnelID <= 0 || len(outNodes) <= 1 { + return nil, false + } + if !isBestTunnelStrategy(asString(outNodes[0]["strategy"])) { + return nil, false + } + + owners, ownerRole := bestExitDisplayOwners(tunnel) + state := &bestExitDisplayState{ + Enabled: true, + Summary: bestExitDisplaySummaryWait, + Status: bestExitDisplayStatusWaiting, + Items: make([]bestExitDisplayItem, 0, len(owners)), + } + + exitsByID := map[int64]map[string]interface{}{} + for _, exit := range outNodes { + if id := asInt64(exit["nodeId"], 0); id > 0 { + exitsByID[id] = exit + } + } + appliedExitIDs := map[int64]string{} + appliedCount := 0 + latestUpdatedAt := int64(0) + latestReason := "" + for _, owner := range owners { + ownerNodeID := asInt64(owner["nodeId"], 0) + if ownerNodeID <= 0 { + continue + } + item := bestExitDisplayItem{ + OwnerNodeID: ownerNodeID, + OwnerNodeName: bestExitDisplayNodeName(owner, ownerNodeID, lookup, bestExitUnknownOwnerName(ownerRole)), + OwnerRole: ownerRole, + ExitNodeName: bestExitDisplaySummaryWait, + Reason: bestExitDisplayStatusWaiting, + } + if snapshot, ok := manager.snapshot(bestExitOwnerKey{TunnelID: tunnelID, OwnerNodeID: ownerNodeID}); ok && snapshot.AppliedExitNodeID > 0 { + exit, ok := exitsByID[snapshot.AppliedExitNodeID] + if !ok { + state.Items = append(state.Items, item) + continue + } + item.ExitNodeID = snapshot.AppliedExitNodeID + item.ExitNodeName = bestExitDisplayNodeName(exit, snapshot.AppliedExitNodeID, lookup, bestExitUnknownExitName) + item.UpdatedAt = snapshot.UpdatedAt + item.Reason = snapshot.Reason + appliedExitIDs[item.ExitNodeID] = item.ExitNodeName + appliedCount++ + if snapshot.UpdatedAt > latestUpdatedAt { + latestUpdatedAt = snapshot.UpdatedAt + latestReason = snapshot.Reason + } + } + state.Items = append(state.Items, item) + } + + if appliedCount == 0 { + return state, true + } + if appliedCount < len(state.Items) { + return state, true + } + state.Status = bestExitDisplayStatusApplied + state.UpdatedAt = latestUpdatedAt + state.Reason = latestReason + if len(appliedExitIDs) == 1 { + for _, name := range appliedExitIDs { + state.Summary = name + } + } else { + state.Summary = bestExitDisplaySummaryMulti + } + return state, true +} + +func bestExitDisplayOwners(tunnel map[string]interface{}) ([]map[string]interface{}, string) { + chainGroups := bestExitDisplayChainGroups(tunnel["chainNodes"]) + if len(chainGroups) > 0 { + return chainGroups[len(chainGroups)-1], "chain" + } + return bestExitDisplayMapSlice(tunnel["inNodeId"]), "entry" +} + +func bestExitDisplayMapSlice(v interface{}) []map[string]interface{} { + switch arr := v.(type) { + case []map[string]interface{}: + return arr + case []interface{}: + out := make([]map[string]interface{}, 0, len(arr)) + for _, item := range arr { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out + default: + return nil + } +} + +func bestExitDisplayChainGroups(v interface{}) [][]map[string]interface{} { + switch groups := v.(type) { + case [][]map[string]interface{}: + return groups + case []interface{}: + out := make([][]map[string]interface{}, 0, len(groups)) + for _, group := range groups { + items := bestExitDisplayMapSlice(group) + if len(items) > 0 { + out = append(out, items) + } + } + return out + default: + return nil + } +} + +func bestExitDisplayNodeName(source map[string]interface{}, nodeID int64, lookup bestExitNodeNameLookup, fallback string) string { + if source != nil { + for _, key := range []string{"nodeName", "name"} { + if name := strings.TrimSpace(asString(source[key])); name != "" { + return name + } + } + } + if lookup != nil { + if name, ok := lookup(nodeID); ok && strings.TrimSpace(name) != "" { + return strings.TrimSpace(name) + } + } + return fallback +} + +func bestExitUnknownOwnerName(role string) string { + if role == "chain" { + return bestExitUnknownChainName + } + return bestExitUnknownEntryName +} diff --git a/go-backend/internal/http/handler/tunnel_best_exit_display_test.go b/go-backend/internal/http/handler/tunnel_best_exit_display_test.go new file mode 100644 index 0000000..265cc6f --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_best_exit_display_test.go @@ -0,0 +1,383 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "go-backend/internal/store/repo" +) + +func TestBestExitDecisionSnapshotIsDefensiveCopy(t *testing.T) { + m := newBestExitManager() + key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10} + now := time.Unix(100, 0) + score := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30, NodeName: "exit-a"}, 10, 0, 20, 0) + + m.observeScores(key, []bestExitCandidateScore{score}, now) + snapshot, ok := m.snapshot(key) + if !ok { + t.Fatalf("expected snapshot") + } + if snapshot.AppliedExitNodeID != 30 || snapshot.UpdatedAt != now.UnixMilli() { + t.Fatalf("unexpected snapshot: %+v", snapshot) + } + if len(snapshot.Scores) != 1 { + t.Fatalf("expected one score in snapshot, got %+v", snapshot.Scores) + } + snapshot.Scores[0].ExitNodeID = 99 + + again, ok := m.snapshot(key) + if !ok { + t.Fatalf("expected second snapshot") + } + if again.Scores[0].ExitNodeID != 30 { + t.Fatalf("snapshot score mutation leaked into manager state: %+v", again.Scores) + } +} + +func TestBuildBestExitDisplayStateForDirectMultiEntryOwners(t *testing.T) { + m := newBestExitManager() + now := time.Unix(100, 0) + m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now) + m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 11}, 31, now.Add(time.Second)) + + tunnel := map[string]interface{}{ + "id": int64(77), + "inNodeId": []map[string]interface{}{ + {"nodeId": int64(10)}, + {"nodeId": int64(11)}, + }, + "outNodeId": []map[string]interface{}{ + {"nodeId": int64(30), "strategy": tunnelStrategyBest}, + {"nodeId": int64(31), "strategy": tunnelStrategyBest}, + }, + "chainNodes": [][]map[string]interface{}{}, + } + names := map[int64]string{10: "入口 A", 11: "入口 B", 30: "香港节点", 31: "日本节点"} + + state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names)) + if !ok { + t.Fatalf("expected best exit state") + } + if !state.Enabled || state.Summary != "多个出口" || state.Status != "applied" { + t.Fatalf("unexpected state summary: %+v", state) + } + if state.UpdatedAt != now.Add(time.Second).UnixMilli() { + t.Fatalf("expected latest updatedAt, got %d", state.UpdatedAt) + } + if len(state.Items) != 2 { + t.Fatalf("expected two owner items, got %+v", state.Items) + } + if state.Items[0].OwnerRole != "entry" || state.Items[0].OwnerNodeName != "入口 A" || state.Items[0].ExitNodeName != "香港节点" { + t.Fatalf("unexpected first item: %+v", state.Items[0]) + } + if state.Items[1].OwnerRole != "entry" || state.Items[1].OwnerNodeName != "入口 B" || state.Items[1].ExitNodeName != "日本节点" { + t.Fatalf("unexpected second item: %+v", state.Items[1]) + } +} + +func TestBuildBestExitDisplayStateForFinalChainHopOwners(t *testing.T) { + m := newBestExitManager() + now := time.Unix(200, 0) + m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 20}, 30, now) + m.setApplied(bestExitOwnerKey{TunnelID: 88, OwnerNodeID: 21}, 30, now.Add(time.Second)) + + tunnel := map[string]interface{}{ + "id": int64(88), + "inNodeId": []map[string]interface{}{ + {"nodeId": int64(10)}, + }, + "outNodeId": []map[string]interface{}{ + {"nodeId": int64(30), "strategy": tunnelStrategyBest}, + {"nodeId": int64(31), "strategy": tunnelStrategyBest}, + }, + "chainNodes": [][]map[string]interface{}{ + {{"nodeId": int64(15), "inx": int64(0)}}, + {{"nodeId": int64(20), "inx": int64(1)}, {"nodeId": int64(21), "inx": int64(1)}}, + }, + } + names := map[int64]string{20: "中转 M1", 21: "中转 M2", 30: "香港节点", 31: "日本节点"} + + state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names)) + if !ok { + t.Fatalf("expected best exit state") + } + if state.Summary != "香港节点" || state.Status != "applied" { + t.Fatalf("expected single-exit summary, got %+v", state) + } + if len(state.Items) != 2 { + t.Fatalf("expected two final-hop owner items, got %+v", state.Items) + } + if state.Items[0].OwnerRole != "chain" || state.Items[0].OwnerNodeName != "中转 M1" || state.Items[0].ExitNodeName != "香港节点" { + t.Fatalf("unexpected first chain owner item: %+v", state.Items[0]) + } + if state.Items[1].OwnerRole != "chain" || state.Items[1].OwnerNodeName != "中转 M2" || state.Items[1].ExitNodeName != "香港节点" { + t.Fatalf("unexpected second chain owner item: %+v", state.Items[1]) + } +} + +func TestBuildBestExitDisplayStateWaitingWhenNoAppliedDecisionExists(t *testing.T) { + tunnel := map[string]interface{}{ + "id": int64(77), + "inNodeId": []map[string]interface{}{ + {"nodeId": int64(10)}, + }, + "outNodeId": []map[string]interface{}{ + {"nodeId": int64(30), "strategy": tunnelStrategyBest}, + {"nodeId": int64(31), "strategy": tunnelStrategyBest}, + }, + "chainNodes": [][]map[string]interface{}{}, + } + names := map[int64]string{10: "入口 A", 30: "香港节点", 31: "日本节点"} + + state, ok := buildBestExitDisplayState(tunnel, newBestExitManager(), testBestExitNameLookup(names)) + if !ok { + t.Fatalf("expected waiting best exit state") + } + if state.Summary != "等待探测" || state.Status != "waiting" { + t.Fatalf("expected waiting state, got %+v", state) + } + if len(state.Items) != 1 || state.Items[0].ExitNodeID != 0 || state.Items[0].ExitNodeName != "等待探测" { + t.Fatalf("unexpected waiting item: %+v", state.Items) + } +} + +func TestBuildBestExitDisplayStateKeepsTopLevelWaitingWhenSomeOwnersPending(t *testing.T) { + m := newBestExitManager() + now := time.Unix(400, 0) + m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now) + + tunnel := map[string]interface{}{ + "id": int64(77), + "inNodeId": []map[string]interface{}{ + {"nodeId": int64(10)}, + {"nodeId": int64(11)}, + }, + "outNodeId": []map[string]interface{}{ + {"nodeId": int64(30), "strategy": tunnelStrategyBest}, + {"nodeId": int64(31), "strategy": tunnelStrategyBest}, + }, + "chainNodes": [][]map[string]interface{}{}, + } + names := map[int64]string{10: "入口 A", 11: "入口 B", 30: "香港节点", 31: "日本节点"} + + state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names)) + if !ok { + t.Fatalf("expected best exit state") + } + if state.Status != bestExitDisplayStatusWaiting || state.Summary != bestExitDisplaySummaryWait { + t.Fatalf("expected top-level waiting for partial owner state, got %+v", state) + } + if len(state.Items) != 2 { + t.Fatalf("expected two owner items, got %+v", state.Items) + } + if state.Items[0].ExitNodeID != 30 || state.Items[0].ExitNodeName != "香港节点" { + t.Fatalf("expected first owner applied details to remain visible, got %+v", state.Items[0]) + } + if state.Items[1].ExitNodeID != 0 || state.Items[1].ExitNodeName != bestExitDisplaySummaryWait { + t.Fatalf("expected second owner waiting details, got %+v", state.Items[1]) + } +} + +func TestBuildBestExitDisplayStateIgnoresAppliedExitRemovedFromTunnel(t *testing.T) { + m := newBestExitManager() + now := time.Unix(500, 0) + m.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 99, now) + + tunnel := map[string]interface{}{ + "id": int64(77), + "inNodeId": []map[string]interface{}{ + {"nodeId": int64(10)}, + }, + "outNodeId": []map[string]interface{}{ + {"nodeId": int64(30), "strategy": tunnelStrategyBest}, + {"nodeId": int64(31), "strategy": tunnelStrategyBest}, + }, + "chainNodes": [][]map[string]interface{}{}, + } + names := map[int64]string{10: "入口 A", 30: "香港节点", 31: "日本节点", 99: "已删除节点"} + + state, ok := buildBestExitDisplayState(tunnel, m, testBestExitNameLookup(names)) + if !ok { + t.Fatalf("expected best exit state") + } + if state.Status != bestExitDisplayStatusWaiting || state.Summary != bestExitDisplaySummaryWait { + t.Fatalf("expected waiting state for stale applied exit, got %+v", state) + } + if len(state.Items) != 1 { + t.Fatalf("expected one item, got %+v", state.Items) + } + if state.Items[0].ExitNodeID != 0 || state.Items[0].ExitNodeName != bestExitDisplaySummaryWait { + t.Fatalf("expected stale exit to be ignored, got %+v", state.Items[0]) + } +} + +func TestBuildBestExitDisplayStateSkipsNonBestAndSingleExitTunnels(t *testing.T) { + nonBest := map[string]interface{}{ + "id": int64(77), + "inNodeId": []map[string]interface{}{{"nodeId": int64(10)}}, + "outNodeId": []map[string]interface{}{ + {"nodeId": int64(30), "strategy": "round"}, + {"nodeId": int64(31), "strategy": "round"}, + }, + } + if state, ok := buildBestExitDisplayState(nonBest, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil { + t.Fatalf("expected non-best tunnel to skip state, got %+v", state) + } + + singleExit := map[string]interface{}{ + "id": int64(78), + "inNodeId": []map[string]interface{}{{"nodeId": int64(10)}}, + "outNodeId": []map[string]interface{}{ + {"nodeId": int64(30), "strategy": tunnelStrategyBest}, + }, + } + if state, ok := buildBestExitDisplayState(singleExit, newBestExitManager(), testBestExitNameLookup(nil)); ok || state != nil { + t.Fatalf("expected single-exit tunnel to skip state, got %+v", state) + } +} + +func TestTunnelListAttachesBestExitStateOnlyForEligibleTunnels(t *testing.T) { + h := setupBestExitTunnelHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/list", nil) + res := httptest.NewRecorder() + h.tunnelList(res, req) + + var payload struct { + Code int `json:"code"` + Data []map[string]any `json:"data"` + } + decodeBestExitTunnelResponse(t, res, &payload) + if payload.Code != 0 { + t.Fatalf("expected success response, got code %d", payload.Code) + } + + bestTunnel := findTunnelResponseItem(t, payload.Data, 77) + if _, ok := bestTunnel["bestExitState"]; !ok { + t.Fatalf("expected eligible best multi-exit tunnel to include bestExitState: %+v", bestTunnel) + } + + singleExitTunnel := findTunnelResponseItem(t, payload.Data, 78) + if _, ok := singleExitTunnel["bestExitState"]; ok { + t.Fatalf("expected single-exit tunnel to omit bestExitState: %+v", singleExitTunnel) + } + + nonBestTunnel := findTunnelResponseItem(t, payload.Data, 79) + if _, ok := nonBestTunnel["bestExitState"]; ok { + t.Fatalf("expected non-best tunnel to omit bestExitState: %+v", nonBestTunnel) + } +} + +func TestTunnelGetAttachesBestExitStateToSelectedTunnel(t *testing.T) { + h := setupBestExitTunnelHandler(t) + + body := bytes.NewReader([]byte(`{"id":77}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/get", body) + res := httptest.NewRecorder() + h.tunnelGet(res, req) + + var payload struct { + Code int `json:"code"` + Data map[string]any `json:"data"` + } + decodeBestExitTunnelResponse(t, res, &payload) + if payload.Code != 0 { + t.Fatalf("expected success response, got code %d", payload.Code) + } + if _, ok := payload.Data["bestExitState"]; !ok { + t.Fatalf("expected selected best multi-exit tunnel to include bestExitState: %+v", payload.Data) + } +} + +func setupBestExitTunnelHandler(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() + + insertNode := func(id int64, name string) { + t.Helper() + 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, id, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { + t.Fatalf("insert node %s: %v", name, err) + } + } + insertNode(10, "entry-a") + insertNode(30, "exit-a") + insertNode(31, "exit-b") + insertNode(32, "exit-c") + + insertTunnel := func(id int64, name string) { + t.Helper() + if err := r.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, inx, ip_preference) + VALUES(?, ?, 1, 1, 'tls', 1, ?, ?, 1, ?, '') + `, id, name, now, now, id).Error; err != nil { + t.Fatalf("insert tunnel %s: %v", name, err) + } + } + insertTunnel(77, "best-multi") + insertTunnel(78, "best-single") + insertTunnel(79, "round-multi") + + insertChain := func(tunnelID int64, chainType string, nodeID int64, strategy string, inx int64) { + t.Helper() + if err := r.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, ?, ?, 30001, ?, ?, 'tls') + `, tunnelID, chainType, nodeID, strategy, inx).Error; err != nil { + t.Fatalf("insert chain tunnel %d/%s/%d: %v", tunnelID, chainType, nodeID, err) + } + } + insertChain(77, "1", 10, "round", 1) + insertChain(77, "3", 30, tunnelStrategyBest, 1) + insertChain(77, "3", 31, tunnelStrategyBest, 2) + insertChain(78, "1", 10, "round", 1) + insertChain(78, "3", 30, tunnelStrategyBest, 1) + insertChain(79, "1", 10, "round", 1) + insertChain(79, "3", 31, "round", 1) + insertChain(79, "3", 32, "round", 2) + + h.bestExit.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, time.UnixMilli(now)) + return h +} + +func decodeBestExitTunnelResponse(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 findTunnelResponseItem(t *testing.T, items []map[string]any, id float64) map[string]any { + t.Helper() + for _, item := range items { + if item["id"] == id { + return item + } + } + t.Fatalf("tunnel %.0f not found in response: %+v", id, items) + return nil +} + +func testBestExitNameLookup(names map[int64]string) bestExitNodeNameLookup { + return func(nodeID int64) (string, bool) { + name := names[nodeID] + return name, name != "" + } +} diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index 5f9feee..c312dae 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -97,6 +97,25 @@ interface ChainTunnel { connectIp?: string; // 连接IP(多IP节点指定连接地址) } +interface BestExitStateItem { + ownerNodeId: number; + ownerNodeName: string; + ownerRole: "entry" | "chain" | string; + exitNodeId?: number; + exitNodeName: string; + updatedAt?: number; + reason?: string; +} + +interface BestExitState { + enabled: boolean; + summary: string; + status: "applied" | "waiting" | string; + updatedAt?: number; + reason?: string; + items: BestExitStateItem[]; +} + interface Tunnel { id: number; inx?: number; @@ -111,6 +130,7 @@ interface Tunnel { flow: number; // 1: 单向, 2: 双向 trafficRatio: number; ipPreference?: string; + bestExitState?: BestExitState; status: number; createdTime: string; } @@ -165,19 +185,128 @@ const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace"; const TUNNEL_ORDER_KEY = "tunnel-order"; +const isObjectRecord = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); + +const toSafeString = (value: unknown): string => { + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return ""; +}; + +const toSafeNumber = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string" || !value.trim()) return undefined; + + const parsed = Number(value); + + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const normalizeBestExitStateItem = ( + value: unknown, +): BestExitStateItem | undefined => { + if (!isObjectRecord(value)) return undefined; + + const ownerNodeId = toSafeNumber(value.ownerNodeId); + + if (ownerNodeId === undefined) return undefined; + + const exitNodeId = toSafeNumber(value.exitNodeId); + const updatedAt = toSafeNumber(value.updatedAt); + const reason = toSafeString(value.reason); + + return { + ownerNodeId, + ownerNodeName: toSafeString(value.ownerNodeName), + ownerRole: toSafeString(value.ownerRole), + ...(exitNodeId !== undefined ? { exitNodeId } : {}), + exitNodeName: toSafeString(value.exitNodeName), + ...(updatedAt !== undefined ? { updatedAt } : {}), + ...(reason ? { reason } : {}), + }; +}; + +const normalizeBestExitState = (value: unknown): BestExitState | undefined => { + if (!isObjectRecord(value) || value.enabled !== true) return undefined; + + const updatedAt = toSafeNumber(value.updatedAt); + const reason = toSafeString(value.reason); + const items = Array.isArray(value.items) + ? value.items.flatMap((item) => { + const normalized = normalizeBestExitStateItem(item); + + return normalized ? [normalized] : []; + }) + : []; + + return { + enabled: true, + summary: toSafeString(value.summary), + status: toSafeString(value.status), + ...(updatedAt !== undefined ? { updatedAt } : {}), + ...(reason ? { reason } : {}), + items, + }; +}; + +const bestExitOwnerRoleText = (role?: string) => { + if (role === "chain") return "中转"; + return "入口"; +}; + +const bestExitDetailTitle = (state?: BestExitState) => { + if (!state?.items?.length) return undefined; + return state.items + .map( + (item) => + `${bestExitOwnerRoleText(item.ownerRole)} ${item.ownerNodeName || item.ownerNodeId} -> ${item.exitNodeName || "等待探测"}`, + ) + .join("\n"); +}; + +const renderBestExitState = (state?: BestExitState) => { + if (!state?.enabled) return null; + + const isWaiting = state.status === "waiting"; + const summaryText = state.summary || "等待探测"; + const displaySummary = + isWaiting && !summaryText.includes("等待") + ? `等待探测 · ${summaryText}` + : summaryText; + const className = isWaiting + ? "border-warning-200/70 bg-warning-50/50 text-warning-700 dark:border-warning-300/20 dark:bg-warning-900/20 dark:text-warning-300" + : "border-success-200/60 bg-success-50/40 text-success-700 dark:border-success-300/20 dark:bg-success-900/20 dark:text-success-300"; + + return ( + + 最优出口:{displaySummary} + + ); +}; + const mapTunnelApiItems = (items: any[]): Tunnel[] => { - return (items || []).map((tunnel) => ({ - ...tunnel, - inx: tunnel.inx ?? 0, - inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [], - outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [], - chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [], - inIp: tunnel.inIp || "", - flow: tunnel.flow ?? 1, - trafficRatio: tunnel.trafficRatio ?? 1, - status: typeof tunnel.status === "number" ? tunnel.status : 0, - createdTime: tunnel.createdTime || "", - })); + return (items || []).map((tunnel) => { + const { bestExitState: rawBestExitState, ...tunnelFields } = tunnel; + const bestExitState = normalizeBestExitState(rawBestExitState); + + return { + ...tunnelFields, + ...(bestExitState ? { bestExitState } : {}), + inx: tunnel.inx ?? 0, + inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [], + outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [], + chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [], + inIp: tunnel.inIp || "", + flow: tunnel.flow ?? 1, + trafficRatio: tunnel.trafficRatio ?? 1, + status: typeof tunnel.status === "number" ? tunnel.status : 0, + createdTime: tunnel.createdTime || "", + }; + }); }; export default function TunnelPage() { @@ -1636,6 +1765,9 @@ export default function TunnelPage() { tunnel.type === 1 ? "text-[10px] h-5 bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700" : "text-[10px] h-5 bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700"; + const bestExitStateContent = renderBestExitState( + tunnel.bestExitState, + ); return ( @@ -1672,24 +1804,27 @@ export default function TunnelPage() { -
- - {tunnel.inNodeId?.length || 0}入口 - - → - - {tunnel.type === 2 - ? tunnel.chainNodes?.length || 0 - : 0} - 跳 - - → - - {tunnel.type === 2 - ? tunnel.outNodeId?.length || 0 - : tunnel.inNodeId?.length || 0} - 出口 - +
+
+ + {tunnel.inNodeId?.length || 0}入口 + + → + + {tunnel.type === 2 + ? tunnel.chainNodes?.length || 0 + : 0} + 跳 + + → + + {tunnel.type === 2 + ? tunnel.outNodeId?.length || 0 + : tunnel.inNodeId?.length || 0} + 出口 + +
+ {bestExitStateContent}
@@ -1758,6 +1893,9 @@ export default function TunnelPage() { tunnel.type === 1 ? "text-xs bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700" : "text-xs bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700"; + const bestExitStateContent = renderBestExitState( + tunnel.bestExitState, + ); return ( @@ -1908,6 +2046,11 @@ export default function TunnelPage() {
+ {bestExitStateContent && ( +
+ {bestExitStateContent} +
+ )} {/* 流量配置 */}