chore: publish pxmon v0.2.0

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