mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
- Implemented `--standalone` mode to launch an embedded server for TUI. - Enhanced TUI client to support API-based log polling when log hooks are unavailable. - Added authentication gate for password input and connection handling. - Improved localization and UX for logs, authentication, and status bar rendering.
543 lines
12 KiB
Go
543 lines
12 KiB
Go
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...)
|
|
}
|