chore: publish pxmon v0.2.0

This commit is contained in:
2026-06-16 21:52:10 +04:00
commit 6b703db02b
69 changed files with 25886 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
package history
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type AvailabilitySnapshot struct {
Timestamp time.Time `json:"timestamp"`
ClusterUp bool `json:"cluster_up"`
VMStates map[string]string `json:"vm_states,omitempty"`
Error string `json:"error,omitempty"`
}
type AvailabilityStore struct {
dir string
}
func NewAvailabilityStore(baseDir string) *AvailabilityStore {
if strings.TrimSpace(baseDir) == "" {
baseDir = "."
}
return &AvailabilityStore{dir: filepath.Join(baseDir, "history", "availability")}
}
func (s *AvailabilityStore) Path(clusterID string) string {
return filepath.Join(s.dir, sanitizeClusterID(clusterID)+".jsonl")
}
func (s *AvailabilityStore) Append(clusterID string, snap AvailabilitySnapshot) error {
if strings.TrimSpace(clusterID) == "" {
return errors.New("empty cluster id")
}
if snap.Timestamp.IsZero() {
snap.Timestamp = time.Now().UTC()
}
if err := os.MkdirAll(s.dir, 0o700); err != nil {
return err
}
f, err := os.OpenFile(s.Path(clusterID), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer f.Close()
b, err := json.Marshal(snap)
if err != nil {
return err
}
_, err = f.Write(append(b, '\n'))
return err
}
func (s *AvailabilityStore) Load(clusterID string, since time.Time) ([]AvailabilitySnapshot, error) {
if strings.TrimSpace(clusterID) == "" {
return nil, errors.New("empty cluster id")
}
f, err := os.Open(s.Path(clusterID))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []AvailabilitySnapshot{}, nil
}
return nil, err
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
out := make([]AvailabilitySnapshot, 0, 512)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var snap AvailabilitySnapshot
if err := json.Unmarshal([]byte(line), &snap); err != nil {
continue
}
if !since.IsZero() && snap.Timestamp.Before(since) {
continue
}
out = append(out, snap)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("scan availability: %w", err)
}
return out, nil
}
+102
View File
@@ -0,0 +1,102 @@
package history
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type CapacityDiskPoint struct {
Mount string `json:"mount"`
UsedBytes uint64 `json:"used_bytes"`
TotalBytes uint64 `json:"total_bytes"`
}
type CapacitySnapshot struct {
Timestamp time.Time `json:"timestamp"`
Disks []CapacityDiskPoint `json:"disks,omitempty"`
}
type CapacityStore struct {
dir string
}
func NewCapacityStore(baseDir string) *CapacityStore {
if strings.TrimSpace(baseDir) == "" {
baseDir = "."
}
return &CapacityStore{dir: filepath.Join(baseDir, "history", "capacity")}
}
func (s *CapacityStore) Path(clusterID string) string {
return filepath.Join(s.dir, sanitizeClusterID(clusterID)+".jsonl")
}
func (s *CapacityStore) Append(clusterID string, snap CapacitySnapshot) error {
if strings.TrimSpace(clusterID) == "" {
return errors.New("empty cluster id")
}
if snap.Timestamp.IsZero() {
snap.Timestamp = time.Now().UTC()
}
if len(snap.Disks) == 0 {
return nil
}
if err := os.MkdirAll(s.dir, 0o700); err != nil {
return err
}
f, err := os.OpenFile(s.Path(clusterID), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer f.Close()
b, err := json.Marshal(snap)
if err != nil {
return err
}
_, err = f.Write(append(b, '\n'))
return err
}
func (s *CapacityStore) Load(clusterID string, since time.Time) ([]CapacitySnapshot, error) {
if strings.TrimSpace(clusterID) == "" {
return nil, errors.New("empty cluster id")
}
f, err := os.Open(s.Path(clusterID))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []CapacitySnapshot{}, nil
}
return nil, err
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
out := make([]CapacitySnapshot, 0, 512)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var snap CapacitySnapshot
if err := json.Unmarshal([]byte(line), &snap); err != nil {
continue
}
if !since.IsZero() && snap.Timestamp.Before(since) {
continue
}
if len(snap.Disks) == 0 {
continue
}
out = append(out, snap)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("scan capacity: %w", err)
}
return out, nil
}
+199
View File
@@ -0,0 +1,199 @@
package history
import (
"bytes"
"fmt"
"time"
chart "github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
// ChartOptions controls rendering of the node network PNG.
type ChartOptions struct {
Title string
Subtitle string
Width int
Height int
Percentile float64
}
// RenderNodeNetworkPNG returns a PNG of the node-wide Rx+Tx time series with
// the requested percentile drawn as a horizontal annotation line.
func RenderNodeNetworkPNG(points []NodeSamplePoint, opts ChartOptions) ([]byte, error) {
if opts.Width <= 0 {
opts.Width = 1280
}
if opts.Height <= 0 {
opts.Height = 640
}
if opts.Percentile <= 0 {
opts.Percentile = 95
}
if opts.Title == "" {
opts.Title = "Node network usage"
}
if len(points) < 2 {
return renderEmptyChart(opts, "insufficient history — need at least 2 samples")
}
xs := make([]time.Time, 0, len(points))
rxs := make([]float64, 0, len(points))
txs := make([]float64, 0, len(points))
totals := make([]float64, 0, len(points))
maxVal := 0.0
for _, p := range points {
xs = append(xs, p.Timestamp)
rxs = append(rxs, p.RxMbps)
txs = append(txs, p.TxMbps)
totals = append(totals, p.TotalMbps)
if p.TotalMbps > maxVal {
maxVal = p.TotalMbps
}
}
pct := PercentileMbps(points, opts.Percentile)
yMax := maxVal * 1.15
if pct*1.10 > yMax {
yMax = pct * 1.10
}
if yMax <= 0 {
yMax = 1
}
percentileSeries := chart.ContinuousSeries{
Name: fmt.Sprintf("P%.0f = %.1f Mbps", opts.Percentile, pct),
Style: chart.Style{
StrokeColor: drawing.ColorFromHex("e74c3c"),
StrokeWidth: 2.0,
StrokeDashArray: []float64{6, 4},
},
XValues: []float64{chart.TimeToFloat64(xs[0]), chart.TimeToFloat64(xs[len(xs)-1])},
YValues: []float64{pct, pct},
}
graph := chart.Chart{
Title: opts.Title,
TitleStyle: chart.Style{
FontSize: 16,
},
Width: opts.Width,
Height: opts.Height,
Background: chart.Style{
Padding: chart.Box{Top: 60, Left: 40, Right: 40, Bottom: 40},
FillColor: drawing.Color{
R: 0xf8, G: 0xf9, B: 0xfa, A: 0xff,
},
},
XAxis: chart.XAxis{
Style: chart.Style{FontSize: 9},
ValueFormatter: chart.TimeValueFormatterWithFormat("15:04:05\n02 Jan"),
},
YAxis: chart.YAxis{
Name: "Mbps",
Style: chart.Style{FontSize: 9},
Range: &chart.ContinuousRange{Min: 0, Max: yMax},
ValueFormatter: func(v any) string {
if f, ok := v.(float64); ok {
return formatMbps(f)
}
return ""
},
},
Series: []chart.Series{
chart.TimeSeries{
Name: "Total Rx+Tx",
Style: chart.Style{
StrokeColor: drawing.ColorFromHex("2d7dd2"),
StrokeWidth: 2.0,
FillColor: drawing.ColorFromHex("2d7dd2").WithAlpha(50),
},
XValues: xs,
YValues: totals,
},
chart.TimeSeries{
Name: "Rx",
Style: chart.Style{
StrokeColor: drawing.ColorFromHex("3cb371"),
StrokeWidth: 1.5,
},
XValues: xs,
YValues: rxs,
},
chart.TimeSeries{
Name: "Tx",
Style: chart.Style{
StrokeColor: drawing.ColorFromHex("ffa500"),
StrokeWidth: 1.5,
},
XValues: xs,
YValues: txs,
},
percentileSeries,
},
}
if opts.Subtitle != "" {
graph.Elements = []chart.Renderable{subtitleRenderable(opts.Subtitle)}
}
buf := &bytes.Buffer{}
if err := graph.Render(chart.PNG, buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func subtitleRenderable(text string) chart.Renderable {
return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) {
r.SetFont(chartDefaults.GetFont())
r.SetFontColor(drawing.Color{R: 90, G: 90, B: 90, A: 0xff})
r.SetFontSize(10)
r.Text(text, cb.Left+10, cb.Top+30)
}
}
func renderEmptyChart(opts ChartOptions, note string) ([]byte, error) {
graph := chart.Chart{
Title: opts.Title,
Width: opts.Width,
Height: opts.Height,
Background: chart.Style{
Padding: chart.Box{Top: 60, Left: 40, Right: 40, Bottom: 40},
},
Series: []chart.Series{
chart.ContinuousSeries{
XValues: []float64{0, 1},
YValues: []float64{0, 0},
Style: chart.Style{
StrokeColor: drawing.ColorTransparent,
},
},
},
}
graph.Elements = []chart.Renderable{
func(r chart.Renderer, cb chart.Box, cs chart.Style) {
r.SetFont(cs.GetFont())
r.SetFontColor(drawing.Color{R: 120, G: 120, B: 120, A: 0xff})
r.SetFontSize(14)
r.Text(note, cb.Left+20, cb.Top+cb.Height()/2)
},
}
buf := &bytes.Buffer{}
if err := graph.Render(chart.PNG, buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func formatMbps(v float64) string {
switch {
case v >= 1000:
return fmt.Sprintf("%.2f Gbps", v/1000)
case v >= 1:
return fmt.Sprintf("%.1f Mbps", v)
default:
return fmt.Sprintf("%.0f Kbps", v*1000)
}
}
+146
View File
@@ -0,0 +1,146 @@
package history
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// InterfaceSample is one per-interface network sample at timestamp.
type InterfaceSample struct {
Interface string `json:"interface"`
RxMbps float64 `json:"rx_mbps"`
TxMbps float64 `json:"tx_mbps"`
RxDrops uint64 `json:"rx_drops"`
TxDrops uint64 `json:"tx_drops"`
}
// NetworkSnapshot stores one full sample containing many interfaces.
type NetworkSnapshot struct {
Timestamp time.Time `json:"timestamp"`
Interfaces []InterfaceSample `json:"interfaces"`
}
// NetworkStore appends and reads network snapshots (JSONL) per cluster.
type NetworkStore struct {
dir string
}
func NewNetworkStore(baseDir string) *NetworkStore {
if strings.TrimSpace(baseDir) == "" {
baseDir = "."
}
return &NetworkStore{
dir: filepath.Join(baseDir, "history", "network"),
}
}
func (s *NetworkStore) Path(clusterID string) string {
return filepath.Join(s.dir, sanitizeClusterID(clusterID)+".jsonl")
}
func (s *NetworkStore) Append(clusterID string, snapshot NetworkSnapshot) error {
if strings.TrimSpace(clusterID) == "" {
return errors.New("empty cluster id")
}
if snapshot.Timestamp.IsZero() {
snapshot.Timestamp = time.Now().UTC()
}
if len(snapshot.Interfaces) == 0 {
return nil
}
if err := os.MkdirAll(s.dir, 0o700); err != nil {
return fmt.Errorf("create history dir: %w", err)
}
path := s.Path(clusterID)
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open history file: %w", err)
}
defer f.Close()
line, err := json.Marshal(snapshot)
if err != nil {
return fmt.Errorf("marshal history snapshot: %w", err)
}
if _, err := f.Write(append(line, '\n')); err != nil {
return fmt.Errorf("append history snapshot: %w", err)
}
return nil
}
func (s *NetworkStore) Load(clusterID string, since time.Time) ([]NetworkSnapshot, error) {
if strings.TrimSpace(clusterID) == "" {
return nil, errors.New("empty cluster id")
}
path := s.Path(clusterID)
f, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []NetworkSnapshot{}, nil
}
return nil, fmt.Errorf("open history file: %w", err)
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1024*64), 1024*1024*8)
out := make([]NetworkSnapshot, 0, 256)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var snap NetworkSnapshot
if err := json.Unmarshal([]byte(line), &snap); err != nil {
continue
}
if !since.IsZero() && snap.Timestamp.Before(since) {
continue
}
if len(snap.Interfaces) == 0 {
continue
}
out = append(out, snap)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("scan history file: %w", err)
}
return out, nil
}
func sanitizeClusterID(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "unknown"
}
var b strings.Builder
for _, r := range v {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := strings.Trim(b.String(), "_")
if out == "" {
return "unknown"
}
return out
}
+242
View File
@@ -0,0 +1,242 @@
package history
import (
"math"
"sort"
"strings"
"time"
)
// NodeSamplePoint is one aggregated sample for the node's primary uplink
// interface at a single point in time. TotalMbps uses max(Rx, Tx) which is
// the same convention a provider uses for 95th-percentile transit billing.
type NodeSamplePoint struct {
Timestamp time.Time
TotalMbps float64 // max(Rx, Tx) for billing P95
RxMbps float64
TxMbps float64
Interface string
}
// isVirtualIface returns true for interfaces that do not represent real
// uplink traffic and should be excluded from P95/billing calculations.
// Virtual interfaces (bridges, taps, veth pairs, docker, loopback) either
// carry no real traffic or mirror the traffic that already flows through
// the physical uplink — double-counting them inflates totals.
func isVirtualIface(name string) bool {
n := strings.ToLower(name)
if n == "lo" || n == "" {
return true
}
prefixes := []string{
"lo", "docker", "br-", "br", "veth", "vnet", "tap", "virbr",
"cni", "flannel", "wg", "tun", "tailscale", "zt", "ipsec",
"kube", "cilium", "ovs", "podman", "dummy",
}
for _, p := range prefixes {
if strings.HasPrefix(n, p) {
return true
}
}
return false
}
// PrimaryInterface picks the physical uplink interface with the highest
// average max(Rx, Tx) over the given snapshots. Virtual interfaces are
// ignored. Returns empty string when no physical candidate exists.
func PrimaryInterface(snapshots []NetworkSnapshot) string {
type acc struct {
sum float64
count int
}
totals := make(map[string]*acc, 16)
for _, snap := range snapshots {
for _, iface := range snap.Interfaces {
if isVirtualIface(iface.Interface) {
continue
}
v := math.Max(iface.RxMbps, iface.TxMbps)
a, ok := totals[iface.Interface]
if !ok {
a = &acc{}
totals[iface.Interface] = a
}
a.sum += v
a.count++
}
}
best := ""
bestAvg := -1.0
for name, a := range totals {
if a.count == 0 {
continue
}
avg := a.sum / float64(a.count)
if avg > bestAvg {
bestAvg = avg
best = name
}
}
return best
}
// AggregateNodeSeries builds a time series for a single interface. If
// ifaceName is empty, PrimaryInterface is used to auto-select the physical
// uplink. TotalMbps uses max(Rx, Tx), matching provider billing convention.
func AggregateNodeSeries(snapshots []NetworkSnapshot, ifaceName string) []NodeSamplePoint {
if ifaceName == "" {
ifaceName = PrimaryInterface(snapshots)
}
out := make([]NodeSamplePoint, 0, len(snapshots))
for _, snap := range snapshots {
for _, iface := range snap.Interfaces {
if iface.Interface != ifaceName {
continue
}
rx := iface.RxMbps
tx := iface.TxMbps
out = append(out, NodeSamplePoint{
Timestamp: snap.Timestamp,
Interface: ifaceName,
RxMbps: rx,
TxMbps: tx,
TotalMbps: math.Max(rx, tx),
})
break
}
}
sort.Slice(out, func(i, j int) bool {
return out[i].Timestamp.Before(out[j].Timestamp)
})
return out
}
// PercentileMbps returns the given percentile (0..100) of the TotalMbps
// field across the series using nearest-rank (inclusive) computation.
// Returns 0 if the series is empty.
func PercentileMbps(points []NodeSamplePoint, percentile float64) float64 {
if len(points) == 0 {
return 0
}
if percentile < 0 {
percentile = 0
}
if percentile > 100 {
percentile = 100
}
values := make([]float64, 0, len(points))
for _, p := range points {
values = append(values, p.TotalMbps)
}
sort.Float64s(values)
if len(values) == 1 {
return values[0]
}
rank := (percentile / 100.0) * float64(len(values)-1)
lo := int(math.Floor(rank))
hi := int(math.Ceil(rank))
if lo == hi {
return values[lo]
}
frac := rank - float64(lo)
return values[lo]*(1-frac) + values[hi]*frac
}
// TopInterfaceByTraffic returns the physical interface with the highest
// average max(Rx, Tx) across the sampled period, together with that
// average throughput in Mbps. Virtual interfaces are ignored.
func TopInterfaceByTraffic(snapshots []NetworkSnapshot) (string, float64) {
type acc struct {
sum float64
count int
}
totals := make(map[string]*acc, 16)
for _, snap := range snapshots {
for _, iface := range snap.Interfaces {
if isVirtualIface(iface.Interface) {
continue
}
v := math.Max(iface.RxMbps, iface.TxMbps)
a, ok := totals[iface.Interface]
if !ok {
a = &acc{}
totals[iface.Interface] = a
}
a.sum += v
a.count++
}
}
name := ""
bestAvg := 0.0
for k, a := range totals {
if a.count == 0 {
continue
}
avg := a.sum / float64(a.count)
if avg > bestAvg {
bestAvg = avg
name = k
}
}
return name, bestAvg
}
// RangeShortcut is a common time-window selector.
type RangeShortcut string
const (
RangeLive RangeShortcut = "live"
RangeHour RangeShortcut = "1h"
RangeDay RangeShortcut = "1d"
RangeMonth RangeShortcut = "1mo"
RangeAll RangeShortcut = "all"
)
// Since returns an absolute start time for the given range shortcut,
// relative to now. RangeAll and RangeLive return zero (no lower bound).
func (r RangeShortcut) Since(now time.Time) time.Time {
switch r {
case RangeHour:
return now.Add(-time.Hour)
case RangeDay:
return now.Add(-24 * time.Hour)
case RangeMonth:
return now.Add(-30 * 24 * time.Hour)
default:
return time.Time{}
}
}
// ParseRangeShortcut accepts user-provided range strings.
func ParseRangeShortcut(raw string) (RangeShortcut, bool) {
switch raw {
case "live", "now":
return RangeLive, true
case "1h", "hour":
return RangeHour, true
case "1d", "day", "24h":
return RangeDay, true
case "1mo", "30d", "month":
return RangeMonth, true
case "all", "":
return RangeAll, true
}
return "", false
}
// Label returns a human-friendly label for the range.
func (r RangeShortcut) Label() string {
switch r {
case RangeLive:
return "live"
case RangeHour:
return "last 1h"
case RangeDay:
return "last 24h"
case RangeMonth:
return "last 30d"
case RangeAll:
return "all time"
}
return string(r)
}