mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 02:06:38 +08:00
feat: show current best exit state
This commit is contained in:
@@ -475,6 +475,7 @@ func (h *Handler) tunnelList(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.attachBestExitStates(items)
|
||||||
response.WriteJSON(w, response.OK(items))
|
response.WriteJSON(w, response.OK(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -842,6 +842,7 @@ func (h *Handler) tunnelGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.attachBestExitStates(items)
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if asInt64(it["id"], 0) == id {
|
if asInt64(it["id"], 0) == id {
|
||||||
response.WriteJSON(w, response.OK(it))
|
response.WriteJSON(w, response.OK(it))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,25 @@ interface ChainTunnel {
|
|||||||
connectIp?: string; // 连接IP(多IP节点指定连接地址)
|
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 {
|
interface Tunnel {
|
||||||
id: number;
|
id: number;
|
||||||
inx?: number;
|
inx?: number;
|
||||||
@@ -111,6 +130,7 @@ interface Tunnel {
|
|||||||
flow: number; // 1: 单向, 2: 双向
|
flow: number; // 1: 单向, 2: 双向
|
||||||
trafficRatio: number;
|
trafficRatio: number;
|
||||||
ipPreference?: string;
|
ipPreference?: string;
|
||||||
|
bestExitState?: BestExitState;
|
||||||
status: number;
|
status: number;
|
||||||
createdTime: string;
|
createdTime: string;
|
||||||
}
|
}
|
||||||
@@ -165,19 +185,128 @@ const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace";
|
|||||||
|
|
||||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||||
|
|
||||||
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
!!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 (
|
||||||
|
<span
|
||||||
|
className={`inline-flex max-w-full items-center rounded border px-1.5 py-0.5 text-[11px] leading-4 ${className}`}
|
||||||
|
title={bestExitDetailTitle(state)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">最优出口:{displaySummary}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||||
return (items || []).map((tunnel) => ({
|
return (items || []).map((tunnel) => {
|
||||||
...tunnel,
|
const { bestExitState: rawBestExitState, ...tunnelFields } = tunnel;
|
||||||
inx: tunnel.inx ?? 0,
|
const bestExitState = normalizeBestExitState(rawBestExitState);
|
||||||
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
|
|
||||||
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
|
return {
|
||||||
chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],
|
...tunnelFields,
|
||||||
inIp: tunnel.inIp || "",
|
...(bestExitState ? { bestExitState } : {}),
|
||||||
flow: tunnel.flow ?? 1,
|
inx: tunnel.inx ?? 0,
|
||||||
trafficRatio: tunnel.trafficRatio ?? 1,
|
inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],
|
||||||
status: typeof tunnel.status === "number" ? tunnel.status : 0,
|
outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],
|
||||||
createdTime: tunnel.createdTime || "",
|
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() {
|
export default function TunnelPage() {
|
||||||
@@ -1636,6 +1765,9 @@ export default function TunnelPage() {
|
|||||||
tunnel.type === 1
|
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-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";
|
: "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 (
|
return (
|
||||||
<TableRow key={tunnel.id}>
|
<TableRow key={tunnel.id}>
|
||||||
@@ -1672,24 +1804,27 @@ export default function TunnelPage() {
|
|||||||
</Chip>
|
</Chip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
{tunnel.inNodeId?.length || 0}入口
|
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
||||||
</span>
|
{tunnel.inNodeId?.length || 0}入口
|
||||||
<span className="text-default-400">→</span>
|
</span>
|
||||||
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
<span className="text-default-400">→</span>
|
||||||
{tunnel.type === 2
|
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
||||||
? tunnel.chainNodes?.length || 0
|
{tunnel.type === 2
|
||||||
: 0}
|
? tunnel.chainNodes?.length || 0
|
||||||
跳
|
: 0}
|
||||||
</span>
|
跳
|
||||||
<span className="text-default-400">→</span>
|
</span>
|
||||||
<span className="font-semibold text-success-700 dark:text-success-400">
|
<span className="text-default-400">→</span>
|
||||||
{tunnel.type === 2
|
<span className="font-semibold text-success-700 dark:text-success-400">
|
||||||
? tunnel.outNodeId?.length || 0
|
{tunnel.type === 2
|
||||||
: tunnel.inNodeId?.length || 0}
|
? tunnel.outNodeId?.length || 0
|
||||||
出口
|
: tunnel.inNodeId?.length || 0}
|
||||||
</span>
|
出口
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{bestExitStateContent}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -1758,6 +1893,9 @@ export default function TunnelPage() {
|
|||||||
tunnel.type === 1
|
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-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";
|
: "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 (
|
return (
|
||||||
<SortableItem key={tunnel.id} id={tunnel.id}>
|
<SortableItem key={tunnel.id} id={tunnel.id}>
|
||||||
@@ -1908,6 +2046,11 @@ export default function TunnelPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{bestExitStateContent && (
|
||||||
|
<div className="mt-2 flex min-w-0 justify-center">
|
||||||
|
{bestExitStateContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 流量配置 */}
|
{/* 流量配置 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user