Files
pxmon/internal/cli/monitor_livecmd.go
T
2026-06-16 21:52:10 +04:00

307 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"context"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// livePluginSpec describes an invocation to rerun on a ticker.
type livePluginSpec struct {
Tool string
Action string
Selector string
Params []string
Interval time.Duration
}
// Display returns a short human label for the header.
func (s livePluginSpec) Display() string {
parts := []string{s.Tool}
if s.Action != "" {
parts = append(parts, s.Action)
}
parts = append(parts, s.Params...)
out := strings.Join(parts, " ")
if s.Selector != "" {
out += " (@" + s.Selector + ")"
}
return out
}
// detectLivePluginInvocation returns a filled livePluginSpec if the parsed
// argv is a plugin command (`kvm`, `lxc`, `lxd`, `bird`, `frr`) with a
// `--live` flag. The flag is consumed so downstream execution sees a clean
// argv without it. Optional `--interval=Ns` adjusts the refresh cadence.
func detectLivePluginInvocation(args []string) (livePluginSpec, bool) {
if len(args) == 0 {
return livePluginSpec{}, false
}
tool := strings.ToLower(strings.TrimSpace(args[0]))
switch tool {
case "kvm", "lxc", "lxd", "bird", "frr":
default:
return livePluginSpec{}, false
}
hasLive := false
interval := 3 * time.Second
rest := args[1:]
clean := make([]string, 0, len(rest))
for i := 0; i < len(rest); i++ {
a := strings.TrimSpace(rest[i])
switch {
case a == "--live", a == "-L":
hasLive = true
case a == "--interval":
if i+1 < len(rest) {
if d, err := time.ParseDuration(rest[i+1]); err == nil {
interval = d
}
i++
}
case strings.HasPrefix(a, "--interval="):
if d, err := time.ParseDuration(strings.TrimPrefix(a, "--interval=")); err == nil {
interval = d
}
default:
clean = append(clean, rest[i])
}
}
if !hasLive {
return livePluginSpec{}, false
}
// Resolve the cluster selector (--cluster/-c NAME) and pull it out of
// the remaining args so action+params stay clean.
selector, cleaned2, err := parseClusterSelectorArg(clean)
if err != nil {
return livePluginSpec{}, false
}
action := ""
params := []string{}
if len(cleaned2) > 0 {
action = strings.ToLower(strings.TrimSpace(cleaned2[0]))
params = cleaned2[1:]
}
if action == "" {
switch tool {
case "kvm", "lxc", "lxd":
action = "list"
default:
action = "status"
}
}
if tool == "kvm" && action == "top" {
// kvm top is now an allocation/specs view, not a live telemetry stream.
return livePluginSpec{}, false
}
if interval < 500*time.Millisecond {
interval = 500 * time.Millisecond
}
return livePluginSpec{
Tool: tool,
Action: action,
Selector: selector,
Params: params,
Interval: interval,
}, true
}
// startLiveCmd flips the model into fullscreen live-command mode and
// schedules the first execution immediately.
func (m monitorModel) startLiveCmd(spec livePluginSpec) (tea.Model, tea.Cmd) {
m.liveCmdActive = true
m.liveCmdSpec = spec
m.liveCmdBuffer = ""
m.liveCmdErr = ""
m.liveCmdRunning = true
m.liveCmdInterval = spec.Interval
m.liveCmdScroll = 0
// Leave the pxmon console so the live view owns the screen.
m.termMode = false
m.termFull = false
m.setStatus("live: " + spec.Display())
return m, m.runLiveCmdCmd(spec)
}
// liveCmdResultMsg carries one iteration of a live-command invocation.
type liveCmdResultMsg struct {
Spec livePluginSpec
Output string
Err string
}
type liveCmdTickMsg struct{}
// runLiveCmdCmd kicks off one execution of the plugin command in a
// goroutine. The result is delivered as liveCmdResultMsg and the caller
// schedules the next tick once it lands.
func (m monitorModel) runLiveCmdCmd(spec livePluginSpec) tea.Cmd {
svc := m.svc
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), pluginActionTimeout(spec.Tool, spec.Action, true))
defer cancel()
out, err := svc.RunPluginAction(ctx, spec.Selector, spec.Tool, spec.Action, spec.Params)
res := liveCmdResultMsg{Spec: spec, Output: stripANSI(out)}
if err != nil {
res.Err = err.Error()
}
return res
}
}
func liveCmdTickCmd(d time.Duration) tea.Cmd {
if d <= 0 {
d = 3 * time.Second
}
return tea.Tick(d, func(_ time.Time) tea.Msg {
return liveCmdTickMsg{}
})
}
// handleLiveCmdKey routes keys while the live-command fullscreen is active.
func (m monitorModel) handleLiveCmdKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
switch v.String() {
case "q", "esc", "ctrl+g", "ctrl+c":
m.liveCmdActive = false
m.liveCmdBuffer = ""
m.liveCmdRunning = false
m.setStatus("live: stopped")
return m, nil
case "r", "R":
m.liveCmdRunning = true
return m, m.runLiveCmdCmd(m.liveCmdSpec)
case "+":
if m.liveCmdInterval > time.Second {
m.liveCmdInterval -= time.Second
}
m.liveCmdSpec.Interval = m.liveCmdInterval
return m, nil
case "-":
m.liveCmdInterval += time.Second
if m.liveCmdInterval > 60*time.Second {
m.liveCmdInterval = 60 * time.Second
}
m.liveCmdSpec.Interval = m.liveCmdInterval
return m, nil
case "alt+up", "up", "k":
m.liveCmdScroll += 3
return m, nil
case "alt+down", "down", "j":
m.liveCmdScroll -= 3
if m.liveCmdScroll < 0 {
m.liveCmdScroll = 0
}
return m, nil
case "alt+shift+up":
m.liveCmdScroll += 15
return m, nil
case "alt+shift+down":
m.liveCmdScroll -= 15
if m.liveCmdScroll < 0 {
m.liveCmdScroll = 0
}
return m, nil
case "pgup":
m.liveCmdScroll += 20
return m, nil
case "pgdown":
m.liveCmdScroll -= 20
if m.liveCmdScroll < 0 {
m.liveCmdScroll = 0
}
return m, nil
case "home":
return m, nil
case "end":
m.liveCmdScroll = 0
return m, nil
case "alt+p":
m.privacyMode = !m.privacyMode
return m, nil
}
return m, nil
}
// renderLiveCmdView paints the fullscreen live command view.
func (m monitorModel) renderLiveCmdView(width, height int) string {
spec := m.liveCmdSpec
title := accentStyle.Bold(true).Render(fmt.Sprintf(" live %s ", spec.Display()))
var ageText string
if !m.liveCmdLastRun.IsZero() {
ageText = fmt.Sprintf("updated %s ago", time.Since(m.liveCmdLastRun).Round(time.Millisecond))
} else {
ageText = "pending…"
}
state := "idle"
if m.liveCmdRunning {
state = "running"
}
meta := dimStyle.Render(fmt.Sprintf(
"interval %s · %s · %s",
m.liveCmdInterval.Round(time.Second), state, ageText,
))
help := dimStyle.Render("q/esc exit · r refresh · +/- interval · alt+↑/↓ scroll · alt+shift+↑/↓ fast · alt+p privacy")
bodyLines := strings.Split(m.liveCmdBuffer, "\n")
if m.liveCmdErr != "" {
bodyLines = append([]string{critStyle.Render("error: " + m.liveCmdErr), ""}, bodyLines...)
}
if len(bodyLines) == 0 || (len(bodyLines) == 1 && bodyLines[0] == "") {
bodyLines = []string{dimStyle.Render("(no output yet)")}
}
viewportH := height - 6
if viewportH < 5 {
viewportH = 5
}
maxOffset := len(bodyLines) - viewportH
if maxOffset < 0 {
maxOffset = 0
}
if m.liveCmdScroll > maxOffset {
m.liveCmdScroll = maxOffset
}
end := len(bodyLines) - m.liveCmdScroll
start := end - viewportH
if start < 0 {
start = 0
}
if end > len(bodyLines) {
end = len(bodyLines)
}
windowed := bodyLines[start:end]
panelW := max(24, width)
bodyW := max(16, panelW-6)
for i := range windowed {
windowed[i] = truncateVisible(windowed[i], bodyW)
}
scrollBadge := ""
if m.liveCmdScroll > 0 {
scrollBadge = warnStyle.Render(fmt.Sprintf(" ↑ scrolled +%d (end to follow) ", m.liveCmdScroll))
}
panel := lipgloss.NewStyle().
BorderStyle(thinBorder).
BorderForeground(ccAccent).
Padding(0, 1).
Width(panelW).
MaxWidth(panelW).
Render(strings.Join(windowed, "\n"))
headerLine := title + " " + meta
if scrollBadge != "" {
headerLine += " " + scrollBadge
}
headerLine = truncateVisible(headerLine, panelW)
help = truncateVisible(help, panelW)
return lipgloss.JoinVertical(lipgloss.Left, headerLine, help, panel)
}