Files
2026-06-16 21:52:10 +04:00

242 lines
6.3 KiB
Go

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