mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 21:00:52 +08:00
feat(tui): add standalone mode and API-based log polling
- 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.
This commit is contained in:
@@ -71,6 +71,7 @@ func main() {
|
|||||||
var configPath string
|
var configPath string
|
||||||
var password string
|
var password string
|
||||||
var tuiMode bool
|
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")
|
||||||
@@ -88,6 +89,7 @@ func main() {
|
|||||||
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(&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()
|
||||||
@@ -483,72 +485,82 @@ func main() {
|
|||||||
cmd.WaitForCloudDeploy()
|
cmd.WaitForCloudDeploy()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Start the main proxy service
|
|
||||||
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
|
||||||
if tuiMode {
|
if tuiMode {
|
||||||
// Install logrus hook to capture logs for TUI
|
if standalone {
|
||||||
hook := tui.NewLogHook(2000)
|
// Standalone mode: start an embedded local server and connect TUI client to it.
|
||||||
hook.SetFormatter(&logging.LogFormatter{})
|
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
||||||
log.AddHook(hook)
|
hook := tui.NewLogHook(2000)
|
||||||
// Suppress logrus stdout output (TUI owns the terminal)
|
hook.SetFormatter(&logging.LogFormatter{})
|
||||||
log.SetOutput(io.Discard)
|
log.AddHook(hook)
|
||||||
|
|
||||||
// Redirect os.Stdout and os.Stderr to /dev/null so that
|
origStdout := os.Stdout
|
||||||
// stray fmt.Print* calls in the backend don't corrupt the TUI.
|
origStderr := os.Stderr
|
||||||
origStdout := os.Stdout
|
origLogOutput := log.StandardLogger().Out
|
||||||
origStderr := os.Stderr
|
log.SetOutput(io.Discard)
|
||||||
devNull, errNull := os.Open(os.DevNull)
|
|
||||||
if errNull == nil {
|
|
||||||
os.Stdout = devNull
|
|
||||||
os.Stderr = devNull
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a random local password for management API authentication.
|
devNull, errOpenDevNull := os.Open(os.DevNull)
|
||||||
// This is passed to the server (accepted for localhost requests)
|
if errOpenDevNull == nil {
|
||||||
// and used by the TUI HTTP client as the Bearer token.
|
os.Stdout = devNull
|
||||||
localMgmtPassword := fmt.Sprintf("tui-%d-%d", os.Getpid(), time.Now().UnixNano())
|
os.Stderr = devNull
|
||||||
if password == "" {
|
}
|
||||||
password = localMgmtPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start server in background
|
restoreIO := func() {
|
||||||
cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password)
|
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)
|
||||||
|
|
||||||
// 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)
|
||||||
|
ready := false
|
||||||
backoff := 100 * time.Millisecond
|
backoff := 100 * time.Millisecond
|
||||||
// Try for up to ~10-15 seconds
|
|
||||||
for i := 0; i < 30; i++ {
|
for i := 0; i < 30; i++ {
|
||||||
if _, err := client.GetConfig(); err == nil {
|
if _, errGetConfig := client.GetConfig(); errGetConfig == nil {
|
||||||
|
ready = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
time.Sleep(backoff)
|
time.Sleep(backoff)
|
||||||
if backoff < 1*time.Second {
|
if backoff < time.Second {
|
||||||
backoff = time.Duration(float64(backoff) * 1.5)
|
backoff = time.Duration(float64(backoff) * 1.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Run TUI (blocking) — use the local password for API auth
|
if !ready {
|
||||||
if err := tui.Run(cfg.Port, password, hook, origStdout); err != nil {
|
restoreIO()
|
||||||
// Restore stdout/stderr before printing error
|
cancel()
|
||||||
os.Stdout = origStdout
|
<-done
|
||||||
os.Stderr = origStderr
|
fmt.Fprintf(os.Stderr, "TUI error: embedded server is not ready\n")
|
||||||
fmt.Fprintf(os.Stderr, "TUI error: %v\n", err)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore stdout/stderr for shutdown messages
|
if errRun := tui.Run(cfg.Port, password, hook, origStdout); errRun != nil {
|
||||||
os.Stdout = origStdout
|
restoreIO()
|
||||||
os.Stderr = origStderr
|
fmt.Fprintf(os.Stderr, "TUI error: %v\n", errRun)
|
||||||
if devNull != nil {
|
} else {
|
||||||
_ = devNull.Close()
|
restoreIO()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown server
|
cancel()
|
||||||
cancel()
|
<-done
|
||||||
<-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 {
|
} else {
|
||||||
|
// Start the main proxy service
|
||||||
|
managementasset.StartAutoUpdater(context.Background(), configFilePath)
|
||||||
cmd.StartService(cfg, configFilePath, password)
|
cmd.StartService(cfg, configFilePath, password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
@@ -25,6 +27,14 @@ type App struct {
|
|||||||
activeTab int
|
activeTab int
|
||||||
tabs []string
|
tabs []string
|
||||||
|
|
||||||
|
standalone bool
|
||||||
|
logsEnabled bool
|
||||||
|
|
||||||
|
authenticated bool
|
||||||
|
authInput textinput.Model
|
||||||
|
authError string
|
||||||
|
authConnecting bool
|
||||||
|
|
||||||
dashboard dashboardModel
|
dashboard dashboardModel
|
||||||
config configTabModel
|
config configTabModel
|
||||||
auth authTabModel
|
auth authTabModel
|
||||||
@@ -34,7 +44,7 @@ type App struct {
|
|||||||
logs logsTabModel
|
logs logsTabModel
|
||||||
|
|
||||||
client *Client
|
client *Client
|
||||||
hook *LogHook
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
ready bool
|
ready bool
|
||||||
@@ -43,32 +53,60 @@ type App struct {
|
|||||||
initialized [7]bool
|
initialized [7]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type authConnectMsg struct {
|
||||||
|
cfg map[string]any
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
// NewApp creates the root TUI application model.
|
// NewApp creates the root TUI application model.
|
||||||
func NewApp(port int, secretKey string, hook *LogHook) App {
|
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)
|
client := NewClient(port, secretKey)
|
||||||
return App{
|
app := App{
|
||||||
activeTab: tabDashboard,
|
activeTab: tabDashboard,
|
||||||
tabs: TabNames(),
|
standalone: standalone,
|
||||||
dashboard: newDashboardModel(client),
|
logsEnabled: true,
|
||||||
config: newConfigTabModel(client),
|
authenticated: !authRequired,
|
||||||
auth: newAuthTabModel(client),
|
authInput: ti,
|
||||||
keys: newKeysTabModel(client),
|
dashboard: newDashboardModel(client),
|
||||||
oauth: newOAuthTabModel(client),
|
config: newConfigTabModel(client),
|
||||||
usage: newUsageTabModel(client),
|
auth: newAuthTabModel(client),
|
||||||
logs: newLogsTabModel(hook),
|
keys: newKeysTabModel(client),
|
||||||
client: client,
|
oauth: newOAuthTabModel(client),
|
||||||
hook: hook,
|
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 {
|
func (a App) Init() tea.Cmd {
|
||||||
// Initialize dashboard and logs on start
|
if !a.authenticated {
|
||||||
a.initialized[tabDashboard] = true
|
return textinput.Blink
|
||||||
a.initialized[tabLogs] = true
|
}
|
||||||
return tea.Batch(
|
cmds := []tea.Cmd{a.dashboard.Init()}
|
||||||
a.dashboard.Init(),
|
if a.logsEnabled {
|
||||||
a.logs.Init(),
|
cmds = append(cmds, a.logs.Init())
|
||||||
)
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -77,6 +115,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
a.width = msg.Width
|
a.width = msg.Width
|
||||||
a.height = msg.Height
|
a.height = msg.Height
|
||||||
a.ready = true
|
a.ready = true
|
||||||
|
if a.width > 0 {
|
||||||
|
a.authInput.Width = a.width - 6
|
||||||
|
}
|
||||||
contentH := a.height - 4 // tab bar + status bar
|
contentH := a.height - 4 // tab bar + status bar
|
||||||
if contentH < 1 {
|
if contentH < 1 {
|
||||||
contentH = 1
|
contentH = 1
|
||||||
@@ -91,32 +132,119 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
a.logs.SetSize(contentW, contentH)
|
a.logs.SetSize(contentW, contentH)
|
||||||
return a, nil
|
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:
|
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() {
|
switch msg.String() {
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
return a, tea.Quit
|
return a, tea.Quit
|
||||||
case "q":
|
case "q":
|
||||||
// Only quit if not in logs tab (where 'q' might be useful)
|
// Only quit if not in logs tab (where 'q' might be useful)
|
||||||
if a.activeTab != tabLogs {
|
if !a.logsEnabled || a.activeTab != tabLogs {
|
||||||
return a, tea.Quit
|
return a, tea.Quit
|
||||||
}
|
}
|
||||||
case "L":
|
case "L":
|
||||||
ToggleLocale()
|
ToggleLocale()
|
||||||
a.tabs = TabNames()
|
a.refreshTabs()
|
||||||
return a.broadcastToAllTabs(localeChangedMsg{})
|
return a.broadcastToAllTabs(localeChangedMsg{})
|
||||||
case "tab":
|
case "tab":
|
||||||
|
if len(a.tabs) == 0 {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
prevTab := a.activeTab
|
prevTab := a.activeTab
|
||||||
a.activeTab = (a.activeTab + 1) % len(a.tabs)
|
a.activeTab = (a.activeTab + 1) % len(a.tabs)
|
||||||
a.tabs = TabNames()
|
|
||||||
return a, a.initTabIfNeeded(prevTab)
|
return a, a.initTabIfNeeded(prevTab)
|
||||||
case "shift+tab":
|
case "shift+tab":
|
||||||
|
if len(a.tabs) == 0 {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
prevTab := a.activeTab
|
prevTab := a.activeTab
|
||||||
a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)
|
a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)
|
||||||
a.tabs = TabNames()
|
|
||||||
return a, a.initTabIfNeeded(prevTab)
|
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
|
// Route msg to active tab
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
switch a.activeTab {
|
switch a.activeTab {
|
||||||
@@ -136,13 +264,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
a.logs, cmd = a.logs.Update(msg)
|
a.logs, cmd = a.logs.Update(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always route logLineMsg to logs tab even if not active,
|
// Keep logs polling alive even when logs tab is not active.
|
||||||
// AND capture the returned cmd to maintain the waitForLog chain.
|
if a.logsEnabled && a.activeTab != tabLogs {
|
||||||
if _, ok := msg.(logLineMsg); ok && a.activeTab != tabLogs {
|
switch msg.(type) {
|
||||||
var logCmd tea.Cmd
|
case logsPollMsg, logsTickMsg, logLineMsg:
|
||||||
a.logs, logCmd = a.logs.Update(msg)
|
var logCmd tea.Cmd
|
||||||
if logCmd != nil {
|
a.logs, logCmd = a.logs.Update(msg)
|
||||||
cmd = logCmd
|
if logCmd != nil {
|
||||||
|
cmd = logCmd
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +282,30 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// localeChangedMsg is broadcast to all tabs when the user toggles locale.
|
// localeChangedMsg is broadcast to all tabs when the user toggles locale.
|
||||||
type localeChangedMsg struct{}
|
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 {
|
func (a *App) initTabIfNeeded(_ int) tea.Cmd {
|
||||||
if a.initialized[a.activeTab] {
|
if a.initialized[a.activeTab] {
|
||||||
return nil
|
return nil
|
||||||
@@ -171,12 +325,19 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd {
|
|||||||
case tabUsage:
|
case tabUsage:
|
||||||
return a.usage.Init()
|
return a.usage.Init()
|
||||||
case tabLogs:
|
case tabLogs:
|
||||||
|
if !a.logsEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return a.logs.Init()
|
return a.logs.Init()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a App) View() string {
|
func (a App) View() string {
|
||||||
|
if !a.authenticated {
|
||||||
|
return a.renderAuthView()
|
||||||
|
}
|
||||||
|
|
||||||
if !a.ready {
|
if !a.ready {
|
||||||
return T("initializing_tui")
|
return T("initializing_tui")
|
||||||
}
|
}
|
||||||
@@ -202,7 +363,9 @@ func (a App) View() string {
|
|||||||
case tabUsage:
|
case tabUsage:
|
||||||
sb.WriteString(a.usage.View())
|
sb.WriteString(a.usage.View())
|
||||||
case tabLogs:
|
case tabLogs:
|
||||||
sb.WriteString(a.logs.View())
|
if a.logsEnabled {
|
||||||
|
sb.WriteString(a.logs.View())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status bar
|
// Status bar
|
||||||
@@ -212,6 +375,27 @@ func (a App) View() string {
|
|||||||
return sb.String()
|
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 {
|
func (a App) renderTabBar() string {
|
||||||
var tabs []string
|
var tabs []string
|
||||||
for i, name := range a.tabs {
|
for i, name := range a.tabs {
|
||||||
@@ -226,18 +410,91 @@ func (a App) renderTabBar() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a App) renderStatusBar() string {
|
func (a App) renderStatusBar() string {
|
||||||
left := T("status_left")
|
left := strings.TrimRight(T("status_left"), " ")
|
||||||
right := T("status_right")
|
right := strings.TrimRight(T("status_right"), " ")
|
||||||
gap := a.width - lipgloss.Width(left) - lipgloss.Width(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 {
|
if gap < 0 {
|
||||||
gap = 0
|
gap = 0
|
||||||
}
|
}
|
||||||
return statusBarStyle.Width(a.width).Render(left + strings.Repeat(" ", gap) + right)
|
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.
|
// Run starts the TUI application.
|
||||||
// output specifies where bubbletea renders. If nil, defaults to os.Stdout.
|
// output specifies where bubbletea renders. If nil, defaults to os.Stdout.
|
||||||
// Pass the real terminal stdout here when os.Stdout has been redirected.
|
|
||||||
func Run(port int, secretKey string, hook *LogHook, output io.Writer) error {
|
func Run(port int, secretKey string, hook *LogHook, output io.Writer) error {
|
||||||
if output == nil {
|
if output == nil {
|
||||||
output = os.Stdout
|
output = os.Stdout
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -20,13 +22,18 @@ type Client struct {
|
|||||||
func NewClient(port int, secretKey string) *Client {
|
func NewClient(port int, secretKey string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||||
secretKey: secretKey,
|
secretKey: strings.TrimSpace(secretKey),
|
||||||
http: &http.Client{
|
http: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
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) {
|
func (c *Client) doRequest(method, path string, body io.Reader) ([]byte, int, error) {
|
||||||
url := c.baseURL + path
|
url := c.baseURL + path
|
||||||
req, err := http.NewRequest(method, url, body)
|
req, err := http.NewRequest(method, url, body)
|
||||||
@@ -150,7 +157,10 @@ func (c *Client) GetAuthFiles() ([]map[string]any, error) {
|
|||||||
|
|
||||||
// DeleteAuthFile deletes a single auth file by name.
|
// DeleteAuthFile deletes a single auth file by name.
|
||||||
func (c *Client) DeleteAuthFile(name string) error {
|
func (c *Client) DeleteAuthFile(name string) error {
|
||||||
_, code, err := c.doRequest("DELETE", "/v0/management/auth-files?name="+name, nil)
|
query := url.Values{}
|
||||||
|
query.Set("name", name)
|
||||||
|
path := "/v0/management/auth-files?" + query.Encode()
|
||||||
|
_, code, err := c.doRequest("DELETE", path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -176,12 +186,57 @@ func (c *Client) PatchAuthFileFields(name string, fields map[string]any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLogs fetches log lines from the server.
|
// GetLogs fetches log lines from the server.
|
||||||
func (c *Client) GetLogs(cutoff int64, limit int) (map[string]any, error) {
|
func (c *Client) GetLogs(after int64, limit int) ([]string, int64, error) {
|
||||||
path := fmt.Sprintf("/v0/management/logs?limit=%d", limit)
|
query := url.Values{}
|
||||||
if cutoff > 0 {
|
if limit > 0 {
|
||||||
path += fmt.Sprintf("&cutoff=%d", cutoff)
|
query.Set("limit", strconv.Itoa(limit))
|
||||||
}
|
}
|
||||||
return c.getJSON(path)
|
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.
|
// GetAPIKeys fetches the list of API keys.
|
||||||
@@ -303,7 +358,10 @@ func (c *Client) GetDebug() (bool, error) {
|
|||||||
// GetAuthStatus polls the OAuth session status.
|
// GetAuthStatus polls the OAuth session status.
|
||||||
// Returns status ("wait", "ok", "error") and optional error message.
|
// Returns status ("wait", "ok", "error") and optional error message.
|
||||||
func (c *Client) GetAuthStatus(state string) (string, string, error) {
|
func (c *Client) GetAuthStatus(state string) (string, string, error) {
|
||||||
wrapper, err := c.getJSON("/v0/management/get-auth-status?state=" + state)
|
query := url.Values{}
|
||||||
|
query.Set("state", state)
|
||||||
|
path := "/v0/management/get-auth-status?" + query.Encode()
|
||||||
|
wrapper, err := c.getJSON(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ type configDataMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type configUpdateMsg struct {
|
type configUpdateMsg struct {
|
||||||
err error
|
path string
|
||||||
|
value any
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func newConfigTabModel(client *Client) configTabModel {
|
func newConfigTabModel(client *Client) configTabModel {
|
||||||
@@ -132,7 +134,7 @@ func (m configTabModel) handleNormalKey(msg tea.KeyMsg) (configTabModel, tea.Cmd
|
|||||||
}
|
}
|
||||||
// Start editing for int/string
|
// Start editing for int/string
|
||||||
m.editing = true
|
m.editing = true
|
||||||
m.textInput.SetValue(f.value)
|
m.textInput.SetValue(configFieldEditValue(f))
|
||||||
m.textInput.Focus()
|
m.textInput.Focus()
|
||||||
m.viewport.SetContent(m.renderContent())
|
m.viewport.SetContent(m.renderContent())
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
@@ -168,8 +170,13 @@ func (m configTabModel) toggleBool(idx int) tea.Cmd {
|
|||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
f := m.fields[idx]
|
f := m.fields[idx]
|
||||||
current := f.value == "true"
|
current := f.value == "true"
|
||||||
err := m.client.PutBoolField(f.apiPath, !current)
|
newValue := !current
|
||||||
return configUpdateMsg{err: err}
|
errPutBool := m.client.PutBoolField(f.apiPath, newValue)
|
||||||
|
return configUpdateMsg{
|
||||||
|
path: f.apiPath,
|
||||||
|
value: newValue,
|
||||||
|
err: errPutBool,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,20 +184,37 @@ func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd {
|
|||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
f := m.fields[idx]
|
f := m.fields[idx]
|
||||||
var err error
|
var err error
|
||||||
|
var value any
|
||||||
switch f.kind {
|
switch f.kind {
|
||||||
case "int":
|
case "int":
|
||||||
v, parseErr := strconv.Atoi(newValue)
|
valueInt, errAtoi := strconv.Atoi(newValue)
|
||||||
if parseErr != nil {
|
if errAtoi != nil {
|
||||||
return configUpdateMsg{err: fmt.Errorf("%s: %s", T("invalid_int"), newValue)}
|
return configUpdateMsg{
|
||||||
|
path: f.apiPath,
|
||||||
|
err: fmt.Errorf("%s: %s", T("invalid_int"), newValue),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
err = m.client.PutIntField(f.apiPath, v)
|
value = valueInt
|
||||||
|
err = m.client.PutIntField(f.apiPath, valueInt)
|
||||||
case "string":
|
case "string":
|
||||||
|
value = newValue
|
||||||
err = m.client.PutStringField(f.apiPath, newValue)
|
err = m.client.PutStringField(f.apiPath, newValue)
|
||||||
}
|
}
|
||||||
return configUpdateMsg{err: err}
|
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) {
|
func (m *configTabModel) SetSize(w, h int) {
|
||||||
m.width = w
|
m.width = w
|
||||||
m.height = h
|
m.height = h
|
||||||
@@ -334,8 +358,10 @@ func (m configTabModel) parseConfig(cfg map[string]any) []configField {
|
|||||||
|
|
||||||
// AMP settings
|
// AMP settings
|
||||||
if amp, ok := cfg["ampcode"].(map[string]any); ok {
|
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})
|
upstreamURL := getString(amp, "upstream-url")
|
||||||
fields = append(fields, configField{"AMP Upstream API Key", "ampcode/upstream-api-key", "string", maskIfNotEmpty(getString(amp, "upstream-api-key")), nil})
|
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})
|
fields = append(fields, configField{"AMP Restrict Mgmt Localhost", "ampcode/restrict-management-to-localhost", "bool", fmt.Sprintf("%v", getBool(amp, "restrict-management-to-localhost")), nil})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,9 +83,16 @@ var zhStrings = map[string]string{
|
|||||||
"error_prefix": "⚠ 错误: ",
|
"error_prefix": "⚠ 错误: ",
|
||||||
|
|
||||||
// ── Status bar ──
|
// ── Status bar ──
|
||||||
"status_left": " CLIProxyAPI 管理终端",
|
"status_left": " CLIProxyAPI 管理终端",
|
||||||
"status_right": "Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 ",
|
"status_right": "Tab/Shift+Tab: 切换 • L: 语言 • q/Ctrl+C: 退出 ",
|
||||||
"initializing_tui": "正在初始化...",
|
"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 ──
|
||||||
"dashboard_title": "📊 仪表盘",
|
"dashboard_title": "📊 仪表盘",
|
||||||
@@ -227,9 +234,16 @@ var enStrings = map[string]string{
|
|||||||
"error_prefix": "⚠ Error: ",
|
"error_prefix": "⚠ Error: ",
|
||||||
|
|
||||||
// ── Status bar ──
|
// ── Status bar ──
|
||||||
"status_left": " CLIProxyAPI Management TUI",
|
"status_left": " CLIProxyAPI Management TUI",
|
||||||
"status_right": "Tab/Shift+Tab: switch • L: lang • q/Ctrl+C: quit ",
|
"status_right": "Tab/Shift+Tab: switch • L: lang • q/Ctrl+C: quit ",
|
||||||
"initializing_tui": "Initializing...",
|
"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 ──
|
||||||
"dashboard_title": "📊 Dashboard",
|
"dashboard_title": "📊 Dashboard",
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// logsTabModel displays real-time log lines from the logrus hook.
|
// logsTabModel displays real-time log lines from hook/API source.
|
||||||
type logsTabModel struct {
|
type logsTabModel struct {
|
||||||
|
client *Client
|
||||||
hook *LogHook
|
hook *LogHook
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
lines []string
|
lines []string
|
||||||
@@ -19,13 +21,22 @@ type logsTabModel struct {
|
|||||||
height int
|
height int
|
||||||
ready bool
|
ready bool
|
||||||
filter string // "", "debug", "info", "warn", "error"
|
filter string // "", "debug", "info", "warn", "error"
|
||||||
|
after int64
|
||||||
|
lastErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// logLineMsg carries a new log line from the logrus hook channel.
|
type logsPollMsg struct {
|
||||||
|
lines []string
|
||||||
|
latest int64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type logsTickMsg struct{}
|
||||||
type logLineMsg string
|
type logLineMsg string
|
||||||
|
|
||||||
func newLogsTabModel(hook *LogHook) logsTabModel {
|
func newLogsTabModel(client *Client, hook *LogHook) logsTabModel {
|
||||||
return logsTabModel{
|
return logsTabModel{
|
||||||
|
client: client,
|
||||||
hook: hook,
|
hook: hook,
|
||||||
maxLines: 5000,
|
maxLines: 5000,
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
@@ -33,11 +44,31 @@ func newLogsTabModel(hook *LogHook) logsTabModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m logsTabModel) Init() tea.Cmd {
|
func (m logsTabModel) Init() tea.Cmd {
|
||||||
return m.waitForLog
|
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{}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForLog listens on the hook channel and returns a logLineMsg.
|
|
||||||
func (m logsTabModel) waitForLog() tea.Msg {
|
func (m logsTabModel) waitForLog() tea.Msg {
|
||||||
|
if m.hook == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
line, ok := <-m.hook.Chan()
|
line, ok := <-m.hook.Chan()
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -50,6 +81,32 @@ func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) {
|
|||||||
case localeChangedMsg:
|
case localeChangedMsg:
|
||||||
m.viewport.SetContent(m.renderLogs())
|
m.viewport.SetContent(m.renderLogs())
|
||||||
return m, nil
|
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:
|
case logLineMsg:
|
||||||
m.lines = append(m.lines, string(msg))
|
m.lines = append(m.lines, string(msg))
|
||||||
if len(m.lines) > m.maxLines {
|
if len(m.lines) > m.maxLines {
|
||||||
@@ -71,6 +128,7 @@ func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
case "c":
|
case "c":
|
||||||
m.lines = nil
|
m.lines = nil
|
||||||
|
m.lastErr = nil
|
||||||
m.viewport.SetContent(m.renderLogs())
|
m.viewport.SetContent(m.renderLogs())
|
||||||
return m, nil
|
return m, nil
|
||||||
case "1":
|
case "1":
|
||||||
@@ -151,6 +209,11 @@ func (m logsTabModel) renderLogs() string {
|
|||||||
sb.WriteString(strings.Repeat("─", m.width))
|
sb.WriteString(strings.Repeat("─", m.width))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
if m.lastErr != nil {
|
||||||
|
sb.WriteString(errorStyle.Render("⚠ Error: " + m.lastErr.Error()))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
if len(m.lines) == 0 {
|
if len(m.lines) == 0 {
|
||||||
sb.WriteString(subtitleStyle.Render(T("logs_waiting")))
|
sb.WriteString(subtitleStyle.Render(T("logs_waiting")))
|
||||||
return sb.String()
|
return sb.String()
|
||||||
|
|||||||
Reference in New Issue
Block a user