243 lines
5.9 KiB
Go
243 lines
5.9 KiB
Go
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)
|
|
}
|