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