chore: publish pxmon v0.2.0
This commit is contained in:
+2969
File diff suppressed because it is too large
Load Diff
+1523
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pxmon/internal/cluster"
|
||||
"pxmon/internal/history"
|
||||
)
|
||||
|
||||
type samplerIfaceCounter struct {
|
||||
RxBytes uint64
|
||||
TxBytes uint64
|
||||
}
|
||||
|
||||
type samplerState struct {
|
||||
Timestamp time.Time
|
||||
Counters map[string]samplerIfaceCounter
|
||||
}
|
||||
|
||||
var (
|
||||
samplerMu sync.Mutex
|
||||
samplerPrev = map[string]*samplerState{}
|
||||
)
|
||||
|
||||
// handleBotUsageCommand intercepts usage/traffic/graph commands before they
|
||||
// reach the generic runObserverScopedCommand bridge so it can send richer
|
||||
// payloads (including PNG attachments) back to Telegram. Returns true when
|
||||
// the command was consumed.
|
||||
func handleBotUsageCommand(ctx context.Context, client *http.Client, svc *cluster.Service, token string, chatID int64, cmd string) bool {
|
||||
fields := strings.Fields(cmd)
|
||||
if len(fields) == 0 {
|
||||
return false
|
||||
}
|
||||
rest := fields
|
||||
if strings.EqualFold(fields[0], "cluster") && len(fields) > 1 {
|
||||
rest = fields[1:]
|
||||
}
|
||||
head := strings.ToLower(rest[0])
|
||||
if head != "usage" && head != "traffic" && head != "graph" && head != "p95" {
|
||||
return false
|
||||
}
|
||||
|
||||
selector := ""
|
||||
rangeStr := defaultBotRange(head)
|
||||
iface := ""
|
||||
for _, f := range rest[1:] {
|
||||
if strings.HasPrefix(f, "--iface=") {
|
||||
iface = strings.TrimSpace(strings.TrimPrefix(f, "--iface="))
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(f, "--range=") {
|
||||
rangeStr = strings.TrimPrefix(f, "--range=")
|
||||
continue
|
||||
}
|
||||
if rng, ok := history.ParseRangeShortcut(f); ok {
|
||||
rangeStr = string(rng)
|
||||
continue
|
||||
}
|
||||
if selector == "" {
|
||||
if head == "p95" && iface == "" && !strings.HasPrefix(f, "-") {
|
||||
iface = f
|
||||
} else {
|
||||
selector = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rng, ok := history.ParseRangeShortcut(strings.TrimSpace(rangeStr))
|
||||
if !ok {
|
||||
_ = telegramSendHTML(ctx, client, token, chatID,
|
||||
fmt.Sprintf("🔴 <b>%s</b>\n<pre>invalid range %q</pre>", head, rangeStr))
|
||||
return true
|
||||
}
|
||||
|
||||
gather, cancel := context.WithTimeout(ctx, 50*time.Second)
|
||||
defer cancel()
|
||||
|
||||
snap, err := svc.CollectUsageSnapshot(gather, selector, rng, "/")
|
||||
if err != nil {
|
||||
_ = telegramSendHTML(ctx, client, token, chatID,
|
||||
fmt.Sprintf("🔴 <b>%s</b>\n<pre>%s</pre>", head, htmlEscapeTelegram(err.Error())))
|
||||
return true
|
||||
}
|
||||
|
||||
switch head {
|
||||
case "usage":
|
||||
body := formatUsageForBot(snap, rng)
|
||||
header := fmt.Sprintf("🟢 <b>usage %s</b> <i>%s</i>", htmlEscapeTelegram(snap.ClusterName), rng.Label())
|
||||
for _, chunk := range splitTelegramText(htmlEscapeTelegram(body), 3400) {
|
||||
_ = telegramSendHTML(ctx, client, token, chatID, header+"\n<pre>"+chunk+"</pre>")
|
||||
header = ""
|
||||
}
|
||||
case "traffic":
|
||||
body := formatTrafficForBot(snap, rng)
|
||||
_ = telegramSendHTML(ctx, client, token, chatID,
|
||||
fmt.Sprintf("🟢 <b>traffic %s</b> <i>%s</i>\n<pre>%s</pre>",
|
||||
htmlEscapeTelegram(snap.ClusterName), rng.Label(), htmlEscapeTelegram(body)))
|
||||
case "graph":
|
||||
png, err := cluster.RenderUsageChartPNG(snap, "")
|
||||
if err != nil {
|
||||
_ = telegramSendHTML(ctx, client, token, chatID,
|
||||
fmt.Sprintf("🔴 <b>graph</b>\n<pre>%s</pre>", htmlEscapeTelegram(err.Error())))
|
||||
return true
|
||||
}
|
||||
caption := fmt.Sprintf("📈 <b>%s</b> <i>%s</i>\nP95: %s · max: %s · avg: %s · %d samples",
|
||||
htmlEscapeTelegram(snap.ClusterName),
|
||||
rng.Label(),
|
||||
formatMbpsHuman(snap.P95TotalMbps),
|
||||
formatMbpsHuman(snap.MaxTotalMbps),
|
||||
formatMbpsHuman(snap.AvgTotalMbps),
|
||||
len(snap.NodeSeries),
|
||||
)
|
||||
filename := sanitizeFilename(snap.ClusterName) + "-" + string(rng) + ".png"
|
||||
if sendErr := telegramSendPhoto(ctx, client, token, chatID, filename, png, caption); sendErr != nil {
|
||||
_ = telegramSendHTML(ctx, client, token, chatID,
|
||||
fmt.Sprintf("🔴 <b>graph</b>\n<pre>send failed: %s</pre>", htmlEscapeTelegram(sendErr.Error())))
|
||||
}
|
||||
case "p95":
|
||||
if strings.TrimSpace(iface) == "" {
|
||||
_ = telegramSendHTML(ctx, client, token, chatID, "🔴 <b>p95</b>\n<pre>iface is required (example: p95 eth0 30d)</pre>")
|
||||
return true
|
||||
}
|
||||
p95Snap, err := svc.CollectInterfaceP95(selector, iface, rng)
|
||||
if err != nil {
|
||||
_ = telegramSendHTML(ctx, client, token, chatID,
|
||||
fmt.Sprintf("🔴 <b>p95</b>\n<pre>%s</pre>", htmlEscapeTelegram(err.Error())))
|
||||
return true
|
||||
}
|
||||
png, err := svc.RenderInterfaceP95GraphPNG(p95Snap)
|
||||
if err != nil {
|
||||
_ = telegramSendHTML(ctx, client, token, chatID,
|
||||
fmt.Sprintf("🔴 <b>p95</b>\n<pre>render failed: %s</pre>", htmlEscapeTelegram(err.Error())))
|
||||
return true
|
||||
}
|
||||
filename := sanitizeFilename(p95Snap.ClusterName) + "-" + sanitizeFilename(p95Snap.Interface) + "-" + string(rng) + ".png"
|
||||
caption := fmt.Sprintf("📈 <b>P95 %s</b> <i>%s</i>\niface: %s\nP95: %s · max: %s · avg: %s · %d samples",
|
||||
htmlEscapeTelegram(p95Snap.ClusterName),
|
||||
rng.Label(),
|
||||
htmlEscapeTelegram(p95Snap.Interface),
|
||||
formatMbpsHuman(p95Snap.P95Mbps),
|
||||
formatMbpsHuman(p95Snap.MaxMbps),
|
||||
formatMbpsHuman(p95Snap.AvgMbps),
|
||||
p95Snap.Samples,
|
||||
)
|
||||
if sendErr := telegramSendPhoto(ctx, client, token, chatID, filename, png, caption); sendErr != nil {
|
||||
_ = telegramSendHTML(ctx, client, token, chatID,
|
||||
fmt.Sprintf("🔴 <b>p95</b>\n<pre>send failed: %s</pre>", htmlEscapeTelegram(sendErr.Error())))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func defaultBotRange(cmd string) string {
|
||||
switch cmd {
|
||||
case "usage":
|
||||
return "live"
|
||||
case "traffic":
|
||||
return "1h"
|
||||
case "graph":
|
||||
return "1d"
|
||||
case "p95":
|
||||
return "30d"
|
||||
}
|
||||
return "live"
|
||||
}
|
||||
|
||||
// trySendGraphAttachmentFromCLIOutput is a safety net for graph commands that
|
||||
// were executed through the generic CLI bridge and returned text like:
|
||||
//
|
||||
// Saved: <file.png>
|
||||
//
|
||||
// It uploads the generated PNG so Telegram users always receive the image.
|
||||
func trySendGraphAttachmentFromCLIOutput(ctx context.Context, client *http.Client, token string, chatID int64, cmd, out string) (bool, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(cmd))
|
||||
if len(fields) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
isGraph := false
|
||||
if strings.EqualFold(fields[0], "graph") {
|
||||
isGraph = true
|
||||
}
|
||||
if len(fields) >= 2 && strings.EqualFold(fields[0], "cluster") && strings.EqualFold(fields[1], "graph") {
|
||||
isGraph = true
|
||||
}
|
||||
if !isGraph {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.ReplaceAll(out, "\r\n", "\n"), "\n")
|
||||
saved := ""
|
||||
p95 := ""
|
||||
for _, line := range lines {
|
||||
t := strings.TrimSpace(stripANSI(line))
|
||||
if strings.HasPrefix(strings.ToLower(t), "saved:") {
|
||||
saved = strings.TrimSpace(t[len("saved:"):])
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(t), "p95:") {
|
||||
p95 = t
|
||||
}
|
||||
}
|
||||
if saved == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(saved)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read graph png %q: %w", saved, err)
|
||||
}
|
||||
|
||||
filename := filepath.Base(saved)
|
||||
caption := "📈 <b>cluster graph</b>"
|
||||
if p95 != "" {
|
||||
caption += "\n" + htmlEscapeTelegram(p95)
|
||||
}
|
||||
if err := telegramSendPhoto(ctx, client, token, chatID, filename, data, caption); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func formatTrafficForBot(snap cluster.UsageSnapshot, rng history.RangeShortcut) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "range: %s\n", rng.Label())
|
||||
fmt.Fprintf(&b, "samples: %d\n", len(snap.NodeSeries))
|
||||
fmt.Fprintf(&b, "P95: %s\n", formatMbpsHuman(snap.P95TotalMbps))
|
||||
fmt.Fprintf(&b, "max: %s\n", formatMbpsHuman(snap.MaxTotalMbps))
|
||||
fmt.Fprintf(&b, "avg: %s\n", formatMbpsHuman(snap.AvgTotalMbps))
|
||||
if snap.TopIfaceName != "" {
|
||||
fmt.Fprintf(&b, "uplink: %s (avg %s)\n", snap.TopIfaceName, formatMbpsHuman(snap.TopIfaceMbps))
|
||||
}
|
||||
if snap.HistoryError != "" {
|
||||
fmt.Fprintf(&b, "note: %s\n", snap.HistoryError)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// telegramSendPhoto uploads a PNG to Telegram via multipart sendPhoto.
|
||||
func telegramSendPhoto(ctx context.Context, client *http.Client, token string, chatID int64, filename string, png []byte, captionHTML string) error {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
|
||||
if err := w.WriteField("chat_id", strconv.FormatInt(chatID, 10)); err != nil {
|
||||
return err
|
||||
}
|
||||
if captionHTML != "" {
|
||||
if err := w.WriteField("caption", captionHTML); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.WriteField("parse_mode", "HTML"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
part, err := w.CreateFormFile("photo", filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := part.Write(png); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoint := "https://api.telegram.org/bot" + token + "/sendPhoto"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
msg := strings.TrimSpace(string(body))
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
}
|
||||
return fmt.Errorf("sendPhoto HTTP %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHistorySampler periodically appends a network snapshot for every
|
||||
// cluster that has an agent installed so long-running non-TUI processes
|
||||
// (like the bot daemon) can build up data for P95/graph queries.
|
||||
func runHistorySampler(ctx context.Context, svc *cluster.Service, interval time.Duration, logErr io.Writer) {
|
||||
if svc == nil || svc.NetworkStore() == nil {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
sampleOnce(ctx, svc, logErr)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
sampleOnce(ctx, svc, logErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sampleOnce(ctx context.Context, svc *cluster.Service, logErr io.Writer) {
|
||||
clusters, _, err := svc.List()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
store := svc.NetworkStore()
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
availStore := history.NewAvailabilityStore(svc.DataDir())
|
||||
capStore := history.NewCapacityStore(svc.DataDir())
|
||||
for _, c := range clusters {
|
||||
if !c.Agent.Installed {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
stats, err := svc.AgentStatsTyped(callCtx, c.ID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(logErr, "history sampler: %s: %v\n", c.Name, err)
|
||||
_ = availStore.Append(c.ID, history.AvailabilitySnapshot{
|
||||
Timestamp: time.Now().UTC(),
|
||||
ClusterUp: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
items := make([]history.InterfaceSample, 0, len(stats.Network))
|
||||
// With a single stats point we don't have rate info; estimate Mbps
|
||||
// per-interface as a simple counter delta versus the sampler's prior
|
||||
// snapshot if available.
|
||||
samplerMu.Lock()
|
||||
prev := samplerPrev[c.ID]
|
||||
samplerMu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
for _, n := range stats.Network {
|
||||
rxMbps := 0.0
|
||||
txMbps := 0.0
|
||||
if prev != nil {
|
||||
if pn, ok := prev.Counters[n.Interface]; ok {
|
||||
elapsed := now.Sub(prev.Timestamp).Seconds()
|
||||
if elapsed > 0.1 {
|
||||
if n.RxBytes >= pn.RxBytes {
|
||||
rxMbps = float64(n.RxBytes-pn.RxBytes) * 8 / elapsed / 1_000_000
|
||||
}
|
||||
if n.TxBytes >= pn.TxBytes {
|
||||
txMbps = float64(n.TxBytes-pn.TxBytes) * 8 / elapsed / 1_000_000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items = append(items, history.InterfaceSample{
|
||||
Interface: n.Interface,
|
||||
RxMbps: rxMbps,
|
||||
TxMbps: txMbps,
|
||||
RxDrops: n.RxDrops,
|
||||
TxDrops: n.TxDrops,
|
||||
})
|
||||
}
|
||||
counters := make(map[string]samplerIfaceCounter, len(stats.Network))
|
||||
for _, n := range stats.Network {
|
||||
counters[n.Interface] = samplerIfaceCounter{RxBytes: n.RxBytes, TxBytes: n.TxBytes}
|
||||
}
|
||||
samplerMu.Lock()
|
||||
samplerPrev[c.ID] = &samplerState{Timestamp: now, Counters: counters}
|
||||
samplerMu.Unlock()
|
||||
|
||||
// Only persist when we have a real delta — skip the first bootstrap
|
||||
// sample per cluster to avoid a meaningless zero row.
|
||||
if prev != nil {
|
||||
snap := history.NetworkSnapshot{
|
||||
Timestamp: now,
|
||||
Interfaces: items,
|
||||
}
|
||||
if err := store.Append(c.ID, snap); err != nil {
|
||||
fmt.Fprintf(logErr, "history sampler: append %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
// Availability and VM state snapshot.
|
||||
vmStates := map[string]string{}
|
||||
vmCtx, vmCancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
if rawStates, vmErr := svc.ListVMStates(vmCtx, c.ID); vmErr == nil {
|
||||
vmStates = rawStates
|
||||
}
|
||||
vmCancel()
|
||||
_ = availStore.Append(c.ID, history.AvailabilitySnapshot{
|
||||
Timestamp: time.Now().UTC(),
|
||||
ClusterUp: true,
|
||||
VMStates: vmStates,
|
||||
})
|
||||
// Capacity snapshot (disk usage trend source).
|
||||
disks := make([]history.CapacityDiskPoint, 0, len(stats.Disk))
|
||||
for _, d := range stats.Disk {
|
||||
if d.TotalBytes == 0 {
|
||||
continue
|
||||
}
|
||||
disks = append(disks, history.CapacityDiskPoint{
|
||||
Mount: d.MountPoint,
|
||||
UsedBytes: d.UsedBytes,
|
||||
TotalBytes: d.TotalBytes,
|
||||
})
|
||||
}
|
||||
if len(disks) > 0 {
|
||||
_ = capStore.Append(c.ID, history.CapacitySnapshot{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Disks: disks,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pxmon/internal/cluster"
|
||||
)
|
||||
|
||||
type observerCommandOptions struct {
|
||||
AllowShellEscape bool
|
||||
StatsAutoOnce bool
|
||||
BlockBotRun bool
|
||||
StripANSI bool
|
||||
EmbeddedConsole bool
|
||||
}
|
||||
|
||||
var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
|
||||
|
||||
func stripANSI(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return ansiEscapeRE.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func runObserverScopedCommand(svc *cluster.Service, configPath, line string, opts observerCommandOptions) (string, int) {
|
||||
out, code := runObserverScopedCommandInner(svc, configPath, line, opts)
|
||||
if opts.StripANSI {
|
||||
out = stripANSI(out)
|
||||
}
|
||||
return out, code
|
||||
}
|
||||
|
||||
func runObserverScopedCommandInner(svc *cluster.Service, configPath, line string, opts observerCommandOptions) (string, int) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return "empty command", 2
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "!") {
|
||||
if !opts.AllowShellEscape {
|
||||
return "shell escape is disabled for this channel", 2
|
||||
}
|
||||
cmdline := strings.TrimSpace(strings.TrimPrefix(line, "!"))
|
||||
if cmdline == "" {
|
||||
return "usage: !<shell command>", 2
|
||||
}
|
||||
cmd := exec.Command("/bin/sh", "-lc", cmdline)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return string(out), exitErr.ExitCode()
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return err.Error(), 1
|
||||
}
|
||||
return string(out), 1
|
||||
}
|
||||
return string(out), 0
|
||||
}
|
||||
|
||||
args, err := parseShellArgs(line)
|
||||
if err != nil || len(args) == 0 {
|
||||
if err != nil {
|
||||
return err.Error(), 2
|
||||
}
|
||||
return "empty command", 2
|
||||
}
|
||||
|
||||
norm := normalizeObserverConsoleArgs(args)
|
||||
if len(norm) == 0 {
|
||||
return "empty command", 2
|
||||
}
|
||||
|
||||
if opts.BlockBotRun && len(norm) >= 3 &&
|
||||
strings.EqualFold(norm[0], "bot") &&
|
||||
strings.EqualFold(norm[1], "telegram") &&
|
||||
strings.EqualFold(norm[2], "run") {
|
||||
return "running `bot telegram run` from bot channel is blocked", 2
|
||||
}
|
||||
|
||||
if opts.StatsAutoOnce && len(norm) >= 2 &&
|
||||
strings.EqualFold(norm[0], "cluster") &&
|
||||
strings.EqualFold(norm[1], "stats") &&
|
||||
!hasArg(norm[2:], "--once") {
|
||||
norm = append(norm, "--once")
|
||||
}
|
||||
|
||||
if handled, out, code := runPluginArgs(svc, norm); handled {
|
||||
return out, code
|
||||
}
|
||||
|
||||
return runObserverCommand(configPath, norm, opts.EmbeddedConsole)
|
||||
}
|
||||
|
||||
func runPluginArgs(svc *cluster.Service, args []string) (bool, string, int) {
|
||||
if len(args) == 0 || svc == nil {
|
||||
return false, "", 0
|
||||
}
|
||||
|
||||
tool := strings.ToLower(strings.TrimSpace(args[0]))
|
||||
switch tool {
|
||||
case "kvm", "lxc", "lxd", "bird", "frr":
|
||||
default:
|
||||
return false, "", 0
|
||||
}
|
||||
|
||||
selector, rest, err := parseClusterSelectorArg(args[1:])
|
||||
if err != nil {
|
||||
return true, err.Error(), 2
|
||||
}
|
||||
|
||||
action := ""
|
||||
params := []string{}
|
||||
if len(rest) > 0 {
|
||||
action = strings.ToLower(strings.TrimSpace(rest[0]))
|
||||
params = rest[1:]
|
||||
}
|
||||
if action == "" {
|
||||
switch tool {
|
||||
case "kvm", "lxc", "lxd":
|
||||
action = "list"
|
||||
default:
|
||||
action = "status"
|
||||
}
|
||||
}
|
||||
if tool == "kvm" && action == "top" {
|
||||
for _, p := range params {
|
||||
if strings.EqualFold(strings.TrimSpace(p), "--live") || strings.EqualFold(strings.TrimSpace(p), "-L") {
|
||||
return true, "kvm top --live is removed; use `kvm top` for allocated VM specs", 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), pluginActionTimeout(tool, action, false))
|
||||
defer cancel()
|
||||
|
||||
out, execErr := svc.RunPluginAction(ctx, selector, tool, action, params)
|
||||
if execErr != nil {
|
||||
return true, execErr.Error(), 1
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
out = "ok"
|
||||
}
|
||||
return true, out, 0
|
||||
}
|
||||
|
||||
func pluginActionTimeout(tool, action string, live bool) time.Duration {
|
||||
tool = strings.ToLower(strings.TrimSpace(tool))
|
||||
action = strings.ToLower(strings.TrimSpace(action))
|
||||
switch tool {
|
||||
case "kvm":
|
||||
switch action {
|
||||
case "top":
|
||||
if live {
|
||||
return 2 * time.Minute
|
||||
}
|
||||
return 90 * time.Second
|
||||
case "net-top", "net":
|
||||
if live {
|
||||
return 90 * time.Second
|
||||
}
|
||||
return 60 * time.Second
|
||||
default:
|
||||
return 45 * time.Second
|
||||
}
|
||||
case "lxc", "lxd":
|
||||
if action == "top" || action == "net-top" || action == "net" {
|
||||
return 45 * time.Second
|
||||
}
|
||||
}
|
||||
if live {
|
||||
return 35 * time.Second
|
||||
}
|
||||
return 25 * time.Second
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,497 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"pxmon/internal/cluster"
|
||||
"pxmon/internal/history"
|
||||
)
|
||||
|
||||
// liveClusterStat is one row on the live dashboard. Fields are best-effort;
|
||||
// if the agent is unreachable we still render the row with an error so the
|
||||
// operator sees the node exists but is degraded.
|
||||
type liveClusterStat struct {
|
||||
ClusterID string
|
||||
Name string
|
||||
Host string
|
||||
Reachable bool
|
||||
Err string
|
||||
CPUPercent float64
|
||||
MemPercent float64
|
||||
MemUsed uint64
|
||||
MemTotal uint64
|
||||
DiskUsePct float64
|
||||
NetMbps float64 // current: last point from history
|
||||
P95Mbps float64 // primary iface, 24h window
|
||||
Uplink string
|
||||
HostName string
|
||||
}
|
||||
|
||||
type monitorLiveMsg struct {
|
||||
entries []liveClusterStat
|
||||
err error
|
||||
}
|
||||
|
||||
type liveAutoTickMsg struct{}
|
||||
|
||||
func liveAutoTickCmd() tea.Cmd {
|
||||
return tea.Tick(liveAutoTickInterval, func(_ time.Time) tea.Msg {
|
||||
return liveAutoTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
livePageSize = 14
|
||||
liveFetchWorkers = 16
|
||||
liveAutoTickInterval = 5 * time.Second
|
||||
liveP95HistoryWindow = 24 * time.Hour
|
||||
liveFetchClusterTO = 5 * time.Second
|
||||
liveFetchOverallTO = 25 * time.Second
|
||||
)
|
||||
|
||||
// fetchLiveCmd gathers live stats for every cluster with an installed agent
|
||||
// in parallel, pairs each with P95 from history, and emits a sorted slice.
|
||||
func (m monitorModel) fetchLiveCmd() tea.Cmd {
|
||||
svc := m.svc
|
||||
return func() tea.Msg {
|
||||
clusters, _, err := svc.List()
|
||||
if err != nil {
|
||||
return monitorLiveMsg{err: err}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), liveFetchOverallTO)
|
||||
defer cancel()
|
||||
|
||||
type work struct {
|
||||
idx int
|
||||
c cluster.Cluster
|
||||
}
|
||||
ch := make(chan work, len(clusters))
|
||||
results := make([]liveClusterStat, len(clusters))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
workers := liveFetchWorkers
|
||||
if workers > len(clusters) {
|
||||
workers = len(clusters)
|
||||
}
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for item := range ch {
|
||||
results[item.idx] = collectLiveRow(ctx, svc, item.c)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i, c := range clusters {
|
||||
ch <- work{idx: i, c: c}
|
||||
}
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
|
||||
out := make([]liveClusterStat, 0, len(results))
|
||||
for _, r := range results {
|
||||
if r.ClusterID == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return monitorLiveMsg{entries: out}
|
||||
}
|
||||
}
|
||||
|
||||
func collectLiveRow(ctx context.Context, svc *cluster.Service, c cluster.Cluster) liveClusterStat {
|
||||
row := liveClusterStat{
|
||||
ClusterID: c.ID,
|
||||
Name: c.Name,
|
||||
Host: c.Host,
|
||||
}
|
||||
if !c.Agent.Installed {
|
||||
row.Err = "agent not installed"
|
||||
return row
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, liveFetchClusterTO)
|
||||
defer cancel()
|
||||
stats, err := svc.AgentStatsTyped(callCtx, c.ID)
|
||||
if err != nil {
|
||||
row.Err = err.Error()
|
||||
return row
|
||||
}
|
||||
row.Reachable = true
|
||||
row.CPUPercent = stats.CPU.UsagePercent
|
||||
row.MemPercent = stats.Memory.UsedPercent
|
||||
row.MemUsed = stats.Memory.UsedBytes
|
||||
row.MemTotal = stats.Memory.TotalBytes
|
||||
row.HostName = stats.Host.Hostname
|
||||
var maxDisk float64
|
||||
for _, d := range stats.Disk {
|
||||
if d.UsedPercent > maxDisk {
|
||||
maxDisk = d.UsedPercent
|
||||
}
|
||||
}
|
||||
row.DiskUsePct = maxDisk
|
||||
|
||||
// Historical P95 from the persisted store (populated by the sampler
|
||||
// running inside TUI or bot daemon).
|
||||
if store := svc.NetworkStore(); store != nil {
|
||||
since := time.Now().Add(-liveP95HistoryWindow)
|
||||
if snaps, err := store.Load(c.ID, since); err == nil && len(snaps) > 0 {
|
||||
primary := history.PrimaryInterface(snaps)
|
||||
row.Uplink = primary
|
||||
series := history.AggregateNodeSeries(snaps, primary)
|
||||
if len(series) > 0 {
|
||||
row.P95Mbps = history.PercentileMbps(series, 95)
|
||||
row.NetMbps = series[len(series)-1].TotalMbps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback / live reading: take a second stats snapshot after a short
|
||||
// pause and derive the instantaneous per-interface Mbps ourselves.
|
||||
// This makes NET Mbps meaningful even when the history store is empty
|
||||
// (e.g. the sampler hasn't been running long).
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
callCtx2, cancel2 := context.WithTimeout(ctx, liveFetchClusterTO)
|
||||
defer cancel2()
|
||||
stats2, err := svc.AgentStatsTyped(callCtx2, c.ID)
|
||||
if err != nil {
|
||||
return row
|
||||
}
|
||||
elapsed := stats2.Timestamp.Sub(stats.Timestamp).Seconds()
|
||||
if elapsed <= 0 {
|
||||
elapsed = 0.7
|
||||
}
|
||||
prev := make(map[string]struct {
|
||||
rx, tx uint64
|
||||
}, len(stats.Network))
|
||||
for _, n := range stats.Network {
|
||||
prev[n.Interface] = struct{ rx, tx uint64 }{n.RxBytes, n.TxBytes}
|
||||
}
|
||||
bestName := ""
|
||||
bestMbps := -1.0
|
||||
for _, n := range stats2.Network {
|
||||
if isLiveVirtual(n.Interface) {
|
||||
continue
|
||||
}
|
||||
p, ok := prev[n.Interface]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var rxB, txB uint64
|
||||
if n.RxBytes >= p.rx {
|
||||
rxB = n.RxBytes - p.rx
|
||||
}
|
||||
if n.TxBytes >= p.tx {
|
||||
txB = n.TxBytes - p.tx
|
||||
}
|
||||
rxMbps := float64(rxB) * 8 / elapsed / 1_000_000
|
||||
txMbps := float64(txB) * 8 / elapsed / 1_000_000
|
||||
m := rxMbps
|
||||
if txMbps > m {
|
||||
m = txMbps
|
||||
}
|
||||
if m > bestMbps {
|
||||
bestMbps = m
|
||||
bestName = n.Interface
|
||||
}
|
||||
}
|
||||
if bestMbps >= 0 {
|
||||
row.NetMbps = bestMbps
|
||||
if row.Uplink == "" {
|
||||
row.Uplink = bestName
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// isLiveVirtual mirrors history.isVirtualIface but is local so we don't
|
||||
// export the original.
|
||||
func isLiveVirtual(name string) bool {
|
||||
n := strings.ToLower(name)
|
||||
if n == "" || n == "lo" {
|
||||
return true
|
||||
}
|
||||
prefixes := []string{
|
||||
"lo", "docker", "br-", "br", "veth", "vnet", "tap", "virbr",
|
||||
"cni", "flannel", "wg", "tun", "tailscale", "zt", "kube",
|
||||
"cilium", "ovs", "podman", "dummy",
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(n, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleLiveKey routes keys while the live dashboard is visible.
|
||||
func (m monitorModel) handleLiveKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch v.String() {
|
||||
case "o", "esc":
|
||||
m.view = viewOverview
|
||||
return m, nil
|
||||
case "c":
|
||||
m.view = viewClusters
|
||||
return m, nil
|
||||
case "r", "R":
|
||||
m.liveLoading = true
|
||||
m.setStatus("live: refreshing")
|
||||
return m, m.fetchLiveCmd()
|
||||
case "up", "k":
|
||||
if m.liveCursor > 0 {
|
||||
m.liveCursor--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.liveCursor < len(m.liveEntries)-1 {
|
||||
m.liveCursor++
|
||||
}
|
||||
return m, nil
|
||||
case "pgup":
|
||||
if m.livePage > 0 {
|
||||
m.livePage--
|
||||
}
|
||||
return m, nil
|
||||
case "pgdown":
|
||||
pages := (len(m.liveEntries) + livePageSize - 1) / livePageSize
|
||||
if m.livePage < pages-1 {
|
||||
m.livePage++
|
||||
}
|
||||
return m, nil
|
||||
case "home":
|
||||
m.liveCursor = 0
|
||||
m.livePage = 0
|
||||
return m, nil
|
||||
case "end":
|
||||
m.liveCursor = len(m.liveEntries) - 1
|
||||
if m.liveCursor < 0 {
|
||||
m.liveCursor = 0
|
||||
}
|
||||
pages := (len(m.liveEntries) + livePageSize - 1) / livePageSize
|
||||
if pages > 0 {
|
||||
m.livePage = pages - 1
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.livePinned == nil {
|
||||
m.livePinned = map[string]bool{}
|
||||
}
|
||||
rows := m.sortedLiveEntries()
|
||||
if m.liveCursor >= 0 && m.liveCursor < len(rows) {
|
||||
id := rows[m.liveCursor].ClusterID
|
||||
if m.livePinned[id] {
|
||||
delete(m.livePinned, id)
|
||||
m.setStatus("live: unpinned " + rows[m.liveCursor].Name)
|
||||
} else {
|
||||
m.livePinned[id] = true
|
||||
m.setStatus("live: pinned " + rows[m.liveCursor].Name)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case "1":
|
||||
m.liveSort = 0
|
||||
return m, nil
|
||||
case "2":
|
||||
m.liveSort = 1
|
||||
return m, nil
|
||||
case "3":
|
||||
m.liveSort = 2
|
||||
return m, nil
|
||||
case "4":
|
||||
m.liveSort = 3
|
||||
return m, nil
|
||||
case "5":
|
||||
m.liveSort = 4
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// sortedLiveEntries returns entries sorted by the active metric. Pinned
|
||||
// clusters always float to the top so the operator keeps eyes on them even
|
||||
// when their usage changes.
|
||||
func (m monitorModel) sortedLiveEntries() []liveClusterStat {
|
||||
entries := make([]liveClusterStat, len(m.liveEntries))
|
||||
copy(entries, m.liveEntries)
|
||||
key := func(r liveClusterStat) float64 {
|
||||
switch m.liveSort {
|
||||
case 1:
|
||||
return r.MemPercent
|
||||
case 2:
|
||||
return r.NetMbps
|
||||
case 3:
|
||||
return r.P95Mbps
|
||||
case 4:
|
||||
return r.DiskUsePct
|
||||
default:
|
||||
return r.CPUPercent
|
||||
}
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
pi := m.livePinned[entries[i].ClusterID]
|
||||
pj := m.livePinned[entries[j].ClusterID]
|
||||
if pi != pj {
|
||||
return pi
|
||||
}
|
||||
return key(entries[i]) > key(entries[j])
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func liveSortLabel(n int) string {
|
||||
switch n {
|
||||
case 1:
|
||||
return "ram"
|
||||
case 2:
|
||||
return "net"
|
||||
case 3:
|
||||
return "p95"
|
||||
case 4:
|
||||
return "disk"
|
||||
default:
|
||||
return "cpu"
|
||||
}
|
||||
}
|
||||
|
||||
// renderLiveDashboard paints the paginated live view.
|
||||
func (m monitorModel) renderLiveDashboard(width int) string {
|
||||
title := titleStyle.Render("━━ Live cluster fleet ━━")
|
||||
help := dimStyle.Render("sort: 1=cpu 2=ram 3=net 4=p95 5=disk enter pin r refresh ↑/↓ select pgup/pgdn page o back")
|
||||
|
||||
if m.liveLoading && len(m.liveEntries) == 0 {
|
||||
body := dimStyle.Render("collecting stats from every cluster…")
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, help, "", body)
|
||||
}
|
||||
if m.liveError != "" && len(m.liveEntries) == 0 {
|
||||
body := critStyle.Render("error: " + m.liveError)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, help, "", body)
|
||||
}
|
||||
if len(m.liveEntries) == 0 {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, help, "",
|
||||
dimStyle.Render("no clusters with installed agents"))
|
||||
}
|
||||
|
||||
entries := m.sortedLiveEntries()
|
||||
total := len(entries)
|
||||
pages := (total + livePageSize - 1) / livePageSize
|
||||
if pages == 0 {
|
||||
pages = 1
|
||||
}
|
||||
if m.livePage >= pages {
|
||||
m.livePage = pages - 1
|
||||
}
|
||||
start := m.livePage * livePageSize
|
||||
end := start + livePageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
page := entries[start:end]
|
||||
|
||||
headerLine := fmt.Sprintf(" %-3s %-16s %-3s %6s %6s %14s %12s %12s %6s %-10s",
|
||||
"#", "NAME", "PIN", "CPU%", "RAM%", "RAM", "NET", "P95", "DISK%", "UPLINK")
|
||||
lines := []string{title, help, "",
|
||||
dimStyle.Render(fmt.Sprintf("sorted by %s · page %d/%d · %d clusters · refresh every %s · enter=pin",
|
||||
liveSortLabel(m.liveSort), m.livePage+1, pages, total, liveAutoTickInterval)),
|
||||
"",
|
||||
accentStyle.Bold(true).Render(headerLine),
|
||||
}
|
||||
|
||||
for i, row := range page {
|
||||
absoluteIdx := start + i
|
||||
selected := absoluteIdx == m.liveCursor
|
||||
pin := " "
|
||||
if m.livePinned[row.ClusterID] {
|
||||
pin = "⚑"
|
||||
}
|
||||
name := row.Name
|
||||
if len(name) > 16 {
|
||||
name = name[:13] + "..."
|
||||
}
|
||||
if !row.Reachable {
|
||||
line := fmt.Sprintf(" %-3d %-16s %-3s %s",
|
||||
absoluteIdx+1, name, pin, critStyle.Render("unreachable: "+clipRight(row.Err, 60)))
|
||||
if selected {
|
||||
line = selectedRowStyle.Render(line)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
cpuStr := colorByThreshold(fmt.Sprintf("%5.1f", row.CPUPercent), row.CPUPercent, 50, 80)
|
||||
ramStr := colorByThreshold(fmt.Sprintf("%5.1f", row.MemPercent), row.MemPercent, 50, 80)
|
||||
ramAbs := fmt.Sprintf("%7s/%-6s", humanBytesUint(row.MemUsed), humanBytesUint(row.MemTotal))
|
||||
netStr := fmt.Sprintf("%12s", formatMbpsHuman(row.NetMbps))
|
||||
var p95Str string
|
||||
if row.P95Mbps > 0 {
|
||||
p95Str = fmt.Sprintf("%12s", formatMbpsHuman(row.P95Mbps))
|
||||
} else {
|
||||
p95Str = dimStyle.Render(fmt.Sprintf("%12s", "— (no hist)"))
|
||||
}
|
||||
diskStr := colorByThreshold(fmt.Sprintf("%5.1f", row.DiskUsePct), row.DiskUsePct, 70, 90)
|
||||
uplink := row.Uplink
|
||||
if uplink == "" {
|
||||
uplink = "-"
|
||||
}
|
||||
if len(uplink) > 10 {
|
||||
uplink = uplink[:10]
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(" %-3d %-16s %-3s %s %s %14s %s %s %s %-10s",
|
||||
absoluteIdx+1, name, pin,
|
||||
cpuStr, ramStr, ramAbs, netStr, p95Str, diskStr, uplink)
|
||||
if selected {
|
||||
body = selectedRowStyle.Render(body)
|
||||
}
|
||||
lines = append(lines, body)
|
||||
}
|
||||
|
||||
// Stats footer.
|
||||
var totalCPU, totalMem, totalNet, totalP95 float64
|
||||
reachable := 0
|
||||
for _, r := range entries {
|
||||
if !r.Reachable {
|
||||
continue
|
||||
}
|
||||
reachable++
|
||||
totalCPU += r.CPUPercent
|
||||
totalMem += r.MemPercent
|
||||
totalNet += r.NetMbps
|
||||
totalP95 += r.P95Mbps
|
||||
}
|
||||
if reachable > 0 {
|
||||
lines = append(lines, "",
|
||||
dimStyle.Render(fmt.Sprintf("fleet avg: cpu %.1f%% · ram %.1f%% · net %s · p95 %s · reachable %d/%d",
|
||||
totalCPU/float64(reachable),
|
||||
totalMem/float64(reachable),
|
||||
formatMbpsHuman(totalNet),
|
||||
formatMbpsHuman(totalP95),
|
||||
reachable, total)))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
var selectedRowStyle = lipgloss.NewStyle().Background(lipgloss.Color("#3A3A3A")).Bold(true)
|
||||
|
||||
func colorByThreshold(text string, value, warn, crit float64) string {
|
||||
switch {
|
||||
case value >= crit:
|
||||
return critStyle.Render(text)
|
||||
case value >= warn:
|
||||
return warnStyle.Render(text)
|
||||
default:
|
||||
return okStyle.Render(text)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Privacy mode redacts sensitive tokens (IPs, long secret-like strings,
|
||||
// hostnames, MAC addresses, ssh key material) from rendered output. The
|
||||
// replacement uses a shifted block pattern (▚▞) which preserves token
|
||||
// length so the layout doesn't shift but makes the content obviously
|
||||
// unreadable — think "frosted glass" rather than the usual `****`.
|
||||
|
||||
var (
|
||||
privacyIPv4 = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
||||
privacyIPv6 = regexp.MustCompile(`\b(?:[0-9a-fA-F]{1,4}:){2,}[0-9a-fA-F:]{0,}\b`)
|
||||
privacyMAC = regexp.MustCompile(`\b(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}\b`)
|
||||
privacyHost = regexp.MustCompile(`\b[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}(?:\.[a-zA-Z0-9\-]{1,63}){1,}\b`)
|
||||
privacyHex = regexp.MustCompile(`\b[A-Fa-f0-9]{24,}\b`)
|
||||
privacyB64 = regexp.MustCompile(`\b[A-Za-z0-9+/]{28,}={0,2}\b`)
|
||||
privacyKey = regexp.MustCompile(`(?i)(password|passwd|token|secret|apikey|api[_-]?key|bearer|authorization)\s*[:=]\s*\S+`)
|
||||
privacyUsrAt = regexp.MustCompile(`[A-Za-z0-9._-]+@[A-Za-z0-9.-]+`)
|
||||
)
|
||||
|
||||
// privacyGlyphs is a small set of dense unicode shade characters. By
|
||||
// cycling through them the redacted range looks like diffused noise
|
||||
// rather than a flat mask.
|
||||
var privacyGlyphs = []rune{'▚', '▞', '▓', '▒'}
|
||||
|
||||
// privacyMask returns a redaction string the same visual length as src.
|
||||
func privacyMask(src string) string {
|
||||
rs := []rune(src)
|
||||
out := make([]rune, len(rs))
|
||||
for i, r := range rs {
|
||||
if r == ' ' || r == '\t' || r == '\n' {
|
||||
out[i] = r
|
||||
continue
|
||||
}
|
||||
out[i] = privacyGlyphs[i%len(privacyGlyphs)]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// privacyRedact scrubs every sensitive pattern from the given line. The
|
||||
// function is intentionally line-scoped — callers apply it row by row so
|
||||
// that multi-line ANSI layouts survive the substitution.
|
||||
func privacyRedact(line string) string {
|
||||
if line == "" {
|
||||
return line
|
||||
}
|
||||
replace := func(re *regexp.Regexp, s string) string {
|
||||
return re.ReplaceAllStringFunc(s, privacyMask)
|
||||
}
|
||||
// Order matters: scrub the longest/most specific patterns first so
|
||||
// later passes don't hit already-masked text.
|
||||
line = privacyKey.ReplaceAllStringFunc(line, func(m string) string {
|
||||
// Keep the label (password/token/etc) but mask the value.
|
||||
idx := strings.IndexAny(m, "=:")
|
||||
if idx < 0 {
|
||||
return privacyMask(m)
|
||||
}
|
||||
return m[:idx+1] + privacyMask(strings.TrimLeft(m[idx+1:], " "))
|
||||
})
|
||||
line = replace(privacyB64, line)
|
||||
line = replace(privacyHex, line)
|
||||
line = replace(privacyMAC, line)
|
||||
line = replace(privacyIPv4, line)
|
||||
line = replace(privacyIPv6, line)
|
||||
line = replace(privacyUsrAt, line)
|
||||
line = replace(privacyHost, line)
|
||||
return line
|
||||
}
|
||||
|
||||
// applyPrivacyMultiline redacts every line independently. Safe to call
|
||||
// on ANSI-styled output — masks only the literal runs a regex matches.
|
||||
func applyPrivacyMultiline(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, ln := range lines {
|
||||
lines[i] = privacyRedact(ln)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/hinshun/vt10x"
|
||||
|
||||
"pxmon/internal/cluster"
|
||||
)
|
||||
|
||||
// sshStartedMsg is dispatched when an embedded SSH session finishes dialing.
|
||||
type sshStartedMsg struct {
|
||||
session *cluster.InteractiveSession
|
||||
cluster cluster.Cluster
|
||||
cols int
|
||||
rows int
|
||||
err error
|
||||
}
|
||||
|
||||
// sshChunkMsg delivers a chunk of remote PTY output to the model.
|
||||
type sshChunkMsg struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// sshClosedMsg signals that the embedded session was torn down.
|
||||
type sshClosedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// sshTickMsg throttles vt10x → view repaints while output is flowing.
|
||||
type sshTickMsg struct{}
|
||||
|
||||
const (
|
||||
sshMinCols = 20
|
||||
sshMinRows = 5
|
||||
sshChunkBuffer = 16384
|
||||
sshRepaintInterval = 33 * time.Millisecond
|
||||
)
|
||||
|
||||
// sshEmbeddedDims returns the usable grid size for the SSH panel.
|
||||
func (m monitorModel) sshEmbeddedDims() (int, int) {
|
||||
w := m.width
|
||||
if w <= 0 {
|
||||
w = 120
|
||||
}
|
||||
h := m.height
|
||||
if h <= 0 {
|
||||
h = 32
|
||||
}
|
||||
cols := w - 4
|
||||
rows := h - 4
|
||||
if cols < sshMinCols {
|
||||
cols = sshMinCols
|
||||
}
|
||||
if rows < sshMinRows {
|
||||
rows = sshMinRows
|
||||
}
|
||||
return cols, rows
|
||||
}
|
||||
|
||||
// startEmbeddedSSHCmd kicks off an SSH dial in a goroutine and returns
|
||||
// the started session through an sshStartedMsg.
|
||||
func (m monitorModel) startEmbeddedSSHCmd(selector string) tea.Cmd {
|
||||
svc := m.svc
|
||||
cols, rows := m.sshEmbeddedDims()
|
||||
c, err := svc.Get(selector)
|
||||
if err != nil {
|
||||
return func() tea.Msg { return sshStartedMsg{err: err} }
|
||||
}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
sess, err := svc.StartInteractiveShell(ctx, c.ID, cols, rows, "xterm-256color")
|
||||
if err != nil {
|
||||
return sshStartedMsg{err: err, cluster: c}
|
||||
}
|
||||
return sshStartedMsg{
|
||||
session: sess,
|
||||
cluster: c,
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readSSHChunkCmd reads the next chunk from the SSH session in a goroutine.
|
||||
func readSSHChunkCmd(sess *cluster.InteractiveSession) tea.Cmd {
|
||||
if sess == nil {
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
buf := make([]byte, sshChunkBuffer)
|
||||
n, err := sess.Read(buf)
|
||||
if n > 0 {
|
||||
data := make([]byte, n)
|
||||
copy(data, buf[:n])
|
||||
return sshChunkMsg{data: data, err: err}
|
||||
}
|
||||
return sshChunkMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// sshRepaintTickCmd schedules a throttled repaint while output flows.
|
||||
func sshRepaintTickCmd() tea.Cmd {
|
||||
return tea.Tick(sshRepaintInterval, func(_ time.Time) tea.Msg {
|
||||
return sshTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
// enterEmbeddedSSH switches the model into embedded SSH view for the
|
||||
// freshly dialed session.
|
||||
func (m *monitorModel) enterEmbeddedSSH(msg sshStartedMsg) {
|
||||
cols := msg.cols
|
||||
rows := msg.rows
|
||||
if cols < sshMinCols {
|
||||
cols = sshMinCols
|
||||
}
|
||||
if rows < sshMinRows {
|
||||
rows = sshMinRows
|
||||
}
|
||||
vt := vt10x.New(vt10x.WithSize(cols, rows))
|
||||
m.sshMode = true
|
||||
m.sshSess = msg.session
|
||||
m.sshCluster = msg.cluster
|
||||
m.sshVT = vt
|
||||
m.sshCols = cols
|
||||
m.sshRows = rows
|
||||
m.sshClosed = false
|
||||
m.sshErr = ""
|
||||
m.sshPendingRepaint = false
|
||||
}
|
||||
|
||||
// exitEmbeddedSSH tears down the embedded session and clears state.
|
||||
func (m *monitorModel) exitEmbeddedSSH(reason string) {
|
||||
if m.sshSess != nil {
|
||||
_ = m.sshSess.Close()
|
||||
}
|
||||
m.sshSess = nil
|
||||
m.sshVT = nil
|
||||
m.sshMode = false
|
||||
m.sshClosed = true
|
||||
m.sshPendingRepaint = false
|
||||
// Return to pxmon console so the user lands where they launched from.
|
||||
m.termMode = true
|
||||
m.termFull = false
|
||||
if reason != "" {
|
||||
m.setStatus(reason)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSSHInput pushes a byte slice to the SSH session's stdin.
|
||||
func (m monitorModel) writeSSHInput(p []byte) {
|
||||
if m.sshSess == nil || len(p) == 0 {
|
||||
return
|
||||
}
|
||||
_, _ = m.sshSess.Write(p)
|
||||
}
|
||||
|
||||
// handleSSHKey routes TUI key events into the remote PTY stdin.
|
||||
func (m monitorModel) handleSSHKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Escape hatch: Ctrl+] closes the embedded session.
|
||||
if v.Type == tea.KeyCtrlCloseBracket {
|
||||
m.exitEmbeddedSSH(fmt.Sprintf("ssh closed (%s)", m.sshCluster.Name))
|
||||
m.appendConsoleOutput(fmt.Sprintf("ssh session closed (%s)", m.sshCluster.Name))
|
||||
return m, nil
|
||||
}
|
||||
// Scrollback controls — mirror the pxmon console (alt+↑/↓, pgup/pgdn,
|
||||
// alt+home/end). Alt+Shift+↑/↓ jump 10 lines at a time for fast review.
|
||||
// These never reach the remote PTY.
|
||||
switch v.String() {
|
||||
case "alt+up":
|
||||
m.scrollSSH(1)
|
||||
return m, nil
|
||||
case "alt+down":
|
||||
m.scrollSSH(-1)
|
||||
return m, nil
|
||||
case "alt+shift+up":
|
||||
m.scrollSSH(10)
|
||||
return m, nil
|
||||
case "alt+shift+down":
|
||||
m.scrollSSH(-10)
|
||||
return m, nil
|
||||
case "pgup":
|
||||
m.scrollSSH(m.sshRows - 1)
|
||||
return m, nil
|
||||
case "pgdown":
|
||||
m.scrollSSH(-(m.sshRows - 1))
|
||||
return m, nil
|
||||
case "alt+home":
|
||||
m.sshScrollOffset = len(m.sshScrollback)
|
||||
m.clampSSHScroll()
|
||||
return m, nil
|
||||
case "alt+end":
|
||||
m.sshScrollOffset = 0
|
||||
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
|
||||
}
|
||||
// Any other input resumes live view if the user was scrolling back.
|
||||
if m.sshScrollOffset != 0 {
|
||||
m.sshScrollOffset = 0
|
||||
}
|
||||
payload := keyMsgToPTY(v)
|
||||
if len(payload) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.writeSSHInput(payload)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// captureSSHScrollback appends remote output (with ANSI sequences stripped)
|
||||
// to the scrollback buffer so the user can scroll through past output even
|
||||
// though vt10x does not retain a scroll history of its own.
|
||||
func (m *monitorModel) captureSSHScrollback(data []byte) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
text := stripANSI(string(data))
|
||||
// Treat stand-alone CR as a rewrite of the current line; drop content
|
||||
// before the CR to avoid duplicating progress-bar style updates.
|
||||
combined := m.sshScrollPending + text
|
||||
combined = strings.ReplaceAll(combined, "\r\n", "\n")
|
||||
lines := strings.Split(combined, "\n")
|
||||
// Last element is either a trailing newline ("") or a partial line; stash.
|
||||
m.sshScrollPending = lines[len(lines)-1]
|
||||
lines = lines[:len(lines)-1]
|
||||
for _, ln := range lines {
|
||||
if idx := strings.LastIndex(ln, "\r"); idx >= 0 {
|
||||
ln = ln[idx+1:]
|
||||
}
|
||||
m.sshScrollback = append(m.sshScrollback, ln)
|
||||
}
|
||||
const maxScrollback = 5000
|
||||
if len(m.sshScrollback) > maxScrollback {
|
||||
m.sshScrollback = m.sshScrollback[len(m.sshScrollback)-maxScrollback:]
|
||||
}
|
||||
// A new chunk means more history arrived behind the current scroll
|
||||
// window — adjust offset so the user keeps looking at the same line.
|
||||
if m.sshScrollOffset > 0 {
|
||||
m.sshScrollOffset += len(lines)
|
||||
m.clampSSHScroll()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *monitorModel) scrollSSH(delta int) {
|
||||
m.sshScrollOffset += delta
|
||||
m.clampSSHScroll()
|
||||
}
|
||||
|
||||
func (m *monitorModel) clampSSHScroll() {
|
||||
maxOffset := len(m.sshScrollback) - m.sshRows
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
if m.sshScrollOffset > maxOffset {
|
||||
m.sshScrollOffset = maxOffset
|
||||
}
|
||||
if m.sshScrollOffset < 0 {
|
||||
m.sshScrollOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
// handleSSHResize adjusts the vt10x grid and notifies the remote side.
|
||||
func (m *monitorModel) handleSSHResize() {
|
||||
if !m.sshMode || m.sshVT == nil || m.sshSess == nil {
|
||||
return
|
||||
}
|
||||
cols, rows := m.sshEmbeddedDims()
|
||||
if cols == m.sshCols && rows == m.sshRows {
|
||||
return
|
||||
}
|
||||
m.sshCols = cols
|
||||
m.sshRows = rows
|
||||
m.sshVT.Resize(cols, rows)
|
||||
_ = m.sshSess.Resize(cols, rows)
|
||||
}
|
||||
|
||||
// renderSSHView produces the TUI view for the embedded SSH session.
|
||||
func (m monitorModel) renderSSHView() string {
|
||||
header := accentStyle.Bold(true).Render(fmt.Sprintf(" ssh://%s@%s:%d (%s) ", m.sshCluster.User, m.sshCluster.Host, m.sshCluster.Port, m.sshCluster.Name))
|
||||
hint := dimStyle.Render("Ctrl+] detach · alt+↑/↓ scroll · pgup/pgdn page · alt+end live")
|
||||
if m.sshScrollOffset > 0 {
|
||||
hint = warnStyle.Render(fmt.Sprintf("↑ scrolled +%d (alt+end to follow)", m.sshScrollOffset)) +
|
||||
dimStyle.Render(" Ctrl+] detach")
|
||||
}
|
||||
|
||||
body := m.renderVTBody()
|
||||
|
||||
panel := lipgloss.NewStyle().
|
||||
BorderStyle(thinBorder).
|
||||
BorderForeground(ccAccent).
|
||||
Padding(0, 1).
|
||||
Render(body)
|
||||
|
||||
status := dimStyle.Render(strings.TrimSpace("status: " + m.statusMsg))
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header+" "+hint, panel, status)
|
||||
}
|
||||
|
||||
// renderVTBody iterates the vt10x grid and emits a styled string block.
|
||||
// When the user has scrolled back it instead renders a window of the
|
||||
// ANSI-stripped scrollback buffer.
|
||||
func (m monitorModel) renderVTBody() string {
|
||||
if m.sshVT == nil {
|
||||
return dimStyle.Render("(session not ready)")
|
||||
}
|
||||
if m.sshScrollOffset > 0 {
|
||||
return m.renderScrollbackBody()
|
||||
}
|
||||
vt := m.sshVT
|
||||
vt.Lock()
|
||||
cols, rows := vt.Size()
|
||||
cur := vt.Cursor()
|
||||
cursorVisible := vt.CursorVisible()
|
||||
|
||||
var lines []string
|
||||
for y := 0; y < rows; y++ {
|
||||
line := renderVTRow(vt, y, cols, cursorVisible, cur.X, cur.Y)
|
||||
lines = append(lines, line)
|
||||
}
|
||||
vt.Unlock()
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderScrollbackBody paints a page of the captured scrollback buffer when
|
||||
// the user is browsing history. Every row is padded to the full grid width
|
||||
// so the panel keeps its live dimensions — otherwise lipgloss sizes the
|
||||
// border to the longest line and the view visibly collapses.
|
||||
func (m monitorModel) renderScrollbackBody() string {
|
||||
rows := m.sshRows
|
||||
cols := m.sshCols
|
||||
if rows <= 0 {
|
||||
rows = sshMinRows
|
||||
}
|
||||
if cols <= 0 {
|
||||
cols = sshMinCols
|
||||
}
|
||||
end := len(m.sshScrollback) - m.sshScrollOffset
|
||||
if end < 0 {
|
||||
end = 0
|
||||
}
|
||||
start := end - rows
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
blank := strings.Repeat(" ", cols)
|
||||
out := make([]string, 0, rows)
|
||||
pad := func(line string) string {
|
||||
rs := []rune(line)
|
||||
if len(rs) > cols {
|
||||
rs = rs[:cols]
|
||||
}
|
||||
if len(rs) < cols {
|
||||
return string(rs) + strings.Repeat(" ", cols-len(rs))
|
||||
}
|
||||
return string(rs)
|
||||
}
|
||||
for i := start; i < end; i++ {
|
||||
out = append(out, pad(m.sshScrollback[i]))
|
||||
}
|
||||
for len(out) < rows {
|
||||
out = append(out, blank)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// renderVTRow renders a single grid row as a styled string.
|
||||
func renderVTRow(vt vt10x.Terminal, row, cols int, cursorVisible bool, cursorX, cursorY int) string {
|
||||
var b strings.Builder
|
||||
var (
|
||||
runRunes strings.Builder
|
||||
runFG vt10x.Color = vt10x.DefaultFG
|
||||
runBG vt10x.Color = vt10x.DefaultBG
|
||||
runStart = true
|
||||
)
|
||||
flush := func() {
|
||||
if runRunes.Len() == 0 {
|
||||
return
|
||||
}
|
||||
style := styleForColors(runFG, runBG)
|
||||
b.WriteString(style.Render(runRunes.String()))
|
||||
runRunes.Reset()
|
||||
}
|
||||
for x := 0; x < cols; x++ {
|
||||
cell := vt.Cell(x, row)
|
||||
ch := cell.Char
|
||||
if ch == 0 {
|
||||
ch = ' '
|
||||
}
|
||||
fg := cell.FG
|
||||
bg := cell.BG
|
||||
isCursor := cursorVisible && x == cursorX && row == cursorY
|
||||
if runStart {
|
||||
runFG = fg
|
||||
runBG = bg
|
||||
runStart = false
|
||||
}
|
||||
if isCursor {
|
||||
// Emit the current run first, then paint the cursor cell with
|
||||
// an explicit, always-visible color so the caret shows even on
|
||||
// empty cells with default FG/BG (where a plain invert would
|
||||
// collapse to the same color).
|
||||
flush()
|
||||
cursorStyle := lipgloss.NewStyle().
|
||||
Background(ccAccent).
|
||||
Foreground(lipgloss.Color("#101010")).
|
||||
Bold(true)
|
||||
b.WriteString(cursorStyle.Render(string(ch)))
|
||||
runFG = fg
|
||||
runBG = bg
|
||||
continue
|
||||
}
|
||||
if fg != runFG || bg != runBG {
|
||||
flush()
|
||||
runFG = fg
|
||||
runBG = bg
|
||||
}
|
||||
runRunes.WriteRune(ch)
|
||||
}
|
||||
flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// styleForColors returns a lipgloss style for the given vt10x colors.
|
||||
func styleForColors(fg, bg vt10x.Color) lipgloss.Style {
|
||||
style := lipgloss.NewStyle()
|
||||
if c, ok := vtColorToLipgloss(fg); ok {
|
||||
style = style.Foreground(c)
|
||||
}
|
||||
if c, ok := vtColorToLipgloss(bg); ok {
|
||||
style = style.Background(c)
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// vtColorToLipgloss maps a vt10x color to a lipgloss color. Returns ok=false
|
||||
// for default so the caller leaves the attribute unset.
|
||||
func vtColorToLipgloss(c vt10x.Color) (lipgloss.TerminalColor, bool) {
|
||||
if c == vt10x.DefaultFG || c == vt10x.DefaultBG || c == vt10x.DefaultCursor {
|
||||
return nil, false
|
||||
}
|
||||
// ANSI basic 16
|
||||
if c < 16 {
|
||||
return lipgloss.Color(fmt.Sprintf("%d", uint32(c))), true
|
||||
}
|
||||
// 256-color palette
|
||||
if c < 256 {
|
||||
return lipgloss.Color(fmt.Sprintf("%d", uint32(c))), true
|
||||
}
|
||||
// Truecolor (24-bit): stored in low 24 bits.
|
||||
r := (uint32(c) >> 16) & 0xff
|
||||
g := (uint32(c) >> 8) & 0xff
|
||||
bl := uint32(c) & 0xff
|
||||
return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, bl)), true
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"pxmon/internal/cluster"
|
||||
"pxmon/internal/history"
|
||||
)
|
||||
|
||||
type monitorUsageMsg struct {
|
||||
snap cluster.UsageSnapshot
|
||||
err error
|
||||
}
|
||||
|
||||
func (m monitorModel) fetchUsageCmd(rng history.RangeShortcut) tea.Cmd {
|
||||
svc := m.svc
|
||||
selector := m.cluster.ID
|
||||
duPath := "/"
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
snap, err := svc.CollectUsageSnapshot(ctx, selector, rng, duPath)
|
||||
return monitorUsageMsg{snap: snap, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m monitorModel) handleUsageKey(v tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch v.String() {
|
||||
case "o", "esc":
|
||||
m.view = viewOverview
|
||||
return m, nil
|
||||
case "n":
|
||||
m.view = viewNetwork
|
||||
return m, nil
|
||||
case "c":
|
||||
m.view = viewClusters
|
||||
return m, nil
|
||||
case "s":
|
||||
m.view = viewSettings
|
||||
return m, nil
|
||||
case "r", "R":
|
||||
m.usageLoading = true
|
||||
m.setStatus("usage: refreshing")
|
||||
return m, m.fetchUsageCmd(m.usageRange)
|
||||
case "1":
|
||||
m.usageRange = history.RangeLive
|
||||
m.usageLoading = true
|
||||
m.setStatus("usage: live")
|
||||
return m, m.fetchUsageCmd(m.usageRange)
|
||||
case "2":
|
||||
m.usageRange = history.RangeHour
|
||||
m.usageLoading = true
|
||||
m.setStatus("usage: last 1h")
|
||||
return m, m.fetchUsageCmd(m.usageRange)
|
||||
case "3":
|
||||
m.usageRange = history.RangeDay
|
||||
m.usageLoading = true
|
||||
m.setStatus("usage: last 24h")
|
||||
return m, m.fetchUsageCmd(m.usageRange)
|
||||
case "4":
|
||||
m.usageRange = history.RangeMonth
|
||||
m.usageLoading = true
|
||||
m.setStatus("usage: last 30d")
|
||||
return m, m.fetchUsageCmd(m.usageRange)
|
||||
case "5":
|
||||
m.usageRange = history.RangeAll
|
||||
m.usageLoading = true
|
||||
m.setStatus("usage: all time")
|
||||
return m, m.fetchUsageCmd(m.usageRange)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m monitorModel) renderUsageDashboard(width int) string {
|
||||
title := titleStyle.Render("━━ Usage / billing ━━")
|
||||
help := dimStyle.Render("range: 1=live 2=1h 3=1d 4=1mo 5=all r refresh o back")
|
||||
|
||||
if m.usageLoading && m.usageSnap == nil {
|
||||
body := dimStyle.Render("loading usage snapshot…")
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, help, "", body)
|
||||
}
|
||||
|
||||
if m.usageSnap == nil {
|
||||
if m.usageErr != nil {
|
||||
body := critStyle.Render("error: " + m.usageErr.Error())
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, help, "", body)
|
||||
}
|
||||
body := dimStyle.Render("press u to load usage")
|
||||
return lipgloss.JoinVertical(lipgloss.Left, title, help, "", body)
|
||||
}
|
||||
|
||||
snap := *m.usageSnap
|
||||
rng := m.usageRange
|
||||
|
||||
lines := []string{title, help, ""}
|
||||
if m.usageLoading {
|
||||
lines = append(lines, dimStyle.Render("(refreshing…)"))
|
||||
}
|
||||
if m.usageErr != nil {
|
||||
lines = append(lines, critStyle.Render("error: "+m.usageErr.Error()))
|
||||
}
|
||||
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("%s %s %s %s %s %s %s %d",
|
||||
dimStyle.Render("cluster:"), accentStyle.Render(snap.ClusterName),
|
||||
dimStyle.Render("range:"), brightStyle.Render(rng.Label()),
|
||||
dimStyle.Render("cpu:"), brightStyle.Render(fmt.Sprintf("%.1f%%", snap.Live.CPU.UsagePercent)),
|
||||
dimStyle.Render("procs:"), snap.Top.TotalProcs,
|
||||
),
|
||||
fmt.Sprintf("%s %s %s %s",
|
||||
dimStyle.Render("ram:"), brightStyle.Render(fmt.Sprintf("%.1f%%", snap.Live.Memory.UsedPercent)),
|
||||
dimStyle.Render("host:"), accentStyle.Render(snap.Live.Host.Hostname),
|
||||
),
|
||||
"",
|
||||
titleStyle.Render("── Network (uplink, max(Rx,Tx)) ──"),
|
||||
)
|
||||
|
||||
if snap.HistoryError != "" {
|
||||
lines = append(lines, warnStyle.Render("history: "+snap.HistoryError))
|
||||
}
|
||||
lines = append(lines,
|
||||
fmt.Sprintf(" %s %s %s %s %s %s (%d samples)",
|
||||
dimStyle.Render("P95:"), okStyle.Render(formatMbpsHuman(snap.P95TotalMbps)),
|
||||
dimStyle.Render("max:"), brightStyle.Render(formatMbpsHuman(snap.MaxTotalMbps)),
|
||||
dimStyle.Render("avg:"), brightStyle.Render(formatMbpsHuman(snap.AvgTotalMbps)),
|
||||
len(snap.NodeSeries),
|
||||
),
|
||||
)
|
||||
if snap.TopIfaceName != "" {
|
||||
lines = append(lines,
|
||||
fmt.Sprintf(" %s %s (avg %s)",
|
||||
dimStyle.Render("uplink:"),
|
||||
accentStyle.Render(snap.TopIfaceName),
|
||||
formatMbpsHuman(snap.TopIfaceMbps)),
|
||||
)
|
||||
}
|
||||
|
||||
// Sparkline from node series
|
||||
if len(snap.NodeSeries) > 1 {
|
||||
lines = append(lines, " "+dimStyle.Render("series:")+" "+renderNodeSparkline(snap.NodeSeries, 40))
|
||||
}
|
||||
|
||||
lines = append(lines, "", titleStyle.Render("── Top processes (by CPU) ──"))
|
||||
if snap.TopError != "" {
|
||||
lines = append(lines, warnStyle.Render("top: "+snap.TopError))
|
||||
} else {
|
||||
for i, p := range snap.Top.TopByCPU {
|
||||
if i >= 6 {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %5d %-10s %s%% %8s %s",
|
||||
p.PID,
|
||||
clipRight(p.User, 10),
|
||||
brightStyle.Render(fmt.Sprintf("%5.1f", p.CPUPercent)),
|
||||
humanBytesUint(p.RSSBytes),
|
||||
accentStyle.Render(clipRight(p.Command, 36))))
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "", titleStyle.Render("── Top processes (by RAM) ──"))
|
||||
if snap.TopError == "" {
|
||||
for i, p := range snap.Top.TopByMemory {
|
||||
if i >= 6 {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %5d %-10s %8s %s",
|
||||
p.PID,
|
||||
clipRight(p.User, 10),
|
||||
brightStyle.Render(humanBytesUint(p.RSSBytes)),
|
||||
accentStyle.Render(clipRight(p.Command, 44))))
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "", titleStyle.Render("── Top folders ──"))
|
||||
if snap.DUError != "" {
|
||||
lines = append(lines, warnStyle.Render("du: "+snap.DUError))
|
||||
} else {
|
||||
for i, d := range snap.DU.Entries {
|
||||
if i >= 8 {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %10s %s",
|
||||
brightStyle.Render(humanBytesUint(d.Bytes)),
|
||||
accentStyle.Render(d.Path)))
|
||||
}
|
||||
if snap.DU.Truncated {
|
||||
lines = append(lines, warnStyle.Render(" (scan truncated by timeout)"))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func renderNodeSparkline(series []history.NodeSamplePoint, width int) string {
|
||||
if len(series) == 0 || width <= 0 {
|
||||
return ""
|
||||
}
|
||||
maxV := 0.0
|
||||
for _, p := range series {
|
||||
if p.TotalMbps > maxV {
|
||||
maxV = p.TotalMbps
|
||||
}
|
||||
}
|
||||
if maxV <= 0 {
|
||||
return strings.Repeat("·", width)
|
||||
}
|
||||
step := len(series) / width
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
runes := []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(series); i += step {
|
||||
v := series[i].TotalMbps
|
||||
idx := int((v / maxV) * float64(len(runes)-1))
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(runes) {
|
||||
idx = len(runes) - 1
|
||||
}
|
||||
b.WriteRune(runes[idx])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func clipRight(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
if n <= 3 {
|
||||
return s[:n]
|
||||
}
|
||||
return s[:n-3] + "..."
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"pxmon/internal/cluster"
|
||||
)
|
||||
|
||||
type shellStatus struct {
|
||||
ActiveCluster string
|
||||
ClusterCount int
|
||||
LoadError error
|
||||
}
|
||||
|
||||
func (a *App) runShell(configPath string, jsonOut bool) int {
|
||||
status := a.loadShellStatus(configPath)
|
||||
a.printShellBanner(status, configPath, jsonOut)
|
||||
|
||||
history := make([]string, 0, 64)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
status = a.loadShellStatus(configPath)
|
||||
fmt.Fprint(a.out, status.prompt())
|
||||
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
fmt.Fprintln(a.out)
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(a.err, "shell input error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
history = append(history, line)
|
||||
|
||||
args, parseErr := parseShellArgs(line)
|
||||
if parseErr != nil {
|
||||
fmt.Fprintf(a.err, "parse error: %v\n", parseErr)
|
||||
continue
|
||||
}
|
||||
if len(args) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(args[0], "pxmon") || strings.EqualFold(args[0], "pxmon") {
|
||||
args = args[1:]
|
||||
}
|
||||
if len(args) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
switch shellToken(args[0]) {
|
||||
case "exit", "quit", "q", ":q":
|
||||
return 0
|
||||
case "clear", "cls":
|
||||
fmt.Fprint(a.out, "\x1b[2J\x1b[H")
|
||||
continue
|
||||
case "history":
|
||||
for i, h := range history {
|
||||
fmt.Fprintf(a.out, "%3d %s\n", i+1, h)
|
||||
}
|
||||
continue
|
||||
case "help", "?":
|
||||
a.printShellHelp()
|
||||
continue
|
||||
case "status", "dashboard":
|
||||
a.printShellStatus(status, configPath, jsonOut)
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := expandShellCommand(args)
|
||||
code := a.runRootCommand(cmd, configPath, jsonOut, false)
|
||||
if code != 0 {
|
||||
fmt.Fprintf(a.err, "[exit %d]\n", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) loadShellStatus(configPath string) shellStatus {
|
||||
status := shellStatus{}
|
||||
|
||||
store, err := cluster.NewStore(configPath)
|
||||
if err != nil {
|
||||
status.LoadError = err
|
||||
return status
|
||||
}
|
||||
svc := cluster.NewService(store)
|
||||
|
||||
clusters, _, err := svc.List()
|
||||
if err != nil {
|
||||
status.LoadError = err
|
||||
return status
|
||||
}
|
||||
status.ClusterCount = len(clusters)
|
||||
|
||||
current, err := svc.Current()
|
||||
if err == nil {
|
||||
status.ActiveCluster = current.Name
|
||||
return status
|
||||
}
|
||||
if !errors.Is(err, cluster.ErrNoActiveCluster) {
|
||||
status.LoadError = err
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (s shellStatus) prompt() string {
|
||||
active := strings.TrimSpace(s.ActiveCluster)
|
||||
if active == "" {
|
||||
active = "none"
|
||||
}
|
||||
active = strings.ReplaceAll(active, "]", "_")
|
||||
return fmt.Sprintf("pxmon[%s]> ", active)
|
||||
}
|
||||
|
||||
func (a *App) printShellBanner(status shellStatus, configPath string, jsonOut bool) {
|
||||
fmt.Fprintln(a.out, "+----------------------------------------------------------------+")
|
||||
fmt.Fprintln(a.out, "| pxmon interactive shell |")
|
||||
fmt.Fprintln(a.out, "| slash: /help /clusters /use <name> /stats [name] /network |")
|
||||
fmt.Fprintln(a.out, "| exit: /quit or /q |")
|
||||
fmt.Fprintln(a.out, "+----------------------------------------------------------------+")
|
||||
a.printShellStatus(status, configPath, jsonOut)
|
||||
fmt.Fprintln(a.out)
|
||||
}
|
||||
|
||||
func (a *App) printShellStatus(status shellStatus, configPath string, jsonOut bool) {
|
||||
active := strings.TrimSpace(status.ActiveCluster)
|
||||
if active == "" {
|
||||
active = "none"
|
||||
}
|
||||
|
||||
jsonState := "off"
|
||||
if jsonOut {
|
||||
jsonState = "on"
|
||||
}
|
||||
|
||||
fmt.Fprintf(a.out, "Clusters: %d | Active: %s | JSON: %s\n", status.ClusterCount, active, jsonState)
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
fmt.Fprintf(a.out, "Config: %s\n", configPath)
|
||||
}
|
||||
if status.LoadError != nil {
|
||||
fmt.Fprintf(a.out, "Status error: %v\n", status.LoadError)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) printShellHelp() {
|
||||
fmt.Fprintln(a.out, "Shell commands:")
|
||||
fmt.Fprintln(a.out, " help Show this help")
|
||||
fmt.Fprintln(a.out, " status Show shell status")
|
||||
fmt.Fprintln(a.out, " history Show command history")
|
||||
fmt.Fprintln(a.out, " clear Clear screen")
|
||||
fmt.Fprintln(a.out, " exit | quit | q Exit shell")
|
||||
fmt.Fprintln(a.out)
|
||||
fmt.Fprintln(a.out, "Shortcuts:")
|
||||
fmt.Fprintln(a.out, " /clusters Alias for 'cluster list'")
|
||||
fmt.Fprintln(a.out, " /stats [name] Alias for 'cluster stats [name]'")
|
||||
fmt.Fprintln(a.out, " /monitor [name] Alias for 'cluster stats [name]'")
|
||||
fmt.Fprintln(a.out, " /network [name] Open network-focused TUI")
|
||||
fmt.Fprintln(a.out, " /connect ... Alias for 'cluster connect ...'")
|
||||
fmt.Fprintln(a.out, " /use <name> Alias for 'cluster use <name>'")
|
||||
fmt.Fprintln(a.out, " /alert ... Alias for 'cluster alert ...'")
|
||||
fmt.Fprintln(a.out)
|
||||
fmt.Fprintln(a.out, "Examples:")
|
||||
fmt.Fprintln(a.out, " /clusters")
|
||||
fmt.Fprintln(a.out, " /connect --name eu-1 --host 10.0.0.10 --user root --auth key --key-path ~/.ssh/id_ed25519")
|
||||
fmt.Fprintln(a.out, " /bootstrap eu-1")
|
||||
fmt.Fprintln(a.out, " /stats eu-1")
|
||||
fmt.Fprintln(a.out, " /network eu-1")
|
||||
fmt.Fprintln(a.out, " /alert set eu-1 --net-mbps 300 --ram 90 --disk 90")
|
||||
fmt.Fprintln(a.out, " /alert set eu-1 --net-sustain-enabled=true --net-sustain-mbps 500 --net-sustain-mins 60 --net-sustain-include net0 --net-sustain-exclude backup")
|
||||
}
|
||||
|
||||
func shellToken(v string) string {
|
||||
token := strings.TrimSpace(strings.ToLower(v))
|
||||
if strings.HasPrefix(token, "/") {
|
||||
token = strings.TrimPrefix(token, "/")
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func expandShellCommand(args []string) []string {
|
||||
if len(args) == 0 {
|
||||
return args
|
||||
}
|
||||
|
||||
out := append([]string(nil), args...)
|
||||
cmd := shellToken(out[0])
|
||||
if cmd == "" {
|
||||
return out
|
||||
}
|
||||
out[0] = cmd
|
||||
|
||||
switch cmd {
|
||||
case "clusters", "nodes":
|
||||
return append([]string{"cluster", "list"}, out[1:]...)
|
||||
case "stats", "monitor", "watch":
|
||||
return append([]string{"cluster", "stats"}, out[1:]...)
|
||||
case "alerts":
|
||||
return append([]string{"cluster", "alert"}, out[1:]...)
|
||||
}
|
||||
|
||||
if isClusterShortcut(cmd) {
|
||||
return append([]string{"cluster", cmd}, out[1:]...)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func isClusterShortcut(cmd string) bool {
|
||||
switch cmd {
|
||||
case "connect", "add", "list", "ls", "show", "get", "current", "use",
|
||||
"ping", "check", "bootstrap", "disconnect", "remove", "rm", "alert":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseShellArgs(line string) ([]string, error) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
args := make([]string, 0, 8)
|
||||
var current strings.Builder
|
||||
var quote rune
|
||||
escaped := false
|
||||
tokenStarted := false
|
||||
|
||||
flush := func() {
|
||||
if tokenStarted {
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
tokenStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range line {
|
||||
switch {
|
||||
case escaped:
|
||||
current.WriteRune(r)
|
||||
escaped = false
|
||||
tokenStarted = true
|
||||
case r == '\\':
|
||||
escaped = true
|
||||
tokenStarted = true
|
||||
case quote != 0:
|
||||
if r == quote {
|
||||
quote = 0
|
||||
} else {
|
||||
current.WriteRune(r)
|
||||
}
|
||||
tokenStarted = true
|
||||
case r == '\'' || r == '"':
|
||||
quote = r
|
||||
tokenStarted = true
|
||||
case unicode.IsSpace(r):
|
||||
flush()
|
||||
default:
|
||||
current.WriteRune(r)
|
||||
tokenStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
if escaped {
|
||||
return nil, errors.New("unterminated escape at end of command")
|
||||
}
|
||||
if quote != 0 {
|
||||
return nil, errors.New("unterminated quoted string")
|
||||
}
|
||||
flush()
|
||||
return args, nil
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"pxmon/internal/agent"
|
||||
"pxmon/internal/cluster"
|
||||
"pxmon/internal/history"
|
||||
)
|
||||
|
||||
func (a *App) runClusterUsage(svc *cluster.Service, args []string, jsonOut bool) int {
|
||||
fs := flag.NewFlagSet("cluster usage", flag.ContinueOnError)
|
||||
fs.SetOutput(a.err)
|
||||
rangeStr := fs.String("range", "live", "Time window: live|1h|1d|1mo|all")
|
||||
duPath := fs.String("du", "/", "Root directory for top-folder scan")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
selector := ""
|
||||
if fs.NArg() > 0 {
|
||||
selector = fs.Arg(0)
|
||||
}
|
||||
|
||||
rng, ok := history.ParseRangeShortcut(strings.TrimSpace(*rangeStr))
|
||||
if !ok {
|
||||
fmt.Fprintf(a.err, "usage: invalid --range %q (expected live|1h|1d|1mo|all)\n", *rangeStr)
|
||||
return 2
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
snap, err := svc.CollectUsageSnapshot(ctx, selector, rng, *duPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.err, "cluster usage: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
_ = writeJSON(a.out, snap)
|
||||
return 0
|
||||
}
|
||||
|
||||
printUsageSnapshot(a.out, snap, rng)
|
||||
return 0
|
||||
}
|
||||
|
||||
func (a *App) runClusterTraffic(svc *cluster.Service, args []string, jsonOut bool) int {
|
||||
fs := flag.NewFlagSet("cluster traffic", flag.ContinueOnError)
|
||||
fs.SetOutput(a.err)
|
||||
rangeStr := fs.String("range", "1h", "Time window: 1h|1d|1mo|all")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
selector := ""
|
||||
if fs.NArg() > 0 {
|
||||
selector = fs.Arg(0)
|
||||
}
|
||||
|
||||
rng, ok := history.ParseRangeShortcut(strings.TrimSpace(*rangeStr))
|
||||
if !ok {
|
||||
fmt.Fprintf(a.err, "traffic: invalid --range %q\n", *rangeStr)
|
||||
return 2
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
snap, err := svc.CollectUsageSnapshot(ctx, selector, rng, "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.err, "cluster traffic: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
_ = writeJSON(a.out, map[string]any{
|
||||
"cluster": snap.ClusterName,
|
||||
"range": rng.Label(),
|
||||
"p95_mbps": snap.P95TotalMbps,
|
||||
"max_mbps": snap.MaxTotalMbps,
|
||||
"avg_mbps": snap.AvgTotalMbps,
|
||||
"samples": len(snap.NodeSeries),
|
||||
"top_iface": snap.TopIfaceName,
|
||||
"top_iface_mbps": snap.TopIfaceMbps,
|
||||
"history_error": snap.HistoryError,
|
||||
})
|
||||
return 0
|
||||
}
|
||||
|
||||
fmt.Fprintf(a.out, "%s %s %s\n", colorLabel("Cluster:"), colorAccent(snap.ClusterName), colorMuted("("+rng.Label()+")"))
|
||||
if snap.HistoryError != "" {
|
||||
fmt.Fprintf(a.out, "%s %s\n", colorWarn("History:"), snap.HistoryError)
|
||||
}
|
||||
fmt.Fprintf(a.out, "%s %s %s %d samples\n",
|
||||
colorLabel("P95: "),
|
||||
colorOK(formatMbpsHuman(snap.P95TotalMbps)),
|
||||
colorMuted("over"),
|
||||
len(snap.NodeSeries),
|
||||
)
|
||||
fmt.Fprintf(a.out, "%s %s %s %s\n",
|
||||
colorLabel("Max: "),
|
||||
colorValue(formatMbpsHuman(snap.MaxTotalMbps)),
|
||||
colorMuted("avg:"),
|
||||
colorInfo(formatMbpsHuman(snap.AvgTotalMbps)),
|
||||
)
|
||||
if snap.TopIfaceName != "" {
|
||||
fmt.Fprintf(a.out, "%s %s %s %s\n",
|
||||
colorLabel("Uplink:"),
|
||||
colorAccent(snap.TopIfaceName),
|
||||
colorMuted("avg max(Rx,Tx):"),
|
||||
colorValue(formatMbpsHuman(snap.TopIfaceMbps)),
|
||||
)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (a *App) runClusterGraph(svc *cluster.Service, args []string, jsonOut bool) int {
|
||||
fs := flag.NewFlagSet("cluster graph", flag.ContinueOnError)
|
||||
fs.SetOutput(a.err)
|
||||
rangeStr := fs.String("range", "1d", "Time window: 1h|1d|1mo|all")
|
||||
outPath := fs.String("out", "", "Output PNG path (default: ./<cluster>-<range>.png)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
selector := ""
|
||||
if fs.NArg() > 0 {
|
||||
selector = fs.Arg(0)
|
||||
}
|
||||
|
||||
rng, ok := history.ParseRangeShortcut(strings.TrimSpace(*rangeStr))
|
||||
if !ok {
|
||||
fmt.Fprintf(a.err, "graph: invalid --range %q\n", *rangeStr)
|
||||
return 2
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
snap, err := svc.CollectUsageSnapshot(ctx, selector, rng, "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.err, "cluster graph: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
png, err := cluster.RenderUsageChartPNG(snap, "")
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.err, "cluster graph: render: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
dest := strings.TrimSpace(*outPath)
|
||||
if dest == "" {
|
||||
dest = filepath.Join(".", fmt.Sprintf("%s-%s.png", sanitizeFilename(snap.ClusterName), rng))
|
||||
}
|
||||
if err := os.WriteFile(dest, png, 0o600); err != nil {
|
||||
fmt.Fprintf(a.err, "cluster graph: write: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
_ = writeJSON(a.out, map[string]any{
|
||||
"path": dest,
|
||||
"bytes": len(png),
|
||||
"p95_mbps": snap.P95TotalMbps,
|
||||
"samples": len(snap.NodeSeries),
|
||||
})
|
||||
return 0
|
||||
}
|
||||
|
||||
fmt.Fprintf(a.out, "%s %s\n", colorLabel("Saved:"), colorAccent(dest))
|
||||
fmt.Fprintf(a.out, "%s %s %s %d samples\n",
|
||||
colorLabel("P95: "),
|
||||
colorOK(formatMbpsHuman(snap.P95TotalMbps)),
|
||||
colorMuted("over"),
|
||||
len(snap.NodeSeries),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
func printUsageSnapshot(w io.Writer, snap cluster.UsageSnapshot, rng history.RangeShortcut) {
|
||||
fmt.Fprintf(w, "%s %s %s\n", colorLabel("Cluster:"), colorAccent(snap.ClusterName), colorMuted("("+rng.Label()+")"))
|
||||
fmt.Fprintf(w, "%s %s %s\n", colorLabel("Host: "), colorAccent(snap.Live.Host.Hostname), colorMuted(snap.Live.Host.OS+"/"+snap.Live.Host.Arch))
|
||||
|
||||
fmt.Fprintf(w, "%s %s %s %s %s %s\n",
|
||||
colorLabel("CPU:"),
|
||||
colorValue(fmt.Sprintf("%.1f%%", snap.Live.CPU.UsagePercent)),
|
||||
colorLabel("RAM:"),
|
||||
colorValue(fmt.Sprintf("%.1f%%", snap.Live.Memory.UsedPercent)),
|
||||
colorLabel("Procs:"),
|
||||
colorInfo(fmt.Sprintf("%d", snap.Top.TotalProcs)),
|
||||
)
|
||||
|
||||
fmt.Fprintf(w, "\n%s\n", colorHeader("━━ Network ━━"))
|
||||
if snap.HistoryError != "" {
|
||||
fmt.Fprintf(w, "%s %s\n", colorWarn("history:"), snap.HistoryError)
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s %s %s %s %s (%d samples)\n",
|
||||
colorLabel("P95:"), colorOK(formatMbpsHuman(snap.P95TotalMbps)),
|
||||
colorLabel("max:"), colorValue(formatMbpsHuman(snap.MaxTotalMbps)),
|
||||
colorLabel("avg:"), colorInfo(formatMbpsHuman(snap.AvgTotalMbps)),
|
||||
len(snap.NodeSeries),
|
||||
)
|
||||
if snap.TopIfaceName != "" {
|
||||
fmt.Fprintf(w, " %s %s %s %s\n",
|
||||
colorLabel("uplink:"),
|
||||
colorAccent(snap.TopIfaceName),
|
||||
colorMuted("avg max(Rx,Tx)"),
|
||||
colorValue(formatMbpsHuman(snap.TopIfaceMbps)),
|
||||
)
|
||||
}
|
||||
|
||||
if snap.TopError != "" {
|
||||
fmt.Fprintf(w, "\n%s %s\n", colorWarn("top:"), snap.TopError)
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n%s\n", colorHeader("━━ Top processes (by CPU) ━━"))
|
||||
printProcessTable(w, snap.Top.TopByCPU)
|
||||
fmt.Fprintf(w, "\n%s\n", colorHeader("━━ Top processes (by RAM) ━━"))
|
||||
printProcessTable(w, snap.Top.TopByMemory)
|
||||
}
|
||||
|
||||
if snap.DUError != "" {
|
||||
fmt.Fprintf(w, "\n%s %s\n", colorWarn("du:"), snap.DUError)
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n%s %s\n", colorHeader("━━ Top folders ━━"), colorMuted(snap.DU.Root))
|
||||
printFolderTable(w, snap.DU.Entries)
|
||||
if snap.DU.Truncated {
|
||||
fmt.Fprintf(w, " %s\n", colorWarn("(scan truncated by timeout)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printProcessTable(w io.Writer, procs []agent.ProcessStat) {
|
||||
if len(procs) == 0 {
|
||||
fmt.Fprintln(w, " (no data)")
|
||||
return
|
||||
}
|
||||
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n",
|
||||
colorHeader("PID"), colorHeader("USER"), colorHeader("CPU%"), colorHeader("RSS"), colorHeader("CMD"))
|
||||
for _, p := range procs {
|
||||
if p.CPUPercent == 0 && p.RSSBytes == 0 {
|
||||
continue
|
||||
}
|
||||
cmd := p.Command
|
||||
if len(cmd) > 40 {
|
||||
cmd = cmd[:37] + "..."
|
||||
}
|
||||
fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n",
|
||||
colorInfo(fmt.Sprintf("%d", p.PID)),
|
||||
colorMuted(p.User),
|
||||
colorValue(fmt.Sprintf("%.1f", p.CPUPercent)),
|
||||
colorValue(humanBytesUint(p.RSSBytes)),
|
||||
colorAccent(cmd),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func printFolderTable(w io.Writer, dirs []agent.DirStat) {
|
||||
if len(dirs) == 0 {
|
||||
fmt.Fprintln(w, " (no data)")
|
||||
return
|
||||
}
|
||||
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintf(tw, " %s\t%s\t%s\n",
|
||||
colorHeader("SIZE"), colorHeader("FILES"), colorHeader("PATH"))
|
||||
for _, d := range dirs {
|
||||
fmt.Fprintf(tw, " %s\t%s\t%s\n",
|
||||
colorValue(humanBytesUint(d.Bytes)),
|
||||
colorInfo(fmt.Sprintf("%d", d.Files)),
|
||||
colorAccent(d.Path),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func formatMbpsHuman(v float64) string {
|
||||
switch {
|
||||
case v >= 1000:
|
||||
return fmt.Sprintf("%.2f Gbps", v/1000)
|
||||
case v >= 1:
|
||||
return fmt.Sprintf("%.1f Mbps", v)
|
||||
case v > 0:
|
||||
return fmt.Sprintf("%.0f Kbps", v*1000)
|
||||
default:
|
||||
return "0 Mbps"
|
||||
}
|
||||
}
|
||||
|
||||
func humanBytesUint(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 sanitizeFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "cluster"
|
||||
}
|
||||
var b bytes.Buffer
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formatUsageForBot returns a plain-text rendering (no ANSI) suitable for
|
||||
// wrapping in a <pre> block in Telegram.
|
||||
func formatUsageForBot(snap cluster.UsageSnapshot, rng history.RangeShortcut) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Cluster: %s (%s)\n", snap.ClusterName, rng.Label())
|
||||
fmt.Fprintf(&b, "Host: %s %s/%s\n", snap.Live.Host.Hostname, snap.Live.Host.OS, snap.Live.Host.Arch)
|
||||
fmt.Fprintf(&b, "CPU: %5.1f%% RAM: %5.1f%% Procs: %d\n",
|
||||
snap.Live.CPU.UsagePercent, snap.Live.Memory.UsedPercent, snap.Top.TotalProcs)
|
||||
|
||||
b.WriteString("\n── Network ──\n")
|
||||
if snap.HistoryError != "" {
|
||||
fmt.Fprintf(&b, "history: %s\n", snap.HistoryError)
|
||||
}
|
||||
fmt.Fprintf(&b, "P95 %s · max %s · avg %s (%d samples)\n",
|
||||
formatMbpsHuman(snap.P95TotalMbps),
|
||||
formatMbpsHuman(snap.MaxTotalMbps),
|
||||
formatMbpsHuman(snap.AvgTotalMbps),
|
||||
len(snap.NodeSeries),
|
||||
)
|
||||
if snap.TopIfaceName != "" {
|
||||
fmt.Fprintf(&b, "uplink: %s (avg max(Rx,Tx): %s)\n", snap.TopIfaceName, formatMbpsHuman(snap.TopIfaceMbps))
|
||||
}
|
||||
|
||||
b.WriteString("\n── Top by CPU ──\n")
|
||||
writeBotProcLine(&b, snap.Top.TopByCPU, 5)
|
||||
b.WriteString("── Top by RAM ──\n")
|
||||
writeBotProcLine(&b, snap.Top.TopByMemory, 5)
|
||||
|
||||
if snap.DUError == "" {
|
||||
fmt.Fprintf(&b, "\n── Top folders (%s) ──\n", snap.DU.Root)
|
||||
for _, d := range snap.DU.Entries {
|
||||
if len(snap.DU.Entries) > 8 {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&b, "%10s %s\n", humanBytesUint(d.Bytes), d.Path)
|
||||
}
|
||||
// if large, take first 8 regardless
|
||||
n := len(snap.DU.Entries)
|
||||
if n > 8 {
|
||||
for i := 0; i < 8; i++ {
|
||||
d := snap.DU.Entries[i]
|
||||
fmt.Fprintf(&b, "%10s %s\n", humanBytesUint(d.Bytes), d.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeBotProcLine(b *strings.Builder, procs []agent.ProcessStat, n int) {
|
||||
if len(procs) == 0 {
|
||||
b.WriteString("(no data)\n")
|
||||
return
|
||||
}
|
||||
if n > len(procs) {
|
||||
n = len(procs)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
p := procs[i]
|
||||
cmd := p.Command
|
||||
if len(cmd) > 28 {
|
||||
cmd = cmd[:25] + "..."
|
||||
}
|
||||
user := p.User
|
||||
if len(user) > 8 {
|
||||
user = user[:8]
|
||||
}
|
||||
fmt.Fprintf(b, "%5d %-8s %5.1f%% %8s %s\n",
|
||||
p.PID, user, p.CPUPercent, humanBytesUint(p.RSSBytes), cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSON is used by subcommands for --json output. Declared in app.go.
|
||||
var _ = json.Marshal
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"pxmon/internal/cluster"
|
||||
)
|
||||
|
||||
func installWinchHandler(fd int, resize chan<- cluster.InteractiveShellSize) chan os.Signal {
|
||||
sigCh := make(chan os.Signal, 4)
|
||||
signal.Notify(sigCh, syscall.SIGWINCH)
|
||||
go func() {
|
||||
for range sigCh {
|
||||
w, h, err := term.GetSize(fd)
|
||||
if err != nil || w <= 0 || h <= 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case resize <- cluster.InteractiveShellSize{Width: w, Height: h}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return sigCh
|
||||
}
|
||||
|
||||
func closeWinchHandler(sigCh chan os.Signal, resize chan cluster.InteractiveShellSize) {
|
||||
if sigCh != nil {
|
||||
signal.Stop(sigCh)
|
||||
close(sigCh)
|
||||
}
|
||||
if resize != nil {
|
||||
close(resize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"pxmon/internal/cluster"
|
||||
)
|
||||
|
||||
func installWinchHandler(fd int, resize chan<- cluster.InteractiveShellSize) chan os.Signal {
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeWinchHandler(sigCh chan os.Signal, resize chan cluster.InteractiveShellSize) {
|
||||
if resize != nil {
|
||||
close(resize)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user