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) }