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: ./-.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
 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