mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
feat(tui): add manager tui
This commit is contained in:
384
internal/tui/config_tab.go
Normal file
384
internal/tui/config_tab.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// configField represents a single editable config field.
|
||||
type configField struct {
|
||||
label string
|
||||
apiPath string // management API path (e.g. "debug", "proxy-url")
|
||||
kind string // "bool", "int", "string", "readonly"
|
||||
value string // current display value
|
||||
rawValue any // raw value from API
|
||||
}
|
||||
|
||||
// configTabModel displays parsed config with interactive editing.
|
||||
type configTabModel struct {
|
||||
client *Client
|
||||
viewport viewport.Model
|
||||
fields []configField
|
||||
cursor int
|
||||
editing bool
|
||||
textInput textinput.Model
|
||||
err error
|
||||
message string // status message (success/error)
|
||||
width int
|
||||
height int
|
||||
ready bool
|
||||
}
|
||||
|
||||
type configDataMsg struct {
|
||||
config map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
type configUpdateMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func newConfigTabModel(client *Client) configTabModel {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 256
|
||||
return configTabModel{
|
||||
client: client,
|
||||
textInput: ti,
|
||||
}
|
||||
}
|
||||
|
||||
func (m configTabModel) Init() tea.Cmd {
|
||||
return m.fetchConfig
|
||||
}
|
||||
|
||||
func (m configTabModel) fetchConfig() tea.Msg {
|
||||
cfg, err := m.client.GetConfig()
|
||||
return configDataMsg{config: cfg, err: err}
|
||||
}
|
||||
|
||||
func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case configDataMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
m.fields = nil
|
||||
} else {
|
||||
m.err = nil
|
||||
m.fields = m.parseConfig(msg.config)
|
||||
}
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
|
||||
case configUpdateMsg:
|
||||
if msg.err != nil {
|
||||
m.message = errorStyle.Render("✗ " + msg.err.Error())
|
||||
} else {
|
||||
m.message = successStyle.Render("✓ Updated successfully")
|
||||
}
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
// Refresh config from server
|
||||
return m, m.fetchConfig
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.editing {
|
||||
return m.handleEditingKey(msg)
|
||||
}
|
||||
return m.handleNormalKey(msg)
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m configTabModel) handleNormalKey(msg tea.KeyMsg) (configTabModel, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
m.message = ""
|
||||
return m, m.fetchConfig
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
// Ensure cursor is visible
|
||||
m.ensureCursorVisible()
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.fields)-1 {
|
||||
m.cursor++
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.ensureCursorVisible()
|
||||
}
|
||||
return m, nil
|
||||
case "enter", " ":
|
||||
if m.cursor >= 0 && m.cursor < len(m.fields) {
|
||||
f := m.fields[m.cursor]
|
||||
if f.kind == "readonly" {
|
||||
return m, nil
|
||||
}
|
||||
if f.kind == "bool" {
|
||||
// Toggle directly
|
||||
return m, m.toggleBool(m.cursor)
|
||||
}
|
||||
// Start editing for int/string
|
||||
m.editing = true
|
||||
m.textInput.SetValue(f.value)
|
||||
m.textInput.Focus()
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, textinput.Blink
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m configTabModel) handleEditingKey(msg tea.KeyMsg) (configTabModel, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
m.editing = false
|
||||
m.textInput.Blur()
|
||||
return m, m.submitEdit(m.cursor, m.textInput.Value())
|
||||
case "esc":
|
||||
m.editing = false
|
||||
m.textInput.Blur()
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, nil
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.textInput, cmd = m.textInput.Update(msg)
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (m configTabModel) toggleBool(idx int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
f := m.fields[idx]
|
||||
current := f.value == "true"
|
||||
err := m.client.PutBoolField(f.apiPath, !current)
|
||||
return configUpdateMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
f := m.fields[idx]
|
||||
var err error
|
||||
switch f.kind {
|
||||
case "int":
|
||||
v, parseErr := strconv.Atoi(newValue)
|
||||
if parseErr != nil {
|
||||
return configUpdateMsg{err: fmt.Errorf("invalid integer: %s", newValue)}
|
||||
}
|
||||
err = m.client.PutIntField(f.apiPath, v)
|
||||
case "string":
|
||||
err = m.client.PutStringField(f.apiPath, newValue)
|
||||
}
|
||||
return configUpdateMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *configTabModel) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(w, h)
|
||||
m.viewport.SetContent(m.renderContent())
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = w
|
||||
m.viewport.Height = h
|
||||
}
|
||||
}
|
||||
|
||||
func (m *configTabModel) ensureCursorVisible() {
|
||||
// Each field takes ~1 line, header takes ~4 lines
|
||||
targetLine := m.cursor + 5
|
||||
if targetLine < m.viewport.YOffset {
|
||||
m.viewport.SetYOffset(targetLine)
|
||||
}
|
||||
if targetLine >= m.viewport.YOffset+m.viewport.Height {
|
||||
m.viewport.SetYOffset(targetLine - m.viewport.Height + 1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m configTabModel) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
}
|
||||
return m.viewport.View()
|
||||
}
|
||||
|
||||
func (m configTabModel) renderContent() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(titleStyle.Render("⚙ Configuration"))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if m.message != "" {
|
||||
sb.WriteString(" " + m.message)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString(helpStyle.Render(" [↑↓/jk] navigate • [Enter/Space] edit • [r] refresh"))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpStyle.Render(" Bool fields: Enter to toggle • String/Int: Enter to type, Enter to confirm, Esc to cancel"))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if m.err != nil {
|
||||
sb.WriteString(errorStyle.Render(" ⚠ Error: " + m.err.Error()))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
if len(m.fields) == 0 {
|
||||
sb.WriteString(subtitleStyle.Render(" No configuration loaded"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
currentSection := ""
|
||||
for i, f := range m.fields {
|
||||
// Section headers
|
||||
section := fieldSection(f.apiPath)
|
||||
if section != currentSection {
|
||||
currentSection = section
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(" ── " + section + " "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
isSelected := i == m.cursor
|
||||
prefix := " "
|
||||
if isSelected {
|
||||
prefix = "▸ "
|
||||
}
|
||||
|
||||
labelStr := lipgloss.NewStyle().
|
||||
Foreground(colorInfo).
|
||||
Bold(isSelected).
|
||||
Width(32).
|
||||
Render(f.label)
|
||||
|
||||
var valueStr string
|
||||
if m.editing && isSelected {
|
||||
valueStr = m.textInput.View()
|
||||
} else {
|
||||
switch f.kind {
|
||||
case "bool":
|
||||
if f.value == "true" {
|
||||
valueStr = successStyle.Render("● ON")
|
||||
} else {
|
||||
valueStr = lipgloss.NewStyle().Foreground(colorMuted).Render("○ OFF")
|
||||
}
|
||||
case "readonly":
|
||||
valueStr = lipgloss.NewStyle().Foreground(colorSubtext).Render(f.value)
|
||||
default:
|
||||
valueStr = valueStyle.Render(f.value)
|
||||
}
|
||||
}
|
||||
|
||||
line := prefix + labelStr + " " + valueStr
|
||||
if isSelected && !m.editing {
|
||||
line = lipgloss.NewStyle().Background(colorSurface).Render(line)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m configTabModel) parseConfig(cfg map[string]any) []configField {
|
||||
var fields []configField
|
||||
|
||||
// Server settings
|
||||
fields = append(fields, configField{"Port", "port", "readonly", fmt.Sprintf("%.0f", getFloat(cfg, "port")), nil})
|
||||
fields = append(fields, configField{"Host", "host", "readonly", getString(cfg, "host"), nil})
|
||||
fields = append(fields, configField{"Debug", "debug", "bool", fmt.Sprintf("%v", getBool(cfg, "debug")), nil})
|
||||
fields = append(fields, configField{"Proxy URL", "proxy-url", "string", getString(cfg, "proxy-url"), nil})
|
||||
fields = append(fields, configField{"Request Retry", "request-retry", "int", fmt.Sprintf("%.0f", getFloat(cfg, "request-retry")), nil})
|
||||
fields = append(fields, configField{"Max Retry Interval (s)", "max-retry-interval", "int", fmt.Sprintf("%.0f", getFloat(cfg, "max-retry-interval")), nil})
|
||||
fields = append(fields, configField{"Force Model Prefix", "force-model-prefix", "string", getString(cfg, "force-model-prefix"), nil})
|
||||
|
||||
// Logging
|
||||
fields = append(fields, configField{"Logging to File", "logging-to-file", "bool", fmt.Sprintf("%v", getBool(cfg, "logging-to-file")), nil})
|
||||
fields = append(fields, configField{"Logs Max Total Size (MB)", "logs-max-total-size-mb", "int", fmt.Sprintf("%.0f", getFloat(cfg, "logs-max-total-size-mb")), nil})
|
||||
fields = append(fields, configField{"Error Logs Max Files", "error-logs-max-files", "int", fmt.Sprintf("%.0f", getFloat(cfg, "error-logs-max-files")), nil})
|
||||
fields = append(fields, configField{"Usage Stats Enabled", "usage-statistics-enabled", "bool", fmt.Sprintf("%v", getBool(cfg, "usage-statistics-enabled")), nil})
|
||||
fields = append(fields, configField{"Request Log", "request-log", "bool", fmt.Sprintf("%v", getBool(cfg, "request-log")), nil})
|
||||
|
||||
// Quota exceeded
|
||||
fields = append(fields, configField{"Switch Project on Quota", "quota-exceeded/switch-project", "bool", fmt.Sprintf("%v", getBoolNested(cfg, "quota-exceeded", "switch-project")), nil})
|
||||
fields = append(fields, configField{"Switch Preview Model", "quota-exceeded/switch-preview-model", "bool", fmt.Sprintf("%v", getBoolNested(cfg, "quota-exceeded", "switch-preview-model")), nil})
|
||||
|
||||
// Routing
|
||||
if routing, ok := cfg["routing"].(map[string]any); ok {
|
||||
fields = append(fields, configField{"Routing Strategy", "routing/strategy", "string", getString(routing, "strategy"), nil})
|
||||
} else {
|
||||
fields = append(fields, configField{"Routing Strategy", "routing/strategy", "string", "", nil})
|
||||
}
|
||||
|
||||
// WebSocket auth
|
||||
fields = append(fields, configField{"WebSocket Auth", "ws-auth", "bool", fmt.Sprintf("%v", getBool(cfg, "ws-auth")), nil})
|
||||
|
||||
// AMP settings
|
||||
if amp, ok := cfg["ampcode"].(map[string]any); ok {
|
||||
fields = append(fields, configField{"AMP Upstream URL", "ampcode/upstream-url", "string", getString(amp, "upstream-url"), nil})
|
||||
fields = append(fields, configField{"AMP Upstream API Key", "ampcode/upstream-api-key", "string", maskIfNotEmpty(getString(amp, "upstream-api-key")), nil})
|
||||
fields = append(fields, configField{"AMP Restrict Mgmt Localhost", "ampcode/restrict-management-to-localhost", "bool", fmt.Sprintf("%v", getBool(amp, "restrict-management-to-localhost")), nil})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func fieldSection(apiPath string) string {
|
||||
if strings.HasPrefix(apiPath, "ampcode/") {
|
||||
return "AMP Code"
|
||||
}
|
||||
if strings.HasPrefix(apiPath, "quota-exceeded/") {
|
||||
return "Quota Exceeded Handling"
|
||||
}
|
||||
if strings.HasPrefix(apiPath, "routing/") {
|
||||
return "Routing"
|
||||
}
|
||||
switch apiPath {
|
||||
case "port", "host", "debug", "proxy-url", "request-retry", "max-retry-interval", "force-model-prefix":
|
||||
return "Server"
|
||||
case "logging-to-file", "logs-max-total-size-mb", "error-logs-max-files", "usage-statistics-enabled", "request-log":
|
||||
return "Logging & Stats"
|
||||
case "ws-auth":
|
||||
return "WebSocket"
|
||||
default:
|
||||
return "Other"
|
||||
}
|
||||
}
|
||||
|
||||
func getBoolNested(m map[string]any, keys ...string) bool {
|
||||
current := m
|
||||
for i, key := range keys {
|
||||
if i == len(keys)-1 {
|
||||
return getBool(current, key)
|
||||
}
|
||||
if nested, ok := current[key].(map[string]any); ok {
|
||||
current = nested
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func maskIfNotEmpty(s string) string {
|
||||
if s == "" {
|
||||
return "(not set)"
|
||||
}
|
||||
return maskKey(s)
|
||||
}
|
||||
Reference in New Issue
Block a user