mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
385 lines
11 KiB
Go
385 lines
11 KiB
Go
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)
|
|
}
|