From 93fe58e31e175a4b9928f1ccda9a845a2a2b43f0 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 19 Feb 2026 03:18:08 +0800 Subject: [PATCH] feat(tui): add standalone mode and API-based log polling - Implemented `--standalone` mode to launch an embedded server for TUI. - Enhanced TUI client to support API-based log polling when log hooks are unavailable. - Added authentication gate for password input and connection handling. - Improved localization and UX for logs, authentication, and status bar rendering. --- cmd/server/main.go | 108 ++++++------ internal/tui/app.go | 331 ++++++++++++++++++++++++++++++++----- internal/tui/client.go | 74 ++++++++- internal/tui/config_tab.go | 48 ++++-- internal/tui/i18n.go | 26 ++- internal/tui/logs_tab.go | 73 +++++++- 6 files changed, 545 insertions(+), 115 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index d85b6c1f..684d9295 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -71,6 +71,7 @@ func main() { var configPath string var password string var tuiMode bool + var standalone bool // Define command-line flags for different operation modes. flag.BoolVar(&login, "login", false, "Login Google Account") @@ -88,6 +89,7 @@ func main() { flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") flag.StringVar(&password, "password", "", "") flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI") + flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server") flag.CommandLine.Usage = func() { out := flag.CommandLine.Output() @@ -483,72 +485,82 @@ func main() { cmd.WaitForCloudDeploy() return } - // Start the main proxy service - managementasset.StartAutoUpdater(context.Background(), configFilePath) if tuiMode { - // Install logrus hook to capture logs for TUI - hook := tui.NewLogHook(2000) - hook.SetFormatter(&logging.LogFormatter{}) - log.AddHook(hook) - // Suppress logrus stdout output (TUI owns the terminal) - log.SetOutput(io.Discard) + if standalone { + // Standalone mode: start an embedded local server and connect TUI client to it. + managementasset.StartAutoUpdater(context.Background(), configFilePath) + hook := tui.NewLogHook(2000) + hook.SetFormatter(&logging.LogFormatter{}) + log.AddHook(hook) - // Redirect os.Stdout and os.Stderr to /dev/null so that - // stray fmt.Print* calls in the backend don't corrupt the TUI. - origStdout := os.Stdout - origStderr := os.Stderr - devNull, errNull := os.Open(os.DevNull) - if errNull == nil { - os.Stdout = devNull - os.Stderr = devNull - } + origStdout := os.Stdout + origStderr := os.Stderr + origLogOutput := log.StandardLogger().Out + log.SetOutput(io.Discard) - // Generate a random local password for management API authentication. - // This is passed to the server (accepted for localhost requests) - // and used by the TUI HTTP client as the Bearer token. - localMgmtPassword := fmt.Sprintf("tui-%d-%d", os.Getpid(), time.Now().UnixNano()) - if password == "" { - password = localMgmtPassword - } + devNull, errOpenDevNull := os.Open(os.DevNull) + if errOpenDevNull == nil { + os.Stdout = devNull + os.Stderr = devNull + } - // Start server in background - cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password) + restoreIO := func() { + os.Stdout = origStdout + os.Stderr = origStderr + log.SetOutput(origLogOutput) + if devNull != nil { + _ = devNull.Close() + } + } + + localMgmtPassword := fmt.Sprintf("tui-%d-%d", os.Getpid(), time.Now().UnixNano()) + if password == "" { + password = localMgmtPassword + } + + cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password) - // Wait for server to be ready by polling management API with exponential backoff - { client := tui.NewClient(cfg.Port, password) + ready := false backoff := 100 * time.Millisecond - // Try for up to ~10-15 seconds for i := 0; i < 30; i++ { - if _, err := client.GetConfig(); err == nil { + if _, errGetConfig := client.GetConfig(); errGetConfig == nil { + ready = true break } time.Sleep(backoff) - if backoff < 1*time.Second { + if backoff < time.Second { backoff = time.Duration(float64(backoff) * 1.5) } } - } - // Run TUI (blocking) — use the local password for API auth - if err := tui.Run(cfg.Port, password, hook, origStdout); err != nil { - // Restore stdout/stderr before printing error - os.Stdout = origStdout - os.Stderr = origStderr - fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) - } + if !ready { + restoreIO() + cancel() + <-done + fmt.Fprintf(os.Stderr, "TUI error: embedded server is not ready\n") + return + } - // Restore stdout/stderr for shutdown messages - os.Stdout = origStdout - os.Stderr = origStderr - if devNull != nil { - _ = devNull.Close() - } + if errRun := tui.Run(cfg.Port, password, hook, origStdout); errRun != nil { + restoreIO() + fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun) + } else { + restoreIO() + } - // Shutdown server - cancel() - <-done + cancel() + <-done + } else { + // Default TUI mode: pure management client. + // The proxy server must already be running. + if errRun := tui.Run(cfg.Port, password, nil, os.Stdout); errRun != nil { + fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun) + } + } } else { + // Start the main proxy service + managementasset.StartAutoUpdater(context.Background(), configFilePath) cmd.StartService(cfg, configFilePath, password) } } diff --git a/internal/tui/app.go b/internal/tui/app.go index f2dcb3a0..b9ee9e1a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1,10 +1,12 @@ package tui import ( + "fmt" "io" "os" "strings" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -25,6 +27,14 @@ type App struct { activeTab int tabs []string + standalone bool + logsEnabled bool + + authenticated bool + authInput textinput.Model + authError string + authConnecting bool + dashboard dashboardModel config configTabModel auth authTabModel @@ -34,7 +44,7 @@ type App struct { logs logsTabModel client *Client - hook *LogHook + width int height int ready bool @@ -43,32 +53,60 @@ type App struct { initialized [7]bool } +type authConnectMsg struct { + cfg map[string]any + err error +} + // NewApp creates the root TUI application model. func NewApp(port int, secretKey string, hook *LogHook) App { + standalone := hook != nil + authRequired := !standalone + ti := textinput.New() + ti.CharLimit = 512 + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '*' + ti.SetValue(strings.TrimSpace(secretKey)) + ti.Focus() + client := NewClient(port, secretKey) - return App{ - activeTab: tabDashboard, - tabs: TabNames(), - dashboard: newDashboardModel(client), - config: newConfigTabModel(client), - auth: newAuthTabModel(client), - keys: newKeysTabModel(client), - oauth: newOAuthTabModel(client), - usage: newUsageTabModel(client), - logs: newLogsTabModel(hook), - client: client, - hook: hook, + app := App{ + activeTab: tabDashboard, + standalone: standalone, + logsEnabled: true, + authenticated: !authRequired, + authInput: ti, + dashboard: newDashboardModel(client), + config: newConfigTabModel(client), + auth: newAuthTabModel(client), + keys: newKeysTabModel(client), + oauth: newOAuthTabModel(client), + usage: newUsageTabModel(client), + logs: newLogsTabModel(client, hook), + client: client, + initialized: [7]bool{ + tabDashboard: true, + tabLogs: true, + }, } + + app.refreshTabs() + if authRequired { + app.initialized = [7]bool{} + } + app.setAuthInputPrompt() + return app } func (a App) Init() tea.Cmd { - // Initialize dashboard and logs on start - a.initialized[tabDashboard] = true - a.initialized[tabLogs] = true - return tea.Batch( - a.dashboard.Init(), - a.logs.Init(), - ) + if !a.authenticated { + return textinput.Blink + } + cmds := []tea.Cmd{a.dashboard.Init()} + if a.logsEnabled { + cmds = append(cmds, a.logs.Init()) + } + return tea.Batch(cmds...) } func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -77,6 +115,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.width = msg.Width a.height = msg.Height a.ready = true + if a.width > 0 { + a.authInput.Width = a.width - 6 + } contentH := a.height - 4 // tab bar + status bar if contentH < 1 { contentH = 1 @@ -91,32 +132,119 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.logs.SetSize(contentW, contentH) return a, nil + case authConnectMsg: + a.authConnecting = false + if msg.err != nil { + a.authError = fmt.Sprintf(T("auth_gate_connect_fail"), msg.err.Error()) + return a, nil + } + a.authError = "" + a.authenticated = true + a.logsEnabled = a.standalone || isLogsEnabledFromConfig(msg.cfg) + a.refreshTabs() + a.initialized = [7]bool{} + a.initialized[tabDashboard] = true + cmds := []tea.Cmd{a.dashboard.Init()} + if a.logsEnabled { + a.initialized[tabLogs] = true + cmds = append(cmds, a.logs.Init()) + } + return a, tea.Batch(cmds...) + + case configUpdateMsg: + var cmdLogs tea.Cmd + if !a.standalone && msg.err == nil && msg.path == "logging-to-file" { + logsEnabledConfig, okConfig := msg.value.(bool) + if okConfig { + logsEnabledBefore := a.logsEnabled + a.logsEnabled = logsEnabledConfig + if logsEnabledBefore != a.logsEnabled { + a.refreshTabs() + } + if !a.logsEnabled { + a.initialized[tabLogs] = false + } + if !logsEnabledBefore && a.logsEnabled { + a.initialized[tabLogs] = true + cmdLogs = a.logs.Init() + } + } + } + + var cmdConfig tea.Cmd + a.config, cmdConfig = a.config.Update(msg) + if cmdConfig != nil && cmdLogs != nil { + return a, tea.Batch(cmdConfig, cmdLogs) + } + if cmdConfig != nil { + return a, cmdConfig + } + return a, cmdLogs + case tea.KeyMsg: + if !a.authenticated { + switch msg.String() { + case "ctrl+c", "q": + return a, tea.Quit + case "L": + ToggleLocale() + a.refreshTabs() + a.setAuthInputPrompt() + return a, nil + case "enter": + if a.authConnecting { + return a, nil + } + password := strings.TrimSpace(a.authInput.Value()) + if password == "" { + a.authError = T("auth_gate_password_required") + return a, nil + } + a.authError = "" + a.authConnecting = true + return a, a.connectWithPassword(password) + default: + var cmd tea.Cmd + a.authInput, cmd = a.authInput.Update(msg) + return a, cmd + } + } + switch msg.String() { case "ctrl+c": return a, tea.Quit case "q": // Only quit if not in logs tab (where 'q' might be useful) - if a.activeTab != tabLogs { + if !a.logsEnabled || a.activeTab != tabLogs { return a, tea.Quit } case "L": ToggleLocale() - a.tabs = TabNames() + a.refreshTabs() return a.broadcastToAllTabs(localeChangedMsg{}) case "tab": + if len(a.tabs) == 0 { + return a, nil + } prevTab := a.activeTab a.activeTab = (a.activeTab + 1) % len(a.tabs) - a.tabs = TabNames() return a, a.initTabIfNeeded(prevTab) case "shift+tab": + if len(a.tabs) == 0 { + return a, nil + } prevTab := a.activeTab a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs) - a.tabs = TabNames() return a, a.initTabIfNeeded(prevTab) } } + if !a.authenticated { + var cmd tea.Cmd + a.authInput, cmd = a.authInput.Update(msg) + return a, cmd + } + // Route msg to active tab var cmd tea.Cmd switch a.activeTab { @@ -136,13 +264,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.logs, cmd = a.logs.Update(msg) } - // Always route logLineMsg to logs tab even if not active, - // AND capture the returned cmd to maintain the waitForLog chain. - if _, ok := msg.(logLineMsg); ok && a.activeTab != tabLogs { - var logCmd tea.Cmd - a.logs, logCmd = a.logs.Update(msg) - if logCmd != nil { - cmd = logCmd + // Keep logs polling alive even when logs tab is not active. + if a.logsEnabled && a.activeTab != tabLogs { + switch msg.(type) { + case logsPollMsg, logsTickMsg, logLineMsg: + var logCmd tea.Cmd + a.logs, logCmd = a.logs.Update(msg) + if logCmd != nil { + cmd = logCmd + } } } @@ -152,6 +282,30 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // localeChangedMsg is broadcast to all tabs when the user toggles locale. type localeChangedMsg struct{} +func (a *App) refreshTabs() { + names := TabNames() + if a.logsEnabled { + a.tabs = names + } else { + filtered := make([]string, 0, len(names)-1) + for idx, name := range names { + if idx == tabLogs { + continue + } + filtered = append(filtered, name) + } + a.tabs = filtered + } + + if len(a.tabs) == 0 { + a.activeTab = tabDashboard + return + } + if a.activeTab >= len(a.tabs) { + a.activeTab = len(a.tabs) - 1 + } +} + func (a *App) initTabIfNeeded(_ int) tea.Cmd { if a.initialized[a.activeTab] { return nil @@ -171,12 +325,19 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd { case tabUsage: return a.usage.Init() case tabLogs: + if !a.logsEnabled { + return nil + } return a.logs.Init() } return nil } func (a App) View() string { + if !a.authenticated { + return a.renderAuthView() + } + if !a.ready { return T("initializing_tui") } @@ -202,7 +363,9 @@ func (a App) View() string { case tabUsage: sb.WriteString(a.usage.View()) case tabLogs: - sb.WriteString(a.logs.View()) + if a.logsEnabled { + sb.WriteString(a.logs.View()) + } } // Status bar @@ -212,6 +375,27 @@ func (a App) View() string { return sb.String() } +func (a App) renderAuthView() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render(T("auth_gate_title"))) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(T("auth_gate_help"))) + sb.WriteString("\n\n") + if a.authConnecting { + sb.WriteString(warningStyle.Render(T("auth_gate_connecting"))) + sb.WriteString("\n\n") + } + if strings.TrimSpace(a.authError) != "" { + sb.WriteString(errorStyle.Render(a.authError)) + sb.WriteString("\n\n") + } + sb.WriteString(a.authInput.View()) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(T("auth_gate_enter"))) + return sb.String() +} + func (a App) renderTabBar() string { var tabs []string for i, name := range a.tabs { @@ -226,18 +410,91 @@ func (a App) renderTabBar() string { } func (a App) renderStatusBar() string { - left := T("status_left") - right := T("status_right") - gap := a.width - lipgloss.Width(left) - lipgloss.Width(right) + left := strings.TrimRight(T("status_left"), " ") + right := strings.TrimRight(T("status_right"), " ") + + width := a.width + if width < 1 { + width = 1 + } + + // statusBarStyle has left/right padding(1), so content area is width-2. + contentWidth := width - 2 + if contentWidth < 0 { + contentWidth = 0 + } + + if lipgloss.Width(left) > contentWidth { + left = fitStringWidth(left, contentWidth) + right = "" + } + + remaining := contentWidth - lipgloss.Width(left) + if remaining < 0 { + remaining = 0 + } + if lipgloss.Width(right) > remaining { + right = fitStringWidth(right, remaining) + } + + gap := contentWidth - lipgloss.Width(left) - lipgloss.Width(right) if gap < 0 { gap = 0 } - return statusBarStyle.Width(a.width).Render(left + strings.Repeat(" ", gap) + right) + return statusBarStyle.Width(width).Render(left + strings.Repeat(" ", gap) + right) +} + +func fitStringWidth(text string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + if lipgloss.Width(text) <= maxWidth { + return text + } + + out := "" + for _, r := range text { + next := out + string(r) + if lipgloss.Width(next) > maxWidth { + break + } + out = next + } + return out +} + +func isLogsEnabledFromConfig(cfg map[string]any) bool { + if cfg == nil { + return true + } + value, ok := cfg["logging-to-file"] + if !ok { + return true + } + enabled, ok := value.(bool) + if !ok { + return true + } + return enabled +} + +func (a *App) setAuthInputPrompt() { + if a == nil { + return + } + a.authInput.Prompt = fmt.Sprintf(" %s: ", T("auth_gate_password")) +} + +func (a App) connectWithPassword(password string) tea.Cmd { + return func() tea.Msg { + a.client.SetSecretKey(password) + cfg, errGetConfig := a.client.GetConfig() + return authConnectMsg{cfg: cfg, err: errGetConfig} + } } // Run starts the TUI application. // output specifies where bubbletea renders. If nil, defaults to os.Stdout. -// Pass the real terminal stdout here when os.Stdout has been redirected. func Run(port int, secretKey string, hook *LogHook, output io.Writer) error { if output == nil { output = os.Stdout diff --git a/internal/tui/client.go b/internal/tui/client.go index 81016cc5..6f75d6be 100644 --- a/internal/tui/client.go +++ b/internal/tui/client.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "strconv" "strings" "time" ) @@ -20,13 +22,18 @@ type Client struct { func NewClient(port int, secretKey string) *Client { return &Client{ baseURL: fmt.Sprintf("http://127.0.0.1:%d", port), - secretKey: secretKey, + secretKey: strings.TrimSpace(secretKey), http: &http.Client{ Timeout: 10 * time.Second, }, } } +// SetSecretKey updates management API bearer token used by this client. +func (c *Client) SetSecretKey(secretKey string) { + c.secretKey = strings.TrimSpace(secretKey) +} + func (c *Client) doRequest(method, path string, body io.Reader) ([]byte, int, error) { url := c.baseURL + path req, err := http.NewRequest(method, url, body) @@ -150,7 +157,10 @@ func (c *Client) GetAuthFiles() ([]map[string]any, error) { // DeleteAuthFile deletes a single auth file by name. func (c *Client) DeleteAuthFile(name string) error { - _, code, err := c.doRequest("DELETE", "/v0/management/auth-files?name="+name, nil) + query := url.Values{} + query.Set("name", name) + path := "/v0/management/auth-files?" + query.Encode() + _, code, err := c.doRequest("DELETE", path, nil) if err != nil { return err } @@ -176,12 +186,57 @@ func (c *Client) PatchAuthFileFields(name string, fields map[string]any) error { } // GetLogs fetches log lines from the server. -func (c *Client) GetLogs(cutoff int64, limit int) (map[string]any, error) { - path := fmt.Sprintf("/v0/management/logs?limit=%d", limit) - if cutoff > 0 { - path += fmt.Sprintf("&cutoff=%d", cutoff) +func (c *Client) GetLogs(after int64, limit int) ([]string, int64, error) { + query := url.Values{} + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) } - return c.getJSON(path) + if after > 0 { + query.Set("after", strconv.FormatInt(after, 10)) + } + + path := "/v0/management/logs" + encodedQuery := query.Encode() + if encodedQuery != "" { + path += "?" + encodedQuery + } + + wrapper, err := c.getJSON(path) + if err != nil { + return nil, after, err + } + + lines := []string{} + if rawLines, ok := wrapper["lines"]; ok && rawLines != nil { + rawJSON, errMarshal := json.Marshal(rawLines) + if errMarshal != nil { + return nil, after, errMarshal + } + if errUnmarshal := json.Unmarshal(rawJSON, &lines); errUnmarshal != nil { + return nil, after, errUnmarshal + } + } + + latest := after + if rawLatest, ok := wrapper["latest-timestamp"]; ok { + switch value := rawLatest.(type) { + case float64: + latest = int64(value) + case json.Number: + if parsed, errParse := value.Int64(); errParse == nil { + latest = parsed + } + case int64: + latest = value + case int: + latest = int64(value) + } + } + if latest < after { + latest = after + } + + return lines, latest, nil } // GetAPIKeys fetches the list of API keys. @@ -303,7 +358,10 @@ func (c *Client) GetDebug() (bool, error) { // GetAuthStatus polls the OAuth session status. // Returns status ("wait", "ok", "error") and optional error message. func (c *Client) GetAuthStatus(state string) (string, string, error) { - wrapper, err := c.getJSON("/v0/management/get-auth-status?state=" + state) + query := url.Values{} + query.Set("state", state) + path := "/v0/management/get-auth-status?" + query.Encode() + wrapper, err := c.getJSON(path) if err != nil { return "", "", err } diff --git a/internal/tui/config_tab.go b/internal/tui/config_tab.go index 762c3ac2..ff9ad040 100644 --- a/internal/tui/config_tab.go +++ b/internal/tui/config_tab.go @@ -41,7 +41,9 @@ type configDataMsg struct { } type configUpdateMsg struct { - err error + path string + value any + err error } func newConfigTabModel(client *Client) configTabModel { @@ -132,7 +134,7 @@ func (m configTabModel) handleNormalKey(msg tea.KeyMsg) (configTabModel, tea.Cmd } // Start editing for int/string m.editing = true - m.textInput.SetValue(f.value) + m.textInput.SetValue(configFieldEditValue(f)) m.textInput.Focus() m.viewport.SetContent(m.renderContent()) return m, textinput.Blink @@ -168,8 +170,13 @@ func (m configTabModel) toggleBool(idx int) tea.Cmd { return func() tea.Msg { f := m.fields[idx] current := f.value == "true" - err := m.client.PutBoolField(f.apiPath, !current) - return configUpdateMsg{err: err} + newValue := !current + errPutBool := m.client.PutBoolField(f.apiPath, newValue) + return configUpdateMsg{ + path: f.apiPath, + value: newValue, + err: errPutBool, + } } } @@ -177,20 +184,37 @@ func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd { return func() tea.Msg { f := m.fields[idx] var err error + var value any switch f.kind { case "int": - v, parseErr := strconv.Atoi(newValue) - if parseErr != nil { - return configUpdateMsg{err: fmt.Errorf("%s: %s", T("invalid_int"), newValue)} + valueInt, errAtoi := strconv.Atoi(newValue) + if errAtoi != nil { + return configUpdateMsg{ + path: f.apiPath, + err: fmt.Errorf("%s: %s", T("invalid_int"), newValue), + } } - err = m.client.PutIntField(f.apiPath, v) + value = valueInt + err = m.client.PutIntField(f.apiPath, valueInt) case "string": + value = newValue err = m.client.PutStringField(f.apiPath, newValue) } - return configUpdateMsg{err: err} + return configUpdateMsg{ + path: f.apiPath, + value: value, + err: err, + } } } +func configFieldEditValue(f configField) string { + if rawString, ok := f.rawValue.(string); ok { + return rawString + } + return f.value +} + func (m *configTabModel) SetSize(w, h int) { m.width = w m.height = h @@ -334,8 +358,10 @@ func (m configTabModel) parseConfig(cfg map[string]any) []configField { // AMP settings if amp, ok := cfg["ampcode"].(map[string]any); ok { - fields = append(fields, configField{"AMP Upstream URL", "ampcode/upstream-url", "string", getString(amp, "upstream-url"), nil}) - fields = append(fields, configField{"AMP Upstream API Key", "ampcode/upstream-api-key", "string", maskIfNotEmpty(getString(amp, "upstream-api-key")), nil}) + upstreamURL := getString(amp, "upstream-url") + upstreamAPIKey := getString(amp, "upstream-api-key") + fields = append(fields, configField{"AMP Upstream URL", "ampcode/upstream-url", "string", upstreamURL, upstreamURL}) + fields = append(fields, configField{"AMP Upstream API Key", "ampcode/upstream-api-key", "string", maskIfNotEmpty(upstreamAPIKey), upstreamAPIKey}) fields = append(fields, configField{"AMP Restrict Mgmt Localhost", "ampcode/restrict-management-to-localhost", "bool", fmt.Sprintf("%v", getBool(amp, "restrict-management-to-localhost")), nil}) } diff --git a/internal/tui/i18n.go b/internal/tui/i18n.go index 84da3851..2964a6c6 100644 --- a/internal/tui/i18n.go +++ b/internal/tui/i18n.go @@ -83,9 +83,16 @@ var zhStrings = map[string]string{ "error_prefix": "⚠ 错误: ", // ── Status bar ── - "status_left": " CLIProxyAPI 管理终端", - "status_right": "Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 ", - "initializing_tui": "正在初始化...", + "status_left": " CLIProxyAPI 管理终端", + "status_right": "Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 ", + "initializing_tui": "正在初始化...", + "auth_gate_title": "🔐 连接管理 API", + "auth_gate_help": " 请输入管理密码并按 Enter 连接", + "auth_gate_password": "密码", + "auth_gate_enter": " Enter: 连接 • q/Ctrl+C: 退出 • L: 语言", + "auth_gate_connecting": "正在连接...", + "auth_gate_connect_fail": "连接失败:%s", + "auth_gate_password_required": "请输入密码", // ── Dashboard ── "dashboard_title": "📊 仪表盘", @@ -227,9 +234,16 @@ var enStrings = map[string]string{ "error_prefix": "⚠ Error: ", // ── Status bar ── - "status_left": " CLIProxyAPI Management TUI", - "status_right": "Tab/Shift+Tab: switch • L: lang • q/Ctrl+C: quit ", - "initializing_tui": "Initializing...", + "status_left": " CLIProxyAPI Management TUI", + "status_right": "Tab/Shift+Tab: switch • L: lang • q/Ctrl+C: quit ", + "initializing_tui": "Initializing...", + "auth_gate_title": "🔐 Connect Management API", + "auth_gate_help": " Enter management password and press Enter to connect", + "auth_gate_password": "Password", + "auth_gate_enter": " Enter: connect • q/Ctrl+C: quit • L: lang", + "auth_gate_connecting": "Connecting...", + "auth_gate_connect_fail": "Connection failed: %s", + "auth_gate_password_required": "password is required", // ── Dashboard ── "dashboard_title": "📊 Dashboard", diff --git a/internal/tui/logs_tab.go b/internal/tui/logs_tab.go index ec7bdfc5..456200d9 100644 --- a/internal/tui/logs_tab.go +++ b/internal/tui/logs_tab.go @@ -3,13 +3,15 @@ package tui import ( "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" ) -// logsTabModel displays real-time log lines from the logrus hook. +// logsTabModel displays real-time log lines from hook/API source. type logsTabModel struct { + client *Client hook *LogHook viewport viewport.Model lines []string @@ -19,13 +21,22 @@ type logsTabModel struct { height int ready bool filter string // "", "debug", "info", "warn", "error" + after int64 + lastErr error } -// logLineMsg carries a new log line from the logrus hook channel. +type logsPollMsg struct { + lines []string + latest int64 + err error +} + +type logsTickMsg struct{} type logLineMsg string -func newLogsTabModel(hook *LogHook) logsTabModel { +func newLogsTabModel(client *Client, hook *LogHook) logsTabModel { return logsTabModel{ + client: client, hook: hook, maxLines: 5000, autoScroll: true, @@ -33,11 +44,31 @@ func newLogsTabModel(hook *LogHook) logsTabModel { } func (m logsTabModel) Init() tea.Cmd { - return m.waitForLog + if m.hook != nil { + return m.waitForLog + } + return m.fetchLogs +} + +func (m logsTabModel) fetchLogs() tea.Msg { + lines, latest, err := m.client.GetLogs(m.after, 200) + return logsPollMsg{ + lines: lines, + latest: latest, + err: err, + } +} + +func (m logsTabModel) waitForNextPoll() tea.Cmd { + return tea.Tick(2*time.Second, func(_ time.Time) tea.Msg { + return logsTickMsg{} + }) } -// waitForLog listens on the hook channel and returns a logLineMsg. func (m logsTabModel) waitForLog() tea.Msg { + if m.hook == nil { + return nil + } line, ok := <-m.hook.Chan() if !ok { return nil @@ -50,6 +81,32 @@ func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) { case localeChangedMsg: m.viewport.SetContent(m.renderLogs()) return m, nil + case logsTickMsg: + if m.hook != nil { + return m, nil + } + return m, m.fetchLogs + case logsPollMsg: + if m.hook != nil { + return m, nil + } + if msg.err != nil { + m.lastErr = msg.err + } else { + m.lastErr = nil + m.after = msg.latest + if len(msg.lines) > 0 { + m.lines = append(m.lines, msg.lines...) + if len(m.lines) > m.maxLines { + m.lines = m.lines[len(m.lines)-m.maxLines:] + } + } + } + m.viewport.SetContent(m.renderLogs()) + if m.autoScroll { + m.viewport.GotoBottom() + } + return m, m.waitForNextPoll() case logLineMsg: m.lines = append(m.lines, string(msg)) if len(m.lines) > m.maxLines { @@ -71,6 +128,7 @@ func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) { return m, nil case "c": m.lines = nil + m.lastErr = nil m.viewport.SetContent(m.renderLogs()) return m, nil case "1": @@ -151,6 +209,11 @@ func (m logsTabModel) renderLogs() string { sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString("\n") + if m.lastErr != nil { + sb.WriteString(errorStyle.Render("⚠ Error: " + m.lastErr.Error())) + sb.WriteString("\n") + } + if len(m.lines) == 0 { sb.WriteString(subtitleStyle.Render(T("logs_waiting"))) return sb.String()