chore: publish pxmon v0.2.0

This commit is contained in:
2026-06-16 21:52:10 +04:00
commit 6b703db02b
69 changed files with 25886 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
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)
}