307 lines
7.7 KiB
Go
307 lines
7.7 KiB
Go
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)
|
||
}
|