diff --git a/docs/superpowers/plans/2026-05-01-best-exit-current-display.md b/docs/superpowers/plans/2026-05-01-best-exit-current-display.md new file mode 100644 index 0000000..c5fa0f0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-best-exit-current-display.md @@ -0,0 +1,892 @@ +# Best Exit Current Display 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:** Show the currently applied `best` exit selection in the tunnel list information, including per-entry/per-final-hop details for multi-owner tunnels. + +**Architecture:** Add a backend-only display layer that snapshots `bestExitManager` state and attaches `bestExitState` to existing `tunnelList`/`tunnelGet` responses. Render that state in the existing tunnel table/grid topology area using compact text and a native `title` detail tooltip. No routing, scoring, persistence, polling, or runtime update behavior changes. + +**Tech Stack:** Go `net/http` handlers + existing repository methods, React/TypeScript in `vite-frontend/src/pages/tunnel.tsx`, Tailwind/shadcn bridge components already in the file. + +--- + +## File Structure + +- Create `go-backend/internal/http/handler/tunnel_best_exit_display.go`: response DTOs, manager snapshot method, tunnel-response parsing helpers, and `Handler.attachBestExitStates`. +- Create `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`: backend display-state unit tests. +- Modify `go-backend/internal/http/handler/handler.go`: call `h.attachBestExitStatesOrLog(items)` in `tunnelList`. +- Modify `go-backend/internal/http/handler/mutations.go`: call `h.attachBestExitStatesOrLog(items)` before returning a single tunnel in `tunnelGet`. +- Modify `vite-frontend/src/pages/tunnel.tsx`: add `bestExitState` types, map API state, helper render functions, and table/grid display. + +--- + +### Task 1: Backend Snapshot And Display-State Tests + +**Files:** +- Create: `go-backend/internal/http/handler/tunnel_best_exit_display_test.go` + +- [ ] **Step 1: Write failing backend display tests** + +Create `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`: + +```go +package handler + +import ( + "testing" + "time" +) + +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 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 testBestExitNameLookup(names map[int64]string) bestExitNodeNameLookup { + return func(nodeID int64) (string, bool) { + name := names[nodeID] + return name, name != "" + } +} +``` + +- [ ] **Step 2: Run backend display tests to verify failure** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run 'TestBestExitDecisionSnapshot|TestBuildBestExitDisplayState' -count=1 +``` + +Expected: FAIL with undefined `snapshot`, `buildBestExitDisplayState`, and `bestExitNodeNameLookup`. + +--- + +### Task 2: Backend Display State Implementation + +**Files:** +- Create: `go-backend/internal/http/handler/tunnel_best_exit_display.go` +- Modify: `go-backend/internal/http/handler/tunnel_best_exit.go` +- Test: `go-backend/internal/http/handler/tunnel_best_exit_display_test.go` + +- [ ] **Step 1: Implement display state and snapshot helpers** + +Create `go-backend/internal/http/handler/tunnel_best_exit_display.go`: + +```go +package handler + +import ( + "log" + "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 { + item.ExitNodeID = snapshot.AppliedExitNodeID + item.ExitNodeName = bestExitDisplayNodeName(exitsByID[snapshot.AppliedExitNodeID], 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 + } + 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 +} + +func (h *Handler) attachBestExitStatesOrLog(items []map[string]interface{}) { + defer func() { + if recovered := recover(); recovered != nil { + log.Printf("best_exit: attach display state failed: %v", recovered) + } + }() + h.attachBestExitStates(items) +} +``` + +- [ ] **Step 2: Replace direct attach calls with panic-safe wrapper** + +Keep `attachBestExitStates` for tests, and use `attachBestExitStatesOrLog` from handlers in Task 3. This step only creates the function above; no handler wiring yet. + +- [ ] **Step 3: Run backend display tests** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run 'TestBestExitDecisionSnapshot|TestBuildBestExitDisplayState' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 4: Run gofmt** + +```bash +gofmt -w internal/http/handler/tunnel_best_exit_display.go internal/http/handler/tunnel_best_exit_display_test.go +``` + +- [ ] **Step 5: Commit backend display implementation** + +```bash +git add go-backend/internal/http/handler/tunnel_best_exit_display.go go-backend/internal/http/handler/tunnel_best_exit_display_test.go +git commit -m "feat: build best exit display state" +``` + +--- + +### Task 3: Attach Best-Exit State To Tunnel List And Get Responses + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` +- Modify: `go-backend/internal/http/handler/mutations.go` +- Test: `go-backend/internal/http/handler/tunnel_best_exit_display_test.go` + +- [ ] **Step 1: Write failing handler attach tests** + +Append to `go-backend/internal/http/handler/tunnel_best_exit_display_test.go`: + +```go +func TestAttachBestExitStatesAddsStateToBestTunnelOnly(t *testing.T) { + h := &Handler{bestExit: newBestExitManager()} + now := time.Unix(300, 0) + h.bestExit.setApplied(bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}, 30, now) + + items := []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}, + }, + }, + { + "id": int64(78), + "inNodeId": []map[string]interface{}{{"nodeId": int64(12)}}, + "outNodeId": []map[string]interface{}{ + {"nodeId": int64(40), "strategy": "round"}, + {"nodeId": int64(41), "strategy": "round"}, + }, + }, + } + + h.attachBestExitStates(items) + state, ok := items[0]["bestExitState"].(*bestExitDisplayState) + if !ok { + t.Fatalf("expected bestExitState on best tunnel, got %#v", items[0]["bestExitState"]) + } + if state.Summary != bestExitUnknownExitName || state.Items[0].ExitNodeID != 30 { + t.Fatalf("unexpected state with fallback names: %+v", state) + } + if _, exists := items[1]["bestExitState"]; exists { + t.Fatalf("non-best tunnel should not have bestExitState: %+v", items[1]) + } +} +``` + +- [ ] **Step 2: Run attach test to verify failure** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run TestAttachBestExitStatesAddsStateToBestTunnelOnly -count=1 +``` + +Expected: PASS. + +- [ ] **Step 3: Wire tunnel list response** + +In `go-backend/internal/http/handler/handler.go`, change `tunnelList` from: + +```go + items, err := h.repo.ListTunnels() + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + response.WriteJSON(w, response.OK(items)) +``` + +to: + +```go + items, err := h.repo.ListTunnels() + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + h.attachBestExitStatesOrLog(items) + response.WriteJSON(w, response.OK(items)) +``` + +- [ ] **Step 4: Wire single tunnel response** + +In `go-backend/internal/http/handler/mutations.go`, change `tunnelGet` from: + +```go + items, err := h.repo.ListTunnels() + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + for _, it := range items { + if asInt64(it["id"], 0) == id { + response.WriteJSON(w, response.OK(it)) + return + } + } +``` + +to: + +```go + items, err := h.repo.ListTunnels() + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + h.attachBestExitStatesOrLog(items) + for _, it := range items { + if asInt64(it["id"], 0) == id { + response.WriteJSON(w, response.OK(it)) + return + } + } +``` + +- [ ] **Step 5: Run focused backend tests** + +Run from `go-backend`: + +```bash +go test ./internal/http/handler -run 'TestBestExitDecisionSnapshot|TestBuildBestExitDisplayState|TestAttachBestExitStatesAddsStateToBestTunnelOnly' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Run gofmt** + +```bash +gofmt -w internal/http/handler/handler.go internal/http/handler/mutations.go internal/http/handler/tunnel_best_exit_display.go internal/http/handler/tunnel_best_exit_display_test.go +``` + +- [ ] **Step 7: Commit response wiring** + +```bash +git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go go-backend/internal/http/handler/tunnel_best_exit_display.go go-backend/internal/http/handler/tunnel_best_exit_display_test.go +git commit -m "feat: expose best exit display state" +``` + +--- + +### Task 4: Frontend Tunnel List Display + +**Files:** +- Modify: `vite-frontend/src/pages/tunnel.tsx` + +- [ ] **Step 1: Add TypeScript types** + +In `vite-frontend/src/pages/tunnel.tsx`, add these interfaces after `interface ChainTunnel`: + +```ts +interface BestExitStateItem { + ownerNodeId: number; + ownerNodeName: string; + ownerRole: "entry" | "chain"; + exitNodeId?: number; + exitNodeName: string; + updatedAt?: number; + reason?: string; +} + +interface BestExitState { + enabled: boolean; + summary: string; + status: "applied" | "waiting"; + updatedAt?: number; + reason?: string; + items: BestExitStateItem[]; +} +``` + +Then add the optional field to `interface Tunnel`: + +```ts + bestExitState?: BestExitState | null; +``` + +- [ ] **Step 2: Preserve API state during mapping** + +In `mapTunnelApiItems`, add `bestExitState` to the returned object: + +```ts + bestExitState: + tunnel.bestExitState && typeof tunnel.bestExitState === "object" + ? { + ...tunnel.bestExitState, + items: Array.isArray(tunnel.bestExitState.items) + ? tunnel.bestExitState.items + : [], + } + : null, +``` + +The mapped object should include this field before `createdTime` or immediately after it. + +- [ ] **Step 3: Add render helpers** + +Add these helper functions after `mapTunnelApiItems` and before `export default function TunnelPage()`: + +```tsx +const bestExitOwnerRoleText = (role: BestExitStateItem["ownerRole"]) => { + return role === "chain" ? "中转" : "入口"; +}; + +const bestExitDetailTitle = (state?: BestExitState | null) => { + if (!state?.enabled || !state.items?.length) { + return ""; + } + return state.items + .map((item) => { + const ownerName = item.ownerNodeName || `${bestExitOwnerRoleText(item.ownerRole)} ${item.ownerNodeId}`; + const exitName = item.exitNodeName || "等待探测"; + return `${ownerName} -> ${exitName}`; + }) + .join("\n"); +}; + +const renderBestExitState = (state?: BestExitState | null) => { + if (!state?.enabled) { + return null; + } + const title = bestExitDetailTitle(state); + const isWaiting = state.status === "waiting"; + + return ( +