chore: publish pxmon v0.2.0
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user