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
+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