chore: publish pxmon v0.2.0
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user