5509 lines
142 KiB
Go
5509 lines
142 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/creack/pty"
|
|
"github.com/hinshun/vt10x"
|
|
"golang.org/x/term"
|
|
|
|
"pxmon/internal/agent"
|
|
"pxmon/internal/cluster"
|
|
"pxmon/internal/history"
|
|
)
|
|
|
|
type MonitorOptions struct {
|
|
Interval time.Duration
|
|
InitialIface string
|
|
AlertPolicy cluster.AlertPolicy
|
|
InitialView string // "overview" | "clusters" | "network"
|
|
}
|
|
|
|
type ifaceRate struct {
|
|
RxMbps float64
|
|
TxMbps float64
|
|
}
|
|
|
|
type monitorRuntime struct {
|
|
policy cluster.AlertPolicy
|
|
selectedIface string
|
|
prevAt time.Time
|
|
prevNet map[string]agent.NetworkStat
|
|
rates map[string]ifaceRate
|
|
vmWarnings []string
|
|
vmLastCheck time.Time
|
|
}
|
|
|
|
type monitorStatsMsg struct {
|
|
stats agent.StatsResponse
|
|
err error
|
|
}
|
|
|
|
type monitorTickMsg struct{}
|
|
type spinnerTickMsg struct{}
|
|
type vmAlertMsg struct {
|
|
warnings []string
|
|
err error
|
|
}
|
|
|
|
type terminalStartedMsg struct {
|
|
session *terminalSession
|
|
err error
|
|
}
|
|
|
|
type terminalOutputMsg struct {
|
|
data string
|
|
err error
|
|
}
|
|
|
|
type terminalClosedMsg struct {
|
|
err error
|
|
}
|
|
|
|
type historyLoadedMsg struct {
|
|
points map[string][]ifaceHistoryPoint
|
|
count int
|
|
err error
|
|
}
|
|
|
|
type historyAppendMsg struct {
|
|
err error
|
|
}
|
|
|
|
type consoleExecResultMsg struct {
|
|
ID int64
|
|
Command string
|
|
Output string
|
|
ExitCode int
|
|
}
|
|
|
|
type telegramSyncMsg struct {
|
|
updated cluster.Telegram
|
|
err error
|
|
}
|
|
|
|
type monitorView int
|
|
|
|
const (
|
|
viewOverview monitorView = iota
|
|
viewClusters
|
|
viewNetwork
|
|
viewSettings
|
|
viewDocs
|
|
viewUsage
|
|
viewLive
|
|
)
|
|
|
|
type inputMode int
|
|
|
|
const (
|
|
inputNone inputMode = iota
|
|
inputSearch
|
|
inputLockerNew
|
|
inputLockerConfirm
|
|
)
|
|
|
|
type historyRange struct {
|
|
Label string
|
|
Since time.Time
|
|
All bool
|
|
}
|
|
|
|
type tuiSettings struct {
|
|
RefreshMillis int `json:"refresh_millis"`
|
|
PageSize int `json:"page_size"`
|
|
HistoryRange string `json:"history_range"`
|
|
}
|
|
|
|
type terminalSession struct {
|
|
cmd *exec.Cmd
|
|
ptmx *os.File
|
|
shell string
|
|
}
|
|
|
|
type graphMetric int
|
|
|
|
const (
|
|
metricRX graphMetric = iota
|
|
metricTX
|
|
metricTotal
|
|
)
|
|
|
|
type ifaceHistoryPoint struct {
|
|
At time.Time
|
|
RxMbps float64
|
|
TxMbps float64
|
|
RxDrops uint64
|
|
TxDrops uint64
|
|
}
|
|
|
|
type networkRow struct {
|
|
Interface string
|
|
CurRxMbps float64
|
|
CurTxMbps float64
|
|
CurTotalMbps float64
|
|
AvgTotalMbps float64
|
|
PeakTotal float64
|
|
ConsumedByte uint64
|
|
Samples int
|
|
RxDrops uint64
|
|
TxDrops uint64
|
|
Spark string
|
|
}
|
|
|
|
type monitorModel struct {
|
|
svc *cluster.Service
|
|
cluster cluster.Cluster
|
|
hasCluster bool
|
|
interval time.Duration
|
|
runtime monitorRuntime
|
|
history map[string][]ifaceHistoryPoint
|
|
historyStore *history.NetworkStore
|
|
historyRange historyRange
|
|
|
|
view monitorView
|
|
inputMode inputMode
|
|
inputBuf string
|
|
searchTerm string
|
|
statusMsg string
|
|
termMode bool
|
|
termFull bool
|
|
termReady bool
|
|
termErr string
|
|
term *terminalSession
|
|
termLines []string
|
|
termPartial string
|
|
termCursor int
|
|
termHistory []string
|
|
termHistPos int
|
|
termDraft string
|
|
termBusy bool
|
|
thinkFrame int
|
|
spinnerActive bool
|
|
spinnerStart time.Time
|
|
contentScroll int
|
|
termScroll int
|
|
termCont string
|
|
termLastSubmit string
|
|
termLastSubmitAt time.Time
|
|
termExecSeq int64
|
|
termExecActive int64
|
|
settingsPath string
|
|
settingsCursor int
|
|
docsScroll int
|
|
telegram cluster.Telegram
|
|
telegramSyncing bool
|
|
lockerEnabled bool
|
|
lockerHash string
|
|
lockInput string
|
|
lockErr string
|
|
locked bool
|
|
lastUnlockAt time.Time
|
|
lockerPending string
|
|
|
|
page int
|
|
pageSize int
|
|
cursor int
|
|
metric graphMetric
|
|
pinnedIface string // if non-empty, cursor follows this interface across refreshes
|
|
|
|
usageRange history.RangeShortcut
|
|
usageSnap *cluster.UsageSnapshot
|
|
usageLoading bool
|
|
usageErr error
|
|
|
|
stats agent.StatsResponse
|
|
hasStats bool
|
|
lastErr error
|
|
loading bool
|
|
width int
|
|
height int
|
|
|
|
sshMode bool
|
|
sshClosed bool
|
|
sshErr string
|
|
sshSess *cluster.InteractiveSession
|
|
sshCluster cluster.Cluster
|
|
sshVT vt10x.Terminal
|
|
sshCols int
|
|
sshRows int
|
|
sshPendingRepaint bool
|
|
sshScrollback []string
|
|
sshScrollPending string
|
|
sshScrollOffset int
|
|
|
|
privacyMode bool
|
|
|
|
liveEntries []liveClusterStat
|
|
liveLoading bool
|
|
liveError string
|
|
livePage int
|
|
liveSort int
|
|
livePinned map[string]bool
|
|
liveCursor int
|
|
|
|
liveCmdActive bool
|
|
liveCmdSpec livePluginSpec
|
|
liveCmdBuffer string
|
|
liveCmdErr string
|
|
liveCmdRunning bool
|
|
liveCmdLastRun time.Time
|
|
liveCmdInterval time.Duration
|
|
liveCmdScroll int
|
|
}
|
|
|
|
var (
|
|
ccAccent = lipgloss.Color("#D97757")
|
|
ccAccentDim = lipgloss.Color("#A55A3F")
|
|
ccBorder = lipgloss.Color("#3A3A3A")
|
|
ccMuted = lipgloss.Color("#7A7A7A")
|
|
ccMutedSoft = lipgloss.Color("#5A5A5A")
|
|
ccText = lipgloss.Color("#E6E6E6")
|
|
ccTextBright = lipgloss.Color("#FFFFFF")
|
|
ccSuccess = lipgloss.Color("#7FB069")
|
|
ccInfo = lipgloss.Color("#6FA8DC")
|
|
ccWarn = lipgloss.Color("#E6B450")
|
|
ccCrit = lipgloss.Color("#E06C75")
|
|
|
|
thinBorder = lipgloss.RoundedBorder()
|
|
|
|
rootStyle = lipgloss.NewStyle().Foreground(ccText)
|
|
|
|
panelStyle = lipgloss.NewStyle().
|
|
BorderStyle(thinBorder).
|
|
BorderForeground(ccBorder).
|
|
Padding(0, 1)
|
|
|
|
headerPanelStyle = panelStyle.Copy().BorderForeground(ccAccent)
|
|
commandPanelStyle = panelStyle.Copy().BorderForeground(ccAccent)
|
|
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(ccTextBright)
|
|
dimStyle = lipgloss.NewStyle().Foreground(ccMuted)
|
|
warnStyle = lipgloss.NewStyle().Bold(true).Foreground(ccWarn)
|
|
critStyle = lipgloss.NewStyle().Bold(true).Foreground(ccCrit)
|
|
okStyle = lipgloss.NewStyle().Bold(true).Foreground(ccSuccess)
|
|
accentStyle = lipgloss.NewStyle().Foreground(ccAccent)
|
|
brightStyle = lipgloss.NewStyle().Bold(true).Foreground(ccTextBright)
|
|
softStyle = lipgloss.NewStyle().Foreground(ccMutedSoft)
|
|
|
|
ansiCSIRegex = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
|
|
ansiOSCRegex = regexp.MustCompile(`\x1b\][^\a]*(\a|\x1b\\)`)
|
|
)
|
|
|
|
func (a *App) runClusterMonitor(svc *cluster.Service, selector string, opts MonitorOptions) int {
|
|
c := cluster.Cluster{}
|
|
hasCluster := true
|
|
resolved, err := svc.Get(selector)
|
|
if err != nil {
|
|
if errorsIsNoActive(err) && strings.TrimSpace(selector) == "" {
|
|
hasCluster = false
|
|
c = cluster.Cluster{Name: "(none)"}
|
|
} else {
|
|
if errorsIsNoActive(err) {
|
|
fmt.Fprintln(a.err, "no active cluster")
|
|
return 1
|
|
}
|
|
fmt.Fprintf(a.err, "resolve cluster: %v\n", err)
|
|
return 1
|
|
}
|
|
} else {
|
|
c = resolved
|
|
}
|
|
|
|
if opts.Interval <= 0 {
|
|
opts.Interval = 2 * time.Second
|
|
}
|
|
if opts.Interval < 500*time.Millisecond {
|
|
opts.Interval = 500 * time.Millisecond
|
|
}
|
|
|
|
settingsPath := filepath.Join(svc.DataDir(), "tui_settings.json")
|
|
stored, _ := loadTUISettings(settingsPath)
|
|
if stored.RefreshMillis >= 500 {
|
|
opts.Interval = time.Duration(stored.RefreshMillis) * time.Millisecond
|
|
}
|
|
pageSize := 8
|
|
if stored.PageSize >= 5 && stored.PageSize <= 50 {
|
|
pageSize = stored.PageSize
|
|
}
|
|
range30d := defaultHistoryRange()
|
|
if strings.TrimSpace(stored.HistoryRange) != "" {
|
|
if parsed, parseErr := parseHistoryRange(stored.HistoryRange); parseErr == nil {
|
|
range30d = parsed
|
|
}
|
|
}
|
|
lockerCfg, _ := svc.GetLocker()
|
|
lockerEnabled := lockerCfg.Enabled && strings.TrimSpace(lockerCfg.PasswordHash) != ""
|
|
locked := false
|
|
if lockerEnabled {
|
|
if isLocked, _, lockErr := svc.IsLocked(); lockErr == nil {
|
|
locked = isLocked
|
|
} else {
|
|
locked = true
|
|
}
|
|
}
|
|
tgCfg, _ := svc.GetTelegram()
|
|
|
|
stdinFD := int(os.Stdin.Fd())
|
|
stdoutFD := int(os.Stdout.Fd())
|
|
interactive := term.IsTerminal(stdinFD) && term.IsTerminal(stdoutFD)
|
|
if !interactive {
|
|
if !hasCluster {
|
|
fmt.Fprintln(a.err, "no active cluster (connect one first, e.g. pxmon cluster connect --name ...)")
|
|
return 1
|
|
}
|
|
stats, fetchErr := fetchAgentStats(svc, c.Name)
|
|
if fetchErr != nil {
|
|
fmt.Fprintf(a.err, "cluster stats: %v\n", fetchErr)
|
|
return 1
|
|
}
|
|
printStatsSnapshot(a.out, stats)
|
|
return 0
|
|
}
|
|
|
|
view := parseView(opts.InitialView)
|
|
|
|
model := monitorModel{
|
|
svc: svc,
|
|
cluster: c,
|
|
interval: opts.Interval,
|
|
runtime: monitorRuntime{
|
|
policy: opts.AlertPolicy,
|
|
selectedIface: strings.TrimSpace(opts.InitialIface),
|
|
prevNet: map[string]agent.NetworkStat{},
|
|
rates: map[string]ifaceRate{},
|
|
},
|
|
history: map[string][]ifaceHistoryPoint{},
|
|
historyStore: history.NewNetworkStore(svc.DataDir()),
|
|
historyRange: range30d,
|
|
settingsPath: settingsPath,
|
|
telegram: tgCfg,
|
|
lockerEnabled: lockerEnabled,
|
|
lockerHash: strings.TrimSpace(lockerCfg.PasswordHash),
|
|
locked: locked,
|
|
|
|
view: view,
|
|
page: 1,
|
|
pageSize: pageSize,
|
|
cursor: 0,
|
|
metric: metricTotal,
|
|
termLines: make([]string, 0, 512),
|
|
termHistory: make([]string, 0, 128),
|
|
termHistPos: -1,
|
|
|
|
loading: true,
|
|
width: 120,
|
|
height: 40,
|
|
}
|
|
model.hasCluster = hasCluster
|
|
|
|
prog := tea.NewProgram(model, tea.WithAltScreen())
|
|
finalModel, err := prog.Run()
|
|
if err != nil {
|
|
fmt.Fprintf(a.err, "cluster stats tui: %v\n", err)
|
|
return 1
|
|
}
|
|
if m, ok := finalModel.(monitorModel); ok {
|
|
if m.term != nil {
|
|
m.term.close()
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m monitorModel) Init() tea.Cmd {
|
|
if !m.hasCluster {
|
|
return nil
|
|
}
|
|
return tea.Batch(
|
|
m.fetchStatsCmd(),
|
|
m.loadHistoryCmd(),
|
|
)
|
|
}
|
|
|
|
func (m monitorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch v := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = v.Width
|
|
m.height = v.Height
|
|
if m.term != nil && m.term.ptmx != nil {
|
|
_ = pty.Setsize(m.term.ptmx, &pty.Winsize{
|
|
Cols: uint16(max(40, m.width-4)),
|
|
Rows: uint16(max(8, m.terminalRows()-2)),
|
|
})
|
|
}
|
|
if m.sshMode {
|
|
m.handleSSHResize()
|
|
}
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
if m.locked {
|
|
return m.handleLockKey(v)
|
|
}
|
|
return m.handleKey(v)
|
|
case terminalStartedMsg:
|
|
if v.err != nil {
|
|
m.termErr = v.err.Error()
|
|
m.setStatus("terminal start failed")
|
|
m.termMode = false
|
|
return m, nil
|
|
}
|
|
m.term = v.session
|
|
m.termReady = true
|
|
m.termErr = ""
|
|
m.setStatus("terminal ready")
|
|
return m, m.readTerminalCmd()
|
|
case terminalOutputMsg:
|
|
if v.data != "" {
|
|
m.appendTerminalText(v.data)
|
|
}
|
|
if v.err != nil {
|
|
m.termErr = v.err.Error()
|
|
m.termReady = false
|
|
return m, nil
|
|
}
|
|
return m, m.readTerminalCmd()
|
|
case terminalClosedMsg:
|
|
if v.err != nil {
|
|
m.termErr = v.err.Error()
|
|
m.setStatus("terminal closed with error")
|
|
} else {
|
|
m.setStatus("terminal closed")
|
|
}
|
|
m.termReady = false
|
|
if m.term != nil {
|
|
m.term.close()
|
|
}
|
|
m.term = nil
|
|
m.termMode = false
|
|
m.termFull = false
|
|
return m, nil
|
|
case sshStartedMsg:
|
|
if v.err != nil {
|
|
m.setStatus("openssh: " + v.err.Error())
|
|
m.appendConsoleOutput("openssh: " + v.err.Error())
|
|
return m, nil
|
|
}
|
|
m.enterEmbeddedSSH(v)
|
|
m.setStatus(fmt.Sprintf("ssh connected: %s", v.cluster.Name))
|
|
return m, readSSHChunkCmd(m.sshSess)
|
|
case sshChunkMsg:
|
|
if !m.sshMode || m.sshSess == nil {
|
|
return m, nil
|
|
}
|
|
if m.sshVT != nil && len(v.data) > 0 {
|
|
_, _ = m.sshVT.Write(v.data)
|
|
m.captureSSHScrollback(v.data)
|
|
}
|
|
if v.err != nil {
|
|
msg := v.err.Error()
|
|
name := m.sshCluster.Name
|
|
m.exitEmbeddedSSH(fmt.Sprintf("ssh closed (%s): %s", name, msg))
|
|
m.appendConsoleOutput(fmt.Sprintf("ssh session closed (%s): %s", name, msg))
|
|
return m, nil
|
|
}
|
|
return m, readSSHChunkCmd(m.sshSess)
|
|
case sshClosedMsg:
|
|
reason := ""
|
|
if v.err != nil {
|
|
reason = v.err.Error()
|
|
}
|
|
m.exitEmbeddedSSH(fmt.Sprintf("ssh closed (%s)%s", m.sshCluster.Name, func() string {
|
|
if reason != "" {
|
|
return ": " + reason
|
|
}
|
|
return ""
|
|
}()))
|
|
return m, nil
|
|
case sshTickMsg:
|
|
return m, nil
|
|
case monitorLiveMsg:
|
|
m.liveLoading = false
|
|
if v.err != nil {
|
|
m.liveError = v.err.Error()
|
|
m.setStatus("live error: " + v.err.Error())
|
|
} else {
|
|
m.liveError = ""
|
|
m.liveEntries = v.entries
|
|
if m.liveCursor >= len(m.liveEntries) {
|
|
m.liveCursor = len(m.liveEntries) - 1
|
|
}
|
|
if m.liveCursor < 0 {
|
|
m.liveCursor = 0
|
|
}
|
|
m.setStatus(fmt.Sprintf("live: %d clusters", len(v.entries)))
|
|
}
|
|
if m.view == viewLive {
|
|
return m, liveAutoTickCmd()
|
|
}
|
|
return m, nil
|
|
case liveAutoTickMsg:
|
|
if m.view == viewLive {
|
|
m.liveLoading = true
|
|
return m, m.fetchLiveCmd()
|
|
}
|
|
return m, nil
|
|
case liveCmdResultMsg:
|
|
if !m.liveCmdActive {
|
|
return m, nil
|
|
}
|
|
m.liveCmdRunning = false
|
|
m.liveCmdLastRun = time.Now()
|
|
if v.Err != "" {
|
|
m.liveCmdErr = v.Err
|
|
} else {
|
|
m.liveCmdErr = ""
|
|
m.liveCmdBuffer = v.Output
|
|
}
|
|
return m, liveCmdTickCmd(m.liveCmdInterval)
|
|
case liveCmdTickMsg:
|
|
if !m.liveCmdActive {
|
|
return m, nil
|
|
}
|
|
m.liveCmdRunning = true
|
|
return m, m.runLiveCmdCmd(m.liveCmdSpec)
|
|
case monitorUsageMsg:
|
|
m.usageLoading = false
|
|
m.usageErr = v.err
|
|
if v.err == nil {
|
|
snap := v.snap
|
|
m.usageSnap = &snap
|
|
m.setStatus("usage: " + m.usageRange.Label())
|
|
} else {
|
|
m.setStatus("usage error: " + v.err.Error())
|
|
}
|
|
return m, nil
|
|
case monitorStatsMsg:
|
|
m.loading = false
|
|
m.lastErr = v.err
|
|
|
|
cmds := []tea.Cmd{m.nextTickCmd()}
|
|
if v.err == nil {
|
|
m.hasStats = true
|
|
m.stats = v.stats
|
|
m.runtime.update(v.stats)
|
|
|
|
snap := m.makeHistorySnapshot(v.stats)
|
|
if len(snap.Interfaces) > 0 {
|
|
m.applyHistorySnapshot(snap)
|
|
cmds = append(cmds, m.appendHistoryCmd(snap))
|
|
}
|
|
if time.Since(m.runtime.vmLastCheck) > 30*time.Second {
|
|
cmds = append(cmds, m.fetchVMAlertCmd())
|
|
}
|
|
}
|
|
|
|
m.clampNetworkNav()
|
|
m.snapToPinnedIface()
|
|
return m, tea.Batch(cmds...)
|
|
case vmAlertMsg:
|
|
m.runtime.vmLastCheck = time.Now()
|
|
if v.err != nil {
|
|
m.runtime.vmWarnings = nil
|
|
return m, nil
|
|
}
|
|
m.runtime.vmWarnings = append([]string(nil), v.warnings...)
|
|
return m, nil
|
|
case monitorTickMsg:
|
|
if m.lockerEnabled {
|
|
isLocked, _, err := m.svc.IsLocked()
|
|
if err != nil {
|
|
m.locked = true
|
|
m.lockInput = ""
|
|
m.lockErr = "locker check failed: " + err.Error()
|
|
return m, nil
|
|
}
|
|
if isLocked {
|
|
m.locked = true
|
|
m.lockInput = ""
|
|
m.lockErr = "session locked: enter password"
|
|
return m, nil
|
|
}
|
|
}
|
|
if !m.hasCluster {
|
|
return m, nil
|
|
}
|
|
if m.loading {
|
|
return m, m.nextTickCmd()
|
|
}
|
|
m.loading = true
|
|
return m, m.fetchStatsCmd()
|
|
case spinnerTickMsg:
|
|
if !m.termBusy {
|
|
m.spinnerActive = false
|
|
return m, nil
|
|
}
|
|
m.thinkFrame++
|
|
return m, spinnerTickCmd()
|
|
case historyLoadedMsg:
|
|
if v.err != nil {
|
|
m.setStatus("history load failed: " + v.err.Error())
|
|
return m, nil
|
|
}
|
|
m.history = v.points
|
|
m.setStatus(fmt.Sprintf("history loaded: %d snapshots (%s)", v.count, m.historyRange.Label))
|
|
m.clampNetworkNav()
|
|
m.snapToPinnedIface()
|
|
return m, nil
|
|
case historyAppendMsg:
|
|
if v.err != nil {
|
|
m.setStatus("history append failed: " + v.err.Error())
|
|
}
|
|
return m, nil
|
|
case consoleExecResultMsg:
|
|
if m.termExecActive != 0 && v.ID != 0 && v.ID != m.termExecActive {
|
|
return m, nil
|
|
}
|
|
m.termExecActive = 0
|
|
m.termBusy = false
|
|
m.spinnerActive = false
|
|
if strings.TrimSpace(v.Output) != "" {
|
|
m.appendConsoleOutput(v.Output)
|
|
}
|
|
if v.ExitCode == 0 {
|
|
m.setStatus("ok: " + v.Command)
|
|
} else {
|
|
m.setStatus(fmt.Sprintf("failed (%d): %s", v.ExitCode, v.Command))
|
|
}
|
|
|
|
cmds := []tea.Cmd{}
|
|
if tg, tgErr := m.svc.GetTelegram(); tgErr == nil {
|
|
m.telegram = tg
|
|
}
|
|
if lk, lkErr := m.svc.GetLocker(); lkErr == nil {
|
|
m.lockerEnabled = lk.Enabled
|
|
m.lockerHash = strings.TrimSpace(lk.PasswordHash)
|
|
}
|
|
if current, err := m.svc.Get(""); err == nil {
|
|
if !m.hasCluster || current.ID != m.cluster.ID {
|
|
m.cluster = current
|
|
m.hasCluster = true
|
|
m.hasStats = false
|
|
m.runtime.prevAt = time.Time{}
|
|
m.runtime.prevNet = map[string]agent.NetworkStat{}
|
|
m.runtime.rates = map[string]ifaceRate{}
|
|
m.runtime.vmWarnings = nil
|
|
m.runtime.vmLastCheck = time.Time{}
|
|
m.history = map[string][]ifaceHistoryPoint{}
|
|
m.page = 1
|
|
m.cursor = 0
|
|
m.pinnedIface = ""
|
|
m.setStatus("active cluster: " + current.Name)
|
|
}
|
|
if !m.loading {
|
|
m.loading = true
|
|
cmds = append(cmds, m.fetchStatsCmd())
|
|
}
|
|
cmds = append(cmds, m.loadHistoryCmd())
|
|
} else if errorsIsNoActive(err) {
|
|
m.hasCluster = false
|
|
m.cluster = cluster.Cluster{Name: "(none)"}
|
|
m.hasStats = false
|
|
m.history = map[string][]ifaceHistoryPoint{}
|
|
m.runtime.prevAt = time.Time{}
|
|
m.runtime.prevNet = map[string]agent.NetworkStat{}
|
|
m.runtime.rates = map[string]ifaceRate{}
|
|
m.runtime.vmWarnings = nil
|
|
m.runtime.vmLastCheck = time.Time{}
|
|
m.setStatus("no active cluster")
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
case telegramSyncMsg:
|
|
m.telegramSyncing = false
|
|
if v.err != nil {
|
|
m.setStatus("telegram update failed: " + v.err.Error())
|
|
return m, nil
|
|
}
|
|
m.telegram = v.updated
|
|
m.setStatus("telegram bot: " + ternary(v.updated.Enabled, "enabled", "disabled"))
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m monitorModel) View() string {
|
|
width := m.width - 2
|
|
if width <= 0 {
|
|
width = 118
|
|
}
|
|
if m.liveCmdActive {
|
|
height := m.height
|
|
if height <= 0 {
|
|
height = 32
|
|
}
|
|
return m.applyPrivacy(m.renderLiveCmdView(width, height))
|
|
}
|
|
if m.sshMode {
|
|
return m.applyPrivacy(m.renderSSHView())
|
|
}
|
|
if width < 68 {
|
|
return dimStyle.Render("Terminal width is too small for dashboard. Increase width to >= 70 columns.")
|
|
}
|
|
if m.termMode && m.termFull {
|
|
sections := []string{
|
|
m.renderHeader(width),
|
|
m.renderCommandDock(width),
|
|
}
|
|
return m.applyPrivacy(m.clampToTerminalHeight(rootStyle.Render(strings.Join(sections, "\n\n"))))
|
|
}
|
|
if m.locked {
|
|
return m.applyPrivacy(m.clampToTerminalHeight(m.renderLockedView(width)))
|
|
}
|
|
|
|
header := m.renderHeader(width)
|
|
dock := m.renderCommandDock(width)
|
|
|
|
// Compute how many lines are left for the main content panel so that the
|
|
// total view height matches the terminal height exactly. This prevents
|
|
// overflow into the terminal scrollback (which otherwise causes stale
|
|
// frames to stack up as the spinner ticks).
|
|
contentBudget := m.contentBudget(header, dock)
|
|
|
|
content := ""
|
|
if !m.hasCluster {
|
|
switch m.view {
|
|
case viewDocs:
|
|
content = m.renderDocsDashboard(width)
|
|
case viewClusters:
|
|
content = m.renderClusterOverviewDashboard(width)
|
|
case viewSettings:
|
|
content = m.renderSettingsDashboard(width)
|
|
default:
|
|
panel, _ := renderFixedPanel(panelStyle, "Workspace", "No active cluster.\n\nOpen PXmon console with `t` and run:\ncluster connect --name eu-1 --host <ip> --user root --auth key --key-path ~/.ssh/id_ed25519", width, contentBudget, m.contentScroll)
|
|
content = panel
|
|
}
|
|
} else {
|
|
switch m.view {
|
|
case viewClusters:
|
|
content = m.renderClusterOverviewDashboard(width)
|
|
case viewNetwork:
|
|
content = m.renderNetworkDashboard(width)
|
|
case viewSettings:
|
|
content = m.renderSettingsDashboard(width)
|
|
case viewDocs:
|
|
content = m.renderDocsDashboard(width)
|
|
case viewUsage:
|
|
content = m.renderUsageDashboard(width)
|
|
case viewLive:
|
|
content = m.renderLiveDashboard(width)
|
|
default:
|
|
content = m.renderOverviewDashboard(width)
|
|
}
|
|
}
|
|
|
|
if contentBudget > 0 {
|
|
clipped, _, _ := clipBodyToHeight(content, contentBudget, m.contentScroll)
|
|
content = clipped
|
|
}
|
|
|
|
sections := []string{header, content, dock}
|
|
return m.applyPrivacy(m.clampToTerminalHeight(rootStyle.Render(strings.Join(sections, "\n\n"))))
|
|
}
|
|
|
|
// contentBudget returns the number of lines available for the main content
|
|
// panel, given the already-rendered header and dock. Returns 0 when the
|
|
// terminal height is unknown or too small; callers should treat that as "do
|
|
// not clip".
|
|
func (m monitorModel) contentBudget(header, dock string) int {
|
|
if m.height <= 0 {
|
|
return 0
|
|
}
|
|
// sections are joined with "\n\n" (2 separators between 3 panels = 2 blank
|
|
// lines). rootStyle.Render doesn't add extra trailing lines.
|
|
const separatorLines = 2
|
|
budget := m.height - visibleHeight(header) - visibleHeight(dock) - separatorLines
|
|
if budget < 3 {
|
|
return 3
|
|
}
|
|
return budget
|
|
}
|
|
|
|
// clampToTerminalHeight forces the rendered output to have EXACTLY m.height
|
|
// lines: truncates if longer, pads with empty lines if shorter. A stable line
|
|
// count makes bubbletea's diff renderer behave predictably and prevents stale
|
|
// frames from accumulating in the terminal scrollback.
|
|
func (m monitorModel) clampToTerminalHeight(s string) string {
|
|
if m.height <= 0 {
|
|
return s
|
|
}
|
|
lines := strings.Split(s, "\n")
|
|
termWidth := m.width
|
|
if termWidth <= 0 {
|
|
termWidth = 120
|
|
}
|
|
for i := range lines {
|
|
lines[i] = truncateVisible(lines[i], termWidth)
|
|
if pad := termWidth - visibleLen(lines[i]); pad > 0 {
|
|
lines[i] += strings.Repeat(" ", pad)
|
|
}
|
|
}
|
|
if len(lines) > m.height {
|
|
lines = lines[:m.height]
|
|
}
|
|
for len(lines) < m.height {
|
|
lines = append(lines, strings.Repeat(" ", termWidth))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// applyPrivacy redacts sensitive patterns in the rendered view when the
|
|
// user has toggled privacy mode on. No-op otherwise.
|
|
func (m monitorModel) applyPrivacy(s string) string {
|
|
if !m.privacyMode {
|
|
return s
|
|
}
|
|
return applyPrivacyMultiline(s)
|
|
}
|
|
|
|
func (m monitorModel) handleKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.liveCmdActive {
|
|
return m.handleLiveCmdKey(v)
|
|
}
|
|
if m.sshMode {
|
|
return m.handleSSHKey(v)
|
|
}
|
|
if m.termMode {
|
|
return m.handleTerminalKey(v)
|
|
}
|
|
|
|
if m.inputMode != inputNone {
|
|
return m.handleInputKey(v)
|
|
}
|
|
|
|
switch v.String() {
|
|
case "q", "ctrl+c", "esc":
|
|
return m, tea.Quit
|
|
case "P", "alt+p":
|
|
m.privacyMode = !m.privacyMode
|
|
if m.privacyMode {
|
|
m.setStatus("privacy: on (alt+p to disable)")
|
|
} else {
|
|
m.setStatus("privacy: off")
|
|
}
|
|
return m, nil
|
|
case "t":
|
|
m.termMode = true
|
|
m.termFull = false
|
|
m.setStatus("pxmon console")
|
|
return m, nil
|
|
case "ctrl+t":
|
|
m.termMode = true
|
|
m.termFull = true
|
|
m.setStatus("pxmon console fullscreen")
|
|
return m, nil
|
|
case "s":
|
|
m.view = viewSettings
|
|
return m, nil
|
|
case "d", "D":
|
|
m.view = viewDocs
|
|
return m, nil
|
|
case "c":
|
|
m.view = viewClusters
|
|
return m, nil
|
|
case "u":
|
|
m.view = viewUsage
|
|
if m.usageRange == "" {
|
|
m.usageRange = history.RangeLive
|
|
}
|
|
m.usageLoading = true
|
|
m.setStatus("usage: loading")
|
|
return m, m.fetchUsageCmd(m.usageRange)
|
|
case "L":
|
|
m.view = viewLive
|
|
m.liveLoading = true
|
|
if m.livePinned == nil {
|
|
m.livePinned = map[string]bool{}
|
|
}
|
|
m.setStatus("live: loading")
|
|
return m, m.fetchLiveCmd()
|
|
case "/":
|
|
if m.view == viewNetwork {
|
|
m.inputMode = inputSearch
|
|
m.inputBuf = m.searchTerm
|
|
m.setStatus("search mode: type query, Enter apply, Esc cancel")
|
|
return m, nil
|
|
}
|
|
case "tab":
|
|
switch m.view {
|
|
case viewOverview:
|
|
m.view = viewClusters
|
|
case viewClusters:
|
|
m.view = viewNetwork
|
|
case viewNetwork:
|
|
m.view = viewSettings
|
|
case viewSettings:
|
|
m.view = viewDocs
|
|
default:
|
|
m.view = viewOverview
|
|
}
|
|
m.contentScroll = 0
|
|
m.clampNetworkNav()
|
|
return m, nil
|
|
case "alt+up":
|
|
if m.contentScroll > 0 {
|
|
m.contentScroll--
|
|
}
|
|
return m, nil
|
|
case "alt+down":
|
|
m.contentScroll++
|
|
return m, nil
|
|
case "alt+shift+up", "alt+pgup":
|
|
m.contentScroll -= 10
|
|
if m.contentScroll < 0 {
|
|
m.contentScroll = 0
|
|
}
|
|
return m, nil
|
|
case "alt+shift+down", "alt+pgdown":
|
|
m.contentScroll += 10
|
|
return m, nil
|
|
case "alt+home":
|
|
m.contentScroll = 0
|
|
return m, nil
|
|
case "r":
|
|
if m.loading {
|
|
m.setStatus("refresh already running")
|
|
return m, nil
|
|
}
|
|
m.loading = true
|
|
return m, m.fetchStatsCmd()
|
|
}
|
|
|
|
if m.view == viewUsage {
|
|
return m.handleUsageKey(v)
|
|
}
|
|
|
|
if m.view == viewLive {
|
|
return m.handleLiveKey(v)
|
|
}
|
|
|
|
if m.view == viewSettings {
|
|
return m.handleSettingsKey(v)
|
|
}
|
|
|
|
if m.view == viewDocs {
|
|
return m.handleDocsKey(v)
|
|
}
|
|
|
|
if m.view == viewClusters {
|
|
switch v.String() {
|
|
case "o":
|
|
m.view = viewOverview
|
|
case "n":
|
|
m.view = viewNetwork
|
|
case "s":
|
|
m.view = viewSettings
|
|
case "d", "D":
|
|
m.view = viewDocs
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
if m.view == viewOverview {
|
|
switch v.String() {
|
|
case "left", "h", "p":
|
|
if m.hasStats {
|
|
m.runtime.selectedIface = cycleIface(interfaceNames(m.stats.Network), m.runtime.selectedIface, -1)
|
|
}
|
|
case "right", "l", "n":
|
|
if m.hasStats {
|
|
m.runtime.selectedIface = cycleIface(interfaceNames(m.stats.Network), m.runtime.selectedIface, 1)
|
|
}
|
|
case "N":
|
|
m.view = viewNetwork
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch v.String() {
|
|
case "o":
|
|
m.view = viewOverview
|
|
case "d":
|
|
m.view = viewDocs
|
|
case "up", "k":
|
|
m.pinnedIface = ""
|
|
m.moveCursor(-1)
|
|
case "down", "j":
|
|
m.pinnedIface = ""
|
|
m.moveCursor(1)
|
|
case "left", "h":
|
|
m.metric = prevMetric(m.metric)
|
|
case "right", "l":
|
|
m.metric = nextMetric(m.metric)
|
|
case "p":
|
|
m.pinnedIface = ""
|
|
m.changePage(-1)
|
|
case "n":
|
|
m.pinnedIface = ""
|
|
m.changePage(1)
|
|
case "pgup":
|
|
m.pinnedIface = ""
|
|
m.changePage(-1)
|
|
case "pgdown":
|
|
m.pinnedIface = ""
|
|
m.changePage(1)
|
|
case "1":
|
|
m.metric = metricRX
|
|
case "2":
|
|
m.metric = metricTX
|
|
case "3":
|
|
m.metric = metricTotal
|
|
case "enter":
|
|
m.toggleIfacePin()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m monitorModel) handleTerminalKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch v.String() {
|
|
case "ctrl+t":
|
|
if m.termFull {
|
|
m.termMode = false
|
|
m.termFull = false
|
|
m.setStatus("console hidden")
|
|
return m, nil
|
|
}
|
|
m.termFull = true
|
|
m.setStatus("console fullscreen")
|
|
return m, nil
|
|
case "ctrl+g":
|
|
m.termMode = false
|
|
m.termFull = false
|
|
m.setStatus("back to UI")
|
|
return m, nil
|
|
case "esc":
|
|
m.termMode = false
|
|
m.termFull = false
|
|
m.setStatus("console hidden")
|
|
return m, nil
|
|
case "ctrl+c":
|
|
m.termPartial = ""
|
|
m.termCursor = 0
|
|
m.termHistPos = -1
|
|
m.setStatus("input cleared")
|
|
return m, nil
|
|
case "alt+p":
|
|
m.privacyMode = !m.privacyMode
|
|
if m.privacyMode {
|
|
m.setStatus("privacy: on")
|
|
} else {
|
|
m.setStatus("privacy: off")
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch v.String() {
|
|
case "alt+up":
|
|
m.scrollConsole(1)
|
|
return m, nil
|
|
case "alt+down":
|
|
m.scrollConsole(-1)
|
|
return m, nil
|
|
case "alt+shift+up":
|
|
m.scrollConsole(10)
|
|
return m, nil
|
|
case "alt+shift+down":
|
|
m.scrollConsole(-10)
|
|
return m, nil
|
|
case "pgup":
|
|
m.scrollConsole(m.dockOutputHeight() - 1)
|
|
return m, nil
|
|
case "pgdown":
|
|
m.scrollConsole(-(m.dockOutputHeight() - 1))
|
|
return m, nil
|
|
case "alt+home":
|
|
m.termScroll = len(m.termLines)
|
|
m.clampTermScroll()
|
|
return m, nil
|
|
case "alt+end":
|
|
m.termScroll = 0
|
|
return m, nil
|
|
}
|
|
|
|
if m.termBusy {
|
|
return m, nil
|
|
}
|
|
|
|
switch v.String() {
|
|
case "up":
|
|
m.historyUp()
|
|
return m, nil
|
|
case "down":
|
|
m.historyDown()
|
|
return m, nil
|
|
case "left":
|
|
m.moveConsoleCursor(-1)
|
|
return m, nil
|
|
case "right":
|
|
m.moveConsoleCursor(1)
|
|
return m, nil
|
|
case "alt+left", "alt+b":
|
|
m.moveConsoleWord(-1)
|
|
return m, nil
|
|
case "alt+right", "alt+f":
|
|
m.moveConsoleWord(1)
|
|
return m, nil
|
|
case "home", "ctrl+a":
|
|
m.termCursor = 0
|
|
return m, nil
|
|
case "end", "ctrl+e":
|
|
m.termCursor = len([]rune(m.termPartial))
|
|
return m, nil
|
|
case "backspace":
|
|
m.deleteConsolePrev()
|
|
return m, nil
|
|
case "delete":
|
|
m.deleteConsoleAt()
|
|
return m, nil
|
|
case "ctrl+u":
|
|
m.deleteConsoleToStart()
|
|
return m, nil
|
|
case "ctrl+k":
|
|
m.deleteConsoleToEnd()
|
|
return m, nil
|
|
case "ctrl+w":
|
|
m.deleteConsoleWord()
|
|
return m, nil
|
|
case "tab":
|
|
m.consoleAutocomplete()
|
|
return m, nil
|
|
case "enter":
|
|
return m.submitConsoleLine()
|
|
}
|
|
|
|
switch v.Type {
|
|
case tea.KeyRunes:
|
|
if len(v.Runes) == 0 {
|
|
return m, nil
|
|
}
|
|
m.insertConsoleText(string(v.Runes))
|
|
case tea.KeySpace:
|
|
m.insertConsoleText(" ")
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m monitorModel) ensureTerminalCmd() tea.Cmd {
|
|
if m.term != nil && m.termReady {
|
|
return nil
|
|
}
|
|
return m.startTerminalCmd()
|
|
}
|
|
|
|
func (m monitorModel) startTerminalCmd() tea.Cmd {
|
|
cols := max(40, m.width-4)
|
|
rows := max(8, m.terminalRows()-2)
|
|
return func() tea.Msg {
|
|
shell := strings.TrimSpace(os.Getenv("SHELL"))
|
|
if shell == "" {
|
|
shell = "/bin/bash"
|
|
}
|
|
cmd := exec.Command(shell, "-i")
|
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
|
|
Cols: uint16(cols),
|
|
Rows: uint16(rows),
|
|
})
|
|
if err != nil {
|
|
return terminalStartedMsg{err: err}
|
|
}
|
|
return terminalStartedMsg{
|
|
session: &terminalSession{
|
|
cmd: cmd,
|
|
ptmx: ptmx,
|
|
shell: shell,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m monitorModel) readTerminalCmd() tea.Cmd {
|
|
session := m.term
|
|
return func() tea.Msg {
|
|
if session == nil || session.ptmx == nil {
|
|
return terminalClosedMsg{}
|
|
}
|
|
buf := make([]byte, 4096)
|
|
n, err := session.ptmx.Read(buf)
|
|
if n > 0 {
|
|
return terminalOutputMsg{data: string(buf[:n])}
|
|
}
|
|
if err != nil {
|
|
if waitErr := session.cmd.Wait(); waitErr != nil && !errors.Is(waitErr, os.ErrClosed) {
|
|
return terminalClosedMsg{err: waitErr}
|
|
}
|
|
return terminalClosedMsg{}
|
|
}
|
|
return terminalOutputMsg{}
|
|
}
|
|
}
|
|
|
|
func (m *monitorModel) appendTerminalText(chunk string) {
|
|
if chunk == "" {
|
|
return
|
|
}
|
|
|
|
clean := sanitizeTerminalChunk(chunk)
|
|
if clean == "" {
|
|
return
|
|
}
|
|
|
|
for _, r := range clean {
|
|
switch r {
|
|
case '\r':
|
|
m.termPartial = ""
|
|
case '\n':
|
|
m.pushTerminalLine(m.termPartial)
|
|
m.termPartial = ""
|
|
case '\b':
|
|
if len(m.termPartial) > 0 {
|
|
m.termPartial = m.termPartial[:len(m.termPartial)-1]
|
|
}
|
|
case '\t':
|
|
m.termPartial += " "
|
|
default:
|
|
m.termPartial += string(r)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *monitorModel) pushTerminalLine(line string) {
|
|
m.termLines = append(m.termLines, line)
|
|
const keep = 800
|
|
if len(m.termLines) > keep {
|
|
m.termLines = m.termLines[len(m.termLines)-keep:]
|
|
}
|
|
m.termScroll = 0
|
|
}
|
|
|
|
func (m monitorModel) terminalRows() int {
|
|
if m.termFull {
|
|
return max(10, m.height-3)
|
|
}
|
|
return max(10, minInt(16, m.height/3))
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func sanitizeTerminalChunk(chunk string) string {
|
|
if chunk == "" {
|
|
return ""
|
|
}
|
|
|
|
s := ansiOSCRegex.ReplaceAllString(chunk, "")
|
|
s = ansiCSIRegex.ReplaceAllStringFunc(s, func(seq string) string {
|
|
if len(seq) > 0 && seq[len(seq)-1] == 'm' {
|
|
return seq
|
|
}
|
|
return ""
|
|
})
|
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
s = strings.ReplaceAll(s, "\x00", "")
|
|
return s
|
|
}
|
|
|
|
func keyMsgToPTY(k tea.KeyMsg) []byte {
|
|
switch k.Type {
|
|
case tea.KeyRunes:
|
|
payload := []byte(string(k.Runes))
|
|
if k.Alt {
|
|
return append([]byte{0x1b}, payload...)
|
|
}
|
|
return payload
|
|
case tea.KeySpace:
|
|
return []byte(" ")
|
|
case tea.KeyEnter:
|
|
return []byte{'\r'}
|
|
case tea.KeyTab:
|
|
return []byte{'\t'}
|
|
case tea.KeyShiftTab:
|
|
return []byte("\x1b[Z")
|
|
case tea.KeyBackspace:
|
|
return []byte{0x7f}
|
|
case tea.KeyUp:
|
|
return []byte("\x1b[A")
|
|
case tea.KeyDown:
|
|
return []byte("\x1b[B")
|
|
case tea.KeyRight:
|
|
return []byte("\x1b[C")
|
|
case tea.KeyLeft:
|
|
return []byte("\x1b[D")
|
|
case tea.KeyHome:
|
|
return []byte("\x1b[H")
|
|
case tea.KeyEnd:
|
|
return []byte("\x1b[F")
|
|
case tea.KeyDelete:
|
|
return []byte("\x1b[3~")
|
|
case tea.KeyInsert:
|
|
return []byte("\x1b[2~")
|
|
case tea.KeyPgUp:
|
|
return []byte("\x1b[5~")
|
|
case tea.KeyPgDown:
|
|
return []byte("\x1b[6~")
|
|
case tea.KeyCtrlC:
|
|
return []byte{0x03}
|
|
case tea.KeyCtrlD:
|
|
return []byte{0x04}
|
|
case tea.KeyCtrlF:
|
|
return []byte{0x06}
|
|
case tea.KeyCtrlL:
|
|
return []byte{0x0c}
|
|
case tea.KeyCtrlK:
|
|
return []byte{0x0b}
|
|
case tea.KeyCtrlN:
|
|
return []byte{0x0e}
|
|
case tea.KeyCtrlP:
|
|
return []byte{0x10}
|
|
case tea.KeyCtrlR:
|
|
return []byte{0x12}
|
|
case tea.KeyCtrlS:
|
|
return []byte{0x13}
|
|
case tea.KeyCtrlU:
|
|
return []byte{0x15}
|
|
case tea.KeyCtrlV:
|
|
return []byte{0x16}
|
|
case tea.KeyCtrlW:
|
|
return []byte{0x17}
|
|
case tea.KeyCtrlX:
|
|
return []byte{0x18}
|
|
case tea.KeyCtrlY:
|
|
return []byte{0x19}
|
|
case tea.KeyCtrlZ:
|
|
return []byte{0x1a}
|
|
case tea.KeyCtrlA:
|
|
return []byte{0x01}
|
|
case tea.KeyCtrlB:
|
|
return []byte{0x02}
|
|
case tea.KeyCtrlE:
|
|
return []byte{0x05}
|
|
case tea.KeyCtrlQ:
|
|
return []byte{0x11}
|
|
default:
|
|
if k.Type >= tea.KeyCtrlA && k.Type <= tea.KeyCtrlZ {
|
|
return []byte{byte(k.Type)}
|
|
}
|
|
if k.String() == "esc" {
|
|
return []byte{0x1b}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *terminalSession) close() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
if s.ptmx != nil {
|
|
_ = s.ptmx.Close()
|
|
}
|
|
if s.cmd != nil && s.cmd.Process != nil {
|
|
_ = s.cmd.Process.Kill()
|
|
_, _ = s.cmd.Process.Wait()
|
|
}
|
|
}
|
|
|
|
func (m monitorModel) handleInputKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch v.String() {
|
|
case "esc":
|
|
m.lockerPending = ""
|
|
m.inputMode = inputNone
|
|
m.inputBuf = ""
|
|
return m, nil
|
|
case "enter":
|
|
value := strings.TrimSpace(m.inputBuf)
|
|
mode := m.inputMode
|
|
m.inputMode = inputNone
|
|
m.inputBuf = ""
|
|
if mode == inputSearch {
|
|
m.searchTerm = value
|
|
m.page = 1
|
|
m.cursor = 0
|
|
m.clampNetworkNav()
|
|
m.setStatus(fmt.Sprintf("search=%q", m.searchTerm))
|
|
return m, nil
|
|
}
|
|
if mode == inputLockerNew {
|
|
if len(value) < 4 {
|
|
m.inputMode = inputLockerNew
|
|
m.setStatus("locker password too short (min 4)")
|
|
return m, nil
|
|
}
|
|
m.lockerPending = value
|
|
m.inputMode = inputLockerConfirm
|
|
m.setStatus("locker: confirm password and press Enter")
|
|
return m, nil
|
|
}
|
|
if mode == inputLockerConfirm {
|
|
if value == "" || m.lockerPending == "" {
|
|
m.setStatus("locker setup cancelled")
|
|
return m, nil
|
|
}
|
|
if value != m.lockerPending {
|
|
m.lockerPending = ""
|
|
m.setStatus("locker passwords do not match")
|
|
return m, nil
|
|
}
|
|
m.lockerPending = ""
|
|
cfg, err := m.svc.SetLockerPassword(value)
|
|
if err != nil {
|
|
m.setStatus("locker setup failed: " + err.Error())
|
|
return m, nil
|
|
}
|
|
m.lockerHash = strings.TrimSpace(cfg.PasswordHash)
|
|
m.lockerEnabled = cfg.Enabled
|
|
if err := m.saveTUISettings(); err != nil {
|
|
m.setStatus("save settings failed: " + err.Error())
|
|
return m, nil
|
|
}
|
|
m.setStatus("locker password set and enabled")
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
case "backspace":
|
|
if len(m.inputBuf) > 0 {
|
|
_, size := utf8.DecodeLastRuneInString(m.inputBuf)
|
|
if size > 0 {
|
|
m.inputBuf = m.inputBuf[:len(m.inputBuf)-size]
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
if v.Type == tea.KeyRunes && len(v.Runes) > 0 {
|
|
m.inputBuf += string(v.Runes)
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m monitorModel) handleLockKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch v.String() {
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
case "backspace":
|
|
if len(m.lockInput) > 0 {
|
|
_, size := utf8.DecodeLastRuneInString(m.lockInput)
|
|
if size > 0 {
|
|
m.lockInput = m.lockInput[:len(m.lockInput)-size]
|
|
}
|
|
}
|
|
return m, nil
|
|
case "enter":
|
|
if strings.TrimSpace(m.lockerHash) == "" {
|
|
m.locked = false
|
|
m.lockErr = ""
|
|
return m, nil
|
|
}
|
|
if err := m.svc.UnlockLocker(m.lockInput); err != nil {
|
|
m.lockInput = ""
|
|
m.lockErr = "invalid password"
|
|
return m, nil
|
|
}
|
|
m.lockInput = ""
|
|
m.lockErr = ""
|
|
m.locked = false
|
|
m.lastUnlockAt = time.Now()
|
|
m.setStatus("unlocked")
|
|
return m, nil
|
|
}
|
|
if v.Type == tea.KeyRunes && len(v.Runes) > 0 {
|
|
m.lockInput += string(v.Runes)
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m monitorModel) renderLockedView(width int) string {
|
|
if width < 68 {
|
|
width = 68
|
|
}
|
|
mask := strings.Repeat("*", len([]rune(m.lockInput)))
|
|
lines := []string{
|
|
"Session locked.",
|
|
"",
|
|
"Enter locker password and press Enter.",
|
|
"Press Ctrl+C to quit.",
|
|
"",
|
|
"Password: " + mask,
|
|
}
|
|
if strings.TrimSpace(m.lockErr) != "" {
|
|
lines = append(lines, "Error: "+m.lockErr)
|
|
}
|
|
return renderPanel("Locker", strings.Join(lines, "\n"), width)
|
|
}
|
|
|
|
func (m monitorModel) handleSettingsKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
const settingsRows = 6
|
|
|
|
switch v.String() {
|
|
case "up", "k":
|
|
m.settingsCursor--
|
|
if m.settingsCursor < 0 {
|
|
m.settingsCursor = settingsRows - 1
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
m.settingsCursor++
|
|
if m.settingsCursor >= settingsRows {
|
|
m.settingsCursor = 0
|
|
}
|
|
return m, nil
|
|
case "left", "h", "-", "_":
|
|
return m.adjustSetting(-1)
|
|
case "right", "l", "+", "=":
|
|
return m.adjustSetting(1)
|
|
case "enter":
|
|
if m.settingsCursor == 5 {
|
|
m.inputMode = inputLockerNew
|
|
m.inputBuf = ""
|
|
m.lockerPending = ""
|
|
m.setStatus("locker: enter new password and press Enter")
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
case "o":
|
|
m.view = viewOverview
|
|
return m, nil
|
|
case "c":
|
|
m.view = viewClusters
|
|
return m, nil
|
|
case "n":
|
|
m.view = viewNetwork
|
|
return m, nil
|
|
case "d", "D":
|
|
m.view = viewDocs
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m monitorModel) handleDocsKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
total := len(docsRawLines())
|
|
viewport := m.docsViewportHeight()
|
|
switch v.String() {
|
|
case "up", "k":
|
|
m.docsScroll--
|
|
case "down", "j":
|
|
m.docsScroll++
|
|
case "pgup":
|
|
m.docsScroll -= max(1, viewport-2)
|
|
case "pgdown", "space":
|
|
m.docsScroll += max(1, viewport-2)
|
|
case "home", "g":
|
|
m.docsScroll = 0
|
|
case "end", "G":
|
|
m.docsScroll = total
|
|
case "o":
|
|
m.view = viewOverview
|
|
return m, nil
|
|
case "c":
|
|
m.view = viewClusters
|
|
return m, nil
|
|
case "n":
|
|
m.view = viewNetwork
|
|
return m, nil
|
|
case "s":
|
|
m.view = viewSettings
|
|
return m, nil
|
|
}
|
|
maxOffset := total - viewport
|
|
if maxOffset < 0 {
|
|
maxOffset = 0
|
|
}
|
|
if m.docsScroll < 0 {
|
|
m.docsScroll = 0
|
|
}
|
|
if m.docsScroll > maxOffset {
|
|
m.docsScroll = maxOffset
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m monitorModel) adjustSetting(delta int) (tea.Model, tea.Cmd) {
|
|
if delta == 0 {
|
|
return m, nil
|
|
}
|
|
|
|
switch m.settingsCursor {
|
|
case 0:
|
|
steps := []time.Duration{
|
|
500 * time.Millisecond,
|
|
time.Second,
|
|
2 * time.Second,
|
|
5 * time.Second,
|
|
10 * time.Second,
|
|
20 * time.Second,
|
|
30 * time.Second,
|
|
60 * time.Second,
|
|
120 * time.Second,
|
|
}
|
|
idx := closestDurationIndex(steps, m.interval)
|
|
idx += delta
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
if idx >= len(steps) {
|
|
idx = len(steps) - 1
|
|
}
|
|
m.interval = steps[idx]
|
|
m.setStatus("refresh interval: " + m.interval.String())
|
|
case 1:
|
|
m.pageSize += delta
|
|
if m.pageSize < 5 {
|
|
m.pageSize = 5
|
|
}
|
|
if m.pageSize > 50 {
|
|
m.pageSize = 50
|
|
}
|
|
m.clampNetworkNav()
|
|
m.setStatus(fmt.Sprintf("network page size: %d", m.pageSize))
|
|
case 2:
|
|
labels := []string{"1h", "24h", "7d", "30d", "all"}
|
|
cur := 0
|
|
for i, label := range labels {
|
|
if strings.EqualFold(m.historyRange.Label, label) {
|
|
cur = i
|
|
break
|
|
}
|
|
}
|
|
cur += delta
|
|
if cur < 0 {
|
|
cur = 0
|
|
}
|
|
if cur >= len(labels) {
|
|
cur = len(labels) - 1
|
|
}
|
|
r, err := parseHistoryRange(labels[cur])
|
|
if err == nil {
|
|
m.historyRange = r
|
|
m.page = 1
|
|
m.cursor = 0
|
|
m.setStatus("history range: " + r.Label)
|
|
_ = m.saveTUISettings()
|
|
return m, m.loadHistoryCmd()
|
|
}
|
|
case 3:
|
|
if m.telegramSyncing {
|
|
m.setStatus("telegram update in progress")
|
|
return m, nil
|
|
}
|
|
cfg := m.telegram
|
|
if strings.TrimSpace(cfg.Token) == "" || len(cfg.AllowedUserIDs) == 0 {
|
|
m.setStatus("telegram is not configured; use `bot telegram set --token ... --allow ...`")
|
|
return m, nil
|
|
}
|
|
targetEnabled := cfg.Enabled
|
|
if delta > 0 {
|
|
targetEnabled = true
|
|
} else if delta < 0 {
|
|
targetEnabled = false
|
|
}
|
|
if targetEnabled == cfg.Enabled {
|
|
return m, nil
|
|
}
|
|
m.telegramSyncing = true
|
|
m.setStatus("telegram bot: applying setting...")
|
|
return m, m.syncTelegramSettingCmd(targetEnabled)
|
|
case 4:
|
|
if strings.TrimSpace(m.lockerHash) == "" {
|
|
m.setStatus("set locker password first (row 'Locker password')")
|
|
return m, nil
|
|
}
|
|
targetEnabled := m.lockerEnabled
|
|
if delta > 0 {
|
|
targetEnabled = true
|
|
} else if delta < 0 {
|
|
targetEnabled = false
|
|
}
|
|
if targetEnabled == m.lockerEnabled {
|
|
return m, nil
|
|
}
|
|
cfg, err := m.svc.SetLockerEnabled(targetEnabled)
|
|
if err != nil {
|
|
m.setStatus("locker update failed: " + err.Error())
|
|
return m, nil
|
|
}
|
|
m.lockerEnabled = cfg.Enabled
|
|
m.lockerHash = strings.TrimSpace(cfg.PasswordHash)
|
|
if !m.lockerEnabled {
|
|
m.locked = false
|
|
m.lockInput = ""
|
|
m.lockErr = ""
|
|
_ = m.svc.LockNow()
|
|
} else {
|
|
m.lastUnlockAt = time.Now()
|
|
}
|
|
m.setStatus("tui locker: " + ternary(m.lockerEnabled, "enabled", "disabled"))
|
|
case 5:
|
|
m.setStatus("press Enter to set locker password")
|
|
}
|
|
|
|
if err := m.saveTUISettings(); err != nil {
|
|
m.setStatus("save settings failed: " + err.Error())
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m monitorModel) syncTelegramSettingCmd(targetEnabled bool) tea.Cmd {
|
|
cfg := m.telegram
|
|
return func() tea.Msg {
|
|
cfg.Enabled = targetEnabled
|
|
updated, err := m.svc.SetTelegram(cfg)
|
|
if err != nil {
|
|
return telegramSyncMsg{err: err}
|
|
}
|
|
if err := syncTelegramBotDaemon(m.svc, m.svc.ConfigPath(), updated.Enabled, telegramBotDefaultPoll); err != nil {
|
|
return telegramSyncMsg{updated: updated, err: err}
|
|
}
|
|
return telegramSyncMsg{updated: updated}
|
|
}
|
|
}
|
|
|
|
func closestDurationIndex(items []time.Duration, current time.Duration) int {
|
|
if len(items) == 0 {
|
|
return 0
|
|
}
|
|
bestIdx := 0
|
|
bestDist := absDuration(items[0] - current)
|
|
for i := 1; i < len(items); i++ {
|
|
d := absDuration(items[i] - current)
|
|
if d < bestDist {
|
|
bestDist = d
|
|
bestIdx = i
|
|
}
|
|
}
|
|
return bestIdx
|
|
}
|
|
|
|
func absDuration(v time.Duration) time.Duration {
|
|
if v < 0 {
|
|
return -v
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (m monitorModel) saveTUISettings() error {
|
|
if strings.TrimSpace(m.settingsPath) == "" {
|
|
return nil
|
|
}
|
|
payload := tuiSettings{
|
|
RefreshMillis: int(m.interval.Milliseconds()),
|
|
PageSize: m.pageSize,
|
|
HistoryRange: m.historyRange.Label,
|
|
}
|
|
return saveTUISettings(m.settingsPath, payload)
|
|
}
|
|
|
|
func loadTUISettings(path string) (tuiSettings, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return tuiSettings{}, errors.New("settings path is empty")
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return tuiSettings{}, nil
|
|
}
|
|
return tuiSettings{}, err
|
|
}
|
|
var cfg tuiSettings
|
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
|
return tuiSettings{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func saveTUISettings(path string, cfg tuiSettings) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return errors.New("settings path is empty")
|
|
}
|
|
if cfg.RefreshMillis < 500 {
|
|
cfg.RefreshMillis = 500
|
|
}
|
|
if cfg.PageSize < 5 {
|
|
cfg.PageSize = 5
|
|
}
|
|
if cfg.PageSize > 50 {
|
|
cfg.PageSize = 50
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
body, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body = append(body, '\n')
|
|
return os.WriteFile(path, body, 0o600)
|
|
}
|
|
|
|
func (m *monitorModel) setStatus(msg string) {
|
|
m.statusMsg = strings.TrimSpace(msg)
|
|
}
|
|
|
|
func (m *monitorModel) moveCursor(delta int) {
|
|
rows := m.networkRows()
|
|
if len(rows) == 0 {
|
|
m.cursor = 0
|
|
return
|
|
}
|
|
|
|
start, end, _ := paginationBounds(len(rows), m.page, m.pageSize)
|
|
pageLen := end - start
|
|
if pageLen <= 0 {
|
|
m.cursor = 0
|
|
return
|
|
}
|
|
|
|
m.cursor += delta
|
|
if m.cursor < 0 {
|
|
if m.page > 1 {
|
|
m.page--
|
|
start, end, _ = paginationBounds(len(rows), m.page, m.pageSize)
|
|
m.cursor = max(0, (end-start)-1)
|
|
} else {
|
|
m.cursor = 0
|
|
}
|
|
return
|
|
}
|
|
if m.cursor >= pageLen {
|
|
if m.page < pageCount(len(rows), m.pageSize) {
|
|
m.page++
|
|
m.cursor = 0
|
|
} else {
|
|
m.cursor = pageLen - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *monitorModel) changePage(delta int) {
|
|
rows := m.networkRows()
|
|
totalPages := pageCount(len(rows), m.pageSize)
|
|
if totalPages < 1 {
|
|
totalPages = 1
|
|
}
|
|
m.page += delta
|
|
if m.page < 1 {
|
|
m.page = 1
|
|
}
|
|
if m.page > totalPages {
|
|
m.page = totalPages
|
|
}
|
|
m.cursor = 0
|
|
}
|
|
|
|
// toggleIfacePin pins the cursor to the currently highlighted network
|
|
// interface so auto-refresh re-sorting can't move the selection onto a
|
|
// different one. Pressing Enter again clears the pin.
|
|
func (m *monitorModel) toggleIfacePin() {
|
|
rows := m.networkRows()
|
|
if len(rows) == 0 {
|
|
m.pinnedIface = ""
|
|
m.setStatus("no interface to pin")
|
|
return
|
|
}
|
|
start, end, _ := paginationBounds(len(rows), m.page, m.pageSize)
|
|
pageRows := rows[start:end]
|
|
if m.cursor < 0 || m.cursor >= len(pageRows) {
|
|
m.pinnedIface = ""
|
|
m.setStatus("no interface to pin")
|
|
return
|
|
}
|
|
current := pageRows[m.cursor].Interface
|
|
if m.pinnedIface == current {
|
|
m.pinnedIface = ""
|
|
m.setStatus("iface unlocked: " + current)
|
|
return
|
|
}
|
|
m.pinnedIface = current
|
|
m.setStatus("iface locked: " + current + " (Enter to unlock)")
|
|
}
|
|
|
|
// snapToPinnedIface repositions page/cursor so the pinned interface is the
|
|
// highlighted row after sort order changes. No-op if nothing is pinned or the
|
|
// pinned interface disappeared from the current result set.
|
|
func (m *monitorModel) snapToPinnedIface() {
|
|
if strings.TrimSpace(m.pinnedIface) == "" {
|
|
return
|
|
}
|
|
rows := m.networkRows()
|
|
if len(rows) == 0 {
|
|
return
|
|
}
|
|
idx := -1
|
|
for i, r := range rows {
|
|
if r.Interface == m.pinnedIface {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
// Pinned interface disappeared (filtered out or went away).
|
|
m.pinnedIface = ""
|
|
return
|
|
}
|
|
if m.pageSize <= 0 {
|
|
return
|
|
}
|
|
m.page = (idx / m.pageSize) + 1
|
|
m.cursor = idx % m.pageSize
|
|
}
|
|
|
|
func (m *monitorModel) clampNetworkNav() {
|
|
rows := m.networkRows()
|
|
totalPages := pageCount(len(rows), m.pageSize)
|
|
if totalPages < 1 {
|
|
totalPages = 1
|
|
}
|
|
if m.page < 1 {
|
|
m.page = 1
|
|
}
|
|
if m.page > totalPages {
|
|
m.page = totalPages
|
|
}
|
|
|
|
start, end, _ := paginationBounds(len(rows), m.page, m.pageSize)
|
|
pageLen := end - start
|
|
if pageLen <= 0 {
|
|
m.cursor = 0
|
|
return
|
|
}
|
|
if m.cursor < 0 {
|
|
m.cursor = 0
|
|
}
|
|
if m.cursor >= pageLen {
|
|
m.cursor = pageLen - 1
|
|
}
|
|
}
|
|
|
|
func (m monitorModel) fetchStatsCmd() tea.Cmd {
|
|
svc := m.svc
|
|
name := m.cluster.Name
|
|
return func() tea.Msg {
|
|
stats, err := fetchAgentStats(svc, name)
|
|
return monitorStatsMsg{stats: stats, err: err}
|
|
}
|
|
}
|
|
|
|
func (m monitorModel) fetchVMAlertCmd() tea.Cmd {
|
|
svc := m.svc
|
|
clusterID := m.cluster.ID
|
|
return func() tea.Msg {
|
|
if strings.TrimSpace(clusterID) == "" {
|
|
return vmAlertMsg{}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
|
defer cancel()
|
|
rep, err := svc.CheckVMAlerts(ctx, clusterID)
|
|
if err != nil {
|
|
return vmAlertMsg{err: err}
|
|
}
|
|
return vmAlertMsg{warnings: rep.Warnings}
|
|
}
|
|
}
|
|
|
|
func (m monitorModel) nextTickCmd() tea.Cmd {
|
|
return tea.Tick(m.interval, func(time.Time) tea.Msg {
|
|
return monitorTickMsg{}
|
|
})
|
|
}
|
|
|
|
func spinnerTickCmd() tea.Cmd {
|
|
return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg {
|
|
return spinnerTickMsg{}
|
|
})
|
|
}
|
|
|
|
var spinnerSymbols = []string{"✻", "✦", "✧", "✦"}
|
|
|
|
var thinkingPhrases = []string{
|
|
"Thinking",
|
|
"Pondering",
|
|
"Cogitating",
|
|
"Musing",
|
|
"Brewing",
|
|
"Contemplating",
|
|
"Ruminating",
|
|
"Synthesizing",
|
|
"Deliberating",
|
|
}
|
|
|
|
func gradientText(text string, frame int) string {
|
|
runes := []rune(text)
|
|
if len(runes) == 0 {
|
|
return ""
|
|
}
|
|
const (
|
|
r1, g1, b1 = 217, 119, 87
|
|
r2, g2, b2 = 255, 220, 150
|
|
)
|
|
var b strings.Builder
|
|
for i, r := range runes {
|
|
phase := float64(i)*0.55 - float64(frame)*0.35
|
|
t := (math.Cos(phase) + 1) / 2
|
|
rr := int(float64(r1)*(1-t) + float64(r2)*t)
|
|
gg := int(float64(g1)*(1-t) + float64(g2)*t)
|
|
bb := int(float64(b1)*(1-t) + float64(b2)*t)
|
|
hex := fmt.Sprintf("#%02X%02X%02X", rr, gg, bb)
|
|
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(hex)).Bold(true).Render(string(r)))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func renderThinkingSpinner(frame int, width int, elapsed int) string {
|
|
sym := spinnerSymbols[(frame/2)%len(spinnerSymbols)]
|
|
phrase := thinkingPhrases[(frame/15)%len(thinkingPhrases)]
|
|
dots := strings.Repeat(".", (frame/3)%4)
|
|
text := phrase + dots
|
|
meta := softStyle.Render(fmt.Sprintf("(%2ds · esc to interrupt)", elapsed))
|
|
line := accentStyle.Bold(true).Render(sym) + " " + gradientText(text, frame/2) + " " + meta
|
|
if width <= 0 {
|
|
return line
|
|
}
|
|
if visibleLen(line) > width {
|
|
line = truncateVisible(line, width)
|
|
}
|
|
if pad := width - visibleLen(line); pad > 0 {
|
|
line += strings.Repeat(" ", pad)
|
|
}
|
|
return line
|
|
}
|
|
|
|
func (m monitorModel) appendHistoryCmd(snap history.NetworkSnapshot) tea.Cmd {
|
|
store := m.historyStore
|
|
clusterID := m.cluster.ID
|
|
return func() tea.Msg {
|
|
if store == nil {
|
|
return historyAppendMsg{}
|
|
}
|
|
return historyAppendMsg{err: store.Append(clusterID, snap)}
|
|
}
|
|
}
|
|
|
|
func (m monitorModel) loadHistoryCmd() tea.Cmd {
|
|
store := m.historyStore
|
|
clusterID := m.cluster.ID
|
|
since := m.historyRange.Since
|
|
if m.historyRange.All {
|
|
since = time.Time{}
|
|
}
|
|
return func() tea.Msg {
|
|
if store == nil {
|
|
return historyLoadedMsg{points: map[string][]ifaceHistoryPoint{}, count: 0}
|
|
}
|
|
snaps, err := store.Load(clusterID, since)
|
|
if err != nil {
|
|
return historyLoadedMsg{err: err}
|
|
}
|
|
return historyLoadedMsg{
|
|
points: buildHistoryPoints(snaps),
|
|
count: len(snaps),
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildHistoryPoints(snaps []history.NetworkSnapshot) map[string][]ifaceHistoryPoint {
|
|
points := make(map[string][]ifaceHistoryPoint, 64)
|
|
for _, snap := range snaps {
|
|
for _, iface := range snap.Interfaces {
|
|
points[iface.Interface] = append(points[iface.Interface], ifaceHistoryPoint{
|
|
At: snap.Timestamp,
|
|
RxMbps: iface.RxMbps,
|
|
TxMbps: iface.TxMbps,
|
|
RxDrops: iface.RxDrops,
|
|
TxDrops: iface.TxDrops,
|
|
})
|
|
}
|
|
}
|
|
return points
|
|
}
|
|
|
|
func (m *monitorModel) makeHistorySnapshot(stats agent.StatsResponse) history.NetworkSnapshot {
|
|
ts := stats.Timestamp
|
|
if ts.IsZero() {
|
|
ts = time.Now().UTC()
|
|
}
|
|
|
|
byIface := make(map[string]agent.NetworkStat, len(stats.Network))
|
|
for _, n := range stats.Network {
|
|
byIface[n.Interface] = n
|
|
}
|
|
|
|
items := make([]history.InterfaceSample, 0, len(m.runtime.rates))
|
|
names := make([]string, 0, len(m.runtime.rates))
|
|
for iface := range m.runtime.rates {
|
|
names = append(names, iface)
|
|
}
|
|
sort.Strings(names)
|
|
for _, iface := range names {
|
|
rate := m.runtime.rates[iface]
|
|
n := byIface[iface]
|
|
items = append(items, history.InterfaceSample{
|
|
Interface: iface,
|
|
RxMbps: rate.RxMbps,
|
|
TxMbps: rate.TxMbps,
|
|
RxDrops: n.RxDrops,
|
|
TxDrops: n.TxDrops,
|
|
})
|
|
}
|
|
|
|
return history.NetworkSnapshot{
|
|
Timestamp: ts,
|
|
Interfaces: items,
|
|
}
|
|
}
|
|
|
|
func (m *monitorModel) applyHistorySnapshot(snap history.NetworkSnapshot) {
|
|
if len(snap.Interfaces) == 0 {
|
|
return
|
|
}
|
|
if m.history == nil {
|
|
m.history = map[string][]ifaceHistoryPoint{}
|
|
}
|
|
|
|
for _, iface := range snap.Interfaces {
|
|
m.history[iface.Interface] = append(m.history[iface.Interface], ifaceHistoryPoint{
|
|
At: snap.Timestamp,
|
|
RxMbps: iface.RxMbps,
|
|
TxMbps: iface.TxMbps,
|
|
RxDrops: iface.RxDrops,
|
|
TxDrops: iface.TxDrops,
|
|
})
|
|
}
|
|
}
|
|
|
|
type dashboardSection struct {
|
|
Title string
|
|
Body string
|
|
}
|
|
|
|
func (m monitorModel) viewLabel() string {
|
|
switch m.view {
|
|
case viewClusters:
|
|
return "clusters"
|
|
case viewNetwork:
|
|
return "network"
|
|
case viewSettings:
|
|
return "settings"
|
|
case viewDocs:
|
|
return "docs"
|
|
case viewUsage:
|
|
return "usage"
|
|
case viewLive:
|
|
return "live"
|
|
default:
|
|
return "overview"
|
|
}
|
|
}
|
|
|
|
func splitMainGridWidths(width int) (leftW, rightW int) {
|
|
if width < 70 {
|
|
return width, 0
|
|
}
|
|
|
|
leftW = int(math.Round(float64(width) * 0.70))
|
|
rightW = width - leftW - 1
|
|
|
|
if rightW < 24 {
|
|
rightW = 24
|
|
leftW = width - rightW - 1
|
|
}
|
|
if leftW < 40 {
|
|
leftW = 40
|
|
rightW = width - leftW - 1
|
|
}
|
|
if rightW < 0 {
|
|
rightW = 0
|
|
}
|
|
return leftW, rightW
|
|
}
|
|
|
|
func renderSectionsBody(width int, sections []dashboardSection) string {
|
|
contentWidth := max(18, width-8)
|
|
sep := dimStyle.Render(strings.Repeat("─", max(8, contentWidth)))
|
|
|
|
lines := make([]string, 0, len(sections)*3)
|
|
for i, s := range sections {
|
|
if i > 0 {
|
|
lines = append(lines, sep)
|
|
}
|
|
lines = append(lines, titleStyle.Render(s.Title))
|
|
body := strings.TrimSpace(s.Body)
|
|
if body == "" {
|
|
body = dimStyle.Render("n/a")
|
|
}
|
|
lines = append(lines, truncateMultiline(body, contentWidth))
|
|
}
|
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
}
|
|
|
|
func (m monitorModel) headerBody(width int) string {
|
|
target := fmt.Sprintf("%s@%s:%d", m.cluster.User, m.cluster.Host, m.cluster.Port)
|
|
if !m.hasCluster {
|
|
target = "(not connected)"
|
|
}
|
|
ts := "-"
|
|
if m.hasStats && !m.stats.Timestamp.IsZero() {
|
|
ts = m.stats.Timestamp.Local().Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
status := okStyle.Render("OK")
|
|
if m.loading {
|
|
status = warnStyle.Render("UPDATING")
|
|
}
|
|
if m.lastErr != nil {
|
|
status = critStyle.Render("ERROR")
|
|
}
|
|
|
|
mode := m.viewLabel()
|
|
|
|
body := strings.Join([]string{
|
|
fmt.Sprintf("✻ Welcome to PXmon (Phylex Monitor)"),
|
|
fmt.Sprintf("cluster: %s (%s)", m.cluster.Name, target),
|
|
fmt.Sprintf("mode: %s · refresh: %s · range: %s · status: %s · time: %s", mode, m.interval, m.historyRange.Label, status, ts),
|
|
}, "\n")
|
|
if m.lastErr != nil {
|
|
errLine := "Last error: " + m.lastErr.Error()
|
|
body += "\n" + critStyle.Render(truncate(errLine, max(24, width-6)))
|
|
}
|
|
return truncateMultiline(body, max(24, width-8))
|
|
}
|
|
|
|
func (m monitorModel) renderHeader(width int) string {
|
|
const headerHeight = 8
|
|
panel, _ := renderFixedPanel(headerPanelStyle, "Session Header", m.headerBody(width), width, headerHeight, 0)
|
|
return panel
|
|
}
|
|
|
|
func (m monitorModel) mainLeftPanel(width int) (string, string) {
|
|
if !m.hasCluster {
|
|
return "Workspace", renderSectionsBody(width, []dashboardSection{
|
|
{
|
|
Title: "Welcome",
|
|
Body: m.welcomeBody(width),
|
|
},
|
|
{
|
|
Title: "Connect Cluster",
|
|
Body: "No active cluster.\n\nOpen command console (`t`) and run:\ncluster connect --name eu-1 --host <ip> --user root --auth key --key-path ~/.ssh/id_ed25519",
|
|
},
|
|
})
|
|
}
|
|
|
|
switch m.view {
|
|
case viewClusters:
|
|
return "Cluster Overview", m.clusterMainBody(width)
|
|
case viewNetwork:
|
|
return "Network View", m.networkMainBody(width)
|
|
case viewSettings:
|
|
return "Settings", m.settingsMainBody(width)
|
|
case viewDocs:
|
|
return "Docs", strings.TrimSpace(strings.Join(docsRawLines(), "\n"))
|
|
default:
|
|
return "Overview", m.overviewMainBody(width)
|
|
}
|
|
}
|
|
|
|
func (m monitorModel) mainRightPanel(width int) string {
|
|
clusterLine := m.cluster.Name
|
|
if !m.hasCluster {
|
|
clusterLine = "(none)"
|
|
}
|
|
|
|
statusLine := m.statusMsg
|
|
if strings.TrimSpace(statusLine) == "" {
|
|
statusLine = "ready"
|
|
}
|
|
|
|
activity := strings.Join([]string{
|
|
fmt.Sprintf("cluster: %s", clusterLine),
|
|
fmt.Sprintf("mode: %s", m.viewLabel()),
|
|
fmt.Sprintf("alerts: %d", len(collectAlerts(m.stats, &m.runtime))),
|
|
fmt.Sprintf("history: %d cmds", len(m.termHistory)),
|
|
fmt.Sprintf("telegram: %s", m.telegramStatusSummary()),
|
|
fmt.Sprintf("status: %s", truncate(statusLine, max(10, width-16))),
|
|
}, "\n")
|
|
|
|
hotkeys := []string{
|
|
"tab cycle views",
|
|
"t focus console",
|
|
"ctrl+t console fullscreen",
|
|
"ctrl+g leave console",
|
|
"c/s/d/o clusters/settings/docs/overview",
|
|
"r refresh stats",
|
|
"q quit",
|
|
}
|
|
if m.view == viewNetwork {
|
|
hotkeys = append(hotkeys, "/ search interfaces")
|
|
hotkeys = append(hotkeys, "n/p page next/prev")
|
|
hotkeys = append(hotkeys, "enter lock/unlock iface")
|
|
}
|
|
if m.view == viewSettings {
|
|
hotkeys = append(hotkeys, "↑/↓ select setting")
|
|
hotkeys = append(hotkeys, "←/→ change value")
|
|
}
|
|
|
|
return renderSectionsBody(width, []dashboardSection{
|
|
{Title: "Activity", Body: activity},
|
|
{Title: "Hotkeys", Body: strings.Join(hotkeys, "\n")},
|
|
})
|
|
}
|
|
|
|
func (m monitorModel) overviewMainBody(width int) string {
|
|
return renderSectionsBody(width, []dashboardSection{
|
|
{Title: "Welcome", Body: m.welcomeBody(width)},
|
|
{Title: "System Snapshot", Body: m.snapshotBody(width)},
|
|
{Title: "Network Top-5", Body: m.overviewNetworkBody(width)},
|
|
{Title: "Alerts", Body: m.alertsBody(width)},
|
|
})
|
|
}
|
|
|
|
func (m monitorModel) clusterMainBody(width int) string {
|
|
clusters, activeID, err := m.svc.List()
|
|
if err != nil {
|
|
return renderSectionsBody(width, []dashboardSection{
|
|
{Title: "Error", Body: "failed to load cluster inventory: " + err.Error()},
|
|
})
|
|
}
|
|
|
|
total := len(clusters)
|
|
agents := 0
|
|
softwareDetected := 0
|
|
activeName := "(none)"
|
|
for _, c := range clusters {
|
|
if c.ID == activeID {
|
|
activeName = c.Name
|
|
}
|
|
if c.Agent.Installed {
|
|
agents++
|
|
}
|
|
if c.Software.Summary() != "-" && c.Software.Summary() != "none" {
|
|
softwareDetected++
|
|
}
|
|
}
|
|
|
|
summary := strings.Join([]string{
|
|
fmt.Sprintf("clusters total: %d", total),
|
|
fmt.Sprintf("active cluster: %s", activeName),
|
|
fmt.Sprintf("agent installed: %d/%d", agents, max(total, 1)),
|
|
fmt.Sprintf("software detected: %d/%d", softwareDetected, max(total, 1)),
|
|
fmt.Sprintf("telegram bot: %s", m.telegramStatusSummary()),
|
|
}, "\n")
|
|
|
|
if total == 0 {
|
|
return renderSectionsBody(width, []dashboardSection{
|
|
{Title: "Summary", Body: summary},
|
|
{
|
|
Title: "Clusters",
|
|
Body: "No clusters connected yet.\nUse command console (`t`) and run:\ncluster connect --name <name> --host <ip> --user root --auth key --key-path ~/.ssh/id_ed25519",
|
|
},
|
|
})
|
|
}
|
|
|
|
var table strings.Builder
|
|
table.WriteString(fmt.Sprintf("%-2s %-14s %-24s %-9s %-18s %-11s\n", "A", "NAME", "TARGET", "AGENT", "SOFTWARE", "UPDATED"))
|
|
for _, c := range clusters {
|
|
active := " "
|
|
if c.ID == activeID {
|
|
active = "*"
|
|
}
|
|
agentState := "no"
|
|
if c.Agent.Installed {
|
|
agentState = fmt.Sprintf("yes:%d", c.Agent.Port)
|
|
}
|
|
target := truncate(fmt.Sprintf("%s@%s:%d", c.User, c.Host, c.Port), 24)
|
|
updated := c.UpdatedAt.Local().Format("01-02 15:04")
|
|
table.WriteString(fmt.Sprintf("%-2s %-14s %-24s %-9s %-18s %-11s\n",
|
|
active,
|
|
truncate(c.Name, 14),
|
|
target,
|
|
truncate(agentState, 9),
|
|
truncate(c.Software.Summary(), 18),
|
|
updated,
|
|
))
|
|
}
|
|
|
|
return renderSectionsBody(width, []dashboardSection{
|
|
{Title: "Summary", Body: summary},
|
|
{Title: "Clusters", Body: strings.TrimRight(table.String(), "\n")},
|
|
})
|
|
}
|
|
|
|
func (m monitorModel) networkMainBody(width int) string {
|
|
rows := m.networkRows()
|
|
start, end, totalPages := paginationBounds(len(rows), m.page, m.pageSize)
|
|
pageRows := m.enrichNetworkRows(rows[start:end])
|
|
|
|
meta := []string{
|
|
fmt.Sprintf("search=%q", m.searchTerm),
|
|
fmt.Sprintf("page=%d/%d", max(1, m.page), max(1, totalPages)),
|
|
fmt.Sprintf("interfaces=%d", len(rows)),
|
|
fmt.Sprintf("range=%s", m.historyRange.Label),
|
|
m.metricTabs(),
|
|
"search: press `/`, type query, Enter apply, Esc cancel",
|
|
}
|
|
|
|
var table strings.Builder
|
|
table.WriteString(fmt.Sprintf("%-2s %-12s %8s %8s %8s %8s %8s %8s %-16s\n",
|
|
"", "iface", "rx", "tx", "total", "avg", "peak", "used", "graph"))
|
|
for i, row := range pageRows {
|
|
marker := " "
|
|
if i == m.cursor {
|
|
marker = ">"
|
|
}
|
|
table.WriteString(fmt.Sprintf("%-2s %-12s %8.2f %8.2f %8.2f %8.2f %8.2f %8s %-16s\n",
|
|
marker,
|
|
truncate(row.Interface, 12),
|
|
row.CurRxMbps,
|
|
row.CurTxMbps,
|
|
row.CurTotalMbps,
|
|
row.AvgTotalMbps,
|
|
row.PeakTotal,
|
|
humanBitsRate(row.ConsumedByte),
|
|
truncate(row.Spark, 16),
|
|
))
|
|
}
|
|
if len(pageRows) == 0 {
|
|
table.WriteString(" " + dimStyle.Render("no interfaces matched current filter"))
|
|
}
|
|
|
|
return renderSectionsBody(width, []dashboardSection{
|
|
{Title: "Controls", Body: strings.Join(meta, "\n")},
|
|
{Title: "Interfaces", Body: strings.TrimRight(table.String(), "\n")},
|
|
{Title: "Details", Body: m.networkDetailsBody(pageRows, width)},
|
|
})
|
|
}
|
|
|
|
func (m monitorModel) settingsMainBody(width int) string {
|
|
rows := []string{
|
|
fmt.Sprintf("Refresh interval: %s", m.interval),
|
|
fmt.Sprintf("Network page size: %d", m.pageSize),
|
|
fmt.Sprintf("History range: %s", m.historyRange.Label),
|
|
fmt.Sprintf("Telegram bot: %s", m.telegramStatusSummary()),
|
|
fmt.Sprintf("TUI locker: %s", m.lockerStatusSummary()),
|
|
fmt.Sprintf("Locker password: %s", ternary(strings.TrimSpace(m.lockerHash) == "", "not set (press Enter)", "set (press Enter to replace)")),
|
|
}
|
|
|
|
var body strings.Builder
|
|
body.WriteString("Use up/down to select setting, left/right (+/-) to change.\n")
|
|
body.WriteString("Settings are persisted locally.\n\n")
|
|
for i, row := range rows {
|
|
prefix := " "
|
|
if i == m.settingsCursor {
|
|
prefix = ">"
|
|
}
|
|
body.WriteString(fmt.Sprintf("%s %s\n", prefix, row))
|
|
}
|
|
body.WriteString("\n")
|
|
body.WriteString("Configure telegram token and allowed users in console:\n")
|
|
body.WriteString("bot telegram set --token <token> --allow <telegram_id> --allow <telegram_id>")
|
|
|
|
return renderSectionsBody(width, []dashboardSection{
|
|
{Title: "TUI Settings", Body: strings.TrimRight(body.String(), "\n")},
|
|
})
|
|
}
|
|
|
|
func (m monitorModel) renderOverviewDashboard(width int) string {
|
|
leftW, sideW := splitWidths(width)
|
|
panelH := m.mainViewportHeight(width)
|
|
|
|
body := renderSectionsBody(leftW, []dashboardSection{
|
|
{Title: "Welcome", Body: m.welcomeBody(leftW)},
|
|
{Title: "System Snapshot", Body: m.snapshotBody(leftW)},
|
|
{Title: "Network Focus", Body: m.overviewNetworkBody(leftW)},
|
|
{Title: "Alerts", Body: m.alertsBody(leftW)},
|
|
})
|
|
left, _ := renderFixedPanel(panelStyle, "Overview", body, leftW, panelH, m.contentScroll)
|
|
|
|
side := ""
|
|
if sideW > 0 {
|
|
side, _ = renderFixedPanel(panelStyle, "Tips & Activity", m.activityPanelBody(sideW), sideW, panelH, m.contentScroll)
|
|
}
|
|
return composeWithSidebar(left, side, width, leftW, sideW)
|
|
}
|
|
|
|
func (m monitorModel) welcomeBody(width int) string {
|
|
lines := []string{
|
|
"✻ Welcome to PXmon (Phylex Monitor)",
|
|
"",
|
|
"Tips for getting started:",
|
|
" • Press `t` to open the command console",
|
|
" • Run `cluster connect` to add a node over SSH",
|
|
" • Use `tab` to cycle overview / clusters / network / settings / docs",
|
|
" • Press `d` to open full Docs page",
|
|
" • Press `?` for shortcuts, `q` to quit",
|
|
}
|
|
out := strings.Join(lines, "\n")
|
|
return truncateMultiline(out, max(24, width-6))
|
|
}
|
|
|
|
func (m monitorModel) snapshotBody(width int) string {
|
|
if !m.hasStats {
|
|
return dimStyle.Render("waiting for first metrics sample...")
|
|
}
|
|
|
|
cpu := m.currentCPUPct()
|
|
mem := m.stats.Memory
|
|
swap := m.stats.Memory.SwapUsedPct
|
|
if m.stats.Memory.SwapTotalBytes == 0 {
|
|
swap = 0
|
|
}
|
|
diskUsed := 0.0
|
|
diskLabel := "n/a"
|
|
if worst := worstDisk(m.stats.Disk); worst != nil {
|
|
diskUsed = worst.UsedPercent
|
|
diskLabel = truncate(worst.MountPoint, 14)
|
|
}
|
|
|
|
barW := 18
|
|
if width > 90 {
|
|
barW = 24
|
|
}
|
|
|
|
rows := []string{
|
|
fmt.Sprintf("CPU %s %6.1f%%", barASCII(cpu, barW), cpu),
|
|
fmt.Sprintf("RAM %s %6.1f%%", barASCII(mem.UsedPercent, barW), mem.UsedPercent),
|
|
fmt.Sprintf("SWAP %s %6.1f%%", barASCII(swap, barW), swap),
|
|
fmt.Sprintf("DISK %s %6.1f%% (%s)", barASCII(diskUsed, barW), diskUsed, diskLabel),
|
|
}
|
|
|
|
iface := strings.TrimSpace(m.runtime.selectedIface)
|
|
if iface != "" {
|
|
if rate, ok := m.runtime.rates[iface]; ok {
|
|
rows = append(rows,
|
|
"",
|
|
fmt.Sprintf("IFACE %s", iface),
|
|
fmt.Sprintf("RX %.2f Mbps | TX %.2f Mbps", rate.RxMbps, rate.TxMbps),
|
|
)
|
|
}
|
|
}
|
|
return strings.Join(rows, "\n")
|
|
}
|
|
|
|
func (m monitorModel) alertsBody(width int) string {
|
|
alerts := collectAlerts(m.stats, &m.runtime)
|
|
if len(alerts) == 0 {
|
|
return okStyle.Render("none")
|
|
}
|
|
|
|
maxRows := 5
|
|
if len(alerts) < maxRows {
|
|
maxRows = len(alerts)
|
|
}
|
|
var b strings.Builder
|
|
for i := 0; i < maxRows; i++ {
|
|
b.WriteString("• ")
|
|
b.WriteString(truncate(alerts[i], max(16, width-8)))
|
|
b.WriteByte('\n')
|
|
}
|
|
if len(alerts) > maxRows {
|
|
b.WriteString(dimStyle.Render(fmt.Sprintf("+%d more", len(alerts)-maxRows)))
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
func (m monitorModel) activityPanelBody(width int) string {
|
|
alertCount := len(collectAlerts(m.stats, &m.runtime))
|
|
statusLine := m.statusMsg
|
|
if strings.TrimSpace(statusLine) == "" {
|
|
statusLine = "ready"
|
|
}
|
|
|
|
mode := "overview"
|
|
switch m.view {
|
|
case viewClusters:
|
|
mode = "clusters"
|
|
case viewNetwork:
|
|
mode = "network"
|
|
case viewSettings:
|
|
mode = "settings"
|
|
case viewDocs:
|
|
mode = "docs"
|
|
}
|
|
|
|
last := "(none)"
|
|
if len(m.termLines) > 0 {
|
|
last = m.termLines[len(m.termLines)-1]
|
|
}
|
|
|
|
lines := []string{
|
|
"Activity",
|
|
fmt.Sprintf("mode: %s", mode),
|
|
fmt.Sprintf("alerts: %d", alertCount),
|
|
fmt.Sprintf("history: %d cmds", len(m.termHistory)),
|
|
fmt.Sprintf("telegram: %s", m.telegramStatusSummary()),
|
|
fmt.Sprintf("status: %s", truncate(statusLine, max(10, width-12))),
|
|
fmt.Sprintf("last: %s", truncate(last, max(10, width-10))),
|
|
"",
|
|
"Hotkeys",
|
|
"tab switch views",
|
|
"d docs page",
|
|
"t console focus",
|
|
"c clusters view",
|
|
"s settings view",
|
|
"/ search (network)",
|
|
"ctrl+g leave console",
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m monitorModel) renderCommandDock(width int) string {
|
|
state := dimStyle.Render("STANDBY")
|
|
title := "Command Console"
|
|
if m.termMode {
|
|
state = okStyle.Render("ACTIVE")
|
|
title = "Command Console (ACTIVE)"
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("state: %s · history: %d", state, len(m.termHistory)))
|
|
b.WriteByte('\n')
|
|
|
|
if m.termMode {
|
|
outputHeight := m.dockOutputHeight()
|
|
innerWidth := width - 6
|
|
if innerWidth < 20 {
|
|
innerWidth = 20
|
|
}
|
|
spinner := ""
|
|
if m.termBusy {
|
|
elapsed := 0
|
|
if !m.spinnerStart.IsZero() {
|
|
elapsed = int(time.Since(m.spinnerStart).Seconds())
|
|
}
|
|
spinner = renderThinkingSpinner(m.thinkFrame, innerWidth, elapsed)
|
|
}
|
|
b.WriteString(m.renderDockOutput(outputHeight, innerWidth, spinner))
|
|
b.WriteByte('\n')
|
|
} else if m.termBusy {
|
|
elapsed := 0
|
|
if !m.spinnerStart.IsZero() {
|
|
elapsed = int(time.Since(m.spinnerStart).Seconds())
|
|
}
|
|
b.WriteString(renderThinkingSpinner(m.thinkFrame, max(20, width-8), elapsed))
|
|
b.WriteByte('\n')
|
|
}
|
|
|
|
if m.termMode {
|
|
input := truncateVisible(m.renderConsoleInput(), max(20, width-6))
|
|
b.WriteString(input)
|
|
} else {
|
|
b.WriteString(accentStyle.Render("> "))
|
|
switch m.inputMode {
|
|
case inputSearch:
|
|
b.WriteString(dimStyle.Render("/" + m.inputBuf))
|
|
case inputLockerNew, inputLockerConfirm:
|
|
b.WriteString(dimStyle.Render("locker password: " + strings.Repeat("*", len([]rune(m.inputBuf)))))
|
|
default:
|
|
b.WriteString(dimStyle.Render("press `t` to activate command console"))
|
|
}
|
|
}
|
|
|
|
b.WriteByte('\n')
|
|
helpLine := m.helpText()
|
|
if strings.TrimSpace(m.statusMsg) != "" {
|
|
helpLine = "status: " + m.statusMsg
|
|
}
|
|
b.WriteString(dimStyle.Render(truncateRunes(helpLine, max(20, width-6))))
|
|
|
|
return renderPanelStyled(commandPanelStyle, title, b.String(), width)
|
|
}
|
|
|
|
func (m monitorModel) dockOutputHeight() int {
|
|
if m.termFull {
|
|
h := m.height - 12
|
|
if h < 10 {
|
|
h = 10
|
|
}
|
|
return h
|
|
}
|
|
// Default 8 lines of output, but shrink when the terminal is small so
|
|
// that header + content + dock still fits without overflowing.
|
|
h := 8
|
|
if m.height > 0 {
|
|
// Reserve ~11 lines for header (6) + content minimum (3) + dock chrome
|
|
// (5: title+state+input+status+border) + separators (2). That leaves
|
|
// m.height - 16 for dock output.
|
|
avail := m.height - 16
|
|
if avail < h {
|
|
h = avail
|
|
}
|
|
if h < 3 {
|
|
h = 3
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (m monitorModel) renderDockOutput(height, width int, spinner string) string {
|
|
if height <= 0 {
|
|
return ""
|
|
}
|
|
contentHeight := height
|
|
if spinner != "" && contentHeight > 1 {
|
|
contentHeight = height - 1
|
|
}
|
|
|
|
src := m.termLines
|
|
lines := make([]string, 0, height)
|
|
if len(src) == 0 {
|
|
lines = append(lines, dimStyle.Render("Observer console ready — type `help` or `cluster list`."))
|
|
for len(lines) < contentHeight {
|
|
lines = append(lines, "")
|
|
}
|
|
} else {
|
|
total := len(src)
|
|
maxOffset := total - contentHeight
|
|
if maxOffset < 0 {
|
|
maxOffset = 0
|
|
}
|
|
offset := m.termScroll
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
if offset > maxOffset {
|
|
offset = maxOffset
|
|
}
|
|
end := total - offset
|
|
start := end - contentHeight
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
window := src[start:end]
|
|
|
|
for _, l := range window {
|
|
lines = append(lines, truncateVisible(l, width))
|
|
}
|
|
for len(lines) < contentHeight {
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
if offset > 0 && contentHeight > 0 {
|
|
badge := softStyle.Render(fmt.Sprintf("↑ scrolled +%d (alt+↓ to follow)", offset))
|
|
avail := width - visibleLen(badge) - 1
|
|
if avail < 0 {
|
|
avail = 0
|
|
}
|
|
lines[contentHeight-1] = strings.Repeat(" ", avail) + badge
|
|
}
|
|
}
|
|
|
|
if spinner != "" {
|
|
lines = append(lines, truncateVisible(spinner, width))
|
|
}
|
|
for len(lines) < height {
|
|
lines = append(lines, "")
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func splitWidths(width int) (leftW, sideW int) {
|
|
if width < 110 {
|
|
return width - 2, 0
|
|
}
|
|
sideW = 30
|
|
if width >= 150 {
|
|
sideW = 36
|
|
}
|
|
leftW = width - sideW - 7
|
|
if leftW < 68 {
|
|
return width - 2, 0
|
|
}
|
|
return leftW, sideW
|
|
}
|
|
|
|
func composeWithSidebar(left, side string, totalWidth, leftW, sideW int) string {
|
|
if sideW <= 0 || leftW <= 0 || strings.TrimSpace(side) == "" {
|
|
return left
|
|
}
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", side)
|
|
}
|
|
|
|
func (m monitorModel) helpText() string {
|
|
const scroll = " | alt+↑/↓ scroll content"
|
|
help := "keys: t console | ctrl+t fullscreen console | tab view | d docs | c clusters | s settings | r refresh | q quit" + scroll
|
|
if m.view == viewOverview {
|
|
help = "keys: left/right iface | tab view | d docs | c clusters | s settings | t console | ctrl+t fullscreen | r refresh | q quit" + scroll
|
|
} else if m.view == viewClusters {
|
|
help = "keys: tab view | o overview | n network | s settings | d docs | t console | ctrl+t fullscreen | r refresh | q quit" + scroll
|
|
} else if m.view == viewNetwork {
|
|
help = "keys: up/down row | left/right or 1/2/3 | n/p page | / search | d docs | c clusters | s settings | t console | ctrl+t fullscreen | r refresh | q quit" + scroll
|
|
} else if m.view == viewSettings {
|
|
help = "keys: up/down setting | left/right adjust | Enter set locker password | d docs | tab view | c clusters | t console | ctrl+t fullscreen | q quit" + scroll
|
|
} else if m.view == viewDocs {
|
|
help = "keys: ↑/↓ or j/k scroll | PgUp/PgDn | Home/End | o/c/n/s switch view | tab cycle | t console | q quit"
|
|
}
|
|
if m.inputMode == inputSearch {
|
|
help = "search mode: type query | Enter apply | Esc cancel"
|
|
}
|
|
if m.inputMode == inputLockerNew {
|
|
help = "locker setup: type new password | Enter continue | Esc cancel"
|
|
}
|
|
if m.inputMode == inputLockerConfirm {
|
|
help = "locker setup: confirm password | Enter save | Esc cancel"
|
|
}
|
|
if m.termMode {
|
|
help = "console: commands | console full/dock/toggle | end line with \\ to continue | alt+←/→ word move | alt+↑/↓ scroll | ctrl+g back"
|
|
}
|
|
return truncate(help, max(20, m.width-16))
|
|
}
|
|
|
|
func (m monitorModel) renderMetrics(width int) string {
|
|
cpuPct := m.currentCPUPct()
|
|
mem := m.stats.Memory
|
|
|
|
swapLabel := "n/a"
|
|
swapBar := barASCII(0, 24)
|
|
if mem.SwapTotalBytes > 0 {
|
|
swapLabel = fmt.Sprintf("%.1f%% (%s / %s)", mem.SwapUsedPct, humanBytes(mem.SwapUsedBytes), humanBytes(mem.SwapTotalBytes))
|
|
swapBar = barASCII(mem.SwapUsedPct, 24)
|
|
}
|
|
|
|
diskLine := "n/a"
|
|
diskBar := barASCII(0, 24)
|
|
if worst := worstDisk(m.stats.Disk); worst != nil {
|
|
diskLine = fmt.Sprintf("%.1f%% %s (%s)", worst.UsedPercent, worst.MountPoint, emptyFallback(worst.Health, "ok"))
|
|
diskBar = barASCII(worst.UsedPercent, 24)
|
|
}
|
|
|
|
cardGap := 1
|
|
cardW := (width - (cardGap * 3)) / 4
|
|
if cardW < 20 {
|
|
cardW = 20
|
|
}
|
|
|
|
cpuCard := renderPanel("CPU", fmt.Sprintf("%.1f%%\n%s\nload %.2f %.2f %.2f", cpuPct, barASCII(cpuPct, 24), m.stats.CPU.Load1, m.stats.CPU.Load5, m.stats.CPU.Load15), cardW)
|
|
ramCard := renderPanel("RAM", fmt.Sprintf("%.1f%%\n%s\n%s / %s", mem.UsedPercent, barASCII(mem.UsedPercent, 24), humanBytes(mem.UsedBytes), humanBytes(mem.TotalBytes)), cardW)
|
|
swapCard := renderPanel("SWAP", fmt.Sprintf("%s\n%s", swapLabel, swapBar), cardW)
|
|
diskCard := renderPanel("DISK", fmt.Sprintf("%s\n%s", diskLine, diskBar), cardW)
|
|
|
|
if width >= 110 {
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, cpuCard, " ", ramCard, " ", swapCard, " ", diskCard)
|
|
}
|
|
|
|
left := lipgloss.JoinVertical(lipgloss.Left, cpuCard, ramCard)
|
|
right := lipgloss.JoinVertical(lipgloss.Left, swapCard, diskCard)
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
|
|
}
|
|
|
|
func (m monitorModel) renderOverviewNetworkAndDisk(width int) string {
|
|
netW := width
|
|
diskW := width
|
|
if width >= 120 {
|
|
netW = int(math.Round(float64(width) * 0.62))
|
|
diskW = width - netW - 1
|
|
}
|
|
|
|
netPanel := renderPanel("Network Top-5 (Current)", m.overviewNetworkBody(netW), netW)
|
|
diskPanel := renderPanel("Disk Health", m.diskBody(diskW), diskW)
|
|
|
|
if width >= 120 {
|
|
return lipgloss.JoinHorizontal(lipgloss.Top, netPanel, " ", diskPanel)
|
|
}
|
|
return lipgloss.JoinVertical(lipgloss.Left, netPanel, diskPanel)
|
|
}
|
|
|
|
func (m monitorModel) overviewNetworkBody(width int) string {
|
|
rows := m.networkRows()
|
|
if len(rows) == 0 {
|
|
return dimStyle.Render("no network interfaces reported")
|
|
}
|
|
|
|
maxRows := 5
|
|
if len(rows) < maxRows {
|
|
maxRows = len(rows)
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("%-2s %-12s %9s %9s %9s\n", "", "iface", "rx", "tx", "total"))
|
|
for i := 0; i < maxRows; i++ {
|
|
r := rows[i]
|
|
prefix := " "
|
|
if r.Interface == m.runtime.selectedIface {
|
|
prefix = ">"
|
|
}
|
|
b.WriteString(fmt.Sprintf("%-2s %-12s %9.2f %9.2f %9.2f\n",
|
|
prefix,
|
|
truncate(r.Interface, 12),
|
|
r.CurRxMbps,
|
|
r.CurTxMbps,
|
|
r.CurTotalMbps,
|
|
))
|
|
}
|
|
if len(rows) > maxRows {
|
|
b.WriteString(dimStyle.Render(fmt.Sprintf("showing top-%d of %d, open Network view with TAB", maxRows, len(rows))))
|
|
} else {
|
|
b.WriteString(dimStyle.Render("open Network view with TAB"))
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
func (m monitorModel) diskBody(width int) string {
|
|
if !m.hasStats {
|
|
return dimStyle.Render("waiting for first metrics sample...")
|
|
}
|
|
if len(m.stats.Disk) == 0 {
|
|
return dimStyle.Render("no mounted disks reported")
|
|
}
|
|
|
|
disks := append([]agent.DiskStats(nil), m.stats.Disk...)
|
|
sort.Slice(disks, func(i, j int) bool {
|
|
return disks[i].UsedPercent > disks[j].UsedPercent
|
|
})
|
|
|
|
maxRows := 8
|
|
if len(disks) < maxRows {
|
|
maxRows = len(disks)
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("%-12s %7s %-9s %s\n", "mount", "used", "health", "warnings"))
|
|
for i := 0; i < maxRows; i++ {
|
|
d := disks[i]
|
|
warns := "-"
|
|
if len(d.Warnings) > 0 {
|
|
warns = truncate(strings.Join(d.Warnings, "; "), max(8, width-36))
|
|
}
|
|
health := emptyFallback(d.Health, "ok")
|
|
b.WriteString(fmt.Sprintf("%-12s %6.1f%% %-9s %s\n",
|
|
truncate(d.MountPoint, 12),
|
|
d.UsedPercent,
|
|
truncate(health, 9),
|
|
warns,
|
|
))
|
|
}
|
|
if len(disks) > maxRows {
|
|
b.WriteString(dimStyle.Render(fmt.Sprintf("+%d more mounts", len(disks)-maxRows)))
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
func (m monitorModel) renderClusterOverviewDashboard(width int) string {
|
|
panelH := m.mainViewportHeight(width)
|
|
clusters, activeID, err := m.svc.List()
|
|
if err != nil {
|
|
main, _ := renderFixedPanel(panelStyle, "Cluster Overview", "failed to load cluster inventory: "+err.Error(), width, panelH, m.contentScroll)
|
|
return main
|
|
}
|
|
|
|
total := len(clusters)
|
|
agents := 0
|
|
softwareDetected := 0
|
|
for _, c := range clusters {
|
|
if c.Agent.Installed {
|
|
agents++
|
|
}
|
|
if c.Software.Summary() != "-" && c.Software.Summary() != "none" {
|
|
softwareDetected++
|
|
}
|
|
}
|
|
|
|
activeName := "(none)"
|
|
for _, c := range clusters {
|
|
if c.ID == activeID {
|
|
activeName = c.Name
|
|
break
|
|
}
|
|
}
|
|
|
|
telegramLine := "Telegram bot: " + m.telegramStatusSummary()
|
|
denom := total
|
|
if denom < 1 {
|
|
denom = 0
|
|
}
|
|
summary := strings.Join([]string{
|
|
fmt.Sprintf("Clusters total: %d", total),
|
|
fmt.Sprintf("Active cluster: %s", activeName),
|
|
fmt.Sprintf("Agent installed: %d/%d", agents, denom),
|
|
fmt.Sprintf("Software detected: %d/%d", softwareDetected, denom),
|
|
telegramLine,
|
|
}, "\n")
|
|
leftW, sideW := splitWidths(width)
|
|
|
|
var tableBody string
|
|
if total == 0 {
|
|
tableBody = "No clusters connected yet.\nUse console (`t`) and run:\ncluster connect --name ... --host ... --user ..."
|
|
} else {
|
|
var table bytes.Buffer
|
|
tw := tabwriter.NewWriter(&table, 0, 2, 2, ' ', 0)
|
|
_, _ = fmt.Fprintln(tw, "ACTIVE\tNAME\tTARGET\tAGENT\tSOFTWARE\tUPDATED")
|
|
for _, c := range clusters {
|
|
active := ""
|
|
if c.ID == activeID {
|
|
active = "*"
|
|
}
|
|
agentState := "no"
|
|
if c.Agent.Installed {
|
|
agentState = fmt.Sprintf("yes:%d", c.Agent.Port)
|
|
}
|
|
target := truncate(fmt.Sprintf("%s@%s:%d", c.User, c.Host, c.Port), 24)
|
|
updated := c.UpdatedAt.Local().Format("01-02 15:04")
|
|
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
active,
|
|
truncate(c.Name, 14),
|
|
target,
|
|
agentState,
|
|
truncate(c.Software.Summary(), 18),
|
|
updated,
|
|
)
|
|
}
|
|
_ = tw.Flush()
|
|
tableBody = strings.TrimRight(table.String(), "\n")
|
|
}
|
|
|
|
body := renderSectionsBody(leftW, []dashboardSection{
|
|
{Title: "Cluster Overview", Body: summary},
|
|
{Title: "Clusters", Body: tableBody},
|
|
})
|
|
main, _ := renderFixedPanel(panelStyle, "Clusters", body, leftW, panelH, m.contentScroll)
|
|
side := ""
|
|
if sideW > 0 {
|
|
side, _ = renderFixedPanel(panelStyle, "Tips & Activity", m.activityPanelBody(sideW), sideW, panelH, m.contentScroll)
|
|
}
|
|
return composeWithSidebar(main, side, width, leftW, sideW)
|
|
}
|
|
|
|
func (m monitorModel) telegramStatusSummary() string {
|
|
if strings.TrimSpace(m.telegram.Token) == "" {
|
|
return "not configured"
|
|
}
|
|
state := "disabled"
|
|
if m.telegram.Enabled {
|
|
state = "enabled"
|
|
}
|
|
return fmt.Sprintf("%s, allowed users: %d", state, len(m.telegram.AllowedUserIDs))
|
|
}
|
|
|
|
func (m monitorModel) lockerStatusSummary() string {
|
|
if strings.TrimSpace(m.lockerHash) == "" {
|
|
return "not configured"
|
|
}
|
|
if m.lockerEnabled {
|
|
return "enabled (re-lock every 6h)"
|
|
}
|
|
return "disabled"
|
|
}
|
|
|
|
func (m monitorModel) renderNetworkDashboard(width int) string {
|
|
rows := m.networkRows()
|
|
start, end, totalPages := paginationBounds(len(rows), m.page, m.pageSize)
|
|
pageRows := m.enrichNetworkRows(rows[start:end])
|
|
leftW, sideW := splitWidths(width)
|
|
panelH := m.mainViewportHeight(width)
|
|
|
|
meta := fmt.Sprintf("search=%q | page=%d/%d | total_ifaces=%d | range=%s",
|
|
m.searchTerm, max(1, m.page), max(1, totalPages), len(rows), m.historyRange.Label)
|
|
if strings.TrimSpace(m.pinnedIface) != "" {
|
|
meta += " | " + accentStyle.Render("locked="+m.pinnedIface)
|
|
}
|
|
meta += "\n" + m.metricTabs()
|
|
meta += "\nsearch: press `/`, Enter to lock/unlock current iface, Esc to cancel"
|
|
|
|
var table strings.Builder
|
|
table.WriteString(fmt.Sprintf("%-2s %-12s %8s %8s %8s %8s %8s %8s %-18s\n",
|
|
"", "iface", "rx", "tx", "total", "avg", "peak", "used", "graph"))
|
|
for i, row := range pageRows {
|
|
marker := " "
|
|
if i == m.cursor {
|
|
marker = ">"
|
|
if row.Interface == m.pinnedIface {
|
|
marker = "*"
|
|
}
|
|
}
|
|
table.WriteString(fmt.Sprintf("%-2s %-12s %8.2f %8.2f %8.2f %8.2f %8.2f %8s %-18s\n",
|
|
marker,
|
|
truncate(row.Interface, 12),
|
|
row.CurRxMbps,
|
|
row.CurTxMbps,
|
|
row.CurTotalMbps,
|
|
row.AvgTotalMbps,
|
|
row.PeakTotal,
|
|
humanBitsRate(row.ConsumedByte),
|
|
truncate(row.Spark, 18),
|
|
))
|
|
}
|
|
if len(pageRows) == 0 {
|
|
table.WriteString(dimStyle.Render("no interfaces matched current filter"))
|
|
}
|
|
|
|
body := renderSectionsBody(leftW, []dashboardSection{
|
|
{Title: "Network View", Body: meta},
|
|
{Title: "Interfaces (paginated)", Body: strings.TrimRight(table.String(), "\n")},
|
|
{Title: "Interface Details", Body: m.networkDetailsBody(pageRows, leftW)},
|
|
})
|
|
main, _ := renderFixedPanel(panelStyle, "Network", body, leftW, panelH, m.contentScroll)
|
|
side := ""
|
|
if sideW > 0 {
|
|
side, _ = renderFixedPanel(panelStyle, "Tips & Activity", m.activityPanelBody(sideW), sideW, panelH, m.contentScroll)
|
|
}
|
|
return composeWithSidebar(main, side, width, leftW, sideW)
|
|
}
|
|
|
|
func (m monitorModel) renderSettingsDashboard(width int) string {
|
|
leftW, sideW := splitWidths(width)
|
|
panelH := m.mainViewportHeight(width)
|
|
|
|
rows := []string{
|
|
fmt.Sprintf("Refresh interval: %s", m.interval),
|
|
fmt.Sprintf("Network page size: %d", m.pageSize),
|
|
fmt.Sprintf("History range: %s", m.historyRange.Label),
|
|
fmt.Sprintf("Telegram bot: %s", m.telegramStatusSummary()),
|
|
fmt.Sprintf("TUI locker: %s", m.lockerStatusSummary()),
|
|
fmt.Sprintf("Locker password: %s", ternary(strings.TrimSpace(m.lockerHash) == "", "not set (Enter to set)", "set (Enter to replace)")),
|
|
}
|
|
|
|
var body strings.Builder
|
|
body.WriteString("Use up/down to select setting, left/right (+/-) to change.\n")
|
|
body.WriteString("These settings are persisted to local config.\n\n")
|
|
for i, row := range rows {
|
|
prefix := " "
|
|
if i == m.settingsCursor {
|
|
prefix = ">"
|
|
}
|
|
body.WriteString(fmt.Sprintf("%s %s\n", prefix, row))
|
|
}
|
|
body.WriteString("\n")
|
|
body.WriteString("Hints:\n")
|
|
body.WriteString("- press `tab` to cycle Overview -> Clusters -> Network -> Settings\n")
|
|
body.WriteString("- press `o` for Overview, `c` for Clusters, `n` for Network, `t` for Console\n")
|
|
body.WriteString("- select 'Locker password' and press Enter to set/replace password\n")
|
|
body.WriteString("- configure Telegram credentials in console:\n")
|
|
body.WriteString(" bot telegram set --token <token> --allow <telegram_id> --allow <telegram_id>")
|
|
|
|
main, _ := renderFixedPanel(panelStyle, "Settings", strings.TrimRight(body.String(), "\n"), leftW, panelH, m.contentScroll)
|
|
side := ""
|
|
if sideW > 0 {
|
|
side, _ = renderFixedPanel(panelStyle, "Tips & Activity", m.activityPanelBody(sideW), sideW, panelH, m.contentScroll)
|
|
}
|
|
return composeWithSidebar(main, side, width, leftW, sideW)
|
|
}
|
|
|
|
func (m monitorModel) docsViewportHeight() int {
|
|
h := m.height - 20
|
|
if h < 10 {
|
|
h = 10
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (m monitorModel) renderDocsDashboard(width int) string {
|
|
panelH := m.mainViewportHeight(width)
|
|
innerWidth := max(26, width-8)
|
|
lines := docsStyledLines(innerWidth)
|
|
viewport := m.docsViewportHeight()
|
|
total := len(lines)
|
|
offset := m.docsScroll
|
|
maxOffset := total - viewport
|
|
if maxOffset < 0 {
|
|
maxOffset = 0
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
if offset > maxOffset {
|
|
offset = maxOffset
|
|
}
|
|
end := offset + viewport
|
|
if end > total {
|
|
end = total
|
|
}
|
|
view := append([]string(nil), lines[offset:end]...)
|
|
for len(view) < viewport {
|
|
view = append(view, "")
|
|
}
|
|
progress := "top"
|
|
if total > 0 {
|
|
progress = fmt.Sprintf("lines %d-%d/%d", offset+1, end, total)
|
|
}
|
|
meta := dimStyle.Render("scroll: ↑/↓ or j/k, PgUp/PgDn, Home/End") + " · " + brightStyle.Render(progress)
|
|
body := meta + "\n\n" + strings.Join(view, "\n")
|
|
panel, _ := renderFixedPanel(panelStyle, "Docs", body, width, panelH, 0)
|
|
return panel
|
|
}
|
|
|
|
func docsStyledLines(width int) []string {
|
|
raw := docsRawLines()
|
|
out := make([]string, 0, len(raw))
|
|
docTitle := lipgloss.NewStyle().Bold(true).Foreground(ccAccent)
|
|
docSection := lipgloss.NewStyle().Bold(true).Foreground(ccTextBright)
|
|
docSubsection := lipgloss.NewStyle().Bold(true).Foreground(ccAccent)
|
|
docCode := lipgloss.NewStyle().Foreground(ccTextBright)
|
|
for _, line := range raw {
|
|
trimmed := strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(line, "### "):
|
|
out = append(out, docSubsection.Render(strings.TrimPrefix(line, "### ")))
|
|
case strings.HasPrefix(line, "## "):
|
|
out = append(out, docSection.Render(strings.TrimPrefix(line, "## ")))
|
|
case strings.HasPrefix(line, "# "):
|
|
out = append(out, docTitle.Render(strings.TrimPrefix(line, "# ")))
|
|
case strings.HasPrefix(line, " "):
|
|
out = append(out, softStyle.Render(truncate(line, width)))
|
|
case strings.HasPrefix(trimmed, "`") && strings.HasSuffix(trimmed, "`"):
|
|
code := strings.TrimSuffix(strings.TrimPrefix(trimmed, "`"), "`")
|
|
out = append(out, docCode.Render(code))
|
|
case strings.HasPrefix(trimmed, "- "):
|
|
out = append(out, "• "+strings.TrimPrefix(trimmed, "- "))
|
|
case trimmed == "":
|
|
out = append(out, "")
|
|
default:
|
|
out = append(out, truncate(line, width))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func docsRawLines() []string {
|
|
return []string{
|
|
"# PXmon (Phylex Monitor) Docs",
|
|
"Fullscreen command reference with command variants and examples.",
|
|
"",
|
|
"## Navigation",
|
|
"- Open docs page: `d` in TUI or `docs` in command console.",
|
|
"- Scroll: `↑/↓`, `j/k`, `PgUp/PgDn`, `Home/End`.",
|
|
"- Cycle views: `tab`.",
|
|
"- Quick jump: `o` overview, `c` clusters, `n` network, `s` settings.",
|
|
"",
|
|
"## Root Commands",
|
|
"`pxmon cluster ...`",
|
|
"`pxmon tui`",
|
|
"`pxmon clusters`",
|
|
"`pxmon network`",
|
|
"`pxmon bot telegram ...`",
|
|
"`pxmon locker ...`",
|
|
"`pxmon config export|import ...`",
|
|
"`pxmon explain [--find <text>]`",
|
|
"",
|
|
"## 🧭 Cluster Command Map",
|
|
"`pxmon cluster connect|add ...`",
|
|
"`pxmon cluster list|ls`",
|
|
"`pxmon cluster show|get [cluster]`",
|
|
"`pxmon cluster current`",
|
|
"`pxmon cluster use <cluster>`",
|
|
"`pxmon cluster disconnect|remove|rm <cluster>`",
|
|
"`pxmon cluster set-auth|auth|password|passwd <cluster> ...`",
|
|
"`pxmon cluster openssh|ssh [cluster]`",
|
|
"`pxmon cluster ping|check [cluster] [--agent]`",
|
|
"`pxmon cluster bootstrap [cluster]`",
|
|
"`pxmon cluster agent status|update|versions [cluster]`",
|
|
"`pxmon cluster stats [cluster] [--once]`",
|
|
"`pxmon cluster usage [cluster] --range <live|1h|1d|1mo|all>`",
|
|
"`pxmon cluster traffic [cluster] --range <1h|1d|1mo|all>`",
|
|
"`pxmon cluster graph [cluster] --range <1d|1mo|all> [--iface <iface>] [--out <file.png>]`",
|
|
"`pxmon cluster p95 [cluster] --iface <iface> --range <duration>`",
|
|
"",
|
|
"## 🔐 Connect Cluster Variants",
|
|
"### SSH key auth",
|
|
"`pxmon cluster connect --name eu-1 --host 10.0.0.10 --user root --auth key --key-path ~/.ssh/id_ed25519 --key-passphrase-file ~/.ssh/id_ed25519.pxmonpassphrase`",
|
|
"### Password auth (store password)",
|
|
"`pxmon cluster connect --name eu-2 --host 10.0.0.20 --user root --auth password --password 'secret' --store-password`",
|
|
"### ipfabric transport + password auth",
|
|
"`pxmon cluster connect --name vm-host --host 198.51.100.30 --user root --type ipfabric --auth password --password 'example-pass' --store-password`",
|
|
"### Insecure host key mode",
|
|
"`pxmon cluster connect --name lab-1 --host 10.0.0.30 --user root --auth key --key-path ~/.ssh/id_ed25519 --insecure-host-key`",
|
|
"### Set active cluster",
|
|
"`pxmon cluster use eu-1`",
|
|
"### Connectivity checks",
|
|
"`pxmon cluster list`",
|
|
"`pxmon cluster ping eu-1`",
|
|
"`pxmon cluster ping eu-1 --agent`",
|
|
"",
|
|
"## Auth Update Variants",
|
|
"### Switch to password auth",
|
|
"`pxmon cluster set-auth eu-1 --auth password --password 'secret' --store-password`",
|
|
"### Switch to key auth",
|
|
"`pxmon cluster set-auth eu-1 --key-path ~/.ssh/id_ed25519 --key-passphrase-file ~/Desktop/passphrase.txt --store-key-passphrase`",
|
|
"### Change transport mode",
|
|
"`pxmon cluster set-auth eu-1 --type ipfabric`",
|
|
"",
|
|
"## Agent Operations",
|
|
"`pxmon cluster bootstrap eu-1`",
|
|
"`pxmon cluster agent status`",
|
|
"`pxmon cluster agent status eu-1`",
|
|
"`pxmon cluster agent update eu-1 --restart-bot=true`",
|
|
"`pxmon cluster agent versions`",
|
|
"",
|
|
"## 🧩 Software Detection",
|
|
"`pxmon cluster software show [cluster]`",
|
|
"`pxmon cluster software scan [cluster]`",
|
|
"",
|
|
"## 📈 Metrics and Network",
|
|
"### Snapshot stats",
|
|
"`pxmon cluster stats eu-1 --once`",
|
|
"`pxmon cluster usage eu-1 --range 1d`",
|
|
"`pxmon cluster traffic eu-1 --range 30d`",
|
|
"`pxmon cluster p95 eu-1 --iface eth0 --range 30d`",
|
|
"`pxmon cluster p95 eu-1 --iface eth0 --range 30d --graph`",
|
|
"`pxmon cluster p95 eu-1 --iface vm2151_net0 --range 30d --graph`",
|
|
"`pxmon cluster graph eu-1 --range 1d --iface vm2151_net0 --out vm2151_net0-1d.png`",
|
|
"",
|
|
"## 🚨 Alert Rules",
|
|
"### Show / set base thresholds",
|
|
"`pxmon cluster alert show [cluster]`",
|
|
"`pxmon cluster alert set eu-1 --cpu 85 --ram 90 --disk 90 --net-mbps 300`",
|
|
"### Sustained network rule variants",
|
|
"`pxmon cluster alert set eu-1 --net-sustain-enabled=true --net-sustain-mbps 500 --net-sustain-mins 60 --net-sustain-cooldown-mins 30 --net-sustain-include net0`",
|
|
"`pxmon cluster alert set eu-1 --net-sustain-iface vm2151_net0 --net-sustain-mbps 500 --net-sustain-mins 60`",
|
|
"`pxmon cluster alert set eu-1 --net-sustain-enabled=true --net-sustain-include net0 --net-sustain-exclude backup`",
|
|
"",
|
|
"## Alert Routing",
|
|
"`pxmon cluster alert-routing show [cluster]`",
|
|
"`pxmon cluster alert-routing set eu-1 --critical-immediate=true --warning-batch-mins 5`",
|
|
"",
|
|
"## VM Alerts",
|
|
"`pxmon cluster alert-vm show [cluster]`",
|
|
"`pxmon cluster alert-vm set eu-1 --enabled --warn-on-shutoff --min-running 118`",
|
|
"`pxmon cluster alert-vm check eu-1`",
|
|
"",
|
|
"## 🏷️ Tags",
|
|
"### Cluster tags",
|
|
"`pxmon cluster tag add eu-1 --tags prod,billing`",
|
|
"`pxmon cluster tag rm eu-1 --tags billing`",
|
|
"`pxmon cluster tag ls eu-1`",
|
|
"### VM tags",
|
|
"`pxmon cluster kvm-tag add eu-1 --vm vm2151 --tags critical,net-heavy`",
|
|
"`pxmon cluster kvm-tag rm eu-1 --vm vm2151 --tags net-heavy`",
|
|
"`pxmon cluster kvm-tag ls eu-1 --vm vm2151`",
|
|
"",
|
|
"## 💾 Backups",
|
|
"### Target management",
|
|
"`pxmon cluster backup target ls`",
|
|
"### Add SFTP target",
|
|
"`pxmon cluster backup target add --name sftp1 --type sftp --sftp-host backup.example.net --sftp-user backup --sftp-password '***' --sftp-base /pxmon`",
|
|
"### Add S3 target",
|
|
"`pxmon cluster backup target add --name s3-main --type s3 --s3-endpoint s3.example.net --s3-region us-east-1 --s3-bucket backups --s3-prefix pxmon --s3-access-key AKIA... --s3-secret-key ... --s3-ssl=true --s3-path-style=true`",
|
|
"### Test / remove target",
|
|
"`pxmon cluster backup target test sftp1`",
|
|
"`pxmon cluster backup target rm sftp1`",
|
|
"### Create backup plan",
|
|
"`pxmon cluster backup plan add --name vm-images --cluster eu-1 --target sftp1 --path /var/lib/libvirt/images --every 6h`",
|
|
"`pxmon cluster backup plan add --name etc-backup --cluster eu-1 --target s3-main --path /etc --path /opt/app/config --every 24h --retain-days 30 --schedule=true`",
|
|
"### Plan list / remove / run",
|
|
"`pxmon cluster backup plan ls`",
|
|
"`pxmon cluster backup plan rm vm-images`",
|
|
"`pxmon cluster backup plan run vm-images`",
|
|
"`pxmon cluster backup run vm-images`",
|
|
"",
|
|
"## Drift and Baseline",
|
|
"`pxmon cluster drift [cluster]`",
|
|
"`pxmon cluster drift baseline set [cluster]`",
|
|
"`pxmon cluster drift baseline show [cluster]`",
|
|
"`pxmon cluster drift ack [cluster] --kind baseline_software --for 24h`",
|
|
"",
|
|
"## 📚 Runbooks",
|
|
"`pxmon cluster runbook list`",
|
|
"`pxmon cluster runbook show vm-health-check`",
|
|
"`pxmon cluster runbook run vm-health-check`",
|
|
"`pxmon cluster runbook rm custom-1`",
|
|
"### Add runbook variants",
|
|
"`pxmon cluster runbook add --id custom-1 --name 'Custom' --step 'Agent|cluster agent status' --step 'Drift|cluster drift'`",
|
|
"`pxmon cluster runbook add --edit`",
|
|
"`pxmon cluster runbook add --from /tmp/pxmon-runbook.json`",
|
|
"",
|
|
"## Runbook Triggers",
|
|
"`pxmon cluster runbook-trigger show [cluster]`",
|
|
"`pxmon cluster runbook-trigger set eu-1 --enabled --on-vm-shutoff --runbook-id vm-health-check --cooldown-mins 30`",
|
|
"",
|
|
"## ⏱️ Scheduler",
|
|
"`pxmon cluster schedule ls`",
|
|
"`pxmon cluster schedule add --name audit --mode observer --cmd 'cluster drift eu-1' --every 30m --backoff 30s --jitter-sec 5 --retry-max 3`",
|
|
"`pxmon cluster schedule add --name mk --cluster eu-1 --mode shell --cmd 'mkdir -p /tmp/test' --every 10m`",
|
|
"`pxmon cluster schedule add --edit`",
|
|
"`pxmon cluster schedule add --from /tmp/pxmon-schedule.json`",
|
|
"`pxmon cluster schedule rm audit`",
|
|
"`pxmon cluster schedule run-due`",
|
|
"`pxmon cluster schedule start --interval 30s`",
|
|
"`pxmon cluster schedule stop`",
|
|
"`pxmon cluster schedule status`",
|
|
"`pxmon cluster schedule logs --tail 200`",
|
|
"`pxmon cluster schedule worker --interval 30s`",
|
|
"",
|
|
"## 📦 Reports, SLO, Capacity, Changes",
|
|
"`pxmon cluster report export --format json --out ./cluster-report.json`",
|
|
"`pxmon cluster report export --format csv --out ./cluster-report.csv`",
|
|
"`pxmon cluster slo eu-1 --range 30d`",
|
|
"`pxmon cluster capacity forecast eu-1 --range 30d`",
|
|
"`pxmon cluster change-history --tail 100`",
|
|
"",
|
|
"## Telegram Bot",
|
|
"`pxmon bot telegram show`",
|
|
"`pxmon bot telegram set --token <token> --allow 123456789 --allow 987654321`",
|
|
"`pxmon bot telegram run`",
|
|
"",
|
|
"## Config Export and Import",
|
|
"`pxmon config export --out ./pxmon-export.enc`",
|
|
"`pxmon config import ./pxmon-export.enc`",
|
|
"",
|
|
"## TUI Console Built-ins",
|
|
"`help`, `history`, `clear`, `overview`, `clusters`, `network`, `settings`, `docs`, `quit`",
|
|
"`console full`, `console dock`, `console toggle`",
|
|
"",
|
|
"## Backup Connection Model",
|
|
"- Archive is built on remote cluster over SSH (`tar` stream).",
|
|
"- Upload to SFTP/S3 is performed from the machine running `pxmon`.",
|
|
"",
|
|
"## Note",
|
|
"This page focuses on command variants and examples.",
|
|
"Use `pxmon cluster help` for short quick-help output.",
|
|
}
|
|
}
|
|
|
|
func (m monitorModel) networkDetailsBody(pageRows []networkRow, width int) string {
|
|
if len(pageRows) == 0 {
|
|
return dimStyle.Render("no interface selected")
|
|
}
|
|
if m.cursor < 0 || m.cursor >= len(pageRows) {
|
|
return dimStyle.Render("no interface selected")
|
|
}
|
|
row := pageRows[m.cursor]
|
|
points := m.history[row.Interface]
|
|
if len(points) == 0 {
|
|
return fmt.Sprintf("Interface: %s\nNo history points yet. Wait a few refresh cycles.", row.Interface)
|
|
}
|
|
|
|
series := metricSeries(points, m.metric)
|
|
cur := metricCurrent(row, m.metric)
|
|
stats := computeMetricStats(points, m.metric)
|
|
last24h := metricUsageBytesSince(points, m.metric, time.Now().UTC().Add(-24*time.Hour))
|
|
last30d := metricUsageBytesSince(points, m.metric, time.Now().UTC().Add(-30*24*time.Hour))
|
|
graphW := max(20, width-12)
|
|
graphH := 10
|
|
var b strings.Builder
|
|
ifaceLabel := row.Interface
|
|
if row.Interface == m.pinnedIface {
|
|
ifaceLabel += " " + accentStyle.Render("[locked]")
|
|
}
|
|
b.WriteString(fmt.Sprintf("Interface: %s\n", ifaceLabel))
|
|
b.WriteString(fmt.Sprintf("Metric: %s | Current: %.2f Mbps | Samples: %d\n", metricLabel(m.metric), cur, stats.Samples))
|
|
b.WriteString(fmt.Sprintf("Usage 24h: %s | Usage 30d: %s\n", humanBytes(last24h), humanBytes(last30d)))
|
|
b.WriteString(fmt.Sprintf("Peak: %.2f Mbps | Peak hold: %s\n", stats.PeakMbps, stats.PeakDuration.Truncate(time.Second)))
|
|
b.WriteString(renderBigGraphASCII(series, graphW, graphH))
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
func (m monitorModel) renderAlerts(width int) string {
|
|
alerts := collectAlerts(m.stats, &m.runtime)
|
|
if len(alerts) == 0 {
|
|
return renderPanel("Alerts", okStyle.Render("none"), width)
|
|
}
|
|
|
|
var b strings.Builder
|
|
maxRows := 7
|
|
if len(alerts) < maxRows {
|
|
maxRows = len(alerts)
|
|
}
|
|
for i := 0; i < maxRows; i++ {
|
|
b.WriteString("- ")
|
|
b.WriteString(truncate(alerts[i], width-8))
|
|
b.WriteByte('\n')
|
|
}
|
|
if len(alerts) > maxRows {
|
|
b.WriteString(dimStyle.Render(fmt.Sprintf("+%d more", len(alerts)-maxRows)))
|
|
}
|
|
return renderPanel("Alerts", strings.TrimRight(b.String(), "\n"), width)
|
|
}
|
|
|
|
func (m monitorModel) renderBottomLine(width int) string {
|
|
prompt := ""
|
|
switch m.inputMode {
|
|
case inputSearch:
|
|
prompt = "/" + m.inputBuf
|
|
case inputLockerNew, inputLockerConfirm:
|
|
prompt = "locker password: " + strings.Repeat("*", len([]rune(m.inputBuf)))
|
|
}
|
|
|
|
help := "keys: t console | ctrl+t fullscreen console | / search(network) | tab switch view | d docs | c clusters | u usage | s settings | r refresh | q quit"
|
|
if m.view == viewOverview {
|
|
help = "keys: left/right iface | tab switch view | d docs | c clusters | u usage | s settings | t console | ctrl+t fullscreen | r refresh | q quit"
|
|
} else if m.view == viewClusters {
|
|
help = "keys: tab switch view | o overview | n network | u usage | s settings | d docs | t console | ctrl+t fullscreen | r refresh | q quit"
|
|
} else if m.view == viewNetwork {
|
|
help = "keys: up/down row | left/right or 1/2/3 metric | n/p page | / search | d docs | u usage | c clusters | s settings | t console | ctrl+t fullscreen | r refresh | q quit"
|
|
} else if m.view == viewSettings {
|
|
help = "keys: up/down setting | left/right adjust | tab switch view | d docs | c clusters | u usage | t console | ctrl+t fullscreen | q quit"
|
|
} else if m.view == viewDocs {
|
|
help = "keys: ↑/↓ or j/k scroll | PgUp/PgDn | Home/End | o/c/n/s switch view | tab switch | t console | q quit"
|
|
} else if m.view == viewUsage {
|
|
help = "keys: 1 live | 2 1h | 3 1d | 4 1mo | 5 all | r refresh | o back | t console | ctrl+t fullscreen | q quit"
|
|
}
|
|
if m.termMode {
|
|
help = "console mode: PXmon (Phylex Monitor) commands | console full/dock/toggle | end line with \\ to continue | alt+←/→ word move | !<cmd> shell escape | ctrl+g or ctrl+t back"
|
|
}
|
|
if m.inputMode == inputSearch {
|
|
help = "search mode: type query text | Enter apply | Esc cancel"
|
|
} else if m.inputMode == inputLockerNew {
|
|
help = "locker setup: type new password | Enter continue | Esc cancel"
|
|
} else if m.inputMode == inputLockerConfirm {
|
|
help = "locker setup: confirm password | Enter save | Esc cancel"
|
|
}
|
|
|
|
parts := []string{}
|
|
if prompt != "" {
|
|
parts = append(parts, prompt)
|
|
}
|
|
if m.statusMsg != "" {
|
|
parts = append(parts, m.statusMsg)
|
|
} else {
|
|
parts = append(parts, help)
|
|
}
|
|
line := strings.Join(parts, " · ")
|
|
hint := softStyle.Render("?") + dimStyle.Render(" for shortcuts")
|
|
body := " " + hint + " " + dimStyle.Render("·") + " " + dimStyle.Render(truncate(line, max(10, width-24)))
|
|
return body
|
|
}
|
|
|
|
func (m monitorModel) renderConsoleInput() string {
|
|
prompt := accentStyle.Render("> ")
|
|
if strings.TrimSpace(m.termCont) != "" {
|
|
prompt = warnStyle.Render("CONT> ")
|
|
}
|
|
runes := []rune(m.termPartial)
|
|
cursor := m.termCursor
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if cursor > len(runes) {
|
|
cursor = len(runes)
|
|
}
|
|
|
|
// Keep cursor editing stable for mid-line cursor positions; highlight when
|
|
// the cursor is at end (common case) to avoid ANSI width/cursor drift.
|
|
if cursor == len(runes) {
|
|
line := highlightConsoleInput(m.termPartial)
|
|
return prompt + line + lipgloss.NewStyle().Reverse(true).Render(" ")
|
|
}
|
|
left := string(runes[:cursor])
|
|
cur := string(runes[cursor])
|
|
right := string(runes[cursor+1:])
|
|
return prompt + left + lipgloss.NewStyle().Reverse(true).Render(cur) + right
|
|
}
|
|
|
|
func (m monitorModel) mainViewportHeight(width int) int {
|
|
if m.height <= 0 {
|
|
return 18
|
|
}
|
|
header := m.renderHeader(width)
|
|
dock := m.renderCommandDock(width)
|
|
h := m.contentBudget(header, dock)
|
|
if h < 8 {
|
|
h = 8
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (m monitorModel) submitConsoleLine() (tea.Model, tea.Cmd) {
|
|
raw := m.termPartial
|
|
line := strings.TrimSpace(raw)
|
|
|
|
m.termPartial = ""
|
|
m.termCursor = 0
|
|
m.termHistPos = -1
|
|
m.termDraft = ""
|
|
|
|
if line == "" && strings.TrimSpace(m.termCont) != "" {
|
|
line = strings.TrimSpace(m.termCont)
|
|
m.termCont = ""
|
|
}
|
|
if line == "" {
|
|
return m, nil
|
|
}
|
|
|
|
normalizedLine := strings.Join(strings.Fields(line), " ")
|
|
now := time.Now()
|
|
if normalizedLine == m.termLastSubmit && now.Sub(m.termLastSubmitAt) < 2500*time.Millisecond {
|
|
m.setStatus("ignored duplicate command")
|
|
return m, nil
|
|
}
|
|
m.termLastSubmit = normalizedLine
|
|
m.termLastSubmitAt = now
|
|
|
|
if strings.HasSuffix(strings.TrimRight(line, " \t"), "\\") {
|
|
part := strings.TrimSpace(strings.TrimSuffix(strings.TrimRight(line, " \t"), "\\"))
|
|
if part != "" {
|
|
if strings.TrimSpace(m.termCont) == "" {
|
|
m.termCont = part
|
|
} else {
|
|
m.termCont += " " + part
|
|
}
|
|
}
|
|
m.pushTerminalLine(accentStyle.Bold(true).Render("… ") + softStyle.Render(part+" \\"))
|
|
m.setStatus("line continued (finish command and press Enter)")
|
|
return m, nil
|
|
}
|
|
if strings.TrimSpace(m.termCont) != "" {
|
|
line = strings.TrimSpace(m.termCont + " " + line)
|
|
m.termCont = ""
|
|
}
|
|
|
|
m.pushTerminalLine(accentStyle.Bold(true).Render("> ") + brightStyle.Render(line))
|
|
m.pushConsoleHistory(line)
|
|
|
|
normalized := strings.Join(strings.Fields(strings.ToLower(line)), " ")
|
|
switch normalized {
|
|
case "clear", "cls":
|
|
m.termLines = nil
|
|
m.setStatus("console cleared")
|
|
return m, nil
|
|
case "history":
|
|
if len(m.termHistory) == 0 {
|
|
m.pushTerminalLine("history is empty")
|
|
return m, nil
|
|
}
|
|
for i, h := range m.termHistory {
|
|
m.pushTerminalLine(fmt.Sprintf("%3d %s", i+1, h))
|
|
}
|
|
return m, nil
|
|
case "help", "?":
|
|
m.appendConsoleOutput(consoleHelpText())
|
|
return m, nil
|
|
case "exit", "quit":
|
|
m.termMode = false
|
|
m.termFull = false
|
|
m.setStatus("console hidden")
|
|
return m, nil
|
|
case "privacy":
|
|
m.privacyMode = !m.privacyMode
|
|
if m.privacyMode {
|
|
m.pushTerminalLine(okStyle.Render("privacy mode: ON") + dimStyle.Render(" (IPs, hostnames, tokens, secrets are redacted)"))
|
|
} else {
|
|
m.pushTerminalLine(warnStyle.Render("privacy mode: OFF"))
|
|
}
|
|
return m, nil
|
|
case "settings", "config":
|
|
m.view = viewSettings
|
|
m.setStatus("opened settings")
|
|
return m, nil
|
|
case "overview":
|
|
m.view = viewOverview
|
|
m.setStatus("opened overview")
|
|
return m, nil
|
|
case "clusters", "cluster-overview":
|
|
m.view = viewClusters
|
|
m.setStatus("opened cluster overview")
|
|
return m, nil
|
|
case "network":
|
|
m.view = viewNetwork
|
|
m.setStatus("opened network")
|
|
return m, nil
|
|
case "docs", "manual":
|
|
m.view = viewDocs
|
|
m.setStatus("opened docs")
|
|
return m, nil
|
|
case "console full", "console fullscreen":
|
|
m.termFull = true
|
|
m.setStatus("console fullscreen")
|
|
return m, nil
|
|
case "console dock":
|
|
m.termFull = false
|
|
m.setStatus("console docked")
|
|
return m, nil
|
|
case "console toggle":
|
|
m.termFull = !m.termFull
|
|
if m.termFull {
|
|
m.setStatus("console fullscreen")
|
|
} else {
|
|
m.setStatus("console docked")
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
if strings.HasPrefix(line, "!") {
|
|
shellLine := strings.TrimSpace(strings.TrimPrefix(line, "!"))
|
|
if shellLine == "" {
|
|
m.pushTerminalLine("usage: !<shell command>")
|
|
return m, nil
|
|
}
|
|
m.termBusy = true
|
|
m.thinkFrame = 0
|
|
m.spinnerStart = time.Now()
|
|
m.termExecSeq++
|
|
m.termExecActive = m.termExecSeq
|
|
m.setStatus("running shell escape")
|
|
cmds := []tea.Cmd{m.runShellEscapeCmd(shellLine, m.termExecActive)}
|
|
if !m.spinnerActive {
|
|
m.spinnerActive = true
|
|
cmds = append(cmds, spinnerTickCmd())
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
if parsed, perr := parseShellArgs(line); perr == nil && len(parsed) > 0 {
|
|
norm := normalizeObserverConsoleArgs(parsed)
|
|
if len(norm) >= 2 && strings.EqualFold(norm[0], "cluster") &&
|
|
(strings.EqualFold(norm[1], "openssh") || strings.EqualFold(norm[1], "ssh")) {
|
|
return m.startSSHSessionCmd(line, norm[2:])
|
|
}
|
|
// Live streaming for plugin commands: `kvm top --live`,
|
|
// `frr bgp --live`, etc. Opens a fullscreen dashboard that
|
|
// re-runs the command on a tick and replaces the buffer.
|
|
if spec, ok := detectLivePluginInvocation(parsed); ok {
|
|
return m.startLiveCmd(spec)
|
|
}
|
|
}
|
|
|
|
m.termBusy = true
|
|
m.thinkFrame = 0
|
|
m.spinnerStart = time.Now()
|
|
m.termExecSeq++
|
|
m.termExecActive = m.termExecSeq
|
|
m.setStatus("running command")
|
|
cmds := []tea.Cmd{m.runObserverConsoleCmd(line, m.termExecActive)}
|
|
if !m.spinnerActive {
|
|
m.spinnerActive = true
|
|
cmds = append(cmds, spinnerTickCmd())
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m monitorModel) startSSHSessionCmd(originalLine string, args []string) (tea.Model, tea.Cmd) {
|
|
selector := ""
|
|
if len(args) > 0 {
|
|
first := strings.TrimSpace(args[0])
|
|
if first != "" && !strings.HasPrefix(first, "-") {
|
|
selector = first
|
|
}
|
|
}
|
|
|
|
c, err := m.svc.Get(selector)
|
|
if err != nil {
|
|
m.appendConsoleOutput("openssh: " + err.Error())
|
|
m.setStatus("openssh: " + err.Error())
|
|
return m, nil
|
|
}
|
|
|
|
banner := fmt.Sprintf("dialing ssh: %s@%s:%d", c.User, c.Host, c.Port)
|
|
m.appendConsoleOutput(banner)
|
|
m.setStatus(banner)
|
|
|
|
// Leave console mode so the embedded view owns the screen.
|
|
m.termMode = false
|
|
m.termFull = false
|
|
|
|
return m, m.startEmbeddedSSHCmd(c.ID)
|
|
}
|
|
|
|
func (m monitorModel) runObserverConsoleCmd(line string, id int64) tea.Cmd {
|
|
configPath := m.svc.ConfigPath()
|
|
return func() tea.Msg {
|
|
out, code := runObserverScopedCommand(m.svc, configPath, line, observerCommandOptions{
|
|
AllowShellEscape: false,
|
|
StatsAutoOnce: false,
|
|
BlockBotRun: false,
|
|
EmbeddedConsole: true,
|
|
})
|
|
return consoleExecResultMsg{
|
|
ID: id,
|
|
Command: line,
|
|
Output: out,
|
|
ExitCode: code,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m monitorModel) runPluginConsole(args []string) (bool, string, int) {
|
|
return runPluginArgs(m.svc, args)
|
|
}
|
|
|
|
func parseClusterSelectorArg(args []string) (string, []string, error) {
|
|
if len(args) == 0 {
|
|
return "", nil, nil
|
|
}
|
|
|
|
out := make([]string, 0, len(args))
|
|
selector := ""
|
|
for i := 0; i < len(args); i++ {
|
|
item := strings.TrimSpace(args[i])
|
|
switch {
|
|
case item == "--cluster" || item == "-c":
|
|
if i+1 >= len(args) {
|
|
return "", nil, errors.New("missing value for --cluster")
|
|
}
|
|
selector = strings.TrimSpace(args[i+1])
|
|
i++
|
|
case strings.HasPrefix(item, "--cluster="):
|
|
selector = strings.TrimSpace(strings.TrimPrefix(item, "--cluster="))
|
|
default:
|
|
out = append(out, args[i])
|
|
}
|
|
}
|
|
|
|
return selector, out, nil
|
|
}
|
|
|
|
func (m monitorModel) runShellEscapeCmd(line string, id int64) tea.Cmd {
|
|
cmdline := strings.TrimSpace(line)
|
|
return func() tea.Msg {
|
|
cmd := exec.Command("/bin/sh", "-lc", cmdline)
|
|
out, err := cmd.CombinedOutput()
|
|
code := 0
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
code = exitErr.ExitCode()
|
|
} else {
|
|
code = 1
|
|
if len(out) == 0 {
|
|
out = []byte(err.Error())
|
|
}
|
|
}
|
|
}
|
|
return consoleExecResultMsg{
|
|
ID: id,
|
|
Command: "!" + cmdline,
|
|
Output: string(out),
|
|
ExitCode: code,
|
|
}
|
|
}
|
|
}
|
|
|
|
func runObserverCommand(configPath string, args []string, embeddedConsole bool) (string, int) {
|
|
if len(args) == 0 {
|
|
return "", 0
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(args[0])) {
|
|
case "shell", "tui", "network", "clusters":
|
|
return "interactive screens are already open in TUI; use UI hotkeys", 2
|
|
case "bot":
|
|
if len(args) >= 3 && strings.EqualFold(args[1], "telegram") && strings.EqualFold(args[2], "run") {
|
|
return "run `bot telegram run` in a standalone terminal session", 2
|
|
}
|
|
case "cluster":
|
|
if len(args) > 1 && strings.EqualFold(args[1], "stats") && !hasArg(args[2:], "--once") {
|
|
return "use UI overview/network for realtime stats; use `cluster stats --once` for snapshot", 2
|
|
}
|
|
if len(args) > 1 && (strings.EqualFold(args[1], "openssh") || strings.EqualFold(args[1], "ssh")) {
|
|
return "run `cluster openssh` directly from the console — it requires an interactive terminal", 2
|
|
}
|
|
}
|
|
|
|
var outBuf bytes.Buffer
|
|
var errBuf bytes.Buffer
|
|
runner := New(&outBuf, &errBuf)
|
|
prevEmbedded := os.Getenv("PXMON_EMBEDDED_CONSOLE")
|
|
if embeddedConsole {
|
|
_ = os.Setenv("PXMON_EMBEDDED_CONSOLE", "1")
|
|
}
|
|
defer func() {
|
|
if embeddedConsole {
|
|
if prevEmbedded == "" {
|
|
_ = os.Unsetenv("PXMON_EMBEDDED_CONSOLE")
|
|
} else {
|
|
_ = os.Setenv("PXMON_EMBEDDED_CONSOLE", prevEmbedded)
|
|
}
|
|
}
|
|
}()
|
|
code := runner.runRootCommand(args, configPath, false, false)
|
|
|
|
out := strings.TrimSpace(outBuf.String())
|
|
errText := strings.TrimSpace(errBuf.String())
|
|
switch {
|
|
case out != "" && errText != "":
|
|
return out + "\n" + errText, code
|
|
case errText != "":
|
|
return errText, code
|
|
default:
|
|
return out, code
|
|
}
|
|
}
|
|
|
|
func normalizeObserverConsoleArgs(args []string) []string {
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
if strings.EqualFold(args[0], "pxmon") || strings.EqualFold(args[0], "pxmon") {
|
|
args = args[1:]
|
|
}
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
|
|
head := strings.ToLower(strings.TrimSpace(args[0]))
|
|
switch head {
|
|
case "qvmtop":
|
|
return []string{"kvm", "top"}
|
|
case "qvmtoplive":
|
|
return []string{"kvm", "top", "--live"}
|
|
case "qvmnettop":
|
|
return []string{"kvm", "net-top"}
|
|
case "qvmnettoplive":
|
|
return []string{"kvm", "net-top", "--live"}
|
|
case "alert", "alerts":
|
|
if len(args) == 1 {
|
|
return []string{"cluster", "alert", "show"}
|
|
}
|
|
return append([]string{"cluster", "alert"}, args[1:]...)
|
|
case "software", "plugins":
|
|
return append([]string{"cluster", "software"}, args[1:]...)
|
|
case "clusters", "nodes":
|
|
return []string{"cluster", "list"}
|
|
case "connect":
|
|
if len(args) == 2 && !strings.HasPrefix(args[1], "-") {
|
|
return []string{"cluster", "use", args[1]}
|
|
}
|
|
return append([]string{"cluster", "connect"}, args[1:]...)
|
|
case "ping", "check", "list", "ls", "show", "get", "current", "use",
|
|
"bootstrap", "stats", "disconnect", "remove", "rm", "openssh", "ssh",
|
|
"set-auth", "password", "passwd", "usage", "traffic", "graph":
|
|
return append([]string{"cluster"}, args...)
|
|
case "cluster":
|
|
if len(args) == 3 && strings.EqualFold(args[1], "connect") && !strings.HasPrefix(args[2], "-") {
|
|
return []string{"cluster", "use", args[2]}
|
|
}
|
|
return args
|
|
default:
|
|
return args
|
|
}
|
|
}
|
|
|
|
func hasArg(args []string, token string) bool {
|
|
for _, a := range args {
|
|
if strings.EqualFold(strings.TrimSpace(a), token) || strings.HasPrefix(strings.ToLower(a), strings.ToLower(token)+"=") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func consoleHelpText() string {
|
|
return strings.Join([]string{
|
|
"Observer Console commands:",
|
|
" cluster list",
|
|
" cluster use <name>",
|
|
" cluster ping <name> [--agent]",
|
|
" cluster connect --name eu-1 --host 10.0.0.10 --port 22 --user root --auth key --key-path ~/.ssh/id_ed25519 --key-passphrase-file ~/.ssh/id_ed25519.pxmonpassphrase",
|
|
" cluster connect --name vm-host --host 1.2.3.4 --user root --type ipfabric --auth password --password 'pw' --store-password",
|
|
" cluster bootstrap <name>",
|
|
" cluster openssh <name> (alias: cluster ssh) — interactive SSH in this TUI",
|
|
" cluster set-auth <name> --auth password --password 'example-pass' --store-password",
|
|
" cluster set-auth <name> --key-path ~/.ssh/id_ed25519 --key-passphrase-file ~/Desktop/passphrase.txt --store-key-passphrase",
|
|
" cluster set-auth <name> --type ipfabric (enable SSH-tunneled agent calls)",
|
|
" cluster repo-tunnel install <name> --gateway <ip:3128> --table <table> -- dnf install -y curl jq",
|
|
" cluster usage [name] [--range live|1h|1d|1mo|all] [--du /] (billing + top procs/folders)",
|
|
" cluster traffic [name] [--range 1h|1d|1mo|all] (P95 text summary)",
|
|
" cluster graph [name] [--range 1d|1mo|all] [--out file.png] (PNG chart with P95 line)",
|
|
" docs (open full docs page)",
|
|
" console full | console dock | console toggle (Command Console layout)",
|
|
" config export ./backup.enc (encrypted passphrase-protected bundle)",
|
|
" config import ./backup.enc [--replace]",
|
|
" alerts set <name> --net-mbps 300 --ram 90 --disk 90",
|
|
" alerts set <name> --net-sustain-enabled=true --net-sustain-mbps 500 --net-sustain-mins 60 --net-sustain-include net0 --net-sustain-exclude backup",
|
|
" cluster software scan <name>",
|
|
" bot telegram show",
|
|
" bot telegram set --token <token> --allow <telegram_id> --allow <telegram_id>",
|
|
"",
|
|
"Aliases:",
|
|
" ping <name> -> cluster ping <name>",
|
|
" alerts ... -> cluster alert ...",
|
|
" connect <name> -> cluster use <name>",
|
|
" software ... -> cluster software ...",
|
|
"",
|
|
"Plugin commands (SSH on selected cluster):",
|
|
" kvm list | kvm start <domain> | kvm stop <domain> | kvm reboot <domain>",
|
|
" kvm top | kvm net-top",
|
|
" lxc list | lxc start <name> | lxc stop <name> | lxc restart <name> | lxc stats <name>",
|
|
" lxc top | lxc net-top",
|
|
" lxd list | lxd start <name> | lxd stop <name> | lxd restart <name> | lxd stats <name>",
|
|
" lxd top | lxd net-top",
|
|
" bird status | bird protocols | bird routes",
|
|
" frr status | frr routes | frr bgp | frr ospf",
|
|
" add --cluster <name> to run on non-active cluster",
|
|
"",
|
|
"Built-ins:",
|
|
" help, history, clear, overview, clusters, network, settings, docs, quit",
|
|
" line continuation: end line with \\; press Enter on empty line to run",
|
|
" edit: alt+left/right (or alt+b/alt+f) moves by word",
|
|
"",
|
|
"Shell escape (secondary):",
|
|
" !ls -la",
|
|
" !ssh root@host",
|
|
}, "\n")
|
|
}
|
|
|
|
func (m *monitorModel) appendConsoleOutput(text string) {
|
|
if strings.TrimSpace(text) == "" {
|
|
return
|
|
}
|
|
clean := sanitizeTerminalChunk(text)
|
|
clean = strings.ReplaceAll(clean, "\r\n", "\n")
|
|
clean = strings.ReplaceAll(clean, "\r", "\n")
|
|
raw := strings.Split(clean, "\n")
|
|
for len(raw) > 0 && strings.TrimSpace(raw[len(raw)-1]) == "" {
|
|
raw = raw[:len(raw)-1]
|
|
}
|
|
branch := softStyle.Render(" ⎿ ")
|
|
indent := " "
|
|
for i, line := range raw {
|
|
if i == 0 {
|
|
m.pushTerminalLine(branch + line)
|
|
} else {
|
|
m.pushTerminalLine(indent + line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *monitorModel) scrollConsole(delta int) {
|
|
m.termScroll += delta
|
|
m.clampTermScroll()
|
|
}
|
|
|
|
func (m *monitorModel) clampTermScroll() {
|
|
maxOffset := len(m.termLines) - m.dockOutputHeight()
|
|
if maxOffset < 0 {
|
|
maxOffset = 0
|
|
}
|
|
if m.termScroll > maxOffset {
|
|
m.termScroll = maxOffset
|
|
}
|
|
if m.termScroll < 0 {
|
|
m.termScroll = 0
|
|
}
|
|
}
|
|
|
|
func (m *monitorModel) pushConsoleHistory(line string) {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
return
|
|
}
|
|
if len(m.termHistory) > 0 && m.termHistory[len(m.termHistory)-1] == line {
|
|
return
|
|
}
|
|
m.termHistory = append(m.termHistory, line)
|
|
if len(m.termHistory) > 200 {
|
|
m.termHistory = m.termHistory[len(m.termHistory)-200:]
|
|
}
|
|
m.termHistPos = -1
|
|
m.termDraft = ""
|
|
}
|
|
|
|
func (m *monitorModel) historyUp() {
|
|
if len(m.termHistory) == 0 {
|
|
return
|
|
}
|
|
if m.termHistPos == -1 {
|
|
m.termDraft = m.termPartial
|
|
m.termHistPos = len(m.termHistory) - 1
|
|
} else if m.termHistPos > 0 {
|
|
m.termHistPos--
|
|
}
|
|
m.termPartial = m.termHistory[m.termHistPos]
|
|
m.termCursor = len([]rune(m.termPartial))
|
|
}
|
|
|
|
func (m *monitorModel) historyDown() {
|
|
if len(m.termHistory) == 0 || m.termHistPos == -1 {
|
|
return
|
|
}
|
|
if m.termHistPos < len(m.termHistory)-1 {
|
|
m.termHistPos++
|
|
m.termPartial = m.termHistory[m.termHistPos]
|
|
} else {
|
|
m.termHistPos = -1
|
|
m.termPartial = m.termDraft
|
|
}
|
|
m.termCursor = len([]rune(m.termPartial))
|
|
}
|
|
|
|
func (m *monitorModel) moveConsoleCursor(delta int) {
|
|
size := len([]rune(m.termPartial))
|
|
m.termCursor += delta
|
|
if m.termCursor < 0 {
|
|
m.termCursor = 0
|
|
}
|
|
if m.termCursor > size {
|
|
m.termCursor = size
|
|
}
|
|
}
|
|
|
|
func (m *monitorModel) moveConsoleWord(dir int) {
|
|
r := []rune(m.termPartial)
|
|
n := len(r)
|
|
c := m.termCursor
|
|
if c < 0 {
|
|
c = 0
|
|
}
|
|
if c > n {
|
|
c = n
|
|
}
|
|
if dir < 0 {
|
|
i := c - 1
|
|
for i >= 0 && (r[i] == ' ' || r[i] == '\t') {
|
|
i--
|
|
}
|
|
for i >= 0 && r[i] != ' ' && r[i] != '\t' {
|
|
i--
|
|
}
|
|
m.termCursor = i + 1
|
|
return
|
|
}
|
|
i := c
|
|
for i < n && (r[i] == ' ' || r[i] == '\t') {
|
|
i++
|
|
}
|
|
for i < n && r[i] != ' ' && r[i] != '\t' {
|
|
i++
|
|
}
|
|
m.termCursor = i
|
|
}
|
|
|
|
func (m *monitorModel) insertConsoleText(text string) {
|
|
left := []rune(m.termPartial)
|
|
cursor := m.termCursor
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if cursor > len(left) {
|
|
cursor = len(left)
|
|
}
|
|
|
|
inserted := []rune(text)
|
|
merged := make([]rune, 0, len(left)+len(inserted))
|
|
merged = append(merged, left[:cursor]...)
|
|
merged = append(merged, inserted...)
|
|
merged = append(merged, left[cursor:]...)
|
|
|
|
m.termPartial = string(merged)
|
|
m.termCursor = cursor + len(inserted)
|
|
m.termHistPos = -1
|
|
}
|
|
|
|
func (m *monitorModel) deleteConsolePrev() {
|
|
r := []rune(m.termPartial)
|
|
if m.termCursor <= 0 || len(r) == 0 {
|
|
return
|
|
}
|
|
cursor := m.termCursor
|
|
if cursor > len(r) {
|
|
cursor = len(r)
|
|
}
|
|
r = append(r[:cursor-1], r[cursor:]...)
|
|
m.termPartial = string(r)
|
|
m.termCursor = cursor - 1
|
|
m.termHistPos = -1
|
|
}
|
|
|
|
func (m *monitorModel) deleteConsoleAt() {
|
|
r := []rune(m.termPartial)
|
|
if len(r) == 0 {
|
|
return
|
|
}
|
|
cursor := m.termCursor
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if cursor >= len(r) {
|
|
return
|
|
}
|
|
r = append(r[:cursor], r[cursor+1:]...)
|
|
m.termPartial = string(r)
|
|
m.termCursor = cursor
|
|
m.termHistPos = -1
|
|
}
|
|
|
|
func (m *monitorModel) deleteConsoleToStart() {
|
|
r := []rune(m.termPartial)
|
|
if len(r) == 0 || m.termCursor <= 0 {
|
|
return
|
|
}
|
|
cursor := m.termCursor
|
|
if cursor > len(r) {
|
|
cursor = len(r)
|
|
}
|
|
m.termPartial = string(r[cursor:])
|
|
m.termCursor = 0
|
|
m.termHistPos = -1
|
|
}
|
|
|
|
func (m *monitorModel) deleteConsoleToEnd() {
|
|
r := []rune(m.termPartial)
|
|
if len(r) == 0 {
|
|
return
|
|
}
|
|
cursor := m.termCursor
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if cursor >= len(r) {
|
|
return
|
|
}
|
|
m.termPartial = string(r[:cursor])
|
|
m.termHistPos = -1
|
|
}
|
|
|
|
func (m *monitorModel) deleteConsoleWord() {
|
|
r := []rune(m.termPartial)
|
|
if len(r) == 0 || m.termCursor <= 0 {
|
|
return
|
|
}
|
|
cursor := m.termCursor
|
|
if cursor > len(r) {
|
|
cursor = len(r)
|
|
}
|
|
i := cursor - 1
|
|
for i >= 0 && (r[i] == ' ' || r[i] == '\t') {
|
|
i--
|
|
}
|
|
for i >= 0 && r[i] != ' ' && r[i] != '\t' {
|
|
i--
|
|
}
|
|
start := i + 1
|
|
merged := append([]rune{}, r[:start]...)
|
|
merged = append(merged, r[cursor:]...)
|
|
m.termPartial = string(merged)
|
|
m.termCursor = start
|
|
m.termHistPos = -1
|
|
}
|
|
|
|
func highlightConsoleInput(line string) string {
|
|
line = strings.TrimRight(line, "\r\n")
|
|
if strings.TrimSpace(line) == "" {
|
|
return ""
|
|
}
|
|
isBuiltIn := func(s string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case "help", "history", "clear", "overview", "clusters", "network", "settings", "quit", "exit", "console", "privacy":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
colorToken := func(tok string, pos int) string {
|
|
switch {
|
|
case pos == 0 && isBuiltIn(tok):
|
|
return okStyle.Render(tok)
|
|
case pos == 0:
|
|
return accentStyle.Bold(true).Render(tok)
|
|
case strings.HasPrefix(tok, "--"):
|
|
return accentStyle.Render(tok)
|
|
case strings.HasPrefix(tok, "-"):
|
|
return softStyle.Render(tok)
|
|
case len(tok) >= 2 && ((tok[0] == '"' && tok[len(tok)-1] == '"') || (tok[0] == '\'' && tok[len(tok)-1] == '\'')):
|
|
return warnStyle.Render(tok)
|
|
default:
|
|
return brightStyle.Render(tok)
|
|
}
|
|
}
|
|
|
|
var out strings.Builder
|
|
tokenPos := 0
|
|
for i := 0; i < len(line); {
|
|
r, size := utf8.DecodeRuneInString(line[i:])
|
|
if r == utf8.RuneError && size == 1 {
|
|
out.WriteByte(line[i])
|
|
i++
|
|
continue
|
|
}
|
|
if r == ' ' || r == '\t' {
|
|
out.WriteRune(r)
|
|
i += size
|
|
continue
|
|
}
|
|
j := i
|
|
for j < len(line) {
|
|
rr, ss := utf8.DecodeRuneInString(line[j:])
|
|
if rr == ' ' || rr == '\t' {
|
|
break
|
|
}
|
|
j += ss
|
|
}
|
|
tok := line[i:j]
|
|
out.WriteString(colorToken(tok, tokenPos))
|
|
tokenPos++
|
|
i = j
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func (m *monitorModel) consoleAutocomplete() {
|
|
r := []rune(m.termPartial)
|
|
cursor := m.termCursor
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if cursor > len(r) {
|
|
cursor = len(r)
|
|
}
|
|
|
|
left := r[:cursor]
|
|
right := r[cursor:]
|
|
|
|
start := len(left)
|
|
for start > 0 {
|
|
ch := left[start-1]
|
|
if ch == ' ' || ch == '\t' {
|
|
break
|
|
}
|
|
start--
|
|
}
|
|
|
|
prefix := strings.ToLower(string(left[start:]))
|
|
base := strings.Fields(string(left[:start]))
|
|
cands := m.consoleCandidates(base, prefix)
|
|
if len(cands) == 0 {
|
|
return
|
|
}
|
|
|
|
if len(cands) == 1 {
|
|
replacement := cands[0]
|
|
newLeft := string(left[:start]) + replacement
|
|
if len(right) == 0 || (len(right) > 0 && right[0] != ' ') {
|
|
newLeft += " "
|
|
}
|
|
m.termPartial = newLeft + string(right)
|
|
m.termCursor = len([]rune(newLeft))
|
|
return
|
|
}
|
|
|
|
common := commonPrefix(cands)
|
|
if len(common) > len(prefix) {
|
|
newLeft := string(left[:start]) + common
|
|
m.termPartial = newLeft + string(right)
|
|
m.termCursor = len([]rune(newLeft))
|
|
return
|
|
}
|
|
|
|
m.pushTerminalLine("suggestions: " + strings.Join(cands, " "))
|
|
}
|
|
|
|
func (m monitorModel) consoleCandidates(base []string, prefix string) []string {
|
|
top := []string{
|
|
"cluster", "alerts", "alert", "software", "plugins", "bot", "ping", "connect",
|
|
"list", "show", "current", "use", "bootstrap", "stats",
|
|
"kvm", "lxc", "lxd", "bird", "frr",
|
|
"help", "history", "clear", "overview", "clusters", "network", "settings", "quit", "exit",
|
|
"console",
|
|
}
|
|
clusterSubs := []string{
|
|
"connect", "add", "list", "ls", "show", "get", "current", "use",
|
|
"ping", "check", "bootstrap", "stats", "alert", "alerts", "software", "plugins",
|
|
"disconnect", "remove", "rm", "help",
|
|
}
|
|
alertSubs := []string{"show", "set"}
|
|
softwareSubs := []string{"show", "scan"}
|
|
kvmSubs := []string{"list", "start", "stop", "reboot", "restart", "destroy", "top", "net-top"}
|
|
lxcSubs := []string{"list", "start", "stop", "restart", "top", "net-top", "stats", "info", "show"}
|
|
birdSubs := []string{"status", "protocols", "routes"}
|
|
frrSubs := []string{"status", "routes", "bgp", "ospf"}
|
|
botSubs := []string{"telegram"}
|
|
botTelegramSubs := []string{"show", "set", "disable", "run"}
|
|
consoleSubs := []string{"full", "fullscreen", "dock", "toggle"}
|
|
|
|
var pool []string
|
|
switch {
|
|
case len(base) == 0:
|
|
pool = top
|
|
case len(base) == 1 && strings.EqualFold(base[0], "cluster"):
|
|
pool = clusterSubs
|
|
case len(base) == 1 && wantsClusterName(base[0]):
|
|
pool = m.clusterNameCandidates()
|
|
case len(base) == 2 && strings.EqualFold(base[0], "cluster") && wantsClusterName(base[1]):
|
|
pool = m.clusterNameCandidates()
|
|
case len(base) == 1 && (strings.EqualFold(base[0], "alert") || strings.EqualFold(base[0], "alerts")):
|
|
pool = alertSubs
|
|
case len(base) == 2 && (strings.EqualFold(base[0], "alert") || strings.EqualFold(base[0], "alerts")):
|
|
if strings.EqualFold(base[1], "set") || strings.EqualFold(base[1], "show") {
|
|
pool = m.clusterNameCandidates()
|
|
}
|
|
case len(base) == 1 && (strings.EqualFold(base[0], "software") || strings.EqualFold(base[0], "plugins")):
|
|
pool = softwareSubs
|
|
case len(base) == 2 && strings.EqualFold(base[0], "cluster") &&
|
|
(strings.EqualFold(base[1], "software") || strings.EqualFold(base[1], "plugins")):
|
|
pool = softwareSubs
|
|
case len(base) == 1 && strings.EqualFold(base[0], "kvm"):
|
|
pool = kvmSubs
|
|
case len(base) == 1 && (strings.EqualFold(base[0], "lxc") || strings.EqualFold(base[0], "lxd")):
|
|
pool = lxcSubs
|
|
case len(base) == 1 && strings.EqualFold(base[0], "bird"):
|
|
pool = birdSubs
|
|
case len(base) == 1 && strings.EqualFold(base[0], "frr"):
|
|
pool = frrSubs
|
|
case len(base) == 1 && strings.EqualFold(base[0], "bot"):
|
|
pool = botSubs
|
|
case len(base) == 2 && strings.EqualFold(base[0], "bot") && strings.EqualFold(base[1], "telegram"):
|
|
pool = botTelegramSubs
|
|
case len(base) == 1 && strings.EqualFold(base[0], "console"):
|
|
pool = consoleSubs
|
|
case len(base) == 2 && strings.EqualFold(base[0], "software") &&
|
|
(strings.EqualFold(base[1], "scan") || strings.EqualFold(base[1], "show")):
|
|
pool = m.clusterNameCandidates()
|
|
case len(base) == 3 && (strings.EqualFold(base[0], "kvm") || strings.EqualFold(base[0], "lxc") || strings.EqualFold(base[0], "lxd") ||
|
|
strings.EqualFold(base[0], "bird") || strings.EqualFold(base[0], "frr")) &&
|
|
(strings.EqualFold(base[1], "list") || strings.EqualFold(base[1], "status") ||
|
|
strings.EqualFold(base[1], "top") || strings.EqualFold(base[1], "net-top") ||
|
|
strings.EqualFold(base[1], "stats") || strings.EqualFold(base[1], "info") || strings.EqualFold(base[1], "show") ||
|
|
strings.EqualFold(base[1], "routes") || strings.EqualFold(base[1], "protocols") ||
|
|
strings.EqualFold(base[1], "bgp") || strings.EqualFold(base[1], "ospf")):
|
|
pool = m.clusterNameCandidates()
|
|
}
|
|
|
|
if len(pool) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(pool))
|
|
for _, item := range pool {
|
|
if prefix == "" || strings.HasPrefix(strings.ToLower(item), prefix) {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return dedupeStrings(out)
|
|
}
|
|
|
|
func (m monitorModel) clusterNameCandidates() []string {
|
|
clusters, _, err := m.svc.List()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(clusters))
|
|
for _, c := range clusters {
|
|
if strings.TrimSpace(c.Name) != "" {
|
|
names = append(names, c.Name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return dedupeStrings(names)
|
|
}
|
|
|
|
func wantsClusterName(token string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(token)) {
|
|
case "use", "show", "get", "ping", "check", "bootstrap", "stats", "disconnect", "remove", "rm", "connect", "software", "plugins":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func commonPrefix(values []string) string {
|
|
if len(values) == 0 {
|
|
return ""
|
|
}
|
|
p := values[0]
|
|
for _, v := range values[1:] {
|
|
for !strings.HasPrefix(v, p) && p != "" {
|
|
p = p[:len(p)-1]
|
|
}
|
|
if p == "" {
|
|
return ""
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
func dedupeStrings(in []string) []string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(in))
|
|
var prev string
|
|
for i, item := range in {
|
|
if i == 0 || item != prev {
|
|
out = append(out, item)
|
|
}
|
|
prev = item
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m monitorModel) metricTabs() string {
|
|
tabs := []graphMetric{metricRX, metricTX, metricTotal}
|
|
parts := make([]string, 0, len(tabs))
|
|
for _, t := range tabs {
|
|
label := metricLabel(t)
|
|
if t == m.metric {
|
|
parts = append(parts, "["+label+"]")
|
|
} else {
|
|
parts = append(parts, " "+label+" ")
|
|
}
|
|
}
|
|
return "metric: " + strings.Join(parts, " ")
|
|
}
|
|
|
|
func (m monitorModel) networkRows() []networkRow {
|
|
lQuery := strings.ToLower(strings.TrimSpace(m.searchTerm))
|
|
|
|
byIfaceStat := make(map[string]agent.NetworkStat, len(m.stats.Network))
|
|
for _, n := range m.stats.Network {
|
|
byIfaceStat[n.Interface] = n
|
|
}
|
|
|
|
all := map[string]struct{}{}
|
|
for iface := range m.runtime.rates {
|
|
all[iface] = struct{}{}
|
|
}
|
|
for iface := range m.history {
|
|
all[iface] = struct{}{}
|
|
}
|
|
for iface := range byIfaceStat {
|
|
all[iface] = struct{}{}
|
|
}
|
|
|
|
rows := make([]networkRow, 0, len(all))
|
|
for iface := range all {
|
|
if lQuery != "" && !strings.Contains(strings.ToLower(iface), lQuery) {
|
|
continue
|
|
}
|
|
|
|
rate := m.runtime.rates[iface]
|
|
stat := byIfaceStat[iface]
|
|
row := networkRow{
|
|
Interface: iface,
|
|
CurRxMbps: rate.RxMbps,
|
|
CurTxMbps: rate.TxMbps,
|
|
CurTotalMbps: rate.RxMbps + rate.TxMbps,
|
|
RxDrops: stat.RxDrops,
|
|
TxDrops: stat.TxDrops,
|
|
Samples: len(m.history[iface]),
|
|
Spark: strings.Repeat(".", 18),
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].CurTotalMbps != rows[j].CurTotalMbps {
|
|
return rows[i].CurTotalMbps > rows[j].CurTotalMbps
|
|
}
|
|
if rows[i].Samples != rows[j].Samples {
|
|
return rows[i].Samples > rows[j].Samples
|
|
}
|
|
return rows[i].Interface < rows[j].Interface
|
|
})
|
|
return rows
|
|
}
|
|
|
|
func (m monitorModel) enrichNetworkRows(rows []networkRow) []networkRow {
|
|
out := append([]networkRow(nil), rows...)
|
|
for i := range out {
|
|
points := m.history[out[i].Interface]
|
|
if len(points) == 0 {
|
|
out[i].Spark = strings.Repeat(".", 18)
|
|
continue
|
|
}
|
|
seriesStats := computeSeriesStats(points)
|
|
out[i].AvgTotalMbps = seriesStats.AvgTotalMbps
|
|
out[i].PeakTotal = seriesStats.PeakTotalMbps
|
|
out[i].ConsumedByte = seriesStats.ConsumedBytes
|
|
out[i].Samples = seriesStats.Samples
|
|
out[i].Spark = sparklineFromHistory(points, 18)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sparklineFromHistory(points []ifaceHistoryPoint, width int) string {
|
|
if len(points) == 0 {
|
|
return strings.Repeat(".", max(1, width))
|
|
}
|
|
totalSeries := make([]float64, 0, len(points))
|
|
for _, p := range points {
|
|
totalSeries = append(totalSeries, p.RxMbps+p.TxMbps)
|
|
}
|
|
return sparklineASCII(totalSeries, width)
|
|
}
|
|
|
|
type seriesStats struct {
|
|
AvgTotalMbps float64
|
|
PeakTotalMbps float64
|
|
ConsumedBytes uint64
|
|
Samples int
|
|
}
|
|
|
|
type metricStats struct {
|
|
PeakMbps float64
|
|
PeakDuration time.Duration
|
|
Samples int
|
|
}
|
|
|
|
func computeSeriesStats(points []ifaceHistoryPoint) seriesStats {
|
|
if len(points) == 0 {
|
|
return seriesStats{}
|
|
}
|
|
|
|
total := 0.0
|
|
peak := 0.0
|
|
for _, p := range points {
|
|
v := p.RxMbps + p.TxMbps
|
|
total += v
|
|
if v > peak {
|
|
peak = v
|
|
}
|
|
}
|
|
|
|
consumed := integrateConsumedBytes(points, metricTotal)
|
|
return seriesStats{
|
|
AvgTotalMbps: round2(total / float64(len(points))),
|
|
PeakTotalMbps: round2(peak),
|
|
ConsumedBytes: consumed,
|
|
Samples: len(points),
|
|
}
|
|
}
|
|
|
|
func computeMetricStats(points []ifaceHistoryPoint, metric graphMetric) metricStats {
|
|
if len(points) == 0 {
|
|
return metricStats{}
|
|
}
|
|
|
|
peak := 0.0
|
|
for _, p := range points {
|
|
v := metricPointValue(p, metric)
|
|
if v > peak {
|
|
peak = v
|
|
}
|
|
}
|
|
|
|
return metricStats{
|
|
PeakMbps: round2(peak),
|
|
PeakDuration: estimatePeakHoldDuration(points, metric),
|
|
Samples: len(points),
|
|
}
|
|
}
|
|
|
|
func integrateConsumedBytes(points []ifaceHistoryPoint, metric graphMetric) uint64 {
|
|
if len(points) < 2 {
|
|
return 0
|
|
}
|
|
|
|
// Assume points are already chronological.
|
|
totalBytes := 0.0
|
|
for i := 1; i < len(points); i++ {
|
|
prev := points[i-1]
|
|
cur := points[i]
|
|
dt := cur.At.Sub(prev.At).Seconds()
|
|
if dt <= 0 || dt > 3600 {
|
|
continue
|
|
}
|
|
prevMbps := metricPointValue(prev, metric)
|
|
curMbps := metricPointValue(cur, metric)
|
|
avgMbps := (prevMbps + curMbps) / 2
|
|
totalBytes += (avgMbps * 1_000_000.0 / 8.0) * dt
|
|
}
|
|
if totalBytes <= 0 {
|
|
return 0
|
|
}
|
|
return uint64(totalBytes)
|
|
}
|
|
|
|
func metricUsageBytesSince(points []ifaceHistoryPoint, metric graphMetric, since time.Time) uint64 {
|
|
if len(points) < 2 {
|
|
return 0
|
|
}
|
|
if since.IsZero() {
|
|
return integrateConsumedBytes(points, metric)
|
|
}
|
|
|
|
filtered := make([]ifaceHistoryPoint, 0, len(points))
|
|
for _, p := range points {
|
|
if p.At.After(since) || p.At.Equal(since) {
|
|
filtered = append(filtered, p)
|
|
}
|
|
}
|
|
return integrateConsumedBytes(filtered, metric)
|
|
}
|
|
|
|
func estimatePeakHoldDuration(points []ifaceHistoryPoint, metric graphMetric) time.Duration {
|
|
if len(points) < 2 {
|
|
return 0
|
|
}
|
|
|
|
peak := 0.0
|
|
for _, p := range points {
|
|
v := metricPointValue(p, metric)
|
|
if v > peak {
|
|
peak = v
|
|
}
|
|
}
|
|
if peak <= 0 {
|
|
return 0
|
|
}
|
|
|
|
threshold := peak * 0.95
|
|
var longest time.Duration
|
|
var run time.Duration
|
|
for i := 1; i < len(points); i++ {
|
|
prev := points[i-1]
|
|
cur := points[i]
|
|
dt := cur.At.Sub(prev.At)
|
|
if dt <= 0 || dt > time.Hour {
|
|
run = 0
|
|
continue
|
|
}
|
|
v1 := metricPointValue(prev, metric)
|
|
v2 := metricPointValue(cur, metric)
|
|
if v1 >= threshold && v2 >= threshold {
|
|
run += dt
|
|
if run > longest {
|
|
longest = run
|
|
}
|
|
} else {
|
|
run = 0
|
|
}
|
|
}
|
|
return longest
|
|
}
|
|
|
|
func metricSeries(points []ifaceHistoryPoint, metric graphMetric) []float64 {
|
|
out := make([]float64, 0, len(points))
|
|
for _, p := range points {
|
|
out = append(out, metricPointValue(p, metric))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func metricCurrent(row networkRow, metric graphMetric) float64 {
|
|
switch metric {
|
|
case metricRX:
|
|
return row.CurRxMbps
|
|
case metricTX:
|
|
return row.CurTxMbps
|
|
default:
|
|
return row.CurTotalMbps
|
|
}
|
|
}
|
|
|
|
func metricPointValue(p ifaceHistoryPoint, metric graphMetric) float64 {
|
|
switch metric {
|
|
case metricRX:
|
|
return p.RxMbps
|
|
case metricTX:
|
|
return p.TxMbps
|
|
default:
|
|
return p.RxMbps + p.TxMbps
|
|
}
|
|
}
|
|
|
|
func metricLabel(metric graphMetric) string {
|
|
switch metric {
|
|
case metricRX:
|
|
return "RX"
|
|
case metricTX:
|
|
return "TX"
|
|
default:
|
|
return "TOTAL"
|
|
}
|
|
}
|
|
|
|
func nextMetric(metric graphMetric) graphMetric {
|
|
switch metric {
|
|
case metricRX:
|
|
return metricTX
|
|
case metricTX:
|
|
return metricTotal
|
|
default:
|
|
return metricRX
|
|
}
|
|
}
|
|
|
|
func prevMetric(metric graphMetric) graphMetric {
|
|
switch metric {
|
|
case metricRX:
|
|
return metricTotal
|
|
case metricTX:
|
|
return metricRX
|
|
default:
|
|
return metricTX
|
|
}
|
|
}
|
|
|
|
func renderBigGraphASCII(values []float64, width, height int) string {
|
|
if width < 20 {
|
|
width = 20
|
|
}
|
|
if height < 4 {
|
|
height = 4
|
|
}
|
|
if len(values) == 0 {
|
|
return dimStyle.Render("no history points for graph")
|
|
}
|
|
|
|
series := resample(values, width)
|
|
maxV := 0.0
|
|
for _, v := range series {
|
|
if v > maxV {
|
|
maxV = v
|
|
}
|
|
}
|
|
if maxV <= 0 {
|
|
return dimStyle.Render(strings.Repeat(".", width))
|
|
}
|
|
|
|
var b strings.Builder
|
|
for row := height; row >= 1; row-- {
|
|
levelVal := maxV * float64(row) / float64(height)
|
|
b.WriteString(fmt.Sprintf("%7.2f |", levelVal))
|
|
for _, v := range series {
|
|
if v >= levelVal {
|
|
b.WriteByte('#')
|
|
} else {
|
|
b.WriteByte(' ')
|
|
}
|
|
}
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString("--------+")
|
|
b.WriteString(strings.Repeat("-", len(series)))
|
|
b.WriteByte('\n')
|
|
b.WriteString(" 0")
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
func paginationBounds(total, page, pageSize int) (start, end, totalPages int) {
|
|
if pageSize <= 0 {
|
|
pageSize = 8
|
|
}
|
|
totalPages = pageCount(total, pageSize)
|
|
if totalPages < 1 {
|
|
totalPages = 1
|
|
}
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if page > totalPages {
|
|
page = totalPages
|
|
}
|
|
start = (page - 1) * pageSize
|
|
if start > total {
|
|
start = total
|
|
}
|
|
end = start + pageSize
|
|
if end > total {
|
|
end = total
|
|
}
|
|
return
|
|
}
|
|
|
|
func pageCount(total, pageSize int) int {
|
|
if pageSize <= 0 {
|
|
pageSize = 8
|
|
}
|
|
if total <= 0 {
|
|
return 1
|
|
}
|
|
return (total + pageSize - 1) / pageSize
|
|
}
|
|
|
|
func parseView(v string) monitorView {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "clusters", "cluster", "cluster-overview":
|
|
return viewClusters
|
|
case "network", "net":
|
|
return viewNetwork
|
|
case "settings", "cfg", "config":
|
|
return viewSettings
|
|
case "docs", "doc", "help", "manual":
|
|
return viewDocs
|
|
default:
|
|
return viewOverview
|
|
}
|
|
}
|
|
|
|
func defaultHistoryRange() historyRange {
|
|
now := time.Now().UTC()
|
|
return historyRange{
|
|
Label: "30d",
|
|
Since: now.Add(-30 * 24 * time.Hour),
|
|
All: false,
|
|
}
|
|
}
|
|
|
|
func parseHistoryRange(token string) (historyRange, error) {
|
|
now := time.Now().UTC()
|
|
switch strings.ToLower(strings.TrimSpace(token)) {
|
|
case "1h":
|
|
return historyRange{Label: "1h", Since: now.Add(-time.Hour)}, nil
|
|
case "24h", "1d":
|
|
return historyRange{Label: "24h", Since: now.Add(-24 * time.Hour)}, nil
|
|
case "7d", "1w":
|
|
return historyRange{Label: "7d", Since: now.Add(-7 * 24 * time.Hour)}, nil
|
|
case "30d", "1m", "month":
|
|
return historyRange{Label: "30d", Since: now.Add(-30 * 24 * time.Hour)}, nil
|
|
case "all":
|
|
return historyRange{Label: "all", All: true}, nil
|
|
default:
|
|
return historyRange{}, errors.New("invalid range, allowed: 1h|24h|7d|30d|all")
|
|
}
|
|
}
|
|
|
|
func sparklineASCII(values []float64, width int) string {
|
|
if width <= 0 {
|
|
width = 16
|
|
}
|
|
if len(values) == 0 {
|
|
return strings.Repeat(".", width)
|
|
}
|
|
|
|
sampled := resample(values, width)
|
|
maxV := 0.0
|
|
for _, v := range sampled {
|
|
if v > maxV {
|
|
maxV = v
|
|
}
|
|
}
|
|
if maxV <= 0 {
|
|
return strings.Repeat(".", len(sampled))
|
|
}
|
|
|
|
levels := []byte{'.', ':', '-', '=', '+', '*', '#', '%', '@'}
|
|
var b strings.Builder
|
|
b.Grow(len(sampled))
|
|
for _, v := range sampled {
|
|
ratio := v / maxV
|
|
if ratio < 0 {
|
|
ratio = 0
|
|
}
|
|
if ratio > 1 {
|
|
ratio = 1
|
|
}
|
|
idx := int(math.Round(ratio * float64(len(levels)-1)))
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
if idx >= len(levels) {
|
|
idx = len(levels) - 1
|
|
}
|
|
b.WriteByte(levels[idx])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func resample(values []float64, width int) []float64 {
|
|
if width <= 0 {
|
|
return []float64{}
|
|
}
|
|
if len(values) <= width {
|
|
out := make([]float64, 0, width)
|
|
out = append(out, values...)
|
|
for len(out) < width {
|
|
out = append(out, values[len(values)-1])
|
|
}
|
|
return out
|
|
}
|
|
|
|
out := make([]float64, 0, width)
|
|
step := float64(len(values)) / float64(width)
|
|
for i := 0; i < width; i++ {
|
|
from := int(math.Floor(float64(i) * step))
|
|
to := int(math.Floor(float64(i+1) * step))
|
|
if to <= from {
|
|
to = from + 1
|
|
}
|
|
if from < 0 {
|
|
from = 0
|
|
}
|
|
if to > len(values) {
|
|
to = len(values)
|
|
}
|
|
sum := 0.0
|
|
for j := from; j < to; j++ {
|
|
sum += values[j]
|
|
}
|
|
out = append(out, sum/float64(to-from))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func humanBitsRate(bytes uint64) string {
|
|
if bytes == 0 {
|
|
return "0B"
|
|
}
|
|
return humanBytes(bytes)
|
|
}
|
|
|
|
func renderPanel(title, body string, width int) string {
|
|
return renderPanelStyled(panelStyle, title, body, width)
|
|
}
|
|
|
|
// clipBodyToHeight returns EXACTLY `height` lines from body starting at
|
|
// `offset`. Pads with empty lines if body is shorter, truncates with scroll
|
|
// indicators if longer. Returns the clamped offset and whether clipping
|
|
// occurred. A fixed line count per panel makes the overall View deterministic
|
|
// across frames, which is what keeps bubbletea's diff renderer stable.
|
|
func clipBodyToHeight(body string, height, offset int) (string, int, bool) {
|
|
if height <= 0 {
|
|
return "", 0, false
|
|
}
|
|
lines := strings.Split(body, "\n")
|
|
total := len(lines)
|
|
if total <= height {
|
|
out := make([]string, height)
|
|
copy(out, lines)
|
|
return strings.Join(out, "\n"), 0, false
|
|
}
|
|
maxOffset := total - height
|
|
if offset > maxOffset {
|
|
offset = maxOffset
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
window := make([]string, height)
|
|
copy(window, lines[offset:offset+height])
|
|
if offset > 0 && height >= 1 {
|
|
window[0] = dimStyle.Render(fmt.Sprintf("↑ %d more (alt+↑/↓ to scroll)", offset))
|
|
}
|
|
if offset < maxOffset && height >= 1 {
|
|
window[height-1] = dimStyle.Render(fmt.Sprintf("↓ %d more (alt+↑/↓ to scroll)", maxOffset-offset))
|
|
}
|
|
return strings.Join(window, "\n"), offset, true
|
|
}
|
|
|
|
// visibleHeight counts the number of newline-separated lines in s.
|
|
func visibleHeight(s string) int {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
return strings.Count(s, "\n") + 1
|
|
}
|
|
|
|
func renderPanelGrid(style lipgloss.Style, title, body string, width, height int) string {
|
|
if width < 24 {
|
|
width = 24
|
|
}
|
|
if height < 3 {
|
|
height = 3
|
|
}
|
|
header := titleStyle.Render(title)
|
|
content := lipgloss.JoinVertical(lipgloss.Left, header, body)
|
|
return style.Copy().
|
|
Width(width).
|
|
MaxWidth(width).
|
|
Height(height).
|
|
MaxHeight(height).
|
|
Render(content)
|
|
}
|
|
|
|
func renderFixedPanel(style lipgloss.Style, title, body string, width, height, offset int) (string, int) {
|
|
if height < 5 {
|
|
height = 5
|
|
}
|
|
bodyHeight := height - 3 // 2 borders + 1 title line
|
|
if bodyHeight < 1 {
|
|
bodyHeight = 1
|
|
}
|
|
clipped, clampedOffset, _ := clipBodyToHeight(body, bodyHeight, offset)
|
|
return renderPanelGrid(style, title, clipped, width, height), clampedOffset
|
|
}
|
|
|
|
func renderPanelStyled(style lipgloss.Style, title, body string, width int) string {
|
|
if width < 24 {
|
|
width = 24
|
|
}
|
|
header := titleStyle.Render(title)
|
|
content := lipgloss.JoinVertical(lipgloss.Left, header, body)
|
|
return style.Width(width).Render(content)
|
|
}
|
|
|
|
func barASCII(percent float64, width int) string {
|
|
if width < 4 {
|
|
width = 4
|
|
}
|
|
if percent < 0 {
|
|
percent = 0
|
|
}
|
|
if percent > 100 {
|
|
percent = 100
|
|
}
|
|
|
|
filled := int(math.Round((percent / 100) * float64(width)))
|
|
if filled < 0 {
|
|
filled = 0
|
|
}
|
|
if filled > width {
|
|
filled = width
|
|
}
|
|
return "[" + strings.Repeat("=", filled) + strings.Repeat("-", width-filled) + "]"
|
|
}
|
|
|
|
func truncate(s string, limit int) string {
|
|
if limit <= 0 {
|
|
return ""
|
|
}
|
|
if len(s) <= limit {
|
|
return s
|
|
}
|
|
if limit <= 3 {
|
|
return s[:limit]
|
|
}
|
|
return s[:limit-3] + "..."
|
|
}
|
|
|
|
func truncateRunes(s string, limit int) string {
|
|
if limit <= 0 {
|
|
return ""
|
|
}
|
|
rs := []rune(s)
|
|
if len(rs) <= limit {
|
|
return s
|
|
}
|
|
if limit <= 3 {
|
|
return string(rs[:limit])
|
|
}
|
|
return string(rs[:limit-1]) + "…"
|
|
}
|
|
|
|
func visibleLen(s string) int {
|
|
stripped := ansiCSIRegex.ReplaceAllString(s, "")
|
|
stripped = ansiOSCRegex.ReplaceAllString(stripped, "")
|
|
return len([]rune(stripped))
|
|
}
|
|
|
|
func truncateVisible(s string, limit int) string {
|
|
if limit <= 0 {
|
|
return ""
|
|
}
|
|
if visibleLen(s) <= limit {
|
|
return s
|
|
}
|
|
runes := []rune(s)
|
|
var b strings.Builder
|
|
visible := 0
|
|
target := limit - 1
|
|
for i := 0; i < len(runes); {
|
|
r := runes[i]
|
|
if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
|
|
b.WriteRune(r)
|
|
i++
|
|
b.WriteRune(runes[i])
|
|
i++
|
|
for i < len(runes) {
|
|
c := runes[i]
|
|
b.WriteRune(c)
|
|
i++
|
|
if c >= 0x40 && c <= 0x7e {
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if visible >= target {
|
|
break
|
|
}
|
|
b.WriteRune(r)
|
|
visible++
|
|
i++
|
|
}
|
|
b.WriteString("\x1b[0m…")
|
|
return b.String()
|
|
}
|
|
|
|
func truncateMultiline(s string, limit int) string {
|
|
if limit <= 0 {
|
|
return ""
|
|
}
|
|
lines := strings.Split(s, "\n")
|
|
for i := range lines {
|
|
lines[i] = truncate(lines[i], limit)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func fetchAgentStats(svc *cluster.Service, selector string) (agent.StatsResponse, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
|
defer cancel()
|
|
return svc.AgentStatsTyped(ctx, selector)
|
|
}
|
|
|
|
func (m *monitorRuntime) update(stats agent.StatsResponse) {
|
|
now := stats.Timestamp
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
}
|
|
|
|
curr := map[string]agent.NetworkStat{}
|
|
for _, n := range stats.Network {
|
|
curr[n.Interface] = n
|
|
}
|
|
|
|
if !m.prevAt.IsZero() {
|
|
dt := now.Sub(m.prevAt).Seconds()
|
|
if dt > 0 {
|
|
for iface, cur := range curr {
|
|
if prev, ok := m.prevNet[iface]; ok {
|
|
rxDelta := diffCounter(cur.RxBytes, prev.RxBytes)
|
|
txDelta := diffCounter(cur.TxBytes, prev.TxBytes)
|
|
m.rates[iface] = ifaceRate{
|
|
RxMbps: round2((float64(rxDelta) * 8 / 1_000_000) / dt),
|
|
TxMbps: round2((float64(txDelta) * 8 / 1_000_000) / dt),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
m.prevAt = now
|
|
m.prevNet = curr
|
|
|
|
ifaces := interfaceNames(stats.Network)
|
|
if len(ifaces) == 0 {
|
|
m.selectedIface = ""
|
|
return
|
|
}
|
|
if m.selectedIface == "" {
|
|
m.selectedIface = ifaces[0]
|
|
return
|
|
}
|
|
for _, iface := range ifaces {
|
|
if iface == m.selectedIface {
|
|
return
|
|
}
|
|
}
|
|
m.selectedIface = ifaces[0]
|
|
}
|
|
|
|
func printStatsSnapshot(w io.Writer, stats agent.StatsResponse) {
|
|
pctColor := func(p float64) string {
|
|
s := fmt.Sprintf("%.1f%%", p)
|
|
switch {
|
|
case p >= 90:
|
|
return colorCrit(s)
|
|
case p >= 70:
|
|
return colorWarn(s)
|
|
default:
|
|
return colorOK(s)
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "%s %s\n", colorLabel("Time:"), colorDim(stats.Timestamp.Format(time.RFC3339)))
|
|
fmt.Fprintf(w, "%s %s %s\n",
|
|
colorLabel("Host:"),
|
|
colorAccent(stats.Host.Hostname),
|
|
colorMuted(fmt.Sprintf("(%s/%s)", stats.Host.OS, stats.Host.Arch)),
|
|
)
|
|
fmt.Fprintf(w, "%s %s %s %s\n",
|
|
colorLabel("CPU: "),
|
|
pctColor(stats.CPU.UsagePercent),
|
|
colorMuted("| load"),
|
|
colorInfo(fmt.Sprintf("%.2f %.2f %.2f", stats.CPU.Load1, stats.CPU.Load5, stats.CPU.Load15)),
|
|
)
|
|
fmt.Fprintf(w, "%s %s %s %s %s %s\n",
|
|
colorLabel("RAM: "),
|
|
pctColor(stats.Memory.UsedPercent),
|
|
colorMuted("|"),
|
|
colorValue(humanBytes(stats.Memory.UsedBytes)),
|
|
colorMuted("/"),
|
|
colorBlue(humanBytes(stats.Memory.TotalBytes)),
|
|
)
|
|
if stats.Memory.SwapTotalBytes > 0 {
|
|
fmt.Fprintf(w, "%s %s %s %s %s %s\n",
|
|
colorLabel("Swap:"),
|
|
pctColor(stats.Memory.SwapUsedPct),
|
|
colorMuted("|"),
|
|
colorValue(humanBytes(stats.Memory.SwapUsedBytes)),
|
|
colorMuted("/"),
|
|
colorBlue(humanBytes(stats.Memory.SwapTotalBytes)),
|
|
)
|
|
}
|
|
|
|
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
|
colorHeader("DISK"),
|
|
colorHeader("USE"),
|
|
colorHeader("HEALTH"),
|
|
colorHeader("WARNINGS"),
|
|
)
|
|
for _, d := range stats.Disk {
|
|
warn := strings.Join(d.Warnings, "; ")
|
|
warnColored := colorDim("-")
|
|
if warn != "" {
|
|
warnColored = colorWarn(warn)
|
|
}
|
|
health := emptyFallback(d.Health, "ok")
|
|
healthColored := colorOK(health)
|
|
switch strings.ToLower(health) {
|
|
case "critical", "fail", "failed":
|
|
healthColored = colorCrit(health)
|
|
case "warn", "warning", "degraded":
|
|
healthColored = colorWarn(health)
|
|
}
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
|
colorInfo(d.MountPoint),
|
|
pctColor(d.UsedPercent),
|
|
healthColored,
|
|
warnColored,
|
|
)
|
|
}
|
|
_ = tw.Flush()
|
|
}
|
|
|
|
func collectAlerts(stats agent.StatsResponse, m *monitorRuntime) []string {
|
|
alerts := []string{}
|
|
p := m.policy
|
|
|
|
cpuPct := stats.CPU.UsagePercent
|
|
if cpuPct <= 0 && stats.CPU.LogicalCores > 0 {
|
|
cpuPct = round2(math.Min((stats.CPU.Load1/float64(stats.CPU.LogicalCores))*100, 100))
|
|
}
|
|
if cpuPct >= p.CPUWarnPercent {
|
|
alerts = append(alerts, fmt.Sprintf("CPU high: %.1f%% >= %.1f%%", cpuPct, p.CPUWarnPercent))
|
|
}
|
|
if stats.Memory.UsedPercent >= p.RAMWarnPercent {
|
|
alerts = append(alerts, fmt.Sprintf("RAM high: %.1f%% >= %.1f%%", stats.Memory.UsedPercent, p.RAMWarnPercent))
|
|
}
|
|
if stats.Memory.SwapTotalBytes > 0 && stats.Memory.SwapUsedPct >= p.SwapWarnPercent {
|
|
alerts = append(alerts, fmt.Sprintf("Swap high: %.1f%% >= %.1f%%", stats.Memory.SwapUsedPct, p.SwapWarnPercent))
|
|
}
|
|
|
|
for _, d := range stats.Disk {
|
|
if d.UsedPercent >= p.DiskWarnPercent {
|
|
alerts = append(alerts, fmt.Sprintf("Disk high on %s: %.1f%% >= %.1f%%", d.MountPoint, d.UsedPercent, p.DiskWarnPercent))
|
|
}
|
|
if strings.ToLower(d.Health) == "critical" {
|
|
alerts = append(alerts, fmt.Sprintf("Disk critical on %s (%s)", d.MountPoint, strings.Join(d.Warnings, "; ")))
|
|
} else if len(d.Warnings) > 0 {
|
|
alerts = append(alerts, fmt.Sprintf("Disk warning on %s (%s)", d.MountPoint, strings.Join(d.Warnings, "; ")))
|
|
}
|
|
}
|
|
|
|
rate := m.rates[m.selectedIface]
|
|
if p.NetWarnMbps > 0 {
|
|
if rate.RxMbps >= p.NetWarnMbps {
|
|
alerts = append(alerts, fmt.Sprintf("Network RX high on %s: %.1f Mbps >= %.1f Mbps", m.selectedIface, rate.RxMbps, p.NetWarnMbps))
|
|
}
|
|
if rate.TxMbps >= p.NetWarnMbps {
|
|
alerts = append(alerts, fmt.Sprintf("Network TX high on %s: %.1f Mbps >= %.1f Mbps", m.selectedIface, rate.TxMbps, p.NetWarnMbps))
|
|
}
|
|
}
|
|
if p.NetSustainEnabled && p.NetSustainMbps > 0 {
|
|
matched := 0
|
|
for iface, r := range m.rates {
|
|
iface = strings.TrimSpace(iface)
|
|
if iface == "" {
|
|
continue
|
|
}
|
|
pinned := strings.TrimSpace(p.NetSustainIface)
|
|
if pinned != "" && !strings.EqualFold(iface, pinned) {
|
|
continue
|
|
}
|
|
if pinned == "" && !ifaceAllowedByFilters(iface, p.NetSustainInclude, p.NetSustainExclude) {
|
|
continue
|
|
}
|
|
if math.Max(r.RxMbps, r.TxMbps) >= p.NetSustainMbps {
|
|
matched++
|
|
if matched <= 3 {
|
|
alerts = append(alerts, fmt.Sprintf("CRITICAL net candidate on %s: now %.1f/%.1f Mbps, sustained threshold %.1f Mbps for %d min",
|
|
iface, r.RxMbps, r.TxMbps, p.NetSustainMbps, p.NetSustainMinutes))
|
|
}
|
|
}
|
|
}
|
|
if matched > 3 {
|
|
alerts = append(alerts, fmt.Sprintf("... plus %d more interfaces above sustained threshold now", matched-3))
|
|
}
|
|
}
|
|
for _, w := range m.vmWarnings {
|
|
if strings.TrimSpace(w) == "" {
|
|
continue
|
|
}
|
|
alerts = append(alerts, "VM alert: "+w)
|
|
}
|
|
|
|
return alerts
|
|
}
|
|
|
|
func worstDisk(disks []agent.DiskStats) *agent.DiskStats {
|
|
if len(disks) == 0 {
|
|
return nil
|
|
}
|
|
worst := disks[0]
|
|
for _, d := range disks[1:] {
|
|
if d.UsedPercent > worst.UsedPercent {
|
|
worst = d
|
|
}
|
|
if strings.ToLower(d.Health) == "critical" && strings.ToLower(worst.Health) != "critical" {
|
|
worst = d
|
|
}
|
|
}
|
|
return &worst
|
|
}
|
|
|
|
func humanBytes(v uint64) string {
|
|
const unit = 1024
|
|
if v < unit {
|
|
return fmt.Sprintf("%d B", v)
|
|
}
|
|
div, exp := uint64(unit), 0
|
|
for n := v / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
pre := "KMGTPE"
|
|
return fmt.Sprintf("%.2f %ciB", float64(v)/float64(div), pre[exp])
|
|
}
|
|
|
|
func interfaceNames(in []agent.NetworkStat) []string {
|
|
names := make([]string, 0, len(in))
|
|
for _, n := range in {
|
|
names = append(names, n.Interface)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func cycleIface(names []string, current string, dir int) string {
|
|
if len(names) == 0 {
|
|
return ""
|
|
}
|
|
idx := 0
|
|
for i, n := range names {
|
|
if n == current {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
idx += dir
|
|
if idx < 0 {
|
|
idx = len(names) - 1
|
|
}
|
|
if idx >= len(names) {
|
|
idx = 0
|
|
}
|
|
return names[idx]
|
|
}
|
|
|
|
func diffCounter(cur, prev uint64) uint64 {
|
|
if cur >= prev {
|
|
return cur - prev
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func errorsIsNoActive(err error) bool {
|
|
return errors.Is(err, cluster.ErrNoActiveCluster)
|
|
}
|
|
|
|
func round2(v float64) float64 {
|
|
return math.Round(v*100) / 100
|
|
}
|
|
|
|
func (m monitorModel) currentCPUPct() float64 {
|
|
cpuPct := m.stats.CPU.UsagePercent
|
|
if cpuPct <= 0 && m.stats.CPU.LogicalCores > 0 {
|
|
cpuPct = round2(math.Min((m.stats.CPU.Load1/float64(m.stats.CPU.LogicalCores))*100, 100))
|
|
}
|
|
return cpuPct
|
|
}
|