From 2c8821891cded38e42d39e304bdf91ddacd1328f Mon Sep 17 00:00:00 2001 From: lhpqaq Date: Mon, 16 Feb 2026 00:24:25 +0800 Subject: [PATCH] fix(tui): update with review --- cmd/server/main.go | 16 +-- go.mod | 2 +- internal/api/server.go | 5 +- internal/tui/app.go | 69 ++++++----- internal/tui/auth_tab.go | 250 ++++++++++++++++++++------------------ internal/tui/dashboard.go | 18 ++- 6 files changed, 197 insertions(+), 163 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index c50fe933..d85b6c1f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -511,22 +511,22 @@ func main() { password = localMgmtPassword } - // Ensure management routes are registered (secret-key must be set) - if cfg.RemoteManagement.SecretKey == "" { - cfg.RemoteManagement.SecretKey = "$tui-placeholder$" - } - // Start server in background cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password) - // Wait for server to be ready by polling management API + // Wait for server to be ready by polling management API with exponential backoff { client := tui.NewClient(cfg.Port, password) - for i := 0; i < 50; i++ { - time.Sleep(100 * time.Millisecond) + backoff := 100 * time.Millisecond + // Try for up to ~10-15 seconds + for i := 0; i < 30; i++ { if _, err := client.GetConfig(); err == nil { break } + time.Sleep(backoff) + if backoff < 1*time.Second { + backoff = time.Duration(float64(backoff) * 1.5) + } } } diff --git a/go.mod b/go.mod index 86ed92f2..34237de9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/router-for-me/CLIProxyAPI/v6 -go 1.24.2 +go 1.26.0 require ( github.com/andybalholm/brotli v1.0.6 diff --git a/internal/api/server.go b/internal/api/server.go index a996c78c..0ba6a697 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -284,8 +284,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk optionState.routerConfigurator(engine, s.handlers, cfg) } - // Register management routes when configuration or environment secrets are available. - hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret + // Register management routes when configuration or environment secrets are available, + // or when a local management password is provided (e.g. TUI mode). + hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret || s.localPassword != "" s.managementRoutesEnabled.Store(hasManagementSecret) if hasManagementSecret { s.registerManagementRoutes() diff --git a/internal/tui/app.go b/internal/tui/app.go index d28a84f3..f2dcb3a0 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -103,38 +103,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "L": ToggleLocale() a.tabs = TabNames() - // Broadcast locale change to ALL tabs so each re-renders - var cmds []tea.Cmd - var cmd tea.Cmd - a.dashboard, cmd = a.dashboard.Update(localeChangedMsg{}) - if cmd != nil { - cmds = append(cmds, cmd) - } - a.config, cmd = a.config.Update(localeChangedMsg{}) - if cmd != nil { - cmds = append(cmds, cmd) - } - a.auth, cmd = a.auth.Update(localeChangedMsg{}) - if cmd != nil { - cmds = append(cmds, cmd) - } - a.keys, cmd = a.keys.Update(localeChangedMsg{}) - if cmd != nil { - cmds = append(cmds, cmd) - } - a.oauth, cmd = a.oauth.Update(localeChangedMsg{}) - if cmd != nil { - cmds = append(cmds, cmd) - } - a.usage, cmd = a.usage.Update(localeChangedMsg{}) - if cmd != nil { - cmds = append(cmds, cmd) - } - a.logs, cmd = a.logs.Update(localeChangedMsg{}) - if cmd != nil { - cmds = append(cmds, cmd) - } - return a, tea.Batch(cmds...) + return a.broadcastToAllTabs(localeChangedMsg{}) case "tab": prevTab := a.activeTab a.activeTab = (a.activeTab + 1) % len(a.tabs) @@ -278,3 +247,39 @@ func Run(port int, secretKey string, hook *LogHook, output io.Writer) error { _, err := p.Run() return err } + +func (a App) broadcastToAllTabs(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + a.dashboard, cmd = a.dashboard.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.config, cmd = a.config.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.auth, cmd = a.auth.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.keys, cmd = a.keys.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.oauth, cmd = a.oauth.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.usage, cmd = a.usage.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + a.logs, cmd = a.logs.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + + return a, tea.Batch(cmds...) +} diff --git a/internal/tui/auth_tab.go b/internal/tui/auth_tab.go index 51852930..51999442 100644 --- a/internal/tui/auth_tab.go +++ b/internal/tui/auth_tab.go @@ -106,132 +106,16 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { case tea.KeyMsg: // ---- Editing mode ---- if m.editing { - switch msg.String() { - case "enter": - value := m.editInput.Value() - fieldKey := authEditableFields[m.editField].key - fileName := m.editFileName - m.editing = false - m.editInput.Blur() - fields := map[string]any{} - if fieldKey == "priority" { -p, err := strconv.Atoi(value) -if err != nil { - return m, func() tea.Msg { - return authActionMsg{err: fmt.Errorf("invalid priority: must be a number")} - } -} - fields[fieldKey] = p - } else { - fields[fieldKey] = value - } - return m, func() tea.Msg { - err := m.client.PatchAuthFileFields(fileName, fields) - if err != nil { - return authActionMsg{err: err} - } - return authActionMsg{action: fmt.Sprintf(T("updated_field"), fieldKey, fileName)} - } - case "esc": - m.editing = false - m.editInput.Blur() - m.viewport.SetContent(m.renderContent()) - return m, nil - default: - var cmd tea.Cmd - m.editInput, cmd = m.editInput.Update(msg) - m.viewport.SetContent(m.renderContent()) - return m, cmd - } + return m.handleEditInput(msg) } // ---- Delete confirmation mode ---- if m.confirm >= 0 { - switch msg.String() { - case "y", "Y": - idx := m.confirm - m.confirm = -1 - if idx < len(m.files) { - name := getString(m.files[idx], "name") - return m, func() tea.Msg { - err := m.client.DeleteAuthFile(name) - if err != nil { - return authActionMsg{err: err} - } - return authActionMsg{action: fmt.Sprintf(T("deleted"), name)} - } - } - m.viewport.SetContent(m.renderContent()) - return m, nil - case "n", "N", "esc": - m.confirm = -1 - m.viewport.SetContent(m.renderContent()) - return m, nil - } - return m, nil + return m.handleConfirmInput(msg) } // ---- Normal mode ---- - switch msg.String() { - case "j", "down": - if len(m.files) > 0 { - m.cursor = (m.cursor + 1) % len(m.files) - m.viewport.SetContent(m.renderContent()) - } - return m, nil - case "k", "up": - if len(m.files) > 0 { - m.cursor = (m.cursor - 1 + len(m.files)) % len(m.files) - m.viewport.SetContent(m.renderContent()) - } - return m, nil - case "enter", " ": - if m.expanded == m.cursor { - m.expanded = -1 - } else { - m.expanded = m.cursor - } - m.viewport.SetContent(m.renderContent()) - return m, nil - case "d", "D": - if m.cursor < len(m.files) { - m.confirm = m.cursor - m.viewport.SetContent(m.renderContent()) - } - return m, nil - case "e", "E": - if m.cursor < len(m.files) { - f := m.files[m.cursor] - name := getString(f, "name") - disabled := getBool(f, "disabled") - newDisabled := !disabled - return m, func() tea.Msg { - err := m.client.ToggleAuthFile(name, newDisabled) - if err != nil { - return authActionMsg{err: err} - } - action := T("enabled") - if newDisabled { - action = T("disabled") - } - return authActionMsg{action: fmt.Sprintf("%s %s", action, name)} - } - } - return m, nil - case "1": - return m, m.startEdit(0) // prefix - case "2": - return m, m.startEdit(1) // proxy_url - case "3": - return m, m.startEdit(2) // priority - case "r": - m.status = "" - return m, m.fetchFiles - default: - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - return m, cmd - } + return m.handleNormalInput(msg) } var cmd tea.Cmd @@ -442,3 +326,131 @@ func max(a, b int) int { } return b } + +func (m authTabModel) handleEditInput(msg tea.KeyMsg) (authTabModel, tea.Cmd) { + switch msg.String() { + case "enter": + value := m.editInput.Value() + fieldKey := authEditableFields[m.editField].key + fileName := m.editFileName + m.editing = false + m.editInput.Blur() + fields := map[string]any{} + if fieldKey == "priority" { + p, err := strconv.Atoi(value) + if err != nil { + return m, func() tea.Msg { + return authActionMsg{err: fmt.Errorf("%s: %s", T("invalid_int"), value)} + } + } + fields[fieldKey] = p + } else { + fields[fieldKey] = value + } + return m, func() tea.Msg { + err := m.client.PatchAuthFileFields(fileName, fields) + if err != nil { + return authActionMsg{err: err} + } + return authActionMsg{action: fmt.Sprintf(T("updated_field"), fieldKey, fileName)} + } + case "esc": + m.editing = false + m.editInput.Blur() + m.viewport.SetContent(m.renderContent()) + return m, nil + default: + var cmd tea.Cmd + m.editInput, cmd = m.editInput.Update(msg) + m.viewport.SetContent(m.renderContent()) + return m, cmd + } +} + +func (m authTabModel) handleConfirmInput(msg tea.KeyMsg) (authTabModel, tea.Cmd) { + switch msg.String() { + case "y", "Y": + idx := m.confirm + m.confirm = -1 + if idx < len(m.files) { + name := getString(m.files[idx], "name") + return m, func() tea.Msg { + err := m.client.DeleteAuthFile(name) + if err != nil { + return authActionMsg{err: err} + } + return authActionMsg{action: fmt.Sprintf(T("deleted"), name)} + } + } + m.viewport.SetContent(m.renderContent()) + return m, nil + case "n", "N", "esc": + m.confirm = -1 + m.viewport.SetContent(m.renderContent()) + return m, nil + } + return m, nil +} + +func (m authTabModel) handleNormalInput(msg tea.KeyMsg) (authTabModel, tea.Cmd) { + switch msg.String() { + case "j", "down": + if len(m.files) > 0 { + m.cursor = (m.cursor + 1) % len(m.files) + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "k", "up": + if len(m.files) > 0 { + m.cursor = (m.cursor - 1 + len(m.files)) % len(m.files) + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "enter", " ": + if m.expanded == m.cursor { + m.expanded = -1 + } else { + m.expanded = m.cursor + } + m.viewport.SetContent(m.renderContent()) + return m, nil + case "d", "D": + if m.cursor < len(m.files) { + m.confirm = m.cursor + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "e", "E": + if m.cursor < len(m.files) { + f := m.files[m.cursor] + name := getString(f, "name") + disabled := getBool(f, "disabled") + newDisabled := !disabled + return m, func() tea.Msg { + err := m.client.ToggleAuthFile(name, newDisabled) + if err != nil { + return authActionMsg{err: err} + } + action := T("enabled") + if newDisabled { + action = T("disabled") + } + return authActionMsg{action: fmt.Sprintf("%s %s", action, name)} + } + } + return m, nil + case "1": + return m, m.startEdit(0) // prefix + case "2": + return m, m.startEdit(1) // proxy_url + case "3": + return m, m.startEdit(2) // priority + case "r": + m.status = "" + return m, m.fetchFiles + default: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index e4215dc6..8561fe9c 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -19,6 +19,12 @@ type dashboardModel struct { width int height int ready bool + + // Cached data for re-rendering on locale change + lastConfig map[string]any + lastUsage map[string]any + lastAuthFiles []map[string]any + lastAPIKeys []string } type dashboardDataMsg struct { @@ -58,14 +64,24 @@ func (m dashboardModel) fetchData() tea.Msg { func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) { switch msg := msg.(type) { case localeChangedMsg: - // Re-fetch data to re-render with new locale + // Re-render immediately with cached data using new locale + m.content = m.renderDashboard(m.lastConfig, m.lastUsage, m.lastAuthFiles, m.lastAPIKeys) + m.viewport.SetContent(m.content) + // Also fetch fresh data in background return m, m.fetchData + case dashboardDataMsg: if msg.err != nil { m.err = msg.err m.content = errorStyle.Render("⚠ Error: " + msg.err.Error()) } else { m.err = nil + // Cache data for locale switching + m.lastConfig = msg.config + m.lastUsage = msg.usage + m.lastAuthFiles = msg.authFiles + m.lastAPIKeys = msg.apiKeys + m.content = m.renderDashboard(msg.config, msg.usage, msg.authFiles, msg.apiKeys) } m.viewport.SetContent(m.content)