415 lines
11 KiB
Go
415 lines
11 KiB
Go
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
|