Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions internal/agent/bandwidth.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ func (a *Agent) handleHistoricalExport(c *gin.Context) {
return
}

// Normalize vnstat 1.x JSON format to 2.x format
// vnstat 1.x uses "hours"/"days"/"months" (plural) and lacks "time" fields
// vnstat 2.x uses "hour"/"day"/"month" (singular) with "time" fields
if jsonVer, ok := bandwidthData["jsonversion"].(string); ok && jsonVer == "1" {
normalizeVnstatV1(bandwidthData)
}

// Add server time information for timezone handling
now := time.Now()
bandwidthData["server_time"] = now.Format(time.RFC3339)
Expand Down Expand Up @@ -160,6 +167,65 @@ func (a *Agent) handlePeakStats(c *gin.Context) {
c.JSON(http.StatusOK, stats)
}

// normalizeVnstatV1 converts vnstat JSON version 1 format to version 2 format.
// v1 uses plural keys ("hours", "days", "months") and hour entries lack "time" fields.
// v2 uses singular keys ("hour", "day", "month") and hour entries include "time" with hour/minute.
func normalizeVnstatV1(data map[string]any) {
data["jsonversion"] = "2"

interfaces, ok := data["interfaces"].([]any)
if !ok {
return
}

for _, iface := range interfaces {
ifaceMap, ok := iface.(map[string]any)
if !ok {
continue
}

traffic, ok := ifaceMap["traffic"].(map[string]any)
if !ok {
continue
}

// Rename plural keys to singular and synthesize "time" for hour entries
if hours, ok := traffic["hours"].([]any); ok {
for _, entry := range hours {
hourMap, ok := entry.(map[string]any)
if !ok {
continue
}
// v1 stores hour-of-day in "id" field (0-23)
if id, ok := hourMap["id"].(float64); ok {
hourMap["time"] = map[string]any{
"hour": int(id),
"minute": 0,
}
}
// v1 hour entries may lack "day" in date
if dateMap, ok := hourMap["date"].(map[string]any); ok {
if _, hasDay := dateMap["day"]; !hasDay {
dateMap["day"] = float64(time.Now().Day())
}
}
}
traffic["hour"] = hours
delete(traffic, "hours")
}

if days, ok := traffic["days"].([]any); ok {
traffic["day"] = days
delete(traffic, "days")
}

if months, ok := traffic["months"].([]any); ok {
traffic["month"] = months
delete(traffic, "months")
}
}
}

// formatBytesPerSecond formats bytes per second to human readable string
func formatBytesPerSecond(bytes int) string {
if bytes == 0 {
Expand Down
162 changes: 162 additions & 0 deletions internal/agent/bandwidth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright (c) 2024-2025, s0up and the autobrr contributors.
// SPDX-License-Identifier: GPL-2.0-or-later

package agent

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNormalizeVnstatV1(t *testing.T) {
// Simulated vnstat 1.18 JSON output
v1JSON := `{
"vnstatversion": "1.18",
"jsonversion": "1",
"interfaces": [{
"id": "eth0",
"nick": "",
"traffic": {
"total": {"rx": 1000000, "tx": 500000},
"hours": [
{"id": 0, "date": {"year": 2025, "month": 1, "day": 6}, "rx": 100, "tx": 50},
{"id": 14, "date": {"year": 2025, "month": 1, "day": 6}, "rx": 200, "tx": 100}
],
"days": [
{"id": 0, "date": {"year": 2025, "month": 1, "day": 6}, "rx": 5000, "tx": 2500}
],
"months": [
{"id": 0, "date": {"year": 2025, "month": 1}, "rx": 100000, "tx": 50000}
]
}
}]
}`

var data map[string]any
require.NoError(t, json.Unmarshal([]byte(v1JSON), &data))

normalizeVnstatV1(data)

// jsonversion should be updated to "2"
assert.Equal(t, "2", data["jsonversion"])

interfaces := data["interfaces"].([]any)
iface := interfaces[0].(map[string]any)
traffic := iface["traffic"].(map[string]any)

// "hours" should be renamed to "hour"
assert.Nil(t, traffic["hours"], "old 'hours' key should be removed")
hourData, ok := traffic["hour"].([]any)
require.True(t, ok, "should have 'hour' key")
assert.Len(t, hourData, 2)

// Hour entries should have "time" field added
firstHour := hourData[0].(map[string]any)
timeField, ok := firstHour["time"].(map[string]any)
require.True(t, ok, "hour entry should have 'time' field")
assert.Equal(t, 0, timeField["hour"].(int))
assert.Equal(t, 0, timeField["minute"].(int))

secondHour := hourData[1].(map[string]any)
timeField2 := secondHour["time"].(map[string]any)
assert.Equal(t, 14, timeField2["hour"].(int))

// "days" should be renamed to "day"
assert.Nil(t, traffic["days"], "old 'days' key should be removed")
dayData, ok := traffic["day"].([]any)
require.True(t, ok, "should have 'day' key")
assert.Len(t, dayData, 1)

// "months" should be renamed to "month"
assert.Nil(t, traffic["months"], "old 'months' key should be removed")
monthData, ok := traffic["month"].([]any)
require.True(t, ok, "should have 'month' key")
assert.Len(t, monthData, 1)
}

func TestNormalizeVnstatV1_HourWithoutDay(t *testing.T) {
// vnstat 1.18 may omit "day" from hour entries
v1JSON := `{
"vnstatversion": "1.18",
"jsonversion": "1",
"interfaces": [{
"id": "eth0",
"nick": "",
"traffic": {
"total": {"rx": 1000, "tx": 500},
"hours": [
{"id": 10, "date": {"year": 2025, "month": 1}, "rx": 100, "tx": 50}
]
}
}]
}`

var data map[string]any
require.NoError(t, json.Unmarshal([]byte(v1JSON), &data))

normalizeVnstatV1(data)

interfaces := data["interfaces"].([]any)
iface := interfaces[0].(map[string]any)
traffic := iface["traffic"].(map[string]any)
hourData := traffic["hour"].([]any)
firstHour := hourData[0].(map[string]any)

timeField := firstHour["time"].(map[string]any)
assert.Equal(t, 10, timeField["hour"].(int))

// Should have day added to date
dateField := firstHour["date"].(map[string]any)
assert.NotNil(t, dateField["day"], "day should be added to date when missing")
}

func TestNormalizeVnstatV1_NoInterfaces(t *testing.T) {
data := map[string]any{
"vnstatversion": "1.18",
"jsonversion": "1",
}

// Should not panic
normalizeVnstatV1(data)
assert.Equal(t, "2", data["jsonversion"])
}

func TestNormalizeVnstatV1_EmptyTraffic(t *testing.T) {
data := map[string]any{
"vnstatversion": "1.18",
"jsonversion": "1",
"interfaces": []any{
map[string]any{
"id": "eth0",
"traffic": map[string]any{},
},
},
}

// Should not panic
normalizeVnstatV1(data)
assert.Equal(t, "2", data["jsonversion"])
}

func TestFormatBytesPerSecond(t *testing.T) {
tests := []struct {
name string
input int
expected string
}{
{"zero", 0, "0 B/s"},
{"bytes", 512, "512.00 B/s"},
{"kibibytes", 1024, "1.00 KiB/s"},
{"mebibytes", 1048576, "1.00 MiB/s"},
{"gibibytes", 1073741824, "1.00 GiB/s"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, formatBytesPerSecond(tt.input))
})
}
}
Loading
Loading