mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
Merge branch 'tui' into dev
This commit is contained in:
@@ -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)
|
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
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -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)
|
请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
|
||||||
|
|
||||||
|
## 管理 TUI
|
||||||
|
|
||||||
|
一个用于管理配置、密钥/认证文件以及查看实时日志的终端界面。使用以下命令启动:
|
||||||
|
`./CLIProxyAPI --tui`
|
||||||
|
|
||||||
## Amp CLI 支持
|
## Amp CLI 支持
|
||||||
|
|
||||||
CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
|
CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
"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/store"
|
||||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
_ "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/usage"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
@@ -68,6 +70,8 @@ func main() {
|
|||||||
var vertexImport string
|
var vertexImport string
|
||||||
var configPath string
|
var configPath string
|
||||||
var password string
|
var password string
|
||||||
|
var tuiMode bool
|
||||||
|
var standalone bool
|
||||||
|
|
||||||
// Define command-line flags for different operation modes.
|
// Define command-line flags for different operation modes.
|
||||||
flag.BoolVar(&login, "login", false, "Login Google Account")
|
flag.BoolVar(&login, "login", false, "Login Google Account")
|
||||||
@@ -84,6 +88,8 @@ func main() {
|
|||||||
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
||||||
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
|
||||||
flag.StringVar(&password, "password", "", "")
|
flag.StringVar(&password, "password", "", "")
|
||||||
|
flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI")
|
||||||
|
flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server")
|
||||||
|
|
||||||
flag.CommandLine.Usage = func() {
|
flag.CommandLine.Usage = func() {
|
||||||
out := flag.CommandLine.Output()
|
out := flag.CommandLine.Output()
|
||||||
@@ -479,8 +485,83 @@ func main() {
|
|||||||
cmd.WaitForCloudDeploy()
|
cmd.WaitForCloudDeploy()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if tuiMode {
|
||||||
|
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)
|
||||||
|
|
||||||
|
origStdout := os.Stdout
|
||||||
|
origStderr := os.Stderr
|
||||||
|
origLogOutput := log.StandardLogger().Out
|
||||||
|
log.SetOutput(io.Discard)
|
||||||
|
|
||||||
|
devNull, errOpenDevNull := os.Open(os.DevNull)
|
||||||
|
if errOpenDevNull == nil {
|
||||||
|
os.Stdout = devNull
|
||||||
|
os.Stderr = devNull
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
client := tui.NewClient(cfg.Port, password)
|
||||||
|
ready := false
|
||||||
|
backoff := 100 * time.Millisecond
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if _, errGetConfig := client.GetConfig(); errGetConfig == nil {
|
||||||
|
ready = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(backoff)
|
||||||
|
if backoff < time.Second {
|
||||||
|
backoff = time.Duration(float64(backoff) * 1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ready {
|
||||||
|
restoreIO()
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
fmt.Fprintf(os.Stderr, "TUI error: embedded server is not ready\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errRun := tui.Run(cfg.Port, password, hook, origStdout); errRun != nil {
|
||||||
|
restoreIO()
|
||||||
|
fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun)
|
||||||
|
} else {
|
||||||
|
restoreIO()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Start the main proxy service
|
||||||
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
||||||
cmd.StartService(cfg, configFilePath, password)
|
cmd.StartService(cfg, configFilePath, password)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
go.mod
21
go.mod
@@ -4,6 +4,10 @@ go 1.26.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.0.6
|
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
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
|
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
|
||||||
@@ -31,8 +35,16 @@ require (
|
|||||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // 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/cloudflare/circl v1.6.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
@@ -40,6 +52,7 @@ require (
|
|||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/emirpasic/gods v1.18.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/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-git/gcfg/v2 v2.0.2 // 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/kevinburke/ssh_config v1.4.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.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-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/md5-simd v1.1.2 // indirect
|
||||||
github.com/minio/sha256-simd v1.0.1 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.5.0 // 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/rs/xid v1.5.0 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // 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/arch v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
|||||||
45
go.sum
45
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/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 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
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 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
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 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
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/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 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
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=
|
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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
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/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 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
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/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 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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.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 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
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 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
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 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
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 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
|||||||
@@ -808,6 +808,87 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
|
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) {
|
func (h *Handler) disableAuth(ctx context.Context, id string) {
|
||||||
if h == nil || h.authManager == nil {
|
if h == nil || h.authManager == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -284,8 +284,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
|||||||
optionState.routerConfigurator(engine, s.handlers, cfg)
|
optionState.routerConfigurator(engine, s.handlers, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register management routes when configuration or environment secrets are available.
|
// Register management routes when configuration or environment secrets are available,
|
||||||
hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret
|
// or when a local management password is provided (e.g. TUI mode).
|
||||||
|
hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret || s.localPassword != ""
|
||||||
s.managementRoutesEnabled.Store(hasManagementSecret)
|
s.managementRoutesEnabled.Store(hasManagementSecret)
|
||||||
if hasManagementSecret {
|
if hasManagementSecret {
|
||||||
s.registerManagementRoutes()
|
s.registerManagementRoutes()
|
||||||
@@ -617,6 +618,7 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
||||||
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
||||||
mgmt.PATCH("/auth-files/status", s.mgmt.PatchAuthFileStatus)
|
mgmt.PATCH("/auth-files/status", s.mgmt.PatchAuthFileStatus)
|
||||||
|
mgmt.PATCH("/auth-files/fields", s.mgmt.PatchAuthFileFields)
|
||||||
mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential)
|
mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential)
|
||||||
|
|
||||||
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
||||||
|
|||||||
@@ -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
|
// WaitForCloudDeploy waits indefinitely for shutdown signals in cloud deploy mode
|
||||||
// when no configuration file is available.
|
// when no configuration file is available.
|
||||||
func WaitForCloudDeploy() {
|
func WaitForCloudDeploy() {
|
||||||
|
|||||||
542
internal/tui/app.go
Normal file
542
internal/tui/app.go
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tab identifiers
|
||||||
|
const (
|
||||||
|
tabDashboard = iota
|
||||||
|
tabConfig
|
||||||
|
tabAuthFiles
|
||||||
|
tabAPIKeys
|
||||||
|
tabOAuth
|
||||||
|
tabUsage
|
||||||
|
tabLogs
|
||||||
|
)
|
||||||
|
|
||||||
|
// App is the root bubbletea model that contains all tab sub-models.
|
||||||
|
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
|
||||||
|
keys keysTabModel
|
||||||
|
oauth oauthTabModel
|
||||||
|
usage usageTabModel
|
||||||
|
logs logsTabModel
|
||||||
|
|
||||||
|
client *Client
|
||||||
|
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
ready bool
|
||||||
|
|
||||||
|
// Track which tabs have been initialized (fetched data)
|
||||||
|
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)
|
||||||
|
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 {
|
||||||
|
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) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 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.logsEnabled || a.activeTab != tabLogs {
|
||||||
|
return a, tea.Quit
|
||||||
|
}
|
||||||
|
case "L":
|
||||||
|
ToggleLocale()
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return a, 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
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
if a.logsEnabled {
|
||||||
|
sb.WriteString(a.logs.View())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(a.renderStatusBar())
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 := 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(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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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...)
|
||||||
|
}
|
||||||
456
internal/tui/auth_tab.go
Normal file
456
internal/tui/auth_tab.go
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
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 localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
|
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 {
|
||||||
|
return m.handleEditInput(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Delete confirmation mode ----
|
||||||
|
if m.confirm >= 0 {
|
||||||
|
return m.handleConfirmInput(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Normal mode ----
|
||||||
|
return m.handleNormalInput(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 T("loading")
|
||||||
|
}
|
||||||
|
return m.viewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m authTabModel) renderContent() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(titleStyle.Render(T("auth_title")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpStyle.Render(T("auth_help1")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpStyle.Render(T("auth_help2")))
|
||||||
|
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(T("no_auth_files")))
|
||||||
|
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 := T("status_active")
|
||||||
|
if disabled {
|
||||||
|
statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("○")
|
||||||
|
statusText = T("status_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(" "+T("confirm_delete"), 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(" " + T("enter_save") + " • " + T("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 == "<nil>" {
|
||||||
|
if field.editable {
|
||||||
|
val = T("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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/tui/browser.go
Normal file
20
internal/tui/browser.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
400
internal/tui/client.go
Normal file
400
internal/tui/client.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"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: 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)
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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(after int64, limit int) ([]string, int64, error) {
|
||||||
|
query := url.Values{}
|
||||||
|
if limit > 0 {
|
||||||
|
query.Set("limit", strconv.Itoa(limit))
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
413
internal/tui/config_tab.go
Normal file
413
internal/tui/config_tab.go
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
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 {
|
||||||
|
path string
|
||||||
|
value any
|
||||||
|
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 localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
|
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(T("updated_ok"))
|
||||||
|
}
|
||||||
|
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(configFieldEditValue(f))
|
||||||
|
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"
|
||||||
|
newValue := !current
|
||||||
|
errPutBool := m.client.PutBoolField(f.apiPath, newValue)
|
||||||
|
return configUpdateMsg{
|
||||||
|
path: f.apiPath,
|
||||||
|
value: newValue,
|
||||||
|
err: errPutBool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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":
|
||||||
|
valueInt, errAtoi := strconv.Atoi(newValue)
|
||||||
|
if errAtoi != nil {
|
||||||
|
return configUpdateMsg{
|
||||||
|
path: f.apiPath,
|
||||||
|
err: fmt.Errorf("%s: %s", T("invalid_int"), newValue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value = valueInt
|
||||||
|
err = m.client.PutIntField(f.apiPath, valueInt)
|
||||||
|
case "string":
|
||||||
|
value = newValue
|
||||||
|
err = m.client.PutStringField(f.apiPath, newValue)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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 T("loading")
|
||||||
|
}
|
||||||
|
return m.viewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m configTabModel) renderContent() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(titleStyle.Render(T("config_title")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
if m.message != "" {
|
||||||
|
sb.WriteString(" " + m.message)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(helpStyle.Render(T("config_help1")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpStyle.Render(T("config_help2")))
|
||||||
|
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(T("no_config")))
|
||||||
|
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 {
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldSection(apiPath string) string {
|
||||||
|
if strings.HasPrefix(apiPath, "ampcode/") {
|
||||||
|
return T("section_ampcode")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(apiPath, "quota-exceeded/") {
|
||||||
|
return T("section_quota")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(apiPath, "routing/") {
|
||||||
|
return T("section_routing")
|
||||||
|
}
|
||||||
|
switch apiPath {
|
||||||
|
case "port", "host", "debug", "proxy-url", "request-retry", "max-retry-interval", "force-model-prefix":
|
||||||
|
return T("section_server")
|
||||||
|
case "logging-to-file", "logs-max-total-size-mb", "error-logs-max-files", "usage-statistics-enabled", "request-log":
|
||||||
|
return T("section_logging")
|
||||||
|
case "ws-auth":
|
||||||
|
return T("section_websocket")
|
||||||
|
default:
|
||||||
|
return T("section_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 T("not_set")
|
||||||
|
}
|
||||||
|
return maskKey(s)
|
||||||
|
}
|
||||||
360
internal/tui/dashboard.go
Normal file
360
internal/tui/dashboard.go
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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 localeChangedMsg:
|
||||||
|
// 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)
|
||||||
|
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 T("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(T("dashboard_title")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpStyle.Render(T("dashboard_help")))
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
// ━━━ Connection Status ━━━
|
||||||
|
connStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess)
|
||||||
|
sb.WriteString(connStyle.Render(T("connected")))
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s", m.client.baseURL))
|
||||||
|
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(T("mgmt_keys")),
|
||||||
|
))
|
||||||
|
|
||||||
|
// 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("%s (%d %s)", T("auth_files_label"), activeAuth, T("active_suffix"))),
|
||||||
|
))
|
||||||
|
|
||||||
|
// 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("%s (✓%d ✗%d)", T("total_requests"), 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(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(T("current_config")))
|
||||||
|
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
|
||||||
|
}{
|
||||||
|
{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
|
||||||
|
}{T("proxy_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(T("routing_strategy")+":"),
|
||||||
|
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(T("model_stats")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
header := fmt.Sprintf(" %-40s %10s %12s", T("model"), T("requests"), T("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 T("bool_yes")
|
||||||
|
}
|
||||||
|
return T("bool_no")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
364
internal/tui/i18n.go
Normal file
364
internal/tui/i18n.go
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// i18n provides a simple internationalization system for the TUI.
|
||||||
|
// Supported locales: "zh" (Chinese, default), "en" (English).
|
||||||
|
|
||||||
|
var currentLocale = "en"
|
||||||
|
|
||||||
|
// 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": "正在初始化...",
|
||||||
|
"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": "📊 仪表盘",
|
||||||
|
"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...",
|
||||||
|
"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",
|
||||||
|
"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...",
|
||||||
|
}
|
||||||
405
internal/tui/keys_tab.go
Normal file
405
internal/tui/keys_tab.go
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
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 and manages API keys.
|
||||||
|
type keysTabModel struct {
|
||||||
|
client *Client
|
||||||
|
viewport viewport.Model
|
||||||
|
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 {
|
||||||
|
apiKeys []string
|
||||||
|
gemini []map[string]any
|
||||||
|
claude []map[string]any
|
||||||
|
codex []map[string]any
|
||||||
|
vertex []map[string]any
|
||||||
|
openai []map[string]any
|
||||||
|
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,
|
||||||
|
confirm: -1,
|
||||||
|
editInput: ti,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
m.err = nil
|
||||||
|
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.renderContent())
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.renderContent())
|
||||||
|
m.ready = true
|
||||||
|
} else {
|
||||||
|
m.viewport.Width = w
|
||||||
|
m.viewport.Height = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m keysTabModel) View() string {
|
||||||
|
if !m.ready {
|
||||||
|
return T("loading")
|
||||||
|
}
|
||||||
|
return m.viewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m keysTabModel) renderContent() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
if m.err != nil {
|
||||||
|
sb.WriteString(errorStyle.Render(T("error_prefix") + m.err.Error()))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ━━━ Access API Keys (interactive) ━━━
|
||||||
|
sb.WriteString(tableHeaderStyle.Render(fmt.Sprintf(" %s (%d)", T("access_keys"), len(m.keys))))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
if len(m.keys) == 0 {
|
||||||
|
sb.WriteString(subtitleStyle.Render(T("no_keys")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range m.keys {
|
||||||
|
cursor := " "
|
||||||
|
rowStyle := lipgloss.NewStyle()
|
||||||
|
if i == m.cursor {
|
||||||
|
cursor = "▸ "
|
||||||
|
rowStyle = lipgloss.NewStyle().Bold(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
info := name
|
||||||
|
if prefix != "" {
|
||||||
|
info += " (prefix: " + prefix + ")"
|
||||||
|
}
|
||||||
|
if baseURL != "" {
|
||||||
|
info += " → " + baseURL
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, info))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.status != "" {
|
||||||
|
sb.WriteString(m.status)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
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:]
|
||||||
|
}
|
||||||
78
internal/tui/loghook.go
Normal file
78
internal/tui/loghook.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
261
internal/tui/logs_tab.go
Normal file
261
internal/tui/logs_tab.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logsTabModel displays real-time log lines from hook/API source.
|
||||||
|
type logsTabModel struct {
|
||||||
|
client *Client
|
||||||
|
hook *LogHook
|
||||||
|
viewport viewport.Model
|
||||||
|
lines []string
|
||||||
|
maxLines int
|
||||||
|
autoScroll bool
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
ready bool
|
||||||
|
filter string // "", "debug", "info", "warn", "error"
|
||||||
|
after int64
|
||||||
|
lastErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type logsPollMsg struct {
|
||||||
|
lines []string
|
||||||
|
latest int64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type logsTickMsg struct{}
|
||||||
|
type logLineMsg string
|
||||||
|
|
||||||
|
func newLogsTabModel(client *Client, hook *LogHook) logsTabModel {
|
||||||
|
return logsTabModel{
|
||||||
|
client: client,
|
||||||
|
hook: hook,
|
||||||
|
maxLines: 5000,
|
||||||
|
autoScroll: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m logsTabModel) Init() tea.Cmd {
|
||||||
|
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{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m logsTabModel) waitForLog() tea.Msg {
|
||||||
|
if m.hook == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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 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 {
|
||||||
|
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.lastErr = 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 T("loading")
|
||||||
|
}
|
||||||
|
return m.viewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m logsTabModel) renderLogs() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
scrollStatus := successStyle.Render(T("logs_auto_scroll"))
|
||||||
|
if !m.autoScroll {
|
||||||
|
scrollStatus = warningStyle.Render(T("logs_paused"))
|
||||||
|
}
|
||||||
|
filterLabel := "ALL"
|
||||||
|
if m.filter != "" {
|
||||||
|
filterLabel = strings.ToUpper(m.filter) + "+"
|
||||||
|
}
|
||||||
|
|
||||||
|
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(T("logs_help")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
473
internal/tui/oauth_tab.go
Normal file
473
internal/tui/oauth_tab.go
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
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 localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
|
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(T("oauth_submit_fail") + ": " + msg.err.Error())
|
||||||
|
} else {
|
||||||
|
m.message = successStyle.Render(T("oauth_submit_ok"))
|
||||||
|
}
|
||||||
|
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(T("oauth_submitting"))
|
||||||
|
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(fmt.Sprintf(T("oauth_initiating"), 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("%s", T("oauth_timeout"))}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: T("oauth_success"),
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
return oauthPollMsg{
|
||||||
|
done: false,
|
||||||
|
err: fmt.Errorf("%s: %s", T("oauth_failed"), errMsg),
|
||||||
|
}
|
||||||
|
case "wait":
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return oauthPollMsg{
|
||||||
|
done: true,
|
||||||
|
message: T("oauth_completed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 T("loading")
|
||||||
|
}
|
||||||
|
return m.viewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m oauthTabModel) renderContent() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(titleStyle.Render(T("oauth_title")))
|
||||||
|
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(T("oauth_press_esc")))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(helpStyle.Render(T("oauth_select")))
|
||||||
|
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(T("oauth_help")))
|
||||||
|
|
||||||
|
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(T("oauth_auth_url")))
|
||||||
|
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(T("oauth_remote_hint")))
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Callback URL input
|
||||||
|
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(" " + T("enter_submit") + " • " + T("esc_cancel")))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(helpStyle.Render(T("oauth_press_c")))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
sb.WriteString(warningStyle.Render(T("oauth_waiting")))
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
126
internal/tui/styles.go
Normal file
126
internal/tui/styles.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
364
internal/tui/usage_tab.go
Normal file
364
internal/tui/usage_tab.go
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
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 localeChangedMsg:
|
||||||
|
m.viewport.SetContent(m.renderContent())
|
||||||
|
return m, nil
|
||||||
|
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 T("loading")
|
||||||
|
}
|
||||||
|
return m.viewport.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m usageTabModel) renderContent() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(titleStyle.Render(T("usage_title")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(helpStyle.Render(T("usage_help")))
|
||||||
|
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(T("usage_no_data")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
usageMap, _ := m.usage["usage"].(map[string]any)
|
||||||
|
if usageMap == nil {
|
||||||
|
sb.WriteString(subtitleStyle.Render(T("usage_no_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(T("usage_total_reqs")),
|
||||||
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)),
|
||||||
|
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(T("usage_total_tokens")),
|
||||||
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)),
|
||||||
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_token_l"), 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(T("usage_rpm")),
|
||||||
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)),
|
||||||
|
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %d", T("usage_total_reqs"), 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(T("usage_tpm")),
|
||||||
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)),
|
||||||
|
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))
|
||||||
|
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(T("usage_req_by_hour")))
|
||||||
|
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(T("usage_tok_by_hour")))
|
||||||
|
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(T("usage_req_by_day")))
|
||||||
|
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(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", T("requests"), T("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(maskKey(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:%s", T("usage_input"), formatLargeNumber(inputTotal)))
|
||||||
|
}
|
||||||
|
if outputTotal > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_output"), formatLargeNumber(outputTotal)))
|
||||||
|
}
|
||||||
|
if cachedTotal > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_cached"), formatLargeNumber(cachedTotal)))
|
||||||
|
}
|
||||||
|
if reasoningTotal > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s:%s", T("usage_reasoning"), 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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user