242 lines
6.3 KiB
Go
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] + "..."
|
|
}
|