Files
2026-06-16 21:52:10 +04:00

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
}