# 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 (
最优出口:{state.summary || "等待探测"}
); }; ``` - [ ] **Step 4: Render in table topology cell** In the table topology `` around line 1674, change the cell content from: ```tsx
{tunnel.inNodeId?.length || 0}入口 → {tunnel.type === 2 ? tunnel.chainNodes?.length || 0 : 0} 跳 → {tunnel.type === 2 ? tunnel.outNodeId?.length || 0 : tunnel.inNodeId?.length || 0} 出口
``` to: ```tsx
{tunnel.inNodeId?.length || 0}入口 → {tunnel.type === 2 ? tunnel.chainNodes?.length || 0 : 0} 跳 → {tunnel.type === 2 ? tunnel.outNodeId?.length || 0 : tunnel.inNodeId?.length || 0} 出口
{renderBestExitState(tunnel.bestExitState)}
``` - [ ] **Step 5: Render in grid card topology section** In the grid card topology section, after the closing `` for the topology row at the end of the block containing `出口` and before the enclosing border section closes, add: ```tsx
{renderBestExitState(tunnel.bestExitState)}
``` The result should put the best-exit summary under the entry -> hop -> exit row inside the topology section. - [ ] **Step 6: Run frontend build** Run from `vite-frontend`: ```bash pnpm run build ``` Expected: PASS with `tsc && vite build` completing successfully. - [ ] **Step 7: Commit frontend display** ```bash git add vite-frontend/src/pages/tunnel.tsx git commit -m "feat: show current best exit in tunnel list" ``` --- ### Task 5: Full Verification And Review **Files:** - Verify only. - [ ] **Step 1: Run backend tests** Run from `go-backend`: ```bash go test ./... ``` Expected: PASS. - [ ] **Step 2: Run frontend build** Run from `vite-frontend`: ```bash pnpm run build ``` Expected: PASS. - [ ] **Step 3: Inspect final diff** Run from repository root: ```bash git diff --stat origin/main...HEAD git diff -- go-backend/internal/http/handler/tunnel_best_exit_display.go go-backend/internal/http/handler/tunnel_best_exit_display_test.go go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/mutations.go vite-frontend/src/pages/tunnel.tsx ``` Expected: Diff only adds best-exit display state, response attachment, frontend list display, and tests. It must not change best-exit scoring, switching, runtime chain update, or agent code. - [ ] **Step 4: Request final code review** Ask a reviewer to check: ```text Review the best-exit current display implementation. Confirm it only exposes current in-memory best-exit state in tunnel list/get responses and renders it in the tunnel list. Verify it does not change routing, scoring, switching, persistence, or polling behavior. ``` Expected: No blocking findings. --- ## Self-Review - Spec coverage: Backend response state is Task 2 and Task 3; direct vs final-hop owner semantics are covered by Task 1 tests; frontend list/grid display is Task 4; no polling and no routing changes are preserved by Task 5 review instructions. - Placeholder scan: The plan contains concrete files, function names, code blocks, commands, and expected outcomes. - Type consistency: `BestExitState`, `BestExitStateItem`, `bestExitDisplayState`, `bestExitDisplayItem`, `bestExitDecisionSnapshot`, and `bestExitNodeNameLookup` are defined before use and names match across tasks.