From 54ad7c1b6b433aa9bdffcfdb88ecbdde63f9dcca Mon Sep 17 00:00:00 2001 From: lhpqaq Date: Sun, 15 Feb 2026 14:52:40 +0800 Subject: [PATCH 1/6] feat(tui): add manager tui --- cmd/server/main.go | 71 ++- go.mod | 23 +- go.sum | 45 ++ .../api/handlers/management/auth_files.go | 81 +++ internal/api/server.go | 1 + internal/cmd/run.go | 28 ++ internal/tui/app.go | 242 +++++++++ internal/tui/auth_tab.go | 436 ++++++++++++++++ internal/tui/browser.go | 20 + internal/tui/client.go | 314 ++++++++++++ internal/tui/config_tab.go | 384 ++++++++++++++ internal/tui/dashboard.go | 345 +++++++++++++ internal/tui/keys_tab.go | 190 +++++++ internal/tui/loghook.go | 78 +++ internal/tui/logs_tab.go | 195 ++++++++ internal/tui/oauth_tab.go | 470 ++++++++++++++++++ internal/tui/styles.go | 126 +++++ internal/tui/usage_tab.go | 361 ++++++++++++++ 18 files changed, 3408 insertions(+), 2 deletions(-) create mode 100644 internal/tui/app.go create mode 100644 internal/tui/auth_tab.go create mode 100644 internal/tui/browser.go create mode 100644 internal/tui/client.go create mode 100644 internal/tui/config_tab.go create mode 100644 internal/tui/dashboard.go create mode 100644 internal/tui/keys_tab.go create mode 100644 internal/tui/loghook.go create mode 100644 internal/tui/logs_tab.go create mode 100644 internal/tui/oauth_tab.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/usage_tab.go diff --git a/cmd/server/main.go b/cmd/server/main.go index dec30484..c50fe933 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "errors" "flag" "fmt" + "io" "io/fs" "net/url" "os" @@ -25,6 +26,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" "github.com/router-for-me/CLIProxyAPI/v6/internal/store" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + "github.com/router-for-me/CLIProxyAPI/v6/internal/tui" "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" @@ -68,6 +70,7 @@ func main() { var vertexImport string var configPath string var password string + var tuiMode bool // Define command-line flags for different operation modes. flag.BoolVar(&login, "login", false, "Login Google Account") @@ -84,6 +87,7 @@ func main() { flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") 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.CommandLine.Usage = func() { out := flag.CommandLine.Output() @@ -481,6 +485,71 @@ func main() { } // Start the main proxy service managementasset.StartAutoUpdater(context.Background(), configFilePath) - cmd.StartService(cfg, configFilePath, password) + 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) + + // 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 + } + + // 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 + } + + // 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 + { + client := tui.NewClient(cfg.Port, password) + for i := 0; i < 50; i++ { + time.Sleep(100 * time.Millisecond) + if _, err := client.GetConfig(); err == nil { + break + } + } + } + + // 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) + } + + // Restore stdout/stderr for shutdown messages + os.Stdout = origStdout + os.Stderr = origStderr + if devNull != nil { + _ = devNull.Close() + } + + // Shutdown server + cancel() + <-done + } else { + cmd.StartService(cfg, configFilePath, password) + } } } diff --git a/go.mod b/go.mod index 38a499be..c2e4383d 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,12 @@ module github.com/router-for-me/CLIProxyAPI/v6 -go 1.24.0 +go 1.24.2 require ( github.com/andybalholm/brotli v1.0.6 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-gonic/gin v1.10.1 github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 @@ -31,8 +34,17 @@ require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect @@ -40,6 +52,7 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect @@ -56,19 +69,27 @@ require ( github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.5.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/go.sum b/go.sum index b57b919a..3c424c5e 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,34 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -33,6 +57,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -99,8 +125,14 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw= @@ -112,6 +144,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= @@ -120,6 +158,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= @@ -159,17 +199,22 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index e2ff23f1..3fde365b 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -808,6 +808,87 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled}) } +// PatchAuthFileFields updates editable fields (prefix, proxy_url, priority) of an auth file. +func (h *Handler) PatchAuthFileFields(c *gin.Context) { + if h.authManager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) + return + } + + var req struct { + Name string `json:"name"` + Prefix *string `json:"prefix"` + ProxyURL *string `json:"proxy_url"` + Priority *int `json:"priority"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + ctx := c.Request.Context() + + // Find auth by name or ID + var targetAuth *coreauth.Auth + if auth, ok := h.authManager.GetByID(name); ok { + targetAuth = auth + } else { + auths := h.authManager.List() + for _, auth := range auths { + if auth.FileName == name { + targetAuth = auth + break + } + } + } + + if targetAuth == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) + return + } + + changed := false + if req.Prefix != nil { + targetAuth.Prefix = *req.Prefix + changed = true + } + if req.ProxyURL != nil { + targetAuth.ProxyURL = *req.ProxyURL + changed = true + } + if req.Priority != nil { + if targetAuth.Metadata == nil { + targetAuth.Metadata = make(map[string]any) + } + if *req.Priority == 0 { + delete(targetAuth.Metadata, "priority") + } else { + targetAuth.Metadata["priority"] = *req.Priority + } + changed = true + } + + if !changed { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + targetAuth.UpdatedAt = time.Now() + + if _, err := h.authManager.Update(ctx, targetAuth); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + func (h *Handler) disableAuth(ctx context.Context, id string) { if h == nil || h.authManager == nil { return diff --git a/internal/api/server.go b/internal/api/server.go index 4cbcbba2..a996c78c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -616,6 +616,7 @@ func (s *Server) registerManagementRoutes() { mgmt.POST("/auth-files", s.mgmt.UploadAuthFile) mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile) mgmt.PATCH("/auth-files/status", s.mgmt.PatchAuthFileStatus) + mgmt.PATCH("/auth-files/fields", s.mgmt.PatchAuthFileFields) mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential) mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 1e968126..d8c4f019 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -55,6 +55,34 @@ func StartService(cfg *config.Config, configPath string, localPassword string) { } } +// StartServiceBackground starts the proxy service in a background goroutine +// and returns a cancel function for shutdown and a done channel. +func StartServiceBackground(cfg *config.Config, configPath string, localPassword string) (cancel func(), done <-chan struct{}) { + builder := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath(configPath). + WithLocalManagementPassword(localPassword) + + ctx, cancelFn := context.WithCancel(context.Background()) + doneCh := make(chan struct{}) + + service, err := builder.Build() + if err != nil { + log.Errorf("failed to build proxy service: %v", err) + close(doneCh) + return cancelFn, doneCh + } + + go func() { + defer close(doneCh) + if err := service.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + log.Errorf("proxy service exited with error: %v", err) + } + }() + + return cancelFn, doneCh +} + // WaitForCloudDeploy waits indefinitely for shutdown signals in cloud deploy mode // when no configuration file is available. func WaitForCloudDeploy() { diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 00000000..c6c21c2b --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,242 @@ +package tui + +import ( + "io" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Tab identifiers +const ( + tabDashboard = iota + tabConfig + tabAuthFiles + tabAPIKeys + tabOAuth + tabUsage + tabLogs +) + +var tabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"} + +// App is the root bubbletea model that contains all tab sub-models. +type App struct { + activeTab int + tabs []string + + dashboard dashboardModel + config configTabModel + auth authTabModel + keys keysTabModel + oauth oauthTabModel + usage usageTabModel + logs logsTabModel + + client *Client + hook *LogHook + width int + height int + ready bool + + // Track which tabs have been initialized (fetched data) + initialized [7]bool +} + +// NewApp creates the root TUI application model. +func NewApp(port int, secretKey string, hook *LogHook) App { + 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, + } +} + +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(), + ) +} + +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + a.ready = true + contentH := a.height - 4 // tab bar + status bar + if contentH < 1 { + contentH = 1 + } + contentW := a.width + a.dashboard.SetSize(contentW, contentH) + a.config.SetSize(contentW, contentH) + a.auth.SetSize(contentW, contentH) + a.keys.SetSize(contentW, contentH) + a.oauth.SetSize(contentW, contentH) + a.usage.SetSize(contentW, contentH) + a.logs.SetSize(contentW, contentH) + return a, nil + + case tea.KeyMsg: + 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 { + return a, tea.Quit + } + case "tab": + prevTab := a.activeTab + a.activeTab = (a.activeTab + 1) % len(a.tabs) + return a, a.initTabIfNeeded(prevTab) + case "shift+tab": + prevTab := a.activeTab + a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs) + return a, a.initTabIfNeeded(prevTab) + } + } + + // Route msg to active tab + var cmd tea.Cmd + switch a.activeTab { + case tabDashboard: + a.dashboard, cmd = a.dashboard.Update(msg) + case tabConfig: + a.config, cmd = a.config.Update(msg) + case tabAuthFiles: + a.auth, cmd = a.auth.Update(msg) + case tabAPIKeys: + a.keys, cmd = a.keys.Update(msg) + case tabOAuth: + a.oauth, cmd = a.oauth.Update(msg) + case tabUsage: + a.usage, cmd = a.usage.Update(msg) + case tabLogs: + 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 + } + } + + return a, cmd +} + +func (a *App) initTabIfNeeded(_ int) tea.Cmd { + if a.initialized[a.activeTab] { + return nil + } + a.initialized[a.activeTab] = true + switch a.activeTab { + case tabDashboard: + return a.dashboard.Init() + case tabConfig: + return a.config.Init() + case tabAuthFiles: + return a.auth.Init() + case tabAPIKeys: + return a.keys.Init() + case tabOAuth: + return a.oauth.Init() + case tabUsage: + return a.usage.Init() + case tabLogs: + return a.logs.Init() + } + return nil +} + +func (a App) View() string { + if !a.ready { + return "Initializing TUI..." + } + + var sb strings.Builder + + // Tab bar + sb.WriteString(a.renderTabBar()) + sb.WriteString("\n") + + // Content + switch a.activeTab { + case tabDashboard: + sb.WriteString(a.dashboard.View()) + case tabConfig: + sb.WriteString(a.config.View()) + case tabAuthFiles: + sb.WriteString(a.auth.View()) + case tabAPIKeys: + sb.WriteString(a.keys.View()) + case tabOAuth: + sb.WriteString(a.oauth.View()) + case tabUsage: + sb.WriteString(a.usage.View()) + case tabLogs: + sb.WriteString(a.logs.View()) + } + + // Status bar + sb.WriteString("\n") + sb.WriteString(a.renderStatusBar()) + + return sb.String() +} + +func (a App) renderTabBar() string { + var tabs []string + for i, name := range a.tabs { + if i == a.activeTab { + tabs = append(tabs, tabActiveStyle.Render(name)) + } else { + tabs = append(tabs, tabInactiveStyle.Render(name)) + } + } + tabBar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + return tabBarStyle.Width(a.width).Render(tabBar) +} + +func (a App) renderStatusBar() string { + left := " CLIProxyAPI Management TUI" + right := "Tab/Shift+Tab: switch • q/Ctrl+C: quit " + gap := a.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 0 { + gap = 0 + } + return statusBarStyle.Width(a.width).Render(left + strings.Repeat(" ", gap) + right) +} + +// 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 + } + app := NewApp(port, secretKey, hook) + p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithOutput(output)) + _, err := p.Run() + return err +} diff --git a/internal/tui/auth_tab.go b/internal/tui/auth_tab.go new file mode 100644 index 00000000..c6a38ae7 --- /dev/null +++ b/internal/tui/auth_tab.go @@ -0,0 +1,436 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// editableField represents an editable field on an auth file. +type editableField struct { + label string + key string // API field key: "prefix", "proxy_url", "priority" +} + +var authEditableFields = []editableField{ + {label: "Prefix", key: "prefix"}, + {label: "Proxy URL", key: "proxy_url"}, + {label: "Priority", key: "priority"}, +} + +// authTabModel displays auth credential files with interactive management. +type authTabModel struct { + client *Client + viewport viewport.Model + files []map[string]any + err error + width int + height int + ready bool + cursor int + expanded int // -1 = none expanded, >=0 = expanded index + confirm int // -1 = no confirmation, >=0 = confirm delete for index + status string + + // Editing state + editing bool // true when editing a field + editField int // index into authEditableFields + editInput textinput.Model // text input for editing + editFileName string // name of file being edited +} + +type authFilesMsg struct { + files []map[string]any + err error +} + +type authActionMsg struct { + action string // "deleted", "toggled", "updated" + err error +} + +func newAuthTabModel(client *Client) authTabModel { + ti := textinput.New() + ti.CharLimit = 256 + return authTabModel{ + client: client, + expanded: -1, + confirm: -1, + editInput: ti, + } +} + +func (m authTabModel) Init() tea.Cmd { + return m.fetchFiles +} + +func (m authTabModel) fetchFiles() tea.Msg { + files, err := m.client.GetAuthFiles() + return authFilesMsg{files: files, err: err} +} + +func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { + switch msg := msg.(type) { + case authFilesMsg: + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + m.files = msg.files + if m.cursor >= len(m.files) { + m.cursor = max(0, len(m.files)-1) + } + m.status = "" + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case authActionMsg: + if msg.err != nil { + m.status = errorStyle.Render("✗ " + msg.err.Error()) + } else { + m.status = successStyle.Render("✓ " + msg.action) + } + m.confirm = -1 + m.viewport.SetContent(m.renderContent()) + return m, m.fetchFiles + + 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, _ := strconv.Atoi(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("Updated %s on %s", 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 + } + } + + // ---- 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("Deleted %s", 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 + } + + // ---- 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 := "Enabled" + if newDisabled { + action = "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 + } + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +// startEdit activates inline editing for a field on the currently selected auth file. +func (m *authTabModel) startEdit(fieldIdx int) tea.Cmd { + if m.cursor >= len(m.files) { + return nil + } + f := m.files[m.cursor] + m.editFileName = getString(f, "name") + m.editField = fieldIdx + m.editing = true + + // Pre-populate with current value + key := authEditableFields[fieldIdx].key + currentVal := getAnyString(f, key) + m.editInput.SetValue(currentVal) + m.editInput.Focus() + m.editInput.Prompt = fmt.Sprintf(" %s: ", authEditableFields[fieldIdx].label) + m.viewport.SetContent(m.renderContent()) + return textinput.Blink +} + +func (m *authTabModel) SetSize(w, h int) { + m.width = w + m.height = h + m.editInput.Width = w - 20 + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderContent()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m authTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m authTabModel) renderContent() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("🔑 Auth Files")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter] expand • [e] enable/disable • [d] delete • [r] refresh")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [1] edit prefix • [2] edit proxy_url • [3] edit priority")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", m.width)) + sb.WriteString("\n") + + if m.err != nil { + sb.WriteString(errorStyle.Render("⚠ Error: " + m.err.Error())) + sb.WriteString("\n") + return sb.String() + } + + if len(m.files) == 0 { + sb.WriteString(subtitleStyle.Render("\n No auth files found")) + sb.WriteString("\n") + return sb.String() + } + + for i, f := range m.files { + name := getString(f, "name") + channel := getString(f, "channel") + email := getString(f, "email") + disabled := getBool(f, "disabled") + + statusIcon := successStyle.Render("●") + statusText := "active" + if disabled { + statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("○") + statusText = "disabled" + } + + cursor := " " + rowStyle := lipgloss.NewStyle() + if i == m.cursor { + cursor = "▸ " + rowStyle = lipgloss.NewStyle().Bold(true) + } + + displayName := name + if len(displayName) > 24 { + displayName = displayName[:21] + "..." + } + displayEmail := email + if len(displayEmail) > 28 { + displayEmail = displayEmail[:25] + "..." + } + + row := fmt.Sprintf("%s%s %-24s %-12s %-28s %s", + cursor, statusIcon, displayName, channel, displayEmail, statusText) + sb.WriteString(rowStyle.Render(row)) + sb.WriteString("\n") + + // Delete confirmation + if m.confirm == i { + sb.WriteString(warningStyle.Render(fmt.Sprintf(" ⚠ Delete %s? [y/n] ", name))) + sb.WriteString("\n") + } + + // Inline edit input + if m.editing && i == m.cursor { + sb.WriteString(m.editInput.View()) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" Enter: save • Esc: cancel")) + sb.WriteString("\n") + } + + // Expanded detail view + if m.expanded == i { + sb.WriteString(m.renderDetail(f)) + } + } + + if m.status != "" { + sb.WriteString("\n") + sb.WriteString(m.status) + sb.WriteString("\n") + } + + return sb.String() +} + +func (m authTabModel) renderDetail(f map[string]any) string { + var sb strings.Builder + + labelStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("111")). + Bold(true) + valueStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + editableMarker := lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")). + Render(" ✎") + + sb.WriteString(" ┌─────────────────────────────────────────────\n") + + fields := []struct { + label string + key string + editable bool + }{ + {"Name", "name", false}, + {"Channel", "channel", false}, + {"Email", "email", false}, + {"Status", "status", false}, + {"Status Msg", "status_message", false}, + {"File Name", "file_name", false}, + {"Auth Type", "auth_type", false}, + {"Prefix", "prefix", true}, + {"Proxy URL", "proxy_url", true}, + {"Priority", "priority", true}, + {"Project ID", "project_id", false}, + {"Disabled", "disabled", false}, + {"Created", "created_at", false}, + {"Updated", "updated_at", false}, + } + + for _, field := range fields { + val := getAnyString(f, field.key) + if val == "" || val == "" { + if field.editable { + val = "(not set)" + } else { + continue + } + } + editMark := "" + if field.editable { + editMark = editableMarker + } + line := fmt.Sprintf(" │ %s %s%s", + labelStyle.Render(fmt.Sprintf("%-12s:", field.label)), + valueStyle.Render(val), + editMark) + sb.WriteString(line) + sb.WriteString("\n") + } + + sb.WriteString(" └─────────────────────────────────────────────\n") + return sb.String() +} + +// getAnyString converts any value to its string representation. +func getAnyString(m map[string]any, key string) string { + v, ok := m[key] + if !ok || v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/tui/browser.go b/internal/tui/browser.go new file mode 100644 index 00000000..5532a5a2 --- /dev/null +++ b/internal/tui/browser.go @@ -0,0 +1,20 @@ +package tui + +import ( + "os/exec" + "runtime" +) + +// openBrowser opens the specified URL in the user's default browser. +func openBrowser(url string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + return exec.Command("xdg-open", url).Start() + } +} diff --git a/internal/tui/client.go b/internal/tui/client.go new file mode 100644 index 00000000..b2e15e68 --- /dev/null +++ b/internal/tui/client.go @@ -0,0 +1,314 @@ +package tui + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client wraps HTTP calls to the management API. +type Client struct { + baseURL string + secretKey string + http *http.Client +} + +// NewClient creates a new management API client. +func NewClient(port int, secretKey string) *Client { + return &Client{ + baseURL: fmt.Sprintf("http://127.0.0.1:%d", port), + secretKey: secretKey, + http: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *Client) doRequest(method, path string, body io.Reader) ([]byte, int, error) { + url := c.baseURL + path + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, 0, err + } + if c.secretKey != "" { + req.Header.Set("Authorization", "Bearer "+c.secretKey) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return data, resp.StatusCode, nil +} + +func (c *Client) get(path string) ([]byte, error) { + data, code, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + if code >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", code, strings.TrimSpace(string(data))) + } + return data, nil +} + +func (c *Client) put(path string, body io.Reader) ([]byte, error) { + data, code, err := c.doRequest("PUT", path, body) + if err != nil { + return nil, err + } + if code >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", code, strings.TrimSpace(string(data))) + } + return data, nil +} + +func (c *Client) patch(path string, body io.Reader) ([]byte, error) { + data, code, err := c.doRequest("PATCH", path, body) + if err != nil { + return nil, err + } + if code >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", code, strings.TrimSpace(string(data))) + } + return data, nil +} + +// getJSON fetches a path and unmarshals JSON into a generic map. +func (c *Client) getJSON(path string) (map[string]any, error) { + data, err := c.get(path) + if err != nil { + return nil, err + } + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} + +// postJSON sends a JSON body via POST and checks for errors. +func (c *Client) postJSON(path string, body any) error { + jsonBody, err := json.Marshal(body) + if err != nil { + return err + } + _, code, err := c.doRequest("POST", path, strings.NewReader(string(jsonBody))) + if err != nil { + return err + } + if code >= 400 { + return fmt.Errorf("HTTP %d", code) + } + return nil +} + +// GetConfig fetches the parsed config. +func (c *Client) GetConfig() (map[string]any, error) { + return c.getJSON("/v0/management/config") +} + +// GetConfigYAML fetches the raw config.yaml content. +func (c *Client) GetConfigYAML() (string, error) { + data, err := c.get("/v0/management/config.yaml") + if err != nil { + return "", err + } + return string(data), nil +} + +// PutConfigYAML uploads new config.yaml content. +func (c *Client) PutConfigYAML(yamlContent string) error { + _, err := c.put("/v0/management/config.yaml", strings.NewReader(yamlContent)) + return err +} + +// GetUsage fetches usage statistics. +func (c *Client) GetUsage() (map[string]any, error) { + return c.getJSON("/v0/management/usage") +} + +// GetAuthFiles lists auth credential files. +// API returns {"files": [...]}. +func (c *Client) GetAuthFiles() ([]map[string]any, error) { + wrapper, err := c.getJSON("/v0/management/auth-files") + if err != nil { + return nil, err + } + return extractList(wrapper, "files") +} + +// 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) + if err != nil { + return err + } + if code >= 400 { + return fmt.Errorf("delete failed (HTTP %d)", code) + } + return nil +} + +// ToggleAuthFile enables or disables an auth file. +func (c *Client) ToggleAuthFile(name string, disabled bool) error { + body, _ := json.Marshal(map[string]any{"name": name, "disabled": disabled}) + _, err := c.patch("/v0/management/auth-files/status", strings.NewReader(string(body))) + return err +} + +// PatchAuthFileFields updates editable fields on an auth file. +func (c *Client) PatchAuthFileFields(name string, fields map[string]any) error { + fields["name"] = name + body, _ := json.Marshal(fields) + _, err := c.patch("/v0/management/auth-files/fields", strings.NewReader(string(body))) + return err +} + +// 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) + } + return c.getJSON(path) +} + +// GetAPIKeys fetches the list of API keys. +// API returns {"api-keys": [...]}. +func (c *Client) GetAPIKeys() ([]string, error) { + wrapper, err := c.getJSON("/v0/management/api-keys") + if err != nil { + return nil, err + } + arr, ok := wrapper["api-keys"] + if !ok { + return nil, nil + } + raw, err := json.Marshal(arr) + if err != nil { + return nil, err + } + var result []string + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return result, nil +} + +// GetGeminiKeys fetches Gemini API keys. +// API returns {"gemini-api-key": [...]}. +func (c *Client) GetGeminiKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/gemini-api-key", "gemini-api-key") +} + +// GetClaudeKeys fetches Claude API keys. +func (c *Client) GetClaudeKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/claude-api-key", "claude-api-key") +} + +// GetCodexKeys fetches Codex API keys. +func (c *Client) GetCodexKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/codex-api-key", "codex-api-key") +} + +// GetVertexKeys fetches Vertex API keys. +func (c *Client) GetVertexKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/vertex-api-key", "vertex-api-key") +} + +// GetOpenAICompat fetches OpenAI compatibility entries. +func (c *Client) GetOpenAICompat() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/openai-compatibility", "openai-compatibility") +} + +// getWrappedKeyList fetches a wrapped list from the API. +func (c *Client) getWrappedKeyList(path, key string) ([]map[string]any, error) { + wrapper, err := c.getJSON(path) + if err != nil { + return nil, err + } + return extractList(wrapper, key) +} + +// extractList pulls an array of maps from a wrapper object by key. +func extractList(wrapper map[string]any, key string) ([]map[string]any, error) { + arr, ok := wrapper[key] + if !ok || arr == nil { + return nil, nil + } + raw, err := json.Marshal(arr) + if err != nil { + return nil, err + } + var result []map[string]any + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return result, nil +} + +// GetDebug fetches the current debug setting. +func (c *Client) GetDebug() (bool, error) { + wrapper, err := c.getJSON("/v0/management/debug") + if err != nil { + return false, err + } + if v, ok := wrapper["debug"]; ok { + if b, ok := v.(bool); ok { + return b, nil + } + } + return false, nil +} + +// 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) + if err != nil { + return "", "", err + } + status := getString(wrapper, "status") + errMsg := getString(wrapper, "error") + return status, errMsg, nil +} + +// ----- Config field update methods ----- + +// PutBoolField updates a boolean config field. +func (c *Client) PutBoolField(path string, value bool) error { + body, _ := json.Marshal(map[string]any{"value": value}) + _, err := c.put("/v0/management/"+path, strings.NewReader(string(body))) + return err +} + +// PutIntField updates an integer config field. +func (c *Client) PutIntField(path string, value int) error { + body, _ := json.Marshal(map[string]any{"value": value}) + _, err := c.put("/v0/management/"+path, strings.NewReader(string(body))) + return err +} + +// PutStringField updates a string config field. +func (c *Client) PutStringField(path string, value string) error { + body, _ := json.Marshal(map[string]any{"value": value}) + _, err := c.put("/v0/management/"+path, strings.NewReader(string(body))) + return err +} + +// DeleteField sends a DELETE request for a config field. +func (c *Client) DeleteField(path string) error { + _, _, err := c.doRequest("DELETE", "/v0/management/"+path, nil) + return err +} diff --git a/internal/tui/config_tab.go b/internal/tui/config_tab.go new file mode 100644 index 00000000..39f3ce68 --- /dev/null +++ b/internal/tui/config_tab.go @@ -0,0 +1,384 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// configField represents a single editable config field. +type configField struct { + label string + apiPath string // management API path (e.g. "debug", "proxy-url") + kind string // "bool", "int", "string", "readonly" + value string // current display value + rawValue any // raw value from API +} + +// configTabModel displays parsed config with interactive editing. +type configTabModel struct { + client *Client + viewport viewport.Model + fields []configField + cursor int + editing bool + textInput textinput.Model + err error + message string // status message (success/error) + width int + height int + ready bool +} + +type configDataMsg struct { + config map[string]any + err error +} + +type configUpdateMsg struct { + err error +} + +func newConfigTabModel(client *Client) configTabModel { + ti := textinput.New() + ti.CharLimit = 256 + return configTabModel{ + client: client, + textInput: ti, + } +} + +func (m configTabModel) Init() tea.Cmd { + return m.fetchConfig +} + +func (m configTabModel) fetchConfig() tea.Msg { + cfg, err := m.client.GetConfig() + return configDataMsg{config: cfg, err: err} +} + +func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) { + switch msg := msg.(type) { + case configDataMsg: + if msg.err != nil { + m.err = msg.err + m.fields = nil + } else { + m.err = nil + m.fields = m.parseConfig(msg.config) + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case configUpdateMsg: + if msg.err != nil { + m.message = errorStyle.Render("✗ " + msg.err.Error()) + } else { + m.message = successStyle.Render("✓ Updated successfully") + } + m.viewport.SetContent(m.renderContent()) + // Refresh config from server + return m, m.fetchConfig + + case tea.KeyMsg: + if m.editing { + return m.handleEditingKey(msg) + } + return m.handleNormalKey(msg) + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m configTabModel) handleNormalKey(msg tea.KeyMsg) (configTabModel, tea.Cmd) { + switch msg.String() { + case "r": + m.message = "" + return m, m.fetchConfig + case "up", "k": + if m.cursor > 0 { + m.cursor-- + m.viewport.SetContent(m.renderContent()) + // Ensure cursor is visible + m.ensureCursorVisible() + } + return m, nil + case "down", "j": + if m.cursor < len(m.fields)-1 { + m.cursor++ + m.viewport.SetContent(m.renderContent()) + m.ensureCursorVisible() + } + return m, nil + case "enter", " ": + if m.cursor >= 0 && m.cursor < len(m.fields) { + f := m.fields[m.cursor] + if f.kind == "readonly" { + return m, nil + } + if f.kind == "bool" { + // Toggle directly + return m, m.toggleBool(m.cursor) + } + // Start editing for int/string + m.editing = true + m.textInput.SetValue(f.value) + m.textInput.Focus() + m.viewport.SetContent(m.renderContent()) + return m, textinput.Blink + } + return m, nil + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m configTabModel) handleEditingKey(msg tea.KeyMsg) (configTabModel, tea.Cmd) { + switch msg.String() { + case "enter": + m.editing = false + m.textInput.Blur() + return m, m.submitEdit(m.cursor, m.textInput.Value()) + case "esc": + m.editing = false + m.textInput.Blur() + m.viewport.SetContent(m.renderContent()) + return m, nil + default: + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + m.viewport.SetContent(m.renderContent()) + return m, cmd + } +} + +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} + } +} + +func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd { + return func() tea.Msg { + f := m.fields[idx] + var err error + switch f.kind { + case "int": + v, parseErr := strconv.Atoi(newValue) + if parseErr != nil { + return configUpdateMsg{err: fmt.Errorf("invalid integer: %s", newValue)} + } + err = m.client.PutIntField(f.apiPath, v) + case "string": + err = m.client.PutStringField(f.apiPath, newValue) + } + return configUpdateMsg{err: err} + } +} + +func (m *configTabModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderContent()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m *configTabModel) ensureCursorVisible() { + // Each field takes ~1 line, header takes ~4 lines + targetLine := m.cursor + 5 + if targetLine < m.viewport.YOffset { + m.viewport.SetYOffset(targetLine) + } + if targetLine >= m.viewport.YOffset+m.viewport.Height { + m.viewport.SetYOffset(targetLine - m.viewport.Height + 1) + } +} + +func (m configTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m configTabModel) renderContent() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("⚙ Configuration")) + sb.WriteString("\n") + + if m.message != "" { + sb.WriteString(" " + m.message) + sb.WriteString("\n") + } + + sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter/Space] edit • [r] refresh")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" Bool fields: Enter to toggle • String/Int: Enter to type, Enter to confirm, Esc to cancel")) + sb.WriteString("\n\n") + + if m.err != nil { + sb.WriteString(errorStyle.Render(" ⚠ Error: " + m.err.Error())) + return sb.String() + } + + if len(m.fields) == 0 { + sb.WriteString(subtitleStyle.Render(" No configuration loaded")) + return sb.String() + } + + currentSection := "" + for i, f := range m.fields { + // Section headers + section := fieldSection(f.apiPath) + if section != currentSection { + currentSection = section + sb.WriteString("\n") + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(" ── " + section + " ")) + sb.WriteString("\n") + } + + isSelected := i == m.cursor + prefix := " " + if isSelected { + prefix = "▸ " + } + + labelStr := lipgloss.NewStyle(). + Foreground(colorInfo). + Bold(isSelected). + Width(32). + Render(f.label) + + var valueStr string + if m.editing && isSelected { + valueStr = m.textInput.View() + } else { + switch f.kind { + case "bool": + if f.value == "true" { + valueStr = successStyle.Render("● ON") + } else { + valueStr = lipgloss.NewStyle().Foreground(colorMuted).Render("○ OFF") + } + case "readonly": + valueStr = lipgloss.NewStyle().Foreground(colorSubtext).Render(f.value) + default: + valueStr = valueStyle.Render(f.value) + } + } + + line := prefix + labelStr + " " + valueStr + if isSelected && !m.editing { + line = lipgloss.NewStyle().Background(colorSurface).Render(line) + } + sb.WriteString(line + "\n") + } + + return sb.String() +} + +func (m configTabModel) parseConfig(cfg map[string]any) []configField { + var fields []configField + + // Server settings + fields = append(fields, configField{"Port", "port", "readonly", fmt.Sprintf("%.0f", getFloat(cfg, "port")), nil}) + fields = append(fields, configField{"Host", "host", "readonly", getString(cfg, "host"), nil}) + fields = append(fields, configField{"Debug", "debug", "bool", fmt.Sprintf("%v", getBool(cfg, "debug")), nil}) + fields = append(fields, configField{"Proxy URL", "proxy-url", "string", getString(cfg, "proxy-url"), nil}) + fields = append(fields, configField{"Request Retry", "request-retry", "int", fmt.Sprintf("%.0f", getFloat(cfg, "request-retry")), nil}) + fields = append(fields, configField{"Max Retry Interval (s)", "max-retry-interval", "int", fmt.Sprintf("%.0f", getFloat(cfg, "max-retry-interval")), nil}) + fields = append(fields, configField{"Force Model Prefix", "force-model-prefix", "string", getString(cfg, "force-model-prefix"), nil}) + + // Logging + fields = append(fields, configField{"Logging to File", "logging-to-file", "bool", fmt.Sprintf("%v", getBool(cfg, "logging-to-file")), nil}) + fields = append(fields, configField{"Logs Max Total Size (MB)", "logs-max-total-size-mb", "int", fmt.Sprintf("%.0f", getFloat(cfg, "logs-max-total-size-mb")), nil}) + fields = append(fields, configField{"Error Logs Max Files", "error-logs-max-files", "int", fmt.Sprintf("%.0f", getFloat(cfg, "error-logs-max-files")), nil}) + fields = append(fields, configField{"Usage Stats Enabled", "usage-statistics-enabled", "bool", fmt.Sprintf("%v", getBool(cfg, "usage-statistics-enabled")), nil}) + fields = append(fields, configField{"Request Log", "request-log", "bool", fmt.Sprintf("%v", getBool(cfg, "request-log")), nil}) + + // Quota exceeded + fields = append(fields, configField{"Switch Project on Quota", "quota-exceeded/switch-project", "bool", fmt.Sprintf("%v", getBoolNested(cfg, "quota-exceeded", "switch-project")), nil}) + fields = append(fields, configField{"Switch Preview Model", "quota-exceeded/switch-preview-model", "bool", fmt.Sprintf("%v", getBoolNested(cfg, "quota-exceeded", "switch-preview-model")), nil}) + + // Routing + if routing, ok := cfg["routing"].(map[string]any); ok { + fields = append(fields, configField{"Routing Strategy", "routing/strategy", "string", getString(routing, "strategy"), nil}) + } else { + fields = append(fields, configField{"Routing Strategy", "routing/strategy", "string", "", nil}) + } + + // WebSocket auth + fields = append(fields, configField{"WebSocket Auth", "ws-auth", "bool", fmt.Sprintf("%v", getBool(cfg, "ws-auth")), nil}) + + // 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}) + fields = append(fields, configField{"AMP Restrict Mgmt Localhost", "ampcode/restrict-management-to-localhost", "bool", fmt.Sprintf("%v", getBool(amp, "restrict-management-to-localhost")), nil}) + } + + return fields +} + +func fieldSection(apiPath string) string { + if strings.HasPrefix(apiPath, "ampcode/") { + return "AMP Code" + } + if strings.HasPrefix(apiPath, "quota-exceeded/") { + return "Quota Exceeded Handling" + } + if strings.HasPrefix(apiPath, "routing/") { + return "Routing" + } + switch apiPath { + case "port", "host", "debug", "proxy-url", "request-retry", "max-retry-interval", "force-model-prefix": + return "Server" + case "logging-to-file", "logs-max-total-size-mb", "error-logs-max-files", "usage-statistics-enabled", "request-log": + return "Logging & Stats" + case "ws-auth": + return "WebSocket" + default: + return "Other" + } +} + +func getBoolNested(m map[string]any, keys ...string) bool { + current := m + for i, key := range keys { + if i == len(keys)-1 { + return getBool(current, key) + } + if nested, ok := current[key].(map[string]any); ok { + current = nested + } else { + return false + } + } + return false +} + +func maskIfNotEmpty(s string) string { + if s == "" { + return "(not set)" + } + return maskKey(s) +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go new file mode 100644 index 00000000..02033830 --- /dev/null +++ b/internal/tui/dashboard.go @@ -0,0 +1,345 @@ +package tui + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// dashboardModel displays server info, stats cards, and config overview. +type dashboardModel struct { + client *Client + viewport viewport.Model + content string + err error + width int + height int + ready bool +} + +type dashboardDataMsg struct { + config map[string]any + usage map[string]any + authFiles []map[string]any + apiKeys []string + err error +} + +func newDashboardModel(client *Client) dashboardModel { + return dashboardModel{ + client: client, + } +} + +func (m dashboardModel) Init() tea.Cmd { + return m.fetchData +} + +func (m dashboardModel) fetchData() tea.Msg { + cfg, cfgErr := m.client.GetConfig() + usage, usageErr := m.client.GetUsage() + authFiles, authErr := m.client.GetAuthFiles() + apiKeys, keysErr := m.client.GetAPIKeys() + + var err error + for _, e := range []error{cfgErr, usageErr, authErr, keysErr} { + if e != nil { + err = e + break + } + } + return dashboardDataMsg{config: cfg, usage: usage, authFiles: authFiles, apiKeys: apiKeys, err: err} +} + +func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) { + switch msg := msg.(type) { + case dashboardDataMsg: + if msg.err != nil { + m.err = msg.err + m.content = errorStyle.Render("⚠ Error: " + msg.err.Error()) + } else { + m.err = nil + m.content = m.renderDashboard(msg.config, msg.usage, msg.authFiles, msg.apiKeys) + } + m.viewport.SetContent(m.content) + return m, nil + + case tea.KeyMsg: + if msg.String() == "r" { + return m, m.fetchData + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *dashboardModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.content) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m dashboardModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("📊 Dashboard")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll")) + sb.WriteString("\n\n") + + // ━━━ Connection Status ━━━ + port := 0.0 + if cfg != nil { + port = getFloat(cfg, "port") + } + connStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess) + sb.WriteString(connStyle.Render("● 已连接")) + sb.WriteString(fmt.Sprintf(" http://127.0.0.1:%.0f", port)) + sb.WriteString("\n\n") + + // ━━━ Stats Cards ━━━ + cardWidth := 25 + if m.width > 0 { + cardWidth = (m.width - 6) / 4 + if cardWidth < 18 { + cardWidth = 18 + } + } + + cardStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Width(cardWidth). + Height(2) + + // Card 1: API Keys + keyCount := len(apiKeys) + card1 := cardStyle.Render(fmt.Sprintf( + "%s\n%s", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("🔑 %d", keyCount)), + lipgloss.NewStyle().Foreground(colorMuted).Render("管理密钥"), + )) + + // Card 2: Auth Files + authCount := len(authFiles) + activeAuth := 0 + for _, f := range authFiles { + if !getBool(f, "disabled") { + activeAuth++ + } + } + card2 := cardStyle.Render(fmt.Sprintf( + "%s\n%s", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("📄 %d", authCount)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("认证文件 (%d active)", activeAuth)), + )) + + // Card 3: Total Requests + totalReqs := int64(0) + successReqs := int64(0) + failedReqs := int64(0) + totalTokens := int64(0) + if usage != nil { + if usageMap, ok := usage["usage"].(map[string]any); ok { + totalReqs = int64(getFloat(usageMap, "total_requests")) + successReqs = int64(getFloat(usageMap, "success_count")) + failedReqs = int64(getFloat(usageMap, "failure_count")) + totalTokens = int64(getFloat(usageMap, "total_tokens")) + } + } + card3 := cardStyle.Render(fmt.Sprintf( + "%s\n%s", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(fmt.Sprintf("📈 %d", totalReqs)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("请求 (✓%d ✗%d)", successReqs, failedReqs)), + )) + + // Card 4: Total Tokens + tokenStr := formatLargeNumber(totalTokens) + card4 := cardStyle.Render(fmt.Sprintf( + "%s\n%s", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("🔤 %s", tokenStr)), + lipgloss.NewStyle().Foreground(colorMuted).Render("总 Tokens"), + )) + + sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) + sb.WriteString("\n\n") + + // ━━━ Current Config ━━━ + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("当前配置")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) + sb.WriteString("\n") + + if cfg != nil { + debug := getBool(cfg, "debug") + retry := getFloat(cfg, "request-retry") + proxyURL := getString(cfg, "proxy-url") + loggingToFile := getBool(cfg, "logging-to-file") + usageEnabled := true + if v, ok := cfg["usage-statistics-enabled"]; ok { + if b, ok2 := v.(bool); ok2 { + usageEnabled = b + } + } + + configItems := []struct { + label string + value string + }{ + {"启用调试模式", boolEmoji(debug)}, + {"启用使用统计", boolEmoji(usageEnabled)}, + {"启用日志记录到文件", boolEmoji(loggingToFile)}, + {"重试次数", fmt.Sprintf("%.0f", retry)}, + } + if proxyURL != "" { + configItems = append(configItems, struct { + label string + value string + }{"代理 URL", proxyURL}) + } + + // Render config items as a compact row + for _, item := range configItems { + sb.WriteString(fmt.Sprintf(" %s %s\n", + labelStyle.Render(item.label+":"), + valueStyle.Render(item.value))) + } + + // Routing strategy + strategy := "round-robin" + if routing, ok := cfg["routing"].(map[string]any); ok { + if s := getString(routing, "strategy"); s != "" { + strategy = s + } + } + sb.WriteString(fmt.Sprintf(" %s %s\n", + labelStyle.Render("路由策略:"), + valueStyle.Render(strategy))) + } + + sb.WriteString("\n") + + // ━━━ Per-Model Usage ━━━ + if usage != nil { + if usageMap, ok := usage["usage"].(map[string]any); ok { + if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("模型统计")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) + sb.WriteString("\n") + + header := fmt.Sprintf(" %-40s %10s %12s", "Model", "Requests", "Tokens") + sb.WriteString(tableHeaderStyle.Render(header)) + sb.WriteString("\n") + + for _, apiSnap := range apis { + if apiMap, ok := apiSnap.(map[string]any); ok { + if models, ok := apiMap["models"].(map[string]any); ok { + for model, v := range models { + if stats, ok := v.(map[string]any); ok { + reqs := int64(getFloat(stats, "total_requests")) + toks := int64(getFloat(stats, "total_tokens")) + row := fmt.Sprintf(" %-40s %10d %12s", truncate(model, 40), reqs, formatLargeNumber(toks)) + sb.WriteString(tableCellStyle.Render(row)) + sb.WriteString("\n") + } + } + } + } + } + } + } + } + + return sb.String() +} + +func formatKV(key, value string) string { + return fmt.Sprintf(" %s %s\n", labelStyle.Render(key+":"), valueStyle.Render(value)) +} + +func getString(m map[string]any, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getFloat(m map[string]any, key string) float64 { + if v, ok := m[key]; ok { + switch n := v.(type) { + case float64: + return n + case json.Number: + f, _ := n.Float64() + return f + } + } + return 0 +} + +func getBool(m map[string]any, key string) bool { + if v, ok := m[key]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false +} + +func boolEmoji(b bool) string { + if b { + return "是 ✓" + } + return "否" +} + +func formatLargeNumber(n int64) string { + if n >= 1_000_000 { + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + } + if n >= 1_000 { + return fmt.Sprintf("%.1fK", float64(n)/1_000) + } + return fmt.Sprintf("%d", n) +} + +func truncate(s string, maxLen int) string { + if len(s) > maxLen { + return s[:maxLen-3] + "..." + } + return s +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/tui/keys_tab.go b/internal/tui/keys_tab.go new file mode 100644 index 00000000..20e9e0f0 --- /dev/null +++ b/internal/tui/keys_tab.go @@ -0,0 +1,190 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +// keysTabModel displays API keys from all providers. +type keysTabModel struct { + client *Client + viewport viewport.Model + content string + err error + width int + height int + ready bool +} + +type keysDataMsg struct { + apiKeys []string + gemini []map[string]any + claude []map[string]any + codex []map[string]any + vertex []map[string]any + openai []map[string]any + err error +} + +func newKeysTabModel(client *Client) keysTabModel { + return keysTabModel{ + client: client, + } +} + +func (m keysTabModel) Init() tea.Cmd { + return m.fetchKeys +} + +func (m keysTabModel) fetchKeys() tea.Msg { + result := keysDataMsg{} + + apiKeys, err := m.client.GetAPIKeys() + if err != nil { + result.err = err + return result + } + result.apiKeys = apiKeys + + // Fetch all key types, ignoring individual errors (they may not be configured) + result.gemini, _ = m.client.GetGeminiKeys() + result.claude, _ = m.client.GetClaudeKeys() + result.codex, _ = m.client.GetCodexKeys() + result.vertex, _ = m.client.GetVertexKeys() + result.openai, _ = m.client.GetOpenAICompat() + + return result +} + +func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) { + switch msg := msg.(type) { + case keysDataMsg: + if msg.err != nil { + m.err = msg.err + m.content = errorStyle.Render("⚠ Error: " + msg.err.Error()) + } else { + m.err = nil + m.content = m.renderKeys(msg) + } + m.viewport.SetContent(m.content) + return m, nil + + case tea.KeyMsg: + if msg.String() == "r" { + return m, m.fetchKeys + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *keysTabModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.content) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m keysTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m keysTabModel) renderKeys(data keysDataMsg) string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("🔐 API Keys")) + sb.WriteString("\n\n") + + // API Keys (access keys) + renderSection(&sb, "Access API Keys", len(data.apiKeys)) + for i, key := range data.apiKeys { + sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, maskKey(key))) + } + sb.WriteString("\n") + + // Gemini Keys + renderProviderKeys(&sb, "Gemini API Keys", data.gemini) + + // Claude Keys + renderProviderKeys(&sb, "Claude API Keys", data.claude) + + // Codex Keys + renderProviderKeys(&sb, "Codex API Keys", data.codex) + + // Vertex Keys + renderProviderKeys(&sb, "Vertex API Keys", data.vertex) + + // OpenAI Compatibility + if len(data.openai) > 0 { + renderSection(&sb, "OpenAI Compatibility", len(data.openai)) + for i, entry := range data.openai { + name := getString(entry, "name") + baseURL := getString(entry, "base-url") + prefix := getString(entry, "prefix") + info := name + if prefix != "" { + info += " (prefix: " + prefix + ")" + } + if baseURL != "" { + info += " → " + baseURL + } + sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, info)) + } + sb.WriteString("\n") + } + + sb.WriteString(helpStyle.Render("Press [r] to refresh • [↑↓] to scroll")) + + return sb.String() +} + +func renderSection(sb *strings.Builder, title string, count int) { + header := fmt.Sprintf("%s (%d)", title, count) + sb.WriteString(tableHeaderStyle.Render(" " + header)) + sb.WriteString("\n") +} + +func renderProviderKeys(sb *strings.Builder, title string, keys []map[string]any) { + if len(keys) == 0 { + return + } + renderSection(sb, title, len(keys)) + for i, key := range keys { + apiKey := getString(key, "api-key") + prefix := getString(key, "prefix") + baseURL := getString(key, "base-url") + info := maskKey(apiKey) + if prefix != "" { + info += " (prefix: " + prefix + ")" + } + if baseURL != "" { + info += " → " + baseURL + } + sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, info)) + } + sb.WriteString("\n") +} + +func maskKey(key string) string { + if len(key) <= 8 { + return strings.Repeat("*", len(key)) + } + return key[:4] + strings.Repeat("*", len(key)-8) + key[len(key)-4:] +} diff --git a/internal/tui/loghook.go b/internal/tui/loghook.go new file mode 100644 index 00000000..157e7fd8 --- /dev/null +++ b/internal/tui/loghook.go @@ -0,0 +1,78 @@ +package tui + +import ( + "fmt" + "strings" + "sync" + + log "github.com/sirupsen/logrus" +) + +// LogHook is a logrus hook that captures log entries and sends them to a channel. +type LogHook struct { + ch chan string + formatter log.Formatter + mu sync.Mutex + levels []log.Level +} + +// NewLogHook creates a new LogHook with a buffered channel of the given size. +func NewLogHook(bufSize int) *LogHook { + return &LogHook{ + ch: make(chan string, bufSize), + formatter: &log.TextFormatter{DisableColors: true, FullTimestamp: true}, + levels: log.AllLevels, + } +} + +// SetFormatter sets a custom formatter for the hook. +func (h *LogHook) SetFormatter(f log.Formatter) { + h.mu.Lock() + defer h.mu.Unlock() + h.formatter = f +} + +// Levels returns the log levels this hook should fire on. +func (h *LogHook) Levels() []log.Level { + return h.levels +} + +// Fire is called by logrus when a log entry is fired. +func (h *LogHook) Fire(entry *log.Entry) error { + h.mu.Lock() + f := h.formatter + h.mu.Unlock() + + var line string + if f != nil { + b, err := f.Format(entry) + if err == nil { + line = strings.TrimRight(string(b), "\n\r") + } else { + line = fmt.Sprintf("[%s] %s", entry.Level, entry.Message) + } + } else { + line = fmt.Sprintf("[%s] %s", entry.Level, entry.Message) + } + + // Non-blocking send + select { + case h.ch <- line: + default: + // Drop oldest if full + select { + case <-h.ch: + default: + } + select { + case h.ch <- line: + default: + } + } + return nil +} + +// Chan returns the channel to read log lines from. +func (h *LogHook) Chan() <-chan string { + return h.ch +} diff --git a/internal/tui/logs_tab.go b/internal/tui/logs_tab.go new file mode 100644 index 00000000..9281d472 --- /dev/null +++ b/internal/tui/logs_tab.go @@ -0,0 +1,195 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +// logsTabModel displays real-time log lines from the logrus hook. +type logsTabModel struct { + hook *LogHook + viewport viewport.Model + lines []string + maxLines int + autoScroll bool + width int + height int + ready bool + filter string // "", "debug", "info", "warn", "error" +} + +// logLineMsg carries a new log line from the logrus hook channel. +type logLineMsg string + +func newLogsTabModel(hook *LogHook) logsTabModel { + return logsTabModel{ + hook: hook, + maxLines: 5000, + autoScroll: true, + } +} + +func (m logsTabModel) Init() tea.Cmd { + return m.waitForLog +} + +// waitForLog listens on the hook channel and returns a logLineMsg. +func (m logsTabModel) waitForLog() tea.Msg { + line, ok := <-m.hook.Chan() + if !ok { + return nil + } + return logLineMsg(line) +} + +func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) { + switch msg := msg.(type) { + case logLineMsg: + m.lines = append(m.lines, string(msg)) + 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.waitForLog + + case tea.KeyMsg: + switch msg.String() { + case "a": + m.autoScroll = !m.autoScroll + if m.autoScroll { + m.viewport.GotoBottom() + } + return m, nil + case "c": + m.lines = nil + m.viewport.SetContent(m.renderLogs()) + return m, nil + case "1": + m.filter = "" + m.viewport.SetContent(m.renderLogs()) + return m, nil + case "2": + m.filter = "info" + m.viewport.SetContent(m.renderLogs()) + return m, nil + case "3": + m.filter = "warn" + m.viewport.SetContent(m.renderLogs()) + return m, nil + case "4": + m.filter = "error" + m.viewport.SetContent(m.renderLogs()) + return m, nil + default: + wasAtBottom := m.viewport.AtBottom() + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + // If user scrolls up, disable auto-scroll + if !m.viewport.AtBottom() && wasAtBottom { + m.autoScroll = false + } + // If user scrolls to bottom, re-enable auto-scroll + if m.viewport.AtBottom() { + m.autoScroll = true + } + return m, cmd + } + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *logsTabModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderLogs()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m logsTabModel) View() string { + if !m.ready { + return "Loading logs..." + } + return m.viewport.View() +} + +func (m logsTabModel) renderLogs() string { + var sb strings.Builder + + scrollStatus := successStyle.Render("● AUTO-SCROLL") + if !m.autoScroll { + scrollStatus = warningStyle.Render("○ PAUSED") + } + filterLabel := "ALL" + if m.filter != "" { + filterLabel = strings.ToUpper(m.filter) + "+" + } + + header := fmt.Sprintf(" 📋 Logs %s Filter: %s Lines: %d", + scrollStatus, filterLabel, len(m.lines)) + sb.WriteString(titleStyle.Render(header)) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [a]uto-scroll • [c]lear • [1]all [2]info+ [3]warn+ [4]error • [↑↓] scroll")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", m.width)) + sb.WriteString("\n") + + if len(m.lines) == 0 { + sb.WriteString(subtitleStyle.Render("\n Waiting for log output...")) + return sb.String() + } + + for _, line := range m.lines { + if m.filter != "" && !m.matchLevel(line) { + continue + } + styled := m.styleLine(line) + sb.WriteString(styled) + sb.WriteString("\n") + } + + return sb.String() +} + +func (m logsTabModel) matchLevel(line string) bool { + switch m.filter { + case "error": + return strings.Contains(line, "[error]") || strings.Contains(line, "[fatal]") || strings.Contains(line, "[panic]") + case "warn": + return strings.Contains(line, "[warn") || strings.Contains(line, "[error]") || strings.Contains(line, "[fatal]") + case "info": + return !strings.Contains(line, "[debug]") + default: + return true + } +} + +func (m logsTabModel) styleLine(line string) string { + if strings.Contains(line, "[error]") || strings.Contains(line, "[fatal]") { + return logErrorStyle.Render(line) + } + if strings.Contains(line, "[warn") { + return logWarnStyle.Render(line) + } + if strings.Contains(line, "[info") { + return logInfoStyle.Render(line) + } + if strings.Contains(line, "[debug]") { + return logDebugStyle.Render(line) + } + return line +} diff --git a/internal/tui/oauth_tab.go b/internal/tui/oauth_tab.go new file mode 100644 index 00000000..2f320c2d --- /dev/null +++ b/internal/tui/oauth_tab.go @@ -0,0 +1,470 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// oauthProvider represents an OAuth provider option. +type oauthProvider struct { + name string + apiPath string // management API path + emoji string +} + +var oauthProviders = []oauthProvider{ + {"Gemini CLI", "gemini-cli-auth-url", "🟦"}, + {"Claude (Anthropic)", "anthropic-auth-url", "🟧"}, + {"Codex (OpenAI)", "codex-auth-url", "🟩"}, + {"Antigravity", "antigravity-auth-url", "🟪"}, + {"Qwen", "qwen-auth-url", "🟨"}, + {"Kimi", "kimi-auth-url", "🟫"}, + {"IFlow", "iflow-auth-url", "⬜"}, +} + +// oauthTabModel handles OAuth login flows. +type oauthTabModel struct { + client *Client + viewport viewport.Model + cursor int + state oauthState + message string + err error + width int + height int + ready bool + + // Remote browser mode + authURL string // auth URL to display + authState string // OAuth state parameter + providerName string // current provider name + callbackInput textinput.Model + inputActive bool // true when user is typing callback URL +} + +type oauthState int + +const ( + oauthIdle oauthState = iota + oauthPending + oauthRemote // remote browser mode: waiting for manual callback + oauthSuccess + oauthError +) + +// Messages +type oauthStartMsg struct { + url string + state string + providerName string + err error +} + +type oauthPollMsg struct { + done bool + message string + err error +} + +type oauthCallbackSubmitMsg struct { + err error +} + +func newOAuthTabModel(client *Client) oauthTabModel { + ti := textinput.New() + ti.Placeholder = "http://localhost:.../auth/callback?code=...&state=..." + ti.CharLimit = 2048 + ti.Prompt = " 回调 URL: " + return oauthTabModel{ + client: client, + callbackInput: ti, + } +} + +func (m oauthTabModel) Init() tea.Cmd { + return nil +} + +func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { + switch msg := msg.(type) { + case oauthStartMsg: + if msg.err != nil { + m.state = oauthError + m.err = msg.err + m.message = errorStyle.Render("✗ " + msg.err.Error()) + m.viewport.SetContent(m.renderContent()) + return m, nil + } + m.authURL = msg.url + m.authState = msg.state + m.providerName = msg.providerName + m.state = oauthRemote + m.callbackInput.SetValue("") + m.callbackInput.Focus() + m.inputActive = true + m.message = "" + m.viewport.SetContent(m.renderContent()) + // Also start polling in the background + return m, tea.Batch(textinput.Blink, m.pollOAuthStatus(msg.state)) + + case oauthPollMsg: + if msg.err != nil { + m.state = oauthError + m.err = msg.err + m.message = errorStyle.Render("✗ " + msg.err.Error()) + m.inputActive = false + m.callbackInput.Blur() + } else if msg.done { + m.state = oauthSuccess + m.message = successStyle.Render("✓ " + msg.message) + m.inputActive = false + m.callbackInput.Blur() + } else { + m.message = warningStyle.Render("⏳ " + msg.message) + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case oauthCallbackSubmitMsg: + if msg.err != nil { + m.message = errorStyle.Render("✗ 提交回调失败: " + msg.err.Error()) + } else { + m.message = successStyle.Render("✓ 回调已提交,等待处理...") + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case tea.KeyMsg: + // ---- Input active: typing callback URL ---- + if m.inputActive { + switch msg.String() { + case "enter": + callbackURL := m.callbackInput.Value() + if callbackURL == "" { + return m, nil + } + m.inputActive = false + m.callbackInput.Blur() + m.message = warningStyle.Render("⏳ 提交回调中...") + m.viewport.SetContent(m.renderContent()) + return m, m.submitCallback(callbackURL) + case "esc": + m.inputActive = false + m.callbackInput.Blur() + m.viewport.SetContent(m.renderContent()) + return m, nil + default: + var cmd tea.Cmd + m.callbackInput, cmd = m.callbackInput.Update(msg) + m.viewport.SetContent(m.renderContent()) + return m, cmd + } + } + + // ---- Remote mode but not typing ---- + if m.state == oauthRemote { + switch msg.String() { + case "c", "C": + // Re-activate input + m.inputActive = true + m.callbackInput.Focus() + m.viewport.SetContent(m.renderContent()) + return m, textinput.Blink + case "esc": + m.state = oauthIdle + m.message = "" + m.authURL = "" + m.authState = "" + m.viewport.SetContent(m.renderContent()) + return m, nil + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + // ---- Pending (auto polling) ---- + if m.state == oauthPending { + if msg.String() == "esc" { + m.state = oauthIdle + m.message = "" + m.viewport.SetContent(m.renderContent()) + } + return m, nil + } + + // ---- Idle ---- + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "down", "j": + if m.cursor < len(oauthProviders)-1 { + m.cursor++ + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "enter": + if m.cursor >= 0 && m.cursor < len(oauthProviders) { + provider := oauthProviders[m.cursor] + m.state = oauthPending + m.message = warningStyle.Render("⏳ 正在初始化 " + provider.name + " 登录...") + m.viewport.SetContent(m.renderContent()) + return m, m.startOAuth(provider) + } + return m, nil + case "esc": + m.state = oauthIdle + m.message = "" + m.err = nil + m.viewport.SetContent(m.renderContent()) + return m, nil + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m oauthTabModel) startOAuth(provider oauthProvider) tea.Cmd { + return func() tea.Msg { + // Call the auth URL endpoint with is_webui=true + data, err := m.client.getJSON("/v0/management/" + provider.apiPath + "?is_webui=true") + if err != nil { + return oauthStartMsg{err: fmt.Errorf("failed to start %s login: %w", provider.name, err)} + } + + authURL := getString(data, "url") + state := getString(data, "state") + if authURL == "" { + return oauthStartMsg{err: fmt.Errorf("no auth URL returned for %s", provider.name)} + } + + // Try to open browser (best effort) + _ = openBrowser(authURL) + + return oauthStartMsg{url: authURL, state: state, providerName: provider.name} + } +} + +func (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd { + return func() tea.Msg { + // Determine provider from current context + providerKey := "" + for _, p := range oauthProviders { + if p.name == m.providerName { + // Map provider name to the canonical key the API expects + switch p.apiPath { + case "gemini-cli-auth-url": + providerKey = "gemini" + case "anthropic-auth-url": + providerKey = "anthropic" + case "codex-auth-url": + providerKey = "codex" + case "antigravity-auth-url": + providerKey = "antigravity" + case "qwen-auth-url": + providerKey = "qwen" + case "kimi-auth-url": + providerKey = "kimi" + case "iflow-auth-url": + providerKey = "iflow" + } + break + } + } + + body := map[string]string{ + "provider": providerKey, + "redirect_url": callbackURL, + "state": m.authState, + } + err := m.client.postJSON("/v0/management/oauth-callback", body) + if err != nil { + return oauthCallbackSubmitMsg{err: err} + } + return oauthCallbackSubmitMsg{} + } +} + +func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd { + return func() tea.Msg { + // Poll session status for up to 5 minutes + deadline := time.Now().Add(5 * time.Minute) + for { + if time.Now().After(deadline) { + return oauthPollMsg{done: false, err: fmt.Errorf("OAuth flow timed out (5 minutes)")} + } + + time.Sleep(2 * time.Second) + + status, errMsg, err := m.client.GetAuthStatus(state) + if err != nil { + continue // Ignore transient errors + } + + switch status { + case "ok": + return oauthPollMsg{ + done: true, + message: "认证成功! 请刷新 Auth Files 标签查看新凭证。", + } + case "error": + return oauthPollMsg{ + done: false, + err: fmt.Errorf("认证失败: %s", errMsg), + } + case "wait": + continue + default: + return oauthPollMsg{ + done: true, + message: "认证流程已完成。", + } + } + } + } +} + +func (m *oauthTabModel) SetSize(w, h int) { + m.width = w + m.height = h + m.callbackInput.Width = w - 16 + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderContent()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m oauthTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m oauthTabModel) renderContent() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("🔐 OAuth 登录")) + sb.WriteString("\n\n") + + if m.message != "" { + sb.WriteString(" " + m.message) + sb.WriteString("\n\n") + } + + // ---- Remote browser mode ---- + if m.state == oauthRemote { + sb.WriteString(m.renderRemoteMode()) + return sb.String() + } + + if m.state == oauthPending { + sb.WriteString(helpStyle.Render(" Press [Esc] to cancel")) + return sb.String() + } + + sb.WriteString(helpStyle.Render(" 选择提供商并按 [Enter] 开始 OAuth 登录:")) + sb.WriteString("\n\n") + + for i, p := range oauthProviders { + isSelected := i == m.cursor + prefix := " " + if isSelected { + prefix = "▸ " + } + + label := fmt.Sprintf("%s %s", p.emoji, p.name) + if isSelected { + label = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(colorPrimary).Padding(0, 1).Render(label) + } else { + label = lipgloss.NewStyle().Foreground(colorText).Padding(0, 1).Render(label) + } + + sb.WriteString(prefix + label + "\n") + } + + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态")) + + return sb.String() +} + +func (m oauthTabModel) renderRemoteMode() string { + var sb strings.Builder + + providerStyle := lipgloss.NewStyle().Bold(true).Foreground(colorHighlight) + sb.WriteString(providerStyle.Render(fmt.Sprintf(" ✦ %s OAuth", m.providerName))) + sb.WriteString("\n\n") + + // Auth URL section + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" 授权链接:")) + sb.WriteString("\n") + + // Wrap URL to fit terminal width + urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + maxURLWidth := m.width - 6 + if maxURLWidth < 40 { + maxURLWidth = 40 + } + wrappedURL := wrapText(m.authURL, maxURLWidth) + for _, line := range wrappedURL { + sb.WriteString(" " + urlStyle.Render(line) + "\n") + } + sb.WriteString("\n") + + sb.WriteString(helpStyle.Render(" 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。")) + sb.WriteString("\n\n") + + // Callback URL input + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" 回调 URL:")) + sb.WriteString("\n") + + if m.inputActive { + sb.WriteString(m.callbackInput.View()) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" Enter: 提交 • Esc: 取消输入")) + } else { + sb.WriteString(helpStyle.Render(" 按 [c] 输入回调 URL • [Esc] 返回")) + } + + sb.WriteString("\n\n") + sb.WriteString(warningStyle.Render(" 等待认证中...")) + + return sb.String() +} + +// wrapText splits a long string into lines of at most maxWidth characters. +func wrapText(s string, maxWidth int) []string { + if maxWidth <= 0 { + return []string{s} + } + var lines []string + for len(s) > maxWidth { + lines = append(lines, s[:maxWidth]) + s = s[maxWidth:] + } + if len(s) > 0 { + lines = append(lines, s) + } + return lines +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 00000000..f09e4322 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,126 @@ +// Package tui provides a terminal-based management interface for CLIProxyAPI. +package tui + +import "github.com/charmbracelet/lipgloss" + +// Color palette +var ( + colorPrimary = lipgloss.Color("#7C3AED") // violet + colorSecondary = lipgloss.Color("#6366F1") // indigo + colorSuccess = lipgloss.Color("#22C55E") // green + colorWarning = lipgloss.Color("#EAB308") // yellow + colorError = lipgloss.Color("#EF4444") // red + colorInfo = lipgloss.Color("#3B82F6") // blue + colorMuted = lipgloss.Color("#6B7280") // gray + colorBg = lipgloss.Color("#1E1E2E") // dark bg + colorSurface = lipgloss.Color("#313244") // slightly lighter + colorText = lipgloss.Color("#CDD6F4") // light text + colorSubtext = lipgloss.Color("#A6ADC8") // dimmer text + colorBorder = lipgloss.Color("#45475A") // border + colorHighlight = lipgloss.Color("#F5C2E7") // pink highlight +) + +// Tab bar styles +var ( + tabActiveStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(colorPrimary). + Padding(0, 2) + + tabInactiveStyle = lipgloss.NewStyle(). + Foreground(colorSubtext). + Background(colorSurface). + Padding(0, 2) + + tabBarStyle = lipgloss.NewStyle(). + Background(colorSurface). + PaddingLeft(1). + PaddingBottom(0) +) + +// Content styles +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorHighlight). + MarginBottom(1) + + subtitleStyle = lipgloss.NewStyle(). + Foreground(colorSubtext). + Italic(true) + + labelStyle = lipgloss.NewStyle(). + Foreground(colorInfo). + Bold(true). + Width(24) + + valueStyle = lipgloss.NewStyle(). + Foreground(colorText) + + sectionStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(1, 2) + + errorStyle = lipgloss.NewStyle(). + Foreground(colorError). + Bold(true) + + successStyle = lipgloss.NewStyle(). + Foreground(colorSuccess) + + warningStyle = lipgloss.NewStyle(). + Foreground(colorWarning) + + statusBarStyle = lipgloss.NewStyle(). + Foreground(colorSubtext). + Background(colorSurface). + PaddingLeft(1). + PaddingRight(1) + + helpStyle = lipgloss.NewStyle(). + Foreground(colorMuted) +) + +// Log level styles +var ( + logDebugStyle = lipgloss.NewStyle().Foreground(colorMuted) + logInfoStyle = lipgloss.NewStyle().Foreground(colorInfo) + logWarnStyle = lipgloss.NewStyle().Foreground(colorWarning) + logErrorStyle = lipgloss.NewStyle().Foreground(colorError) +) + +// Table styles +var ( + tableHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorHighlight). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colorBorder) + + tableCellStyle = lipgloss.NewStyle(). + Foreground(colorText). + PaddingRight(2) + + tableSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(colorPrimary). + Bold(true) +) + +func logLevelStyle(level string) lipgloss.Style { + switch level { + case "debug": + return logDebugStyle + case "info": + return logInfoStyle + case "warn", "warning": + return logWarnStyle + case "error", "fatal", "panic": + return logErrorStyle + default: + return logInfoStyle + } +} diff --git a/internal/tui/usage_tab.go b/internal/tui/usage_tab.go new file mode 100644 index 00000000..ebbf832d --- /dev/null +++ b/internal/tui/usage_tab.go @@ -0,0 +1,361 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// usageTabModel displays usage statistics with charts and breakdowns. +type usageTabModel struct { + client *Client + viewport viewport.Model + usage map[string]any + err error + width int + height int + ready bool +} + +type usageDataMsg struct { + usage map[string]any + err error +} + +func newUsageTabModel(client *Client) usageTabModel { + return usageTabModel{ + client: client, + } +} + +func (m usageTabModel) Init() tea.Cmd { + return m.fetchData +} + +func (m usageTabModel) fetchData() tea.Msg { + usage, err := m.client.GetUsage() + return usageDataMsg{usage: usage, err: err} +} + +func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) { + switch msg := msg.(type) { + case usageDataMsg: + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + m.usage = msg.usage + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case tea.KeyMsg: + if msg.String() == "r" { + return m, m.fetchData + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *usageTabModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderContent()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m usageTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m usageTabModel) renderContent() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("📈 使用统计")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll")) + sb.WriteString("\n\n") + + if m.err != nil { + sb.WriteString(errorStyle.Render("⚠ Error: " + m.err.Error())) + sb.WriteString("\n") + return sb.String() + } + + if m.usage == nil { + sb.WriteString(subtitleStyle.Render(" Usage data not available")) + sb.WriteString("\n") + return sb.String() + } + + usageMap, _ := m.usage["usage"].(map[string]any) + if usageMap == nil { + sb.WriteString(subtitleStyle.Render(" No usage data")) + sb.WriteString("\n") + return sb.String() + } + + totalReqs := int64(getFloat(usageMap, "total_requests")) + successCnt := int64(getFloat(usageMap, "success_count")) + failureCnt := int64(getFloat(usageMap, "failure_count")) + totalTokens := int64(getFloat(usageMap, "total_tokens")) + + // ━━━ Overview Cards ━━━ + cardWidth := 20 + if m.width > 0 { + cardWidth = (m.width - 6) / 4 + if cardWidth < 16 { + cardWidth = 16 + } + } + cardStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Width(cardWidth). + Height(3) + + // Total Requests + card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf( + "%s\n%s\n%s", + lipgloss.NewStyle().Foreground(colorMuted).Render("总请求数"), + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● 成功: %d ● 失败: %d", successCnt, failureCnt)), + )) + + // Total Tokens + card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf( + "%s\n%s\n%s", + lipgloss.NewStyle().Foreground(colorMuted).Render("总 Token 数"), + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token: %s", formatLargeNumber(totalTokens))), + )) + + // RPM + rpm := float64(0) + if totalReqs > 0 { + if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 { + rpm = float64(totalReqs) / float64(len(rByH)) / 60.0 + } + } + card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf( + "%s\n%s\n%s", + lipgloss.NewStyle().Foreground(colorMuted).Render("RPM"), + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总请求数: %d", totalReqs)), + )) + + // TPM + tpm := float64(0) + if totalTokens > 0 { + if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 { + tpm = float64(totalTokens) / float64(len(tByH)) / 60.0 + } + } + card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf( + "%s\n%s\n%s", + lipgloss.NewStyle().Foreground(colorMuted).Render("TPM"), + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token数: %s", formatLargeNumber(totalTokens))), + )) + + sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) + sb.WriteString("\n\n") + + // ━━━ Requests by Hour (ASCII bar chart) ━━━ + if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按小时)")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) + sb.WriteString("\n") + sb.WriteString(renderBarChart(rByH, m.width-6, lipgloss.Color("111"))) + sb.WriteString("\n") + } + + // ━━━ Tokens by Hour ━━━ + if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("Token 使用趋势 (按小时)")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) + sb.WriteString("\n") + sb.WriteString(renderBarChart(tByH, m.width-6, lipgloss.Color("214"))) + sb.WriteString("\n") + } + + // ━━━ Requests by Day ━━━ + if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按天)")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) + sb.WriteString("\n") + sb.WriteString(renderBarChart(rByD, m.width-6, lipgloss.Color("76"))) + sb.WriteString("\n") + } + + // ━━━ API Detail Stats ━━━ + if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("API 详细统计")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", minInt(m.width, 80))) + sb.WriteString("\n") + + header := fmt.Sprintf(" %-30s %10s %12s", "API", "Requests", "Tokens") + sb.WriteString(tableHeaderStyle.Render(header)) + sb.WriteString("\n") + + for apiName, apiSnap := range apis { + if apiMap, ok := apiSnap.(map[string]any); ok { + apiReqs := int64(getFloat(apiMap, "total_requests")) + apiToks := int64(getFloat(apiMap, "total_tokens")) + + row := fmt.Sprintf(" %-30s %10d %12s", + truncate(apiName, 30), apiReqs, formatLargeNumber(apiToks)) + sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row)) + sb.WriteString("\n") + + // Per-model breakdown + if models, ok := apiMap["models"].(map[string]any); ok { + for model, v := range models { + if stats, ok := v.(map[string]any); ok { + mReqs := int64(getFloat(stats, "total_requests")) + mToks := int64(getFloat(stats, "total_tokens")) + mRow := fmt.Sprintf(" ├─ %-28s %10d %12s", + truncate(model, 28), mReqs, formatLargeNumber(mToks)) + sb.WriteString(tableCellStyle.Render(mRow)) + sb.WriteString("\n") + + // Token type breakdown from details + sb.WriteString(m.renderTokenBreakdown(stats)) + } + } + } + } + } + } + + sb.WriteString("\n") + return sb.String() +} + +// renderTokenBreakdown aggregates input/output/cached/reasoning tokens from model details. +func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string { + details, ok := modelStats["details"] + if !ok { + return "" + } + detailList, ok := details.([]any) + if !ok || len(detailList) == 0 { + return "" + } + + var inputTotal, outputTotal, cachedTotal, reasoningTotal int64 + for _, d := range detailList { + dm, ok := d.(map[string]any) + if !ok { + continue + } + tokens, ok := dm["tokens"].(map[string]any) + if !ok { + continue + } + inputTotal += int64(getFloat(tokens, "input_tokens")) + outputTotal += int64(getFloat(tokens, "output_tokens")) + cachedTotal += int64(getFloat(tokens, "cached_tokens")) + reasoningTotal += int64(getFloat(tokens, "reasoning_tokens")) + } + + if inputTotal == 0 && outputTotal == 0 && cachedTotal == 0 && reasoningTotal == 0 { + return "" + } + + parts := []string{} + if inputTotal > 0 { + parts = append(parts, fmt.Sprintf("输入:%s", formatLargeNumber(inputTotal))) + } + if outputTotal > 0 { + parts = append(parts, fmt.Sprintf("输出:%s", formatLargeNumber(outputTotal))) + } + if cachedTotal > 0 { + parts = append(parts, fmt.Sprintf("缓存:%s", formatLargeNumber(cachedTotal))) + } + if reasoningTotal > 0 { + parts = append(parts, fmt.Sprintf("思考:%s", formatLargeNumber(reasoningTotal))) + } + + return fmt.Sprintf(" │ %s\n", + lipgloss.NewStyle().Foreground(colorMuted).Render(strings.Join(parts, " "))) +} + +// renderBarChart renders a simple ASCII horizontal bar chart. +func renderBarChart(data map[string]any, maxBarWidth int, barColor lipgloss.Color) string { + if maxBarWidth < 10 { + maxBarWidth = 10 + } + + // Sort keys + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + + // Find max value + maxVal := float64(0) + for _, k := range keys { + v := getFloat(data, k) + if v > maxVal { + maxVal = v + } + } + if maxVal == 0 { + return "" + } + + barStyle := lipgloss.NewStyle().Foreground(barColor) + var sb strings.Builder + + labelWidth := 12 + barAvail := maxBarWidth - labelWidth - 12 + if barAvail < 5 { + barAvail = 5 + } + + for _, k := range keys { + v := getFloat(data, k) + barLen := int(v / maxVal * float64(barAvail)) + if barLen < 1 && v > 0 { + barLen = 1 + } + bar := strings.Repeat("█", barLen) + label := k + if len(label) > labelWidth { + label = label[:labelWidth] + } + sb.WriteString(fmt.Sprintf(" %-*s %s %s\n", + labelWidth, label, + barStyle.Render(bar), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%.0f", v)), + )) + } + + return sb.String() +} From f31f7f701aae2ea9185696b64bffe984c83b45e4 Mon Sep 17 00:00:00 2001 From: lhpqaq Date: Sun, 15 Feb 2026 15:42:59 +0800 Subject: [PATCH 2/6] feat(tui): add i18n --- go.mod | 2 +- internal/tui/app.go | 50 +++++- internal/tui/auth_tab.go | 31 ++-- internal/tui/client.go | 28 +++ internal/tui/config_tab.go | 33 ++-- internal/tui/dashboard.go | 47 +++-- internal/tui/i18n.go | 350 +++++++++++++++++++++++++++++++++++++ internal/tui/keys_tab.go | 295 ++++++++++++++++++++++++++----- internal/tui/logs_tab.go | 17 +- internal/tui/oauth_tab.go | 41 +++-- internal/tui/usage_tab.go | 47 ++--- 11 files changed, 793 insertions(+), 148 deletions(-) create mode 100644 internal/tui/i18n.go diff --git a/go.mod b/go.mod index c2e4383d..86ed92f2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.2 require ( github.com/andybalholm/brotli v1.0.6 + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -34,7 +35,6 @@ require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect diff --git a/internal/tui/app.go b/internal/tui/app.go index c6c21c2b..d28a84f3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -20,8 +20,6 @@ const ( tabLogs ) -var tabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"} - // App is the root bubbletea model that contains all tab sub-models. type App struct { activeTab int @@ -50,7 +48,7 @@ func NewApp(port int, secretKey string, hook *LogHook) App { client := NewClient(port, secretKey) return App{ activeTab: tabDashboard, - tabs: tabNames, + tabs: TabNames(), dashboard: newDashboardModel(client), config: newConfigTabModel(client), auth: newAuthTabModel(client), @@ -102,13 +100,50 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.activeTab != tabLogs { return a, tea.Quit } + 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...) case "tab": prevTab := a.activeTab a.activeTab = (a.activeTab + 1) % len(a.tabs) + a.tabs = TabNames() return a, a.initTabIfNeeded(prevTab) case "shift+tab": prevTab := a.activeTab a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs) + a.tabs = TabNames() return a, a.initTabIfNeeded(prevTab) } } @@ -145,6 +180,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } +// localeChangedMsg is broadcast to all tabs when the user toggles locale. +type localeChangedMsg struct{} + func (a *App) initTabIfNeeded(_ int) tea.Cmd { if a.initialized[a.activeTab] { return nil @@ -171,7 +209,7 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd { func (a App) View() string { if !a.ready { - return "Initializing TUI..." + return T("initializing_tui") } var sb strings.Builder @@ -219,8 +257,8 @@ func (a App) renderTabBar() string { } func (a App) renderStatusBar() string { - left := " CLIProxyAPI Management TUI" - right := "Tab/Shift+Tab: switch • q/Ctrl+C: quit " + left := T("status_left") + right := T("status_right") gap := a.width - lipgloss.Width(left) - lipgloss.Width(right) if gap < 0 { gap = 0 diff --git a/internal/tui/auth_tab.go b/internal/tui/auth_tab.go index c6a38ae7..88f9a246 100644 --- a/internal/tui/auth_tab.go +++ b/internal/tui/auth_tab.go @@ -76,6 +76,9 @@ func (m authTabModel) fetchFiles() tea.Msg { func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case authFilesMsg: if msg.err != nil { m.err = msg.err @@ -122,7 +125,7 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { if err != nil { return authActionMsg{err: err} } - return authActionMsg{action: fmt.Sprintf("Updated %s on %s", fieldKey, fileName)} + return authActionMsg{action: fmt.Sprintf(T("updated_field"), fieldKey, fileName)} } case "esc": m.editing = false @@ -150,7 +153,7 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { if err != nil { return authActionMsg{err: err} } - return authActionMsg{action: fmt.Sprintf("Deleted %s", name)} + return authActionMsg{action: fmt.Sprintf(T("deleted"), name)} } } m.viewport.SetContent(m.renderContent()) @@ -202,9 +205,9 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { if err != nil { return authActionMsg{err: err} } - action := "Enabled" + action := T("enabled") if newDisabled { - action = "Disabled" + action = T("disabled") } return authActionMsg{action: fmt.Sprintf("%s %s", action, name)} } @@ -267,7 +270,7 @@ func (m *authTabModel) SetSize(w, h int) { func (m authTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -275,11 +278,11 @@ func (m authTabModel) View() string { func (m authTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("🔑 Auth Files")) + sb.WriteString(titleStyle.Render(T("auth_title"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter] expand • [e] enable/disable • [d] delete • [r] refresh")) + sb.WriteString(helpStyle.Render(T("auth_help1"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [1] edit prefix • [2] edit proxy_url • [3] edit priority")) + sb.WriteString(helpStyle.Render(T("auth_help2"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString("\n") @@ -291,7 +294,7 @@ func (m authTabModel) renderContent() string { } if len(m.files) == 0 { - sb.WriteString(subtitleStyle.Render("\n No auth files found")) + sb.WriteString(subtitleStyle.Render(T("no_auth_files"))) sb.WriteString("\n") return sb.String() } @@ -303,10 +306,10 @@ func (m authTabModel) renderContent() string { disabled := getBool(f, "disabled") statusIcon := successStyle.Render("●") - statusText := "active" + statusText := T("status_active") if disabled { statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("○") - statusText = "disabled" + statusText = T("status_disabled") } cursor := " " @@ -332,7 +335,7 @@ func (m authTabModel) renderContent() string { // Delete confirmation if m.confirm == i { - sb.WriteString(warningStyle.Render(fmt.Sprintf(" ⚠ Delete %s? [y/n] ", name))) + sb.WriteString(warningStyle.Render(fmt.Sprintf(" "+T("confirm_delete"), name))) sb.WriteString("\n") } @@ -340,7 +343,7 @@ func (m authTabModel) renderContent() string { if m.editing && i == m.cursor { sb.WriteString(m.editInput.View()) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" Enter: save • Esc: cancel")) + sb.WriteString(helpStyle.Render(" " + T("enter_save") + " • " + T("esc_cancel"))) sb.WriteString("\n") } @@ -398,7 +401,7 @@ func (m authTabModel) renderDetail(f map[string]any) string { val := getAnyString(f, field.key) if val == "" || val == "" { if field.editable { - val = "(not set)" + val = T("not_set") } else { continue } diff --git a/internal/tui/client.go b/internal/tui/client.go index b2e15e68..81016cc5 100644 --- a/internal/tui/client.go +++ b/internal/tui/client.go @@ -206,6 +206,34 @@ func (c *Client) GetAPIKeys() ([]string, error) { return result, nil } +// AddAPIKey adds a new API key by sending old=nil, new=key which appends. +func (c *Client) AddAPIKey(key string) error { + body := map[string]any{"old": nil, "new": key} + jsonBody, _ := json.Marshal(body) + _, err := c.patch("/v0/management/api-keys", strings.NewReader(string(jsonBody))) + return err +} + +// EditAPIKey replaces an API key at the given index. +func (c *Client) EditAPIKey(index int, newValue string) error { + body := map[string]any{"index": index, "value": newValue} + jsonBody, _ := json.Marshal(body) + _, err := c.patch("/v0/management/api-keys", strings.NewReader(string(jsonBody))) + return err +} + +// DeleteAPIKey deletes an API key by index. +func (c *Client) DeleteAPIKey(index int) error { + _, code, err := c.doRequest("DELETE", fmt.Sprintf("/v0/management/api-keys?index=%d", index), nil) + if err != nil { + return err + } + if code >= 400 { + return fmt.Errorf("delete failed (HTTP %d)", code) + } + return nil +} + // GetGeminiKeys fetches Gemini API keys. // API returns {"gemini-api-key": [...]}. func (c *Client) GetGeminiKeys() ([]map[string]any, error) { diff --git a/internal/tui/config_tab.go b/internal/tui/config_tab.go index 39f3ce68..762c3ac2 100644 --- a/internal/tui/config_tab.go +++ b/internal/tui/config_tab.go @@ -64,6 +64,9 @@ func (m configTabModel) fetchConfig() tea.Msg { func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case configDataMsg: if msg.err != nil { m.err = msg.err @@ -79,7 +82,7 @@ func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) { if msg.err != nil { m.message = errorStyle.Render("✗ " + msg.err.Error()) } else { - m.message = successStyle.Render("✓ Updated successfully") + m.message = successStyle.Render(T("updated_ok")) } m.viewport.SetContent(m.renderContent()) // Refresh config from server @@ -178,7 +181,7 @@ func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd { case "int": v, parseErr := strconv.Atoi(newValue) if parseErr != nil { - return configUpdateMsg{err: fmt.Errorf("invalid integer: %s", newValue)} + return configUpdateMsg{err: fmt.Errorf("%s: %s", T("invalid_int"), newValue)} } err = m.client.PutIntField(f.apiPath, v) case "string": @@ -214,7 +217,7 @@ func (m *configTabModel) ensureCursorVisible() { func (m configTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -222,7 +225,7 @@ func (m configTabModel) View() string { func (m configTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("⚙ Configuration")) + sb.WriteString(titleStyle.Render(T("config_title"))) sb.WriteString("\n") if m.message != "" { @@ -230,9 +233,9 @@ func (m configTabModel) renderContent() string { sb.WriteString("\n") } - sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter/Space] edit • [r] refresh")) + sb.WriteString(helpStyle.Render(T("config_help1"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" Bool fields: Enter to toggle • String/Int: Enter to type, Enter to confirm, Esc to cancel")) + sb.WriteString(helpStyle.Render(T("config_help2"))) sb.WriteString("\n\n") if m.err != nil { @@ -241,7 +244,7 @@ func (m configTabModel) renderContent() string { } if len(m.fields) == 0 { - sb.WriteString(subtitleStyle.Render(" No configuration loaded")) + sb.WriteString(subtitleStyle.Render(T("no_config"))) return sb.String() } @@ -341,23 +344,23 @@ func (m configTabModel) parseConfig(cfg map[string]any) []configField { func fieldSection(apiPath string) string { if strings.HasPrefix(apiPath, "ampcode/") { - return "AMP Code" + return T("section_ampcode") } if strings.HasPrefix(apiPath, "quota-exceeded/") { - return "Quota Exceeded Handling" + return T("section_quota") } if strings.HasPrefix(apiPath, "routing/") { - return "Routing" + return T("section_routing") } switch apiPath { case "port", "host", "debug", "proxy-url", "request-retry", "max-retry-interval", "force-model-prefix": - return "Server" + return T("section_server") case "logging-to-file", "logs-max-total-size-mb", "error-logs-max-files", "usage-statistics-enabled", "request-log": - return "Logging & Stats" + return T("section_logging") case "ws-auth": - return "WebSocket" + return T("section_websocket") default: - return "Other" + return T("section_other") } } @@ -378,7 +381,7 @@ func getBoolNested(m map[string]any, keys ...string) bool { func maskIfNotEmpty(s string) string { if s == "" { - return "(not set)" + return T("not_set") } return maskKey(s) } diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 02033830..e4215dc6 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -57,6 +57,9 @@ 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 + return m, m.fetchData case dashboardDataMsg: if msg.err != nil { m.err = msg.err @@ -97,7 +100,7 @@ func (m *dashboardModel) SetSize(w, h int) { func (m dashboardModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -105,19 +108,15 @@ func (m dashboardModel) View() string { func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string { var sb strings.Builder - sb.WriteString(titleStyle.Render("📊 Dashboard")) + sb.WriteString(titleStyle.Render(T("dashboard_title"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll")) + sb.WriteString(helpStyle.Render(T("dashboard_help"))) sb.WriteString("\n\n") // ━━━ Connection Status ━━━ - port := 0.0 - if cfg != nil { - port = getFloat(cfg, "port") - } connStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess) - sb.WriteString(connStyle.Render("● 已连接")) - sb.WriteString(fmt.Sprintf(" http://127.0.0.1:%.0f", port)) + sb.WriteString(connStyle.Render(T("connected"))) + sb.WriteString(fmt.Sprintf(" %s", m.client.baseURL)) sb.WriteString("\n\n") // ━━━ Stats Cards ━━━ @@ -141,7 +140,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m card1 := cardStyle.Render(fmt.Sprintf( "%s\n%s", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("🔑 %d", keyCount)), - lipgloss.NewStyle().Foreground(colorMuted).Render("管理密钥"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("mgmt_keys")), )) // Card 2: Auth Files @@ -155,7 +154,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m card2 := cardStyle.Render(fmt.Sprintf( "%s\n%s", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("📄 %d", authCount)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("认证文件 (%d active)", activeAuth)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (%d %s)", T("auth_files_label"), activeAuth, T("active_suffix"))), )) // Card 3: Total Requests @@ -174,7 +173,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m card3 := cardStyle.Render(fmt.Sprintf( "%s\n%s", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(fmt.Sprintf("📈 %d", totalReqs)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("请求 (✓%d ✗%d)", successReqs, failedReqs)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (✓%d ✗%d)", T("total_requests"), successReqs, failedReqs)), )) // Card 4: Total Tokens @@ -182,14 +181,14 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m card4 := cardStyle.Render(fmt.Sprintf( "%s\n%s", lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("🔤 %s", tokenStr)), - lipgloss.NewStyle().Foreground(colorMuted).Render("总 Tokens"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("total_tokens")), )) sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) sb.WriteString("\n\n") // ━━━ Current Config ━━━ - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("当前配置")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("current_config"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") @@ -210,16 +209,16 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m label string value string }{ - {"启用调试模式", boolEmoji(debug)}, - {"启用使用统计", boolEmoji(usageEnabled)}, - {"启用日志记录到文件", boolEmoji(loggingToFile)}, - {"重试次数", fmt.Sprintf("%.0f", retry)}, + {T("debug_mode"), boolEmoji(debug)}, + {T("usage_stats"), boolEmoji(usageEnabled)}, + {T("log_to_file"), boolEmoji(loggingToFile)}, + {T("retry_count"), fmt.Sprintf("%.0f", retry)}, } if proxyURL != "" { configItems = append(configItems, struct { label string value string - }{"代理 URL", proxyURL}) + }{T("proxy_url"), proxyURL}) } // Render config items as a compact row @@ -237,7 +236,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m } } sb.WriteString(fmt.Sprintf(" %s %s\n", - labelStyle.Render("路由策略:"), + labelStyle.Render(T("routing_strategy")+":"), valueStyle.Render(strategy))) } @@ -247,12 +246,12 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m if usage != nil { if usageMap, ok := usage["usage"].(map[string]any); ok { if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("模型统计")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("model_stats"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") - header := fmt.Sprintf(" %-40s %10s %12s", "Model", "Requests", "Tokens") + header := fmt.Sprintf(" %-40s %10s %12s", T("model"), T("requests"), T("tokens")) sb.WriteString(tableHeaderStyle.Render(header)) sb.WriteString("\n") @@ -315,9 +314,9 @@ func getBool(m map[string]any, key string) bool { func boolEmoji(b bool) string { if b { - return "是 ✓" + return T("bool_yes") } - return "否" + return T("bool_no") } func formatLargeNumber(n int64) string { diff --git a/internal/tui/i18n.go b/internal/tui/i18n.go new file mode 100644 index 00000000..1b54a9af --- /dev/null +++ b/internal/tui/i18n.go @@ -0,0 +1,350 @@ +package tui + +// i18n provides a simple internationalization system for the TUI. +// Supported locales: "zh" (Chinese, default), "en" (English). + +var currentLocale = "zh" + +// SetLocale changes the active locale. +func SetLocale(locale string) { + if _, ok := locales[locale]; ok { + currentLocale = locale + } +} + +// CurrentLocale returns the active locale code. +func CurrentLocale() string { + return currentLocale +} + +// ToggleLocale switches between zh and en. +func ToggleLocale() { + if currentLocale == "zh" { + currentLocale = "en" + } else { + currentLocale = "zh" + } +} + +// T returns the translated string for the given key. +func T(key string) string { + if m, ok := locales[currentLocale]; ok { + if v, ok := m[key]; ok { + return v + } + } + // Fallback to English + if m, ok := locales["en"]; ok { + if v, ok := m[key]; ok { + return v + } + } + return key +} + +var locales = map[string]map[string]string{ + "zh": zhStrings, + "en": enStrings, +} + +// ────────────────────────────────────────── +// Tab names +// ────────────────────────────────────────── +var zhTabNames = []string{"仪表盘", "配置", "认证文件", "API 密钥", "OAuth", "使用统计", "日志"} +var enTabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"} + +// TabNames returns tab names in the current locale. +func TabNames() []string { + if currentLocale == "zh" { + return zhTabNames + } + return enTabNames +} + +var zhStrings = map[string]string{ + // ── Common ── + "loading": "加载中...", + "refresh": "刷新", + "save": "保存", + "cancel": "取消", + "confirm": "确认", + "yes": "是", + "no": "否", + "error": "错误", + "success": "成功", + "navigate": "导航", + "scroll": "滚动", + "enter_save": "Enter: 保存", + "esc_cancel": "Esc: 取消", + "enter_submit": "Enter: 提交", + "press_r": "[r] 刷新", + "press_scroll": "[↑↓] 滚动", + "not_set": "(未设置)", + "error_prefix": "⚠ 错误: ", + + // ── Status bar ── + "status_left": " CLIProxyAPI 管理终端", + "status_right": "Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 ", + "initializing_tui": "正在初始化...", + + // ── Dashboard ── + "dashboard_title": "📊 仪表盘", + "dashboard_help": " [r] 刷新 • [↑↓] 滚动", + "connected": "● 已连接", + "mgmt_keys": "管理密钥", + "auth_files_label": "认证文件", + "active_suffix": "活跃", + "total_requests": "请求", + "success_label": "成功", + "failure_label": "失败", + "total_tokens": "总 Tokens", + "current_config": "当前配置", + "debug_mode": "启用调试模式", + "usage_stats": "启用使用统计", + "log_to_file": "启用日志记录到文件", + "retry_count": "重试次数", + "proxy_url": "代理 URL", + "routing_strategy": "路由策略", + "model_stats": "模型统计", + "model": "模型", + "requests": "请求数", + "tokens": "Tokens", + "bool_yes": "是 ✓", + "bool_no": "否", + + // ── Config ── + "config_title": "⚙ 配置", + "config_help1": " [↑↓/jk] 导航 • [Enter/Space] 编辑 • [r] 刷新", + "config_help2": " 布尔: Enter 切换 • 文本/数字: Enter 输入, Enter 确认, Esc 取消", + "updated_ok": "✓ 更新成功", + "no_config": " 未加载配置", + "invalid_int": "无效整数", + "section_server": "服务器", + "section_logging": "日志与统计", + "section_quota": "配额超限处理", + "section_routing": "路由", + "section_websocket": "WebSocket", + "section_ampcode": "AMP Code", + "section_other": "其他", + + // ── Auth Files ── + "auth_title": "🔑 认证文件", + "auth_help1": " [↑↓/jk] 导航 • [Enter] 展开 • [e] 启用/停用 • [d] 删除 • [r] 刷新", + "auth_help2": " [1] 编辑 prefix • [2] 编辑 proxy_url • [3] 编辑 priority", + "no_auth_files": " 无认证文件", + "confirm_delete": "⚠ 删除 %s? [y/n]", + "deleted": "已删除 %s", + "enabled": "已启用", + "disabled": "已停用", + "updated_field": "已更新 %s 的 %s", + "status_active": "活跃", + "status_disabled": "已停用", + + // ── API Keys ── + "keys_title": "🔐 API 密钥", + "keys_help": " [↑↓/jk] 导航 • [a] 添加 • [e] 编辑 • [d] 删除 • [c] 复制 • [r] 刷新", + "no_keys": " 无 API Key,按 [a] 添加", + "access_keys": "Access API Keys", + "confirm_delete_key": "⚠ 确认删除 %s? [y/n]", + "key_added": "已添加 API Key", + "key_updated": "已更新 API Key", + "key_deleted": "已删除 API Key", + "copied": "✓ 已复制到剪贴板", + "copy_failed": "✗ 复制失败", + "new_key_prompt": " New Key: ", + "edit_key_prompt": " Edit Key: ", + "enter_add": " Enter: 添加 • Esc: 取消", + "enter_save_esc": " Enter: 保存 • Esc: 取消", + + // ── OAuth ── + "oauth_title": "🔐 OAuth 登录", + "oauth_select": " 选择提供商并按 [Enter] 开始 OAuth 登录:", + "oauth_help": " [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态", + "oauth_initiating": "⏳ 正在初始化 %s 登录...", + "oauth_success": "认证成功! 请刷新 Auth Files 标签查看新凭证。", + "oauth_completed": "认证流程已完成。", + "oauth_failed": "认证失败", + "oauth_timeout": "OAuth 流程超时 (5 分钟)", + "oauth_press_esc": " 按 [Esc] 取消", + "oauth_auth_url": " 授权链接:", + "oauth_remote_hint": " 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。", + "oauth_callback_url": " 回调 URL:", + "oauth_press_c": " 按 [c] 输入回调 URL • [Esc] 返回", + "oauth_submitting": "⏳ 提交回调中...", + "oauth_submit_ok": "✓ 回调已提交,等待处理...", + "oauth_submit_fail": "✗ 提交回调失败", + "oauth_waiting": " 等待认证中...", + + // ── Usage ── + "usage_title": "📈 使用统计", + "usage_help": " [r] 刷新 • [↑↓] 滚动", + "usage_no_data": " 使用数据不可用", + "usage_total_reqs": "总请求数", + "usage_total_tokens": "总 Token 数", + "usage_success": "成功", + "usage_failure": "失败", + "usage_total_token_l": "总Token", + "usage_rpm": "RPM", + "usage_tpm": "TPM", + "usage_req_by_hour": "请求趋势 (按小时)", + "usage_tok_by_hour": "Token 使用趋势 (按小时)", + "usage_req_by_day": "请求趋势 (按天)", + "usage_api_detail": "API 详细统计", + "usage_input": "输入", + "usage_output": "输出", + "usage_cached": "缓存", + "usage_reasoning": "思考", + + // ── Logs ── + "logs_title": "📋 日志", + "logs_auto_scroll": "● 自动滚动", + "logs_paused": "○ 已暂停", + "logs_filter": "过滤", + "logs_lines": "行数", + "logs_help": " [a] 自动滚动 • [c] 清除 • [1] 全部 [2] info+ [3] warn+ [4] error • [↑↓] 滚动", + "logs_waiting": " 等待日志输出...", +} + +var enStrings = map[string]string{ + // ── Common ── + "loading": "Loading...", + "refresh": "Refresh", + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "yes": "Yes", + "no": "No", + "error": "Error", + "success": "Success", + "navigate": "Navigate", + "scroll": "Scroll", + "enter_save": "Enter: Save", + "esc_cancel": "Esc: Cancel", + "enter_submit": "Enter: Submit", + "press_r": "[r] Refresh", + "press_scroll": "[↑↓] Scroll", + "not_set": "(not set)", + "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...", + + // ── Dashboard ── + "dashboard_title": "📊 Dashboard", + "dashboard_help": " [r] Refresh • [↑↓] Scroll", + "connected": "● Connected", + "mgmt_keys": "Mgmt Keys", + "auth_files_label": "Auth Files", + "active_suffix": "active", + "total_requests": "Requests", + "success_label": "Success", + "failure_label": "Failed", + "total_tokens": "Total Tokens", + "current_config": "Current Config", + "debug_mode": "Debug Mode", + "usage_stats": "Usage Statistics", + "log_to_file": "Log to File", + "retry_count": "Retry Count", + "proxy_url": "Proxy URL", + "routing_strategy": "Routing Strategy", + "model_stats": "Model Stats", + "model": "Model", + "requests": "Requests", + "tokens": "Tokens", + "bool_yes": "Yes ✓", + "bool_no": "No", + + // ── Config ── + "config_title": "⚙ Configuration", + "config_help1": " [↑↓/jk] Navigate • [Enter/Space] Edit • [r] Refresh", + "config_help2": " Bool: Enter to toggle • String/Int: Enter to type, Enter to confirm, Esc to cancel", + "updated_ok": "✓ Updated successfully", + "no_config": " No configuration loaded", + "invalid_int": "invalid integer", + "section_server": "Server", + "section_logging": "Logging & Stats", + "section_quota": "Quota Exceeded Handling", + "section_routing": "Routing", + "section_websocket": "WebSocket", + "section_ampcode": "AMP Code", + "section_other": "Other", + + // ── Auth Files ── + "auth_title": "🔑 Auth Files", + "auth_help1": " [↑↓/jk] Navigate • [Enter] Expand • [e] Enable/Disable • [d] Delete • [r] Refresh", + "auth_help2": " [1] Edit prefix • [2] Edit proxy_url • [3] Edit priority", + "no_auth_files": " No auth files found", + "confirm_delete": "⚠ Delete %s? [y/n]", + "deleted": "Deleted %s", + "enabled": "Enabled", + "disabled": "Disabled", + "updated_field": "Updated %s on %s", + "status_active": "active", + "status_disabled": "disabled", + + // ── API Keys ── + "keys_title": "🔐 API Keys", + "keys_help": " [↑↓/jk] Navigate • [a] Add • [e] Edit • [d] Delete • [c] Copy • [r] Refresh", + "no_keys": " No API Keys. Press [a] to add", + "access_keys": "Access API Keys", + "confirm_delete_key": "⚠ Delete %s? [y/n]", + "key_added": "API Key added", + "key_updated": "API Key updated", + "key_deleted": "API Key deleted", + "copied": "✓ Copied to clipboard", + "copy_failed": "✗ Copy failed", + "new_key_prompt": " New Key: ", + "edit_key_prompt": " Edit Key: ", + "enter_add": " Enter: Add • Esc: Cancel", + "enter_save_esc": " Enter: Save • Esc: Cancel", + + // ── OAuth ── + "oauth_title": "🔐 OAuth Login", + "oauth_select": " Select a provider and press [Enter] to start OAuth login:", + "oauth_help": " [↑↓/jk] Navigate • [Enter] Login • [Esc] Clear status", + "oauth_initiating": "⏳ Initiating %s login...", + "oauth_success": "Authentication successful! Refresh Auth Files tab to see the new credential.", + "oauth_completed": "Authentication flow completed.", + "oauth_failed": "Authentication failed", + "oauth_timeout": "OAuth flow timed out (5 minutes)", + "oauth_press_esc": " Press [Esc] to cancel", + "oauth_auth_url": " Authorization URL:", + "oauth_remote_hint": " Remote browser mode: Open the URL above in browser, paste the callback URL below after authorization.", + "oauth_callback_url": " Callback URL:", + "oauth_press_c": " Press [c] to enter callback URL • [Esc] to go back", + "oauth_submitting": "⏳ Submitting callback...", + "oauth_submit_ok": "✓ Callback submitted, waiting...", + "oauth_submit_fail": "✗ Callback submission failed", + "oauth_waiting": " Waiting for authentication...", + + // ── Usage ── + "usage_title": "📈 Usage Statistics", + "usage_help": " [r] Refresh • [↑↓] Scroll", + "usage_no_data": " Usage data not available", + "usage_total_reqs": "Total Requests", + "usage_total_tokens": "Total Tokens", + "usage_success": "Success", + "usage_failure": "Failed", + "usage_total_token_l": "Total Tokens", + "usage_rpm": "RPM", + "usage_tpm": "TPM", + "usage_req_by_hour": "Requests by Hour", + "usage_tok_by_hour": "Token Usage by Hour", + "usage_req_by_day": "Requests by Day", + "usage_api_detail": "API Detail Statistics", + "usage_input": "Input", + "usage_output": "Output", + "usage_cached": "Cached", + "usage_reasoning": "Reasoning", + + // ── Logs ── + "logs_title": "📋 Logs", + "logs_auto_scroll": "● AUTO-SCROLL", + "logs_paused": "○ PAUSED", + "logs_filter": "Filter", + "logs_lines": "Lines", + "logs_help": " [a] Auto-scroll • [c] Clear • [1] All [2] info+ [3] warn+ [4] error • [↑↓] Scroll", + "logs_waiting": " Waiting for log output...", +} diff --git a/internal/tui/keys_tab.go b/internal/tui/keys_tab.go index 20e9e0f0..770f7f1e 100644 --- a/internal/tui/keys_tab.go +++ b/internal/tui/keys_tab.go @@ -4,19 +4,36 @@ import ( "fmt" "strings" + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) -// keysTabModel displays API keys from all providers. +// keysTabModel displays and manages API keys. type keysTabModel struct { client *Client viewport viewport.Model - content string + keys []string + gemini []map[string]any + claude []map[string]any + codex []map[string]any + vertex []map[string]any + openai []map[string]any err error width int height int ready bool + cursor int + confirm int // -1 = no deletion pending + status string + + // Editing / Adding + editing bool + adding bool + editIdx int + editInput textinput.Model } type keysDataMsg struct { @@ -29,9 +46,19 @@ type keysDataMsg struct { err error } +type keyActionMsg struct { + action string + err error +} + func newKeysTabModel(client *Client) keysTabModel { + ti := textinput.New() + ti.CharLimit = 512 + ti.Prompt = " Key: " return keysTabModel{ - client: client, + client: client, + confirm: -1, + editInput: ti, } } @@ -41,44 +68,185 @@ func (m keysTabModel) Init() tea.Cmd { func (m keysTabModel) fetchKeys() tea.Msg { result := keysDataMsg{} - apiKeys, err := m.client.GetAPIKeys() if err != nil { result.err = err return result } result.apiKeys = apiKeys - - // Fetch all key types, ignoring individual errors (they may not be configured) result.gemini, _ = m.client.GetGeminiKeys() result.claude, _ = m.client.GetClaudeKeys() result.codex, _ = m.client.GetCodexKeys() result.vertex, _ = m.client.GetVertexKeys() result.openai, _ = m.client.GetOpenAICompat() - return result } func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case keysDataMsg: if msg.err != nil { m.err = msg.err - m.content = errorStyle.Render("⚠ Error: " + msg.err.Error()) } else { m.err = nil - m.content = m.renderKeys(msg) + m.keys = msg.apiKeys + m.gemini = msg.gemini + m.claude = msg.claude + m.codex = msg.codex + m.vertex = msg.vertex + m.openai = msg.openai + if m.cursor >= len(m.keys) { + m.cursor = max(0, len(m.keys)-1) + } } - m.viewport.SetContent(m.content) + m.viewport.SetContent(m.renderContent()) return m, nil - case tea.KeyMsg: - if msg.String() == "r" { - return m, m.fetchKeys + case keyActionMsg: + if msg.err != nil { + m.status = errorStyle.Render("✗ " + msg.err.Error()) + } else { + m.status = successStyle.Render("✓ " + msg.action) + } + m.confirm = -1 + m.viewport.SetContent(m.renderContent()) + return m, m.fetchKeys + + case tea.KeyMsg: + // ---- Editing / Adding mode ---- + if m.editing || m.adding { + switch msg.String() { + case "enter": + value := strings.TrimSpace(m.editInput.Value()) + if value == "" { + m.editing = false + m.adding = false + m.editInput.Blur() + m.viewport.SetContent(m.renderContent()) + return m, nil + } + isAdding := m.adding + editIdx := m.editIdx + m.editing = false + m.adding = false + m.editInput.Blur() + if isAdding { + return m, func() tea.Msg { + err := m.client.AddAPIKey(value) + if err != nil { + return keyActionMsg{err: err} + } + return keyActionMsg{action: T("key_added")} + } + } + return m, func() tea.Msg { + err := m.client.EditAPIKey(editIdx, value) + if err != nil { + return keyActionMsg{err: err} + } + return keyActionMsg{action: T("key_updated")} + } + case "esc": + m.editing = false + m.adding = 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 + } + } + + // ---- Delete confirmation ---- + if m.confirm >= 0 { + switch msg.String() { + case "y", "Y": + idx := m.confirm + m.confirm = -1 + return m, func() tea.Msg { + err := m.client.DeleteAPIKey(idx) + if err != nil { + return keyActionMsg{err: err} + } + return keyActionMsg{action: T("key_deleted")} + } + case "n", "N", "esc": + m.confirm = -1 + m.viewport.SetContent(m.renderContent()) + return m, nil + } + return m, nil + } + + // ---- Normal mode ---- + switch msg.String() { + case "j", "down": + if len(m.keys) > 0 { + m.cursor = (m.cursor + 1) % len(m.keys) + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "k", "up": + if len(m.keys) > 0 { + m.cursor = (m.cursor - 1 + len(m.keys)) % len(m.keys) + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "a": + // Add new key + m.adding = true + m.editing = false + m.editInput.SetValue("") + m.editInput.Prompt = T("new_key_prompt") + m.editInput.Focus() + m.viewport.SetContent(m.renderContent()) + return m, textinput.Blink + case "e": + // Edit selected key + if m.cursor < len(m.keys) { + m.editing = true + m.adding = false + m.editIdx = m.cursor + m.editInput.SetValue(m.keys[m.cursor]) + m.editInput.Prompt = T("edit_key_prompt") + m.editInput.Focus() + m.viewport.SetContent(m.renderContent()) + return m, textinput.Blink + } + return m, nil + case "d": + // Delete selected key + if m.cursor < len(m.keys) { + m.confirm = m.cursor + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "c": + // Copy selected key to clipboard + if m.cursor < len(m.keys) { + key := m.keys[m.cursor] + if err := clipboard.WriteAll(key); err != nil { + m.status = errorStyle.Render(T("copy_failed") + ": " + err.Error()) + } else { + m.status = successStyle.Render(T("copied")) + } + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "r": + m.status = "" + return m, m.fetchKeys + default: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd } - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - return m, cmd } var cmd tea.Cmd @@ -89,9 +257,10 @@ func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) { func (m *keysTabModel) SetSize(w, h int) { m.width = w m.height = h + m.editInput.Width = w - 16 if !m.ready { m.viewport = viewport.New(w, h) - m.viewport.SetContent(m.content) + m.viewport.SetContent(m.renderContent()) m.ready = true } else { m.viewport.Width = w @@ -101,40 +270,83 @@ func (m *keysTabModel) SetSize(w, h int) { func (m keysTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } -func (m keysTabModel) renderKeys(data keysDataMsg) string { +func (m keysTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("🔐 API Keys")) - sb.WriteString("\n\n") - - // API Keys (access keys) - renderSection(&sb, "Access API Keys", len(data.apiKeys)) - for i, key := range data.apiKeys { - sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, maskKey(key))) - } + sb.WriteString(titleStyle.Render(T("keys_title"))) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(T("keys_help"))) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString("\n") - // Gemini Keys - renderProviderKeys(&sb, "Gemini API Keys", data.gemini) + if m.err != nil { + sb.WriteString(errorStyle.Render(T("error_prefix") + m.err.Error())) + sb.WriteString("\n") + return sb.String() + } - // Claude Keys - renderProviderKeys(&sb, "Claude API Keys", data.claude) + // ━━━ Access API Keys (interactive) ━━━ + sb.WriteString(tableHeaderStyle.Render(fmt.Sprintf(" %s (%d)", T("access_keys"), len(m.keys)))) + sb.WriteString("\n") - // Codex Keys - renderProviderKeys(&sb, "Codex API Keys", data.codex) + if len(m.keys) == 0 { + sb.WriteString(subtitleStyle.Render(T("no_keys"))) + sb.WriteString("\n") + } - // Vertex Keys - renderProviderKeys(&sb, "Vertex API Keys", data.vertex) + for i, key := range m.keys { + cursor := " " + rowStyle := lipgloss.NewStyle() + if i == m.cursor { + cursor = "▸ " + rowStyle = lipgloss.NewStyle().Bold(true) + } - // OpenAI Compatibility - if len(data.openai) > 0 { - renderSection(&sb, "OpenAI Compatibility", len(data.openai)) - for i, entry := range data.openai { + row := fmt.Sprintf("%s%d. %s", cursor, i+1, maskKey(key)) + sb.WriteString(rowStyle.Render(row)) + sb.WriteString("\n") + + // Delete confirmation + if m.confirm == i { + sb.WriteString(warningStyle.Render(fmt.Sprintf(" "+T("confirm_delete_key"), maskKey(key)))) + sb.WriteString("\n") + } + + // Edit input + if m.editing && m.editIdx == i { + sb.WriteString(m.editInput.View()) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(T("enter_save_esc"))) + sb.WriteString("\n") + } + } + + // Add input + if m.adding { + sb.WriteString("\n") + sb.WriteString(m.editInput.View()) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(T("enter_add"))) + sb.WriteString("\n") + } + + sb.WriteString("\n") + + // ━━━ Provider Keys (read-only display) ━━━ + renderProviderKeys(&sb, "Gemini API Keys", m.gemini) + renderProviderKeys(&sb, "Claude API Keys", m.claude) + renderProviderKeys(&sb, "Codex API Keys", m.codex) + renderProviderKeys(&sb, "Vertex API Keys", m.vertex) + + if len(m.openai) > 0 { + renderSection(&sb, "OpenAI Compatibility", len(m.openai)) + for i, entry := range m.openai { name := getString(entry, "name") baseURL := getString(entry, "base-url") prefix := getString(entry, "prefix") @@ -150,7 +362,10 @@ func (m keysTabModel) renderKeys(data keysDataMsg) string { sb.WriteString("\n") } - sb.WriteString(helpStyle.Render("Press [r] to refresh • [↑↓] to scroll")) + if m.status != "" { + sb.WriteString(m.status) + sb.WriteString("\n") + } return sb.String() } diff --git a/internal/tui/logs_tab.go b/internal/tui/logs_tab.go index 9281d472..ec7bdfc5 100644 --- a/internal/tui/logs_tab.go +++ b/internal/tui/logs_tab.go @@ -47,6 +47,9 @@ func (m logsTabModel) waitForLog() tea.Msg { func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderLogs()) + return m, nil case logLineMsg: m.lines = append(m.lines, string(msg)) if len(m.lines) > m.maxLines { @@ -122,7 +125,7 @@ func (m *logsTabModel) SetSize(w, h int) { func (m logsTabModel) View() string { if !m.ready { - return "Loading logs..." + return T("loading") } return m.viewport.View() } @@ -130,26 +133,26 @@ func (m logsTabModel) View() string { func (m logsTabModel) renderLogs() string { var sb strings.Builder - scrollStatus := successStyle.Render("● AUTO-SCROLL") + scrollStatus := successStyle.Render(T("logs_auto_scroll")) if !m.autoScroll { - scrollStatus = warningStyle.Render("○ PAUSED") + scrollStatus = warningStyle.Render(T("logs_paused")) } filterLabel := "ALL" if m.filter != "" { filterLabel = strings.ToUpper(m.filter) + "+" } - header := fmt.Sprintf(" 📋 Logs %s Filter: %s Lines: %d", - scrollStatus, filterLabel, len(m.lines)) + header := fmt.Sprintf(" %s %s %s: %s %s: %d", + T("logs_title"), scrollStatus, T("logs_filter"), filterLabel, T("logs_lines"), len(m.lines)) sb.WriteString(titleStyle.Render(header)) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [a]uto-scroll • [c]lear • [1]all [2]info+ [3]warn+ [4]error • [↑↓] scroll")) + sb.WriteString(helpStyle.Render(T("logs_help"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", m.width)) sb.WriteString("\n") if len(m.lines) == 0 { - sb.WriteString(subtitleStyle.Render("\n Waiting for log output...")) + sb.WriteString(subtitleStyle.Render(T("logs_waiting"))) return sb.String() } diff --git a/internal/tui/oauth_tab.go b/internal/tui/oauth_tab.go index 2f320c2d..3989e3d8 100644 --- a/internal/tui/oauth_tab.go +++ b/internal/tui/oauth_tab.go @@ -93,6 +93,9 @@ func (m oauthTabModel) Init() tea.Cmd { func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case oauthStartMsg: if msg.err != nil { m.state = oauthError @@ -133,9 +136,9 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { case oauthCallbackSubmitMsg: if msg.err != nil { - m.message = errorStyle.Render("✗ 提交回调失败: " + msg.err.Error()) + m.message = errorStyle.Render(T("oauth_submit_fail") + ": " + msg.err.Error()) } else { - m.message = successStyle.Render("✓ 回调已提交,等待处理...") + m.message = successStyle.Render(T("oauth_submit_ok")) } m.viewport.SetContent(m.renderContent()) return m, nil @@ -151,7 +154,7 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { } m.inputActive = false m.callbackInput.Blur() - m.message = warningStyle.Render("⏳ 提交回调中...") + m.message = warningStyle.Render(T("oauth_submitting")) m.viewport.SetContent(m.renderContent()) return m, m.submitCallback(callbackURL) case "esc": @@ -217,7 +220,7 @@ func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { if m.cursor >= 0 && m.cursor < len(oauthProviders) { provider := oauthProviders[m.cursor] m.state = oauthPending - m.message = warningStyle.Render("⏳ 正在初始化 " + provider.name + " 登录...") + m.message = warningStyle.Render(fmt.Sprintf(T("oauth_initiating"), provider.name)) m.viewport.SetContent(m.renderContent()) return m, m.startOAuth(provider) } @@ -307,7 +310,7 @@ func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd { deadline := time.Now().Add(5 * time.Minute) for { if time.Now().After(deadline) { - return oauthPollMsg{done: false, err: fmt.Errorf("OAuth flow timed out (5 minutes)")} + return oauthPollMsg{done: false, err: fmt.Errorf("%s", T("oauth_timeout"))} } time.Sleep(2 * time.Second) @@ -321,19 +324,19 @@ func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd { case "ok": return oauthPollMsg{ done: true, - message: "认证成功! 请刷新 Auth Files 标签查看新凭证。", + message: T("oauth_success"), } case "error": return oauthPollMsg{ done: false, - err: fmt.Errorf("认证失败: %s", errMsg), + err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg), } case "wait": continue default: return oauthPollMsg{ done: true, - message: "认证流程已完成。", + message: T("oauth_completed"), } } } @@ -356,7 +359,7 @@ func (m *oauthTabModel) SetSize(w, h int) { func (m oauthTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -364,7 +367,7 @@ func (m oauthTabModel) View() string { func (m oauthTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("🔐 OAuth 登录")) + sb.WriteString(titleStyle.Render(T("oauth_title"))) sb.WriteString("\n\n") if m.message != "" { @@ -379,11 +382,11 @@ func (m oauthTabModel) renderContent() string { } if m.state == oauthPending { - sb.WriteString(helpStyle.Render(" Press [Esc] to cancel")) + sb.WriteString(helpStyle.Render(T("oauth_press_esc"))) return sb.String() } - sb.WriteString(helpStyle.Render(" 选择提供商并按 [Enter] 开始 OAuth 登录:")) + sb.WriteString(helpStyle.Render(T("oauth_select"))) sb.WriteString("\n\n") for i, p := range oauthProviders { @@ -404,7 +407,7 @@ func (m oauthTabModel) renderContent() string { } sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [↑↓/jk] 导航 • [Enter] 登录 • [Esc] 清除状态")) + sb.WriteString(helpStyle.Render(T("oauth_help"))) return sb.String() } @@ -417,7 +420,7 @@ func (m oauthTabModel) renderRemoteMode() string { sb.WriteString("\n\n") // Auth URL section - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" 授权链接:")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_auth_url"))) sb.WriteString("\n") // Wrap URL to fit terminal width @@ -432,23 +435,23 @@ func (m oauthTabModel) renderRemoteMode() string { } sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" 远程浏览器模式:在浏览器中打开上述链接完成授权后,将回调 URL 粘贴到下方。")) + sb.WriteString(helpStyle.Render(T("oauth_remote_hint"))) sb.WriteString("\n\n") // Callback URL input - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" 回调 URL:")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(T("oauth_callback_url"))) sb.WriteString("\n") if m.inputActive { sb.WriteString(m.callbackInput.View()) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" Enter: 提交 • Esc: 取消输入")) + sb.WriteString(helpStyle.Render(" " + T("enter_submit") + " • " + T("esc_cancel"))) } else { - sb.WriteString(helpStyle.Render(" 按 [c] 输入回调 URL • [Esc] 返回")) + sb.WriteString(helpStyle.Render(T("oauth_press_c"))) } sb.WriteString("\n\n") - sb.WriteString(warningStyle.Render(" 等待认证中...")) + sb.WriteString(warningStyle.Render(T("oauth_waiting"))) return sb.String() } diff --git a/internal/tui/usage_tab.go b/internal/tui/usage_tab.go index ebbf832d..a40a760f 100644 --- a/internal/tui/usage_tab.go +++ b/internal/tui/usage_tab.go @@ -43,6 +43,9 @@ func (m usageTabModel) fetchData() tea.Msg { func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) { switch msg := msg.(type) { + case localeChangedMsg: + m.viewport.SetContent(m.renderContent()) + return m, nil case usageDataMsg: if msg.err != nil { m.err = msg.err @@ -82,7 +85,7 @@ func (m *usageTabModel) SetSize(w, h int) { func (m usageTabModel) View() string { if !m.ready { - return "Loading..." + return T("loading") } return m.viewport.View() } @@ -90,9 +93,9 @@ func (m usageTabModel) View() string { func (m usageTabModel) renderContent() string { var sb strings.Builder - sb.WriteString(titleStyle.Render("📈 使用统计")) + sb.WriteString(titleStyle.Render(T("usage_title"))) sb.WriteString("\n") - sb.WriteString(helpStyle.Render(" [r] refresh • [↑↓] scroll")) + sb.WriteString(helpStyle.Render(T("usage_help"))) sb.WriteString("\n\n") if m.err != nil { @@ -102,14 +105,14 @@ func (m usageTabModel) renderContent() string { } if m.usage == nil { - sb.WriteString(subtitleStyle.Render(" Usage data not available")) + sb.WriteString(subtitleStyle.Render(T("usage_no_data"))) sb.WriteString("\n") return sb.String() } usageMap, _ := m.usage["usage"].(map[string]any) if usageMap == nil { - sb.WriteString(subtitleStyle.Render(" No usage data")) + sb.WriteString(subtitleStyle.Render(T("usage_no_data"))) sb.WriteString("\n") return sb.String() } @@ -137,17 +140,17 @@ func (m usageTabModel) renderContent() string { // Total Requests card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf( "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render("总请求数"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_reqs")), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● 成功: %d ● 失败: %d", successCnt, failureCnt)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● %s: %d ● %s: %d", T("usage_success"), successCnt, T("usage_failure"), failureCnt)), )) // Total Tokens card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf( "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render("总 Token 数"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_tokens")), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token: %s", formatLargeNumber(totalTokens))), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_token_l"), formatLargeNumber(totalTokens))), )) // RPM @@ -159,9 +162,9 @@ func (m usageTabModel) renderContent() string { } card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf( "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render("RPM"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_rpm")), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总请求数: %d", totalReqs)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %d", T("usage_total_reqs"), totalReqs)), )) // TPM @@ -173,9 +176,9 @@ func (m usageTabModel) renderContent() string { } card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf( "%s\n%s\n%s", - lipgloss.NewStyle().Foreground(colorMuted).Render("TPM"), + lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_tpm")), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)), - lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("总Token数: %s", formatLargeNumber(totalTokens))), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_tokens"), formatLargeNumber(totalTokens))), )) sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) @@ -183,7 +186,7 @@ func (m usageTabModel) renderContent() string { // ━━━ Requests by Hour (ASCII bar chart) ━━━ if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按小时)")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_hour"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") @@ -193,7 +196,7 @@ func (m usageTabModel) renderContent() string { // ━━━ Tokens by Hour ━━━ if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("Token 使用趋势 (按小时)")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_tok_by_hour"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") @@ -203,7 +206,7 @@ func (m usageTabModel) renderContent() string { // ━━━ Requests by Day ━━━ if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("请求趋势 (按天)")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_day"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 60))) sb.WriteString("\n") @@ -213,12 +216,12 @@ func (m usageTabModel) renderContent() string { // ━━━ API Detail Stats ━━━ if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { - sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("API 详细统计")) + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_api_detail"))) sb.WriteString("\n") sb.WriteString(strings.Repeat("─", minInt(m.width, 80))) sb.WriteString("\n") - header := fmt.Sprintf(" %-30s %10s %12s", "API", "Requests", "Tokens") + header := fmt.Sprintf(" %-30s %10s %12s", "API", T("requests"), T("tokens")) sb.WriteString(tableHeaderStyle.Render(header)) sb.WriteString("\n") @@ -289,16 +292,16 @@ func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string { parts := []string{} if inputTotal > 0 { - parts = append(parts, fmt.Sprintf("输入:%s", formatLargeNumber(inputTotal))) + parts = append(parts, fmt.Sprintf("%s:%s", T("usage_input"), formatLargeNumber(inputTotal))) } if outputTotal > 0 { - parts = append(parts, fmt.Sprintf("输出:%s", formatLargeNumber(outputTotal))) + parts = append(parts, fmt.Sprintf("%s:%s", T("usage_output"), formatLargeNumber(outputTotal))) } if cachedTotal > 0 { - parts = append(parts, fmt.Sprintf("缓存:%s", formatLargeNumber(cachedTotal))) + parts = append(parts, fmt.Sprintf("%s:%s", T("usage_cached"), formatLargeNumber(cachedTotal))) } if reasoningTotal > 0 { - parts = append(parts, fmt.Sprintf("思考:%s", formatLargeNumber(reasoningTotal))) + parts = append(parts, fmt.Sprintf("%s:%s", T("usage_reasoning"), formatLargeNumber(reasoningTotal))) } return fmt.Sprintf(" │ %s\n", From 020df41efe33bf57fa1795326cc189f8a4c23e18 Mon Sep 17 00:00:00 2001 From: lhpqaq Date: Mon, 16 Feb 2026 00:04:04 +0800 Subject: [PATCH 3/6] chore(tui): update readme, fix usage --- README.md | 5 +++++ README_CN.md | 5 +++++ internal/tui/i18n.go | 2 +- internal/tui/usage_tab.go | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4fa495c6..2fd90ca8 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,11 @@ CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/) see [MANAGEMENT_API.md](https://help.router-for.me/management/api) +## Management TUI + +A terminal-based interface for managing configuration, keys/auth files, and viewing real-time logs. Run with: +`./CLIProxyAPI --tui` + ## Amp CLI Support CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools: diff --git a/README_CN.md b/README_CN.md index 5c91cbdc..b377c910 100644 --- a/README_CN.md +++ b/README_CN.md @@ -64,6 +64,11 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo 请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api) +## 管理 TUI + +一个用于管理配置、密钥/认证文件以及查看实时日志的终端界面。使用以下命令启动: +`./CLIProxyAPI --tui` + ## Amp CLI 支持 CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具: diff --git a/internal/tui/i18n.go b/internal/tui/i18n.go index 1b54a9af..84da3851 100644 --- a/internal/tui/i18n.go +++ b/internal/tui/i18n.go @@ -3,7 +3,7 @@ package tui // i18n provides a simple internationalization system for the TUI. // Supported locales: "zh" (Chinese, default), "en" (English). -var currentLocale = "zh" +var currentLocale = "en" // SetLocale changes the active locale. func SetLocale(locale string) { diff --git a/internal/tui/usage_tab.go b/internal/tui/usage_tab.go index a40a760f..9e6da7f8 100644 --- a/internal/tui/usage_tab.go +++ b/internal/tui/usage_tab.go @@ -231,7 +231,7 @@ func (m usageTabModel) renderContent() string { apiToks := int64(getFloat(apiMap, "total_tokens")) row := fmt.Sprintf(" %-30s %10d %12s", - truncate(apiName, 30), apiReqs, formatLargeNumber(apiToks)) + truncate(maskKey(apiName), 30), apiReqs, formatLargeNumber(apiToks)) sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row)) sb.WriteString("\n") From 0a2555b0f3af5e81103a5c4ba6af7c886cc9d5f8 Mon Sep 17 00:00:00 2001 From: haopeng Date: Mon, 16 Feb 2026 00:11:31 +0800 Subject: [PATCH 4/6] Update internal/tui/auth_tab.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- internal/tui/auth_tab.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/tui/auth_tab.go b/internal/tui/auth_tab.go index 88f9a246..51852930 100644 --- a/internal/tui/auth_tab.go +++ b/internal/tui/auth_tab.go @@ -115,7 +115,12 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { m.editInput.Blur() fields := map[string]any{} if fieldKey == "priority" { - p, _ := strconv.Atoi(value) +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 From 2c8821891cded38e42d39e304bdf91ddacd1328f Mon Sep 17 00:00:00 2001 From: lhpqaq Date: Mon, 16 Feb 2026 00:24:25 +0800 Subject: [PATCH 5/6] 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) From 93fe58e31e175a4b9928f1ccda9a845a2a2b43f0 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 19 Feb 2026 03:18:08 +0800 Subject: [PATCH 6/6] 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()