mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
fix(tui): update with review
This commit is contained in:
@@ -511,22 +511,22 @@ func main() {
|
|||||||
password = localMgmtPassword
|
password = localMgmtPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure management routes are registered (secret-key must be set)
|
|
||||||
if cfg.RemoteManagement.SecretKey == "" {
|
|
||||||
cfg.RemoteManagement.SecretKey = "$tui-placeholder$"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start server in background
|
// Start server in background
|
||||||
cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password)
|
cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password)
|
||||||
|
|
||||||
// Wait for server to be ready by polling management API
|
// Wait for server to be ready by polling management API with exponential backoff
|
||||||
{
|
{
|
||||||
client := tui.NewClient(cfg.Port, password)
|
client := tui.NewClient(cfg.Port, password)
|
||||||
for i := 0; i < 50; i++ {
|
backoff := 100 * time.Millisecond
|
||||||
time.Sleep(100 * time.Millisecond)
|
// Try for up to ~10-15 seconds
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
if _, err := client.GetConfig(); err == nil {
|
if _, err := client.GetConfig(); err == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
time.Sleep(backoff)
|
||||||
|
if backoff < 1*time.Second {
|
||||||
|
backoff = time.Duration(float64(backoff) * 1.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module github.com/router-for-me/CLIProxyAPI/v6
|
module github.com/router-for-me/CLIProxyAPI/v6
|
||||||
|
|
||||||
go 1.24.2
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.0.6
|
github.com/andybalholm/brotli v1.0.6
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -103,38 +103,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "L":
|
case "L":
|
||||||
ToggleLocale()
|
ToggleLocale()
|
||||||
a.tabs = TabNames()
|
a.tabs = TabNames()
|
||||||
// Broadcast locale change to ALL tabs so each re-renders
|
return a.broadcastToAllTabs(localeChangedMsg{})
|
||||||
var cmds []tea.Cmd
|
|
||||||
var cmd tea.Cmd
|
|
||||||
a.dashboard, cmd = a.dashboard.Update(localeChangedMsg{})
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
a.config, cmd = a.config.Update(localeChangedMsg{})
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
a.auth, cmd = a.auth.Update(localeChangedMsg{})
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
a.keys, cmd = a.keys.Update(localeChangedMsg{})
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
a.oauth, cmd = a.oauth.Update(localeChangedMsg{})
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
a.usage, cmd = a.usage.Update(localeChangedMsg{})
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
a.logs, cmd = a.logs.Update(localeChangedMsg{})
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
return a, tea.Batch(cmds...)
|
|
||||||
case "tab":
|
case "tab":
|
||||||
prevTab := a.activeTab
|
prevTab := a.activeTab
|
||||||
a.activeTab = (a.activeTab + 1) % len(a.tabs)
|
a.activeTab = (a.activeTab + 1) % len(a.tabs)
|
||||||
@@ -278,3 +247,39 @@ func Run(port int, secretKey string, hook *LogHook, output io.Writer) error {
|
|||||||
_, err := p.Run()
|
_, err := p.Run()
|
||||||
return err
|
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...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,132 +106,16 @@ func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) {
|
|||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// ---- Editing mode ----
|
// ---- Editing mode ----
|
||||||
if m.editing {
|
if m.editing {
|
||||||
switch msg.String() {
|
return m.handleEditInput(msg)
|
||||||
case "enter":
|
|
||||||
value := m.editInput.Value()
|
|
||||||
fieldKey := authEditableFields[m.editField].key
|
|
||||||
fileName := m.editFileName
|
|
||||||
m.editing = false
|
|
||||||
m.editInput.Blur()
|
|
||||||
fields := map[string]any{}
|
|
||||||
if fieldKey == "priority" {
|
|
||||||
p, err := strconv.Atoi(value)
|
|
||||||
if err != nil {
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
return authActionMsg{err: fmt.Errorf("invalid priority: must be a number")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fields[fieldKey] = p
|
|
||||||
} else {
|
|
||||||
fields[fieldKey] = value
|
|
||||||
}
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
err := m.client.PatchAuthFileFields(fileName, fields)
|
|
||||||
if err != nil {
|
|
||||||
return authActionMsg{err: err}
|
|
||||||
}
|
|
||||||
return authActionMsg{action: fmt.Sprintf(T("updated_field"), fieldKey, fileName)}
|
|
||||||
}
|
|
||||||
case "esc":
|
|
||||||
m.editing = false
|
|
||||||
m.editInput.Blur()
|
|
||||||
m.viewport.SetContent(m.renderContent())
|
|
||||||
return m, nil
|
|
||||||
default:
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.editInput, cmd = m.editInput.Update(msg)
|
|
||||||
m.viewport.SetContent(m.renderContent())
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Delete confirmation mode ----
|
// ---- Delete confirmation mode ----
|
||||||
if m.confirm >= 0 {
|
if m.confirm >= 0 {
|
||||||
switch msg.String() {
|
return m.handleConfirmInput(msg)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Normal mode ----
|
// ---- Normal mode ----
|
||||||
switch msg.String() {
|
return m.handleNormalInput(msg)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -442,3 +326,131 @@ func max(a, b int) int {
|
|||||||
}
|
}
|
||||||
return b
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ type dashboardModel struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
ready bool
|
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 {
|
type dashboardDataMsg struct {
|
||||||
@@ -58,14 +64,24 @@ func (m dashboardModel) fetchData() tea.Msg {
|
|||||||
func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {
|
func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case localeChangedMsg:
|
case localeChangedMsg:
|
||||||
// Re-fetch data to re-render with new locale
|
// Re-render immediately with cached data using new locale
|
||||||
|
m.content = m.renderDashboard(m.lastConfig, m.lastUsage, m.lastAuthFiles, m.lastAPIKeys)
|
||||||
|
m.viewport.SetContent(m.content)
|
||||||
|
// Also fetch fresh data in background
|
||||||
return m, m.fetchData
|
return m, m.fetchData
|
||||||
|
|
||||||
case dashboardDataMsg:
|
case dashboardDataMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
m.content = errorStyle.Render("⚠ Error: " + msg.err.Error())
|
m.content = errorStyle.Render("⚠ Error: " + msg.err.Error())
|
||||||
} else {
|
} else {
|
||||||
m.err = nil
|
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.content = m.renderDashboard(msg.config, msg.usage, msg.authFiles, msg.apiKeys)
|
||||||
}
|
}
|
||||||
m.viewport.SetContent(m.content)
|
m.viewport.SetContent(m.content)
|
||||||
|
|||||||
Reference in New Issue
Block a user