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 --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 --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 --host --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 --allow --allow ") 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 --allow --allow ") 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 ]`", "", "## 🧭 Cluster Command Map", "`pxmon cluster connect|add ...`", "`pxmon cluster list|ls`", "`pxmon cluster show|get [cluster]`", "`pxmon cluster current`", "`pxmon cluster use `", "`pxmon cluster disconnect|remove|rm `", "`pxmon cluster set-auth|auth|password|passwd ...`", "`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 `", "`pxmon cluster traffic [cluster] --range <1h|1d|1mo|all>`", "`pxmon cluster graph [cluster] --range <1d|1mo|all> [--iface ] [--out ]`", "`pxmon cluster p95 [cluster] --iface --range `", "", "## 🔐 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 --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 | ! 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: !") 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 ", " cluster ping [--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 ", " cluster openssh (alias: cluster ssh) — interactive SSH in this TUI", " cluster set-auth --auth password --password 'example-pass' --store-password", " cluster set-auth --key-path ~/.ssh/id_ed25519 --key-passphrase-file ~/Desktop/passphrase.txt --store-key-passphrase", " cluster set-auth --type ipfabric (enable SSH-tunneled agent calls)", " cluster repo-tunnel install --gateway --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 --net-mbps 300 --ram 90 --disk 90", " alerts set --net-sustain-enabled=true --net-sustain-mbps 500 --net-sustain-mins 60 --net-sustain-include net0 --net-sustain-exclude backup", " cluster software scan ", " bot telegram show", " bot telegram set --token --allow --allow ", "", "Aliases:", " ping -> cluster ping ", " alerts ... -> cluster alert ...", " connect -> cluster use ", " software ... -> cluster software ...", "", "Plugin commands (SSH on selected cluster):", " kvm list | kvm start | kvm stop | kvm reboot ", " kvm top | kvm net-top", " lxc list | lxc start | lxc stop | lxc restart | lxc stats ", " lxc top | lxc net-top", " lxd list | lxd start | lxd stop | lxd restart | lxd stats ", " lxd top | lxd net-top", " bird status | bird protocols | bird routes", " frr status | frr routes | frr bgp | frr ospf", " add --cluster 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 }