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.
This commit is contained in:
Luis Pater
2026-02-19 03:18:08 +08:00
parent 2c8821891c
commit 93fe58e31e
6 changed files with 545 additions and 115 deletions

View File

@@ -71,6 +71,7 @@ func main() {
var configPath string var configPath string
var password string var password string
var tuiMode bool var tuiMode bool
var standalone bool
// Define command-line flags for different operation modes. // Define command-line flags for different operation modes.
flag.BoolVar(&login, "login", false, "Login Google Account") 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(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
flag.StringVar(&password, "password", "", "") flag.StringVar(&password, "password", "", "")
flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI") 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() { flag.CommandLine.Usage = func() {
out := flag.CommandLine.Output() out := flag.CommandLine.Output()
@@ -483,72 +485,82 @@ func main() {
cmd.WaitForCloudDeploy() cmd.WaitForCloudDeploy()
return return
} }
// Start the main proxy service
managementasset.StartAutoUpdater(context.Background(), configFilePath)
if tuiMode { if tuiMode {
// Install logrus hook to capture logs for TUI 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 := tui.NewLogHook(2000)
hook.SetFormatter(&logging.LogFormatter{}) hook.SetFormatter(&logging.LogFormatter{})
log.AddHook(hook) log.AddHook(hook)
// Suppress logrus stdout output (TUI owns the terminal)
log.SetOutput(io.Discard)
// 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 origStdout := os.Stdout
origStderr := os.Stderr origStderr := os.Stderr
devNull, errNull := os.Open(os.DevNull) origLogOutput := log.StandardLogger().Out
if errNull == nil { log.SetOutput(io.Discard)
devNull, errOpenDevNull := os.Open(os.DevNull)
if errOpenDevNull == nil {
os.Stdout = devNull os.Stdout = devNull
os.Stderr = devNull os.Stderr = devNull
} }
// Generate a random local password for management API authentication. restoreIO := func() {
// This is passed to the server (accepted for localhost requests) os.Stdout = origStdout
// and used by the TUI HTTP client as the Bearer token. os.Stderr = origStderr
log.SetOutput(origLogOutput)
if devNull != nil {
_ = devNull.Close()
}
}
localMgmtPassword := fmt.Sprintf("tui-%d-%d", os.Getpid(), time.Now().UnixNano()) localMgmtPassword := fmt.Sprintf("tui-%d-%d", os.Getpid(), time.Now().UnixNano())
if password == "" { if password == "" {
password = localMgmtPassword password = localMgmtPassword
} }
// Start server in background
cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password) 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) client := tui.NewClient(cfg.Port, password)
ready := false
backoff := 100 * time.Millisecond backoff := 100 * time.Millisecond
// Try for up to ~10-15 seconds
for i := 0; i < 30; i++ { for i := 0; i < 30; i++ {
if _, err := client.GetConfig(); err == nil { if _, errGetConfig := client.GetConfig(); errGetConfig == nil {
ready = true
break break
} }
time.Sleep(backoff) time.Sleep(backoff)
if backoff < 1*time.Second { if backoff < time.Second {
backoff = time.Duration(float64(backoff) * 1.5) backoff = time.Duration(float64(backoff) * 1.5)
} }
} }
if !ready {
restoreIO()
cancel()
<-done
fmt.Fprintf(os.Stderr, "TUI error: embedded server is not ready\n")
return
} }
// Run TUI (blocking) — use the local password for API auth if errRun := tui.Run(cfg.Port, password, hook, origStdout); errRun != nil {
if err := tui.Run(cfg.Port, password, hook, origStdout); err != nil { restoreIO()
// Restore stdout/stderr before printing error fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun)
os.Stdout = origStdout } else {
os.Stderr = origStderr restoreIO()
fmt.Fprintf(os.Stderr, "TUI error: %v\n", err)
} }
// Restore stdout/stderr for shutdown messages
os.Stdout = origStdout
os.Stderr = origStderr
if devNull != nil {
_ = devNull.Close()
}
// Shutdown server
cancel() cancel()
<-done <-done
} else { } 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) cmd.StartService(cfg, configFilePath, password)
} }
} }

View File

@@ -1,10 +1,12 @@
package tui package tui
import ( import (
"fmt"
"io" "io"
"os" "os"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -25,6 +27,14 @@ type App struct {
activeTab int activeTab int
tabs []string tabs []string
standalone bool
logsEnabled bool
authenticated bool
authInput textinput.Model
authError string
authConnecting bool
dashboard dashboardModel dashboard dashboardModel
config configTabModel config configTabModel
auth authTabModel auth authTabModel
@@ -34,7 +44,7 @@ type App struct {
logs logsTabModel logs logsTabModel
client *Client client *Client
hook *LogHook
width int width int
height int height int
ready bool ready bool
@@ -43,32 +53,60 @@ type App struct {
initialized [7]bool initialized [7]bool
} }
type authConnectMsg struct {
cfg map[string]any
err error
}
// NewApp creates the root TUI application model. // NewApp creates the root TUI application model.
func NewApp(port int, secretKey string, hook *LogHook) App { 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) client := NewClient(port, secretKey)
return App{ app := App{
activeTab: tabDashboard, activeTab: tabDashboard,
tabs: TabNames(), standalone: standalone,
logsEnabled: true,
authenticated: !authRequired,
authInput: ti,
dashboard: newDashboardModel(client), dashboard: newDashboardModel(client),
config: newConfigTabModel(client), config: newConfigTabModel(client),
auth: newAuthTabModel(client), auth: newAuthTabModel(client),
keys: newKeysTabModel(client), keys: newKeysTabModel(client),
oauth: newOAuthTabModel(client), oauth: newOAuthTabModel(client),
usage: newUsageTabModel(client), usage: newUsageTabModel(client),
logs: newLogsTabModel(hook), logs: newLogsTabModel(client, hook),
client: client, client: client,
hook: hook, 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 { func (a App) Init() tea.Cmd {
// Initialize dashboard and logs on start if !a.authenticated {
a.initialized[tabDashboard] = true return textinput.Blink
a.initialized[tabLogs] = true }
return tea.Batch( cmds := []tea.Cmd{a.dashboard.Init()}
a.dashboard.Init(), if a.logsEnabled {
a.logs.Init(), cmds = append(cmds, a.logs.Init())
) }
return tea.Batch(cmds...)
} }
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.width = msg.Width
a.height = msg.Height a.height = msg.Height
a.ready = true a.ready = true
if a.width > 0 {
a.authInput.Width = a.width - 6
}
contentH := a.height - 4 // tab bar + status bar contentH := a.height - 4 // tab bar + status bar
if contentH < 1 { if contentH < 1 {
contentH = 1 contentH = 1
@@ -91,32 +132,119 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.logs.SetSize(contentW, contentH) a.logs.SetSize(contentW, contentH)
return a, nil 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: 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() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
return a, tea.Quit return a, tea.Quit
case "q": case "q":
// Only quit if not in logs tab (where 'q' might be useful) // 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 return a, tea.Quit
} }
case "L": case "L":
ToggleLocale() ToggleLocale()
a.tabs = TabNames() a.refreshTabs()
return a.broadcastToAllTabs(localeChangedMsg{}) return a.broadcastToAllTabs(localeChangedMsg{})
case "tab": case "tab":
if len(a.tabs) == 0 {
return a, nil
}
prevTab := a.activeTab prevTab := a.activeTab
a.activeTab = (a.activeTab + 1) % len(a.tabs) a.activeTab = (a.activeTab + 1) % len(a.tabs)
a.tabs = TabNames()
return a, a.initTabIfNeeded(prevTab) return a, a.initTabIfNeeded(prevTab)
case "shift+tab": case "shift+tab":
if len(a.tabs) == 0 {
return a, nil
}
prevTab := a.activeTab prevTab := a.activeTab
a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs) a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)
a.tabs = TabNames()
return a, a.initTabIfNeeded(prevTab) 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 // Route msg to active tab
var cmd tea.Cmd var cmd tea.Cmd
switch a.activeTab { switch a.activeTab {
@@ -136,15 +264,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.logs, cmd = a.logs.Update(msg) a.logs, cmd = a.logs.Update(msg)
} }
// Always route logLineMsg to logs tab even if not active, // Keep logs polling alive even when logs tab is not active.
// AND capture the returned cmd to maintain the waitForLog chain. if a.logsEnabled && a.activeTab != tabLogs {
if _, ok := msg.(logLineMsg); ok && a.activeTab != tabLogs { switch msg.(type) {
case logsPollMsg, logsTickMsg, logLineMsg:
var logCmd tea.Cmd var logCmd tea.Cmd
a.logs, logCmd = a.logs.Update(msg) a.logs, logCmd = a.logs.Update(msg)
if logCmd != nil { if logCmd != nil {
cmd = logCmd cmd = logCmd
} }
} }
}
return a, cmd return a, cmd
} }
@@ -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. // localeChangedMsg is broadcast to all tabs when the user toggles locale.
type localeChangedMsg struct{} 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 { func (a *App) initTabIfNeeded(_ int) tea.Cmd {
if a.initialized[a.activeTab] { if a.initialized[a.activeTab] {
return nil return nil
@@ -171,12 +325,19 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd {
case tabUsage: case tabUsage:
return a.usage.Init() return a.usage.Init()
case tabLogs: case tabLogs:
if !a.logsEnabled {
return nil
}
return a.logs.Init() return a.logs.Init()
} }
return nil return nil
} }
func (a App) View() string { func (a App) View() string {
if !a.authenticated {
return a.renderAuthView()
}
if !a.ready { if !a.ready {
return T("initializing_tui") return T("initializing_tui")
} }
@@ -202,8 +363,10 @@ func (a App) View() string {
case tabUsage: case tabUsage:
sb.WriteString(a.usage.View()) sb.WriteString(a.usage.View())
case tabLogs: case tabLogs:
if a.logsEnabled {
sb.WriteString(a.logs.View()) sb.WriteString(a.logs.View())
} }
}
// Status bar // Status bar
sb.WriteString("\n") sb.WriteString("\n")
@@ -212,6 +375,27 @@ func (a App) View() string {
return sb.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 { func (a App) renderTabBar() string {
var tabs []string var tabs []string
for i, name := range a.tabs { for i, name := range a.tabs {
@@ -226,18 +410,91 @@ func (a App) renderTabBar() string {
} }
func (a App) renderStatusBar() string { func (a App) renderStatusBar() string {
left := T("status_left") left := strings.TrimRight(T("status_left"), " ")
right := T("status_right") right := strings.TrimRight(T("status_right"), " ")
gap := a.width - lipgloss.Width(left) - lipgloss.Width(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 { if gap < 0 {
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. // Run starts the TUI application.
// output specifies where bubbletea renders. If nil, defaults to os.Stdout. // 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 { func Run(port int, secretKey string, hook *LogHook, output io.Writer) error {
if output == nil { if output == nil {
output = os.Stdout output = os.Stdout

View File

@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -20,13 +22,18 @@ type Client struct {
func NewClient(port int, secretKey string) *Client { func NewClient(port int, secretKey string) *Client {
return &Client{ return &Client{
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port), baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
secretKey: secretKey, secretKey: strings.TrimSpace(secretKey),
http: &http.Client{ http: &http.Client{
Timeout: 10 * time.Second, 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) { func (c *Client) doRequest(method, path string, body io.Reader) ([]byte, int, error) {
url := c.baseURL + path url := c.baseURL + path
req, err := http.NewRequest(method, url, body) 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. // DeleteAuthFile deletes a single auth file by name.
func (c *Client) DeleteAuthFile(name string) error { 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 { if err != nil {
return err return err
} }
@@ -176,12 +186,57 @@ func (c *Client) PatchAuthFileFields(name string, fields map[string]any) error {
} }
// GetLogs fetches log lines from the server. // GetLogs fetches log lines from the server.
func (c *Client) GetLogs(cutoff int64, limit int) (map[string]any, error) { func (c *Client) GetLogs(after int64, limit int) ([]string, int64, error) {
path := fmt.Sprintf("/v0/management/logs?limit=%d", limit) query := url.Values{}
if cutoff > 0 { if limit > 0 {
path += fmt.Sprintf("&cutoff=%d", cutoff) 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. // GetAPIKeys fetches the list of API keys.
@@ -303,7 +358,10 @@ func (c *Client) GetDebug() (bool, error) {
// GetAuthStatus polls the OAuth session status. // GetAuthStatus polls the OAuth session status.
// Returns status ("wait", "ok", "error") and optional error message. // Returns status ("wait", "ok", "error") and optional error message.
func (c *Client) GetAuthStatus(state string) (string, string, error) { 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 { if err != nil {
return "", "", err return "", "", err
} }

View File

@@ -41,6 +41,8 @@ type configDataMsg struct {
} }
type configUpdateMsg struct { type configUpdateMsg struct {
path string
value any
err error err error
} }
@@ -132,7 +134,7 @@ func (m configTabModel) handleNormalKey(msg tea.KeyMsg) (configTabModel, tea.Cmd
} }
// Start editing for int/string // Start editing for int/string
m.editing = true m.editing = true
m.textInput.SetValue(f.value) m.textInput.SetValue(configFieldEditValue(f))
m.textInput.Focus() m.textInput.Focus()
m.viewport.SetContent(m.renderContent()) m.viewport.SetContent(m.renderContent())
return m, textinput.Blink return m, textinput.Blink
@@ -168,8 +170,13 @@ func (m configTabModel) toggleBool(idx int) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
f := m.fields[idx] f := m.fields[idx]
current := f.value == "true" current := f.value == "true"
err := m.client.PutBoolField(f.apiPath, !current) newValue := !current
return configUpdateMsg{err: err} errPutBool := m.client.PutBoolField(f.apiPath, newValue)
return configUpdateMsg{
path: f.apiPath,
value: newValue,
err: errPutBool,
}
} }
} }
@@ -177,18 +184,35 @@ func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
f := m.fields[idx] f := m.fields[idx]
var err error var err error
var value any
switch f.kind { switch f.kind {
case "int": case "int":
v, parseErr := strconv.Atoi(newValue) valueInt, errAtoi := strconv.Atoi(newValue)
if parseErr != nil { if errAtoi != nil {
return configUpdateMsg{err: fmt.Errorf("%s: %s", T("invalid_int"), newValue)} 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": case "string":
value = newValue
err = m.client.PutStringField(f.apiPath, 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) { func (m *configTabModel) SetSize(w, h int) {
@@ -334,8 +358,10 @@ func (m configTabModel) parseConfig(cfg map[string]any) []configField {
// AMP settings // AMP settings
if amp, ok := cfg["ampcode"].(map[string]any); ok { 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}) upstreamURL := getString(amp, "upstream-url")
fields = append(fields, configField{"AMP Upstream API Key", "ampcode/upstream-api-key", "string", maskIfNotEmpty(getString(amp, "upstream-api-key")), nil}) 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}) fields = append(fields, configField{"AMP Restrict Mgmt Localhost", "ampcode/restrict-management-to-localhost", "bool", fmt.Sprintf("%v", getBool(amp, "restrict-management-to-localhost")), nil})
} }

View File

@@ -86,6 +86,13 @@ var zhStrings = map[string]string{
"status_left": " CLIProxyAPI 管理终端", "status_left": " CLIProxyAPI 管理终端",
"status_right": "Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 ", "status_right": "Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 ",
"initializing_tui": "正在初始化...", "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 ──
"dashboard_title": "📊 仪表盘", "dashboard_title": "📊 仪表盘",
@@ -230,6 +237,13 @@ var enStrings = map[string]string{
"status_left": " CLIProxyAPI Management TUI", "status_left": " CLIProxyAPI Management TUI",
"status_right": "Tab/Shift+Tab: switch • L: lang • q/Ctrl+C: quit ", "status_right": "Tab/Shift+Tab: switch • L: lang • q/Ctrl+C: quit ",
"initializing_tui": "Initializing...", "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 ──
"dashboard_title": "📊 Dashboard", "dashboard_title": "📊 Dashboard",

View File

@@ -3,13 +3,15 @@ package tui
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" 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 { type logsTabModel struct {
client *Client
hook *LogHook hook *LogHook
viewport viewport.Model viewport viewport.Model
lines []string lines []string
@@ -19,13 +21,22 @@ type logsTabModel struct {
height int height int
ready bool ready bool
filter string // "", "debug", "info", "warn", "error" 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 type logLineMsg string
func newLogsTabModel(hook *LogHook) logsTabModel { func newLogsTabModel(client *Client, hook *LogHook) logsTabModel {
return logsTabModel{ return logsTabModel{
client: client,
hook: hook, hook: hook,
maxLines: 5000, maxLines: 5000,
autoScroll: true, autoScroll: true,
@@ -33,11 +44,31 @@ func newLogsTabModel(hook *LogHook) logsTabModel {
} }
func (m logsTabModel) Init() tea.Cmd { func (m logsTabModel) Init() tea.Cmd {
if m.hook != nil {
return m.waitForLog 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 { func (m logsTabModel) waitForLog() tea.Msg {
if m.hook == nil {
return nil
}
line, ok := <-m.hook.Chan() line, ok := <-m.hook.Chan()
if !ok { if !ok {
return nil return nil
@@ -50,6 +81,32 @@ func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) {
case localeChangedMsg: case localeChangedMsg:
m.viewport.SetContent(m.renderLogs()) m.viewport.SetContent(m.renderLogs())
return m, nil 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: case logLineMsg:
m.lines = append(m.lines, string(msg)) m.lines = append(m.lines, string(msg))
if len(m.lines) > m.maxLines { if len(m.lines) > m.maxLines {
@@ -71,6 +128,7 @@ func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) {
return m, nil return m, nil
case "c": case "c":
m.lines = nil m.lines = nil
m.lastErr = nil
m.viewport.SetContent(m.renderLogs()) m.viewport.SetContent(m.renderLogs())
return m, nil return m, nil
case "1": case "1":
@@ -151,6 +209,11 @@ func (m logsTabModel) renderLogs() string {
sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString(strings.Repeat("─", m.width))
sb.WriteString("\n") sb.WriteString("\n")
if m.lastErr != nil {
sb.WriteString(errorStyle.Render("⚠ Error: " + m.lastErr.Error()))
sb.WriteString("\n")
}
if len(m.lines) == 0 { if len(m.lines) == 0 {
sb.WriteString(subtitleStyle.Render(T("logs_waiting"))) sb.WriteString(subtitleStyle.Render(T("logs_waiting")))
return sb.String() return sb.String()