chore: publish pxmon v0.2.0
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user