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] + "..." }