Skip to content

Commit 2e0e7d8

Browse files
authored
fix(agent): handle vnstat 1.x JSON format to prevent frontend crash (#169)
1 parent 8ea9bec commit 2e0e7d8

3 files changed

Lines changed: 358 additions & 180 deletions

File tree

internal/agent/bandwidth.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ func (a *Agent) handleHistoricalExport(c *gin.Context) {
5252
return
5353
}
5454

55+
// Normalize vnstat 1.x JSON format to 2.x format
56+
// vnstat 1.x uses "hours"/"days"/"months" (plural) and lacks "time" fields
57+
// vnstat 2.x uses "hour"/"day"/"month" (singular) with "time" fields
58+
if jsonVer, ok := bandwidthData["jsonversion"].(string); ok && jsonVer == "1" {
59+
normalizeVnstatV1(bandwidthData)
60+
}
61+
5562
// Add server time information for timezone handling
5663
now := time.Now()
5764
bandwidthData["server_time"] = now.Format(time.RFC3339)
@@ -160,6 +167,65 @@ func (a *Agent) handlePeakStats(c *gin.Context) {
160167
c.JSON(http.StatusOK, stats)
161168
}
162169

170+
// normalizeVnstatV1 converts vnstat JSON version 1 format to version 2 format.
171+
// v1 uses plural keys ("hours", "days", "months") and hour entries lack "time" fields.
172+
// v2 uses singular keys ("hour", "day", "month") and hour entries include "time" with hour/minute.
173+
func normalizeVnstatV1(data map[string]any) {
174+
data["jsonversion"] = "2"
175+
176+
interfaces, ok := data["interfaces"].([]any)
177+
if !ok {
178+
return
179+
}
180+
181+
for _, iface := range interfaces {
182+
ifaceMap, ok := iface.(map[string]any)
183+
if !ok {
184+
continue
185+
}
186+
187+
traffic, ok := ifaceMap["traffic"].(map[string]any)
188+
if !ok {
189+
continue
190+
}
191+
192+
// Rename plural keys to singular and synthesize "time" for hour entries
193+
if hours, ok := traffic["hours"].([]any); ok {
194+
for _, entry := range hours {
195+
hourMap, ok := entry.(map[string]any)
196+
if !ok {
197+
continue
198+
}
199+
// v1 stores hour-of-day in "id" field (0-23)
200+
if id, ok := hourMap["id"].(float64); ok {
201+
hourMap["time"] = map[string]any{
202+
"hour": int(id),
203+
"minute": 0,
204+
}
205+
}
206+
// v1 hour entries may lack "day" in date
207+
if dateMap, ok := hourMap["date"].(map[string]any); ok {
208+
if _, hasDay := dateMap["day"]; !hasDay {
209+
dateMap["day"] = float64(time.Now().Day())
210+
}
211+
}
212+
}
213+
traffic["hour"] = hours
214+
delete(traffic, "hours")
215+
}
216+
217+
if days, ok := traffic["days"].([]any); ok {
218+
traffic["day"] = days
219+
delete(traffic, "days")
220+
}
221+
222+
if months, ok := traffic["months"].([]any); ok {
223+
traffic["month"] = months
224+
delete(traffic, "months")
225+
}
226+
}
227+
}
228+
163229
// formatBytesPerSecond formats bytes per second to human readable string
164230
func formatBytesPerSecond(bytes int) string {
165231
if bytes == 0 {

internal/agent/bandwidth_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2024-2025, s0up and the autobrr contributors.
2+
// SPDX-License-Identifier: GPL-2.0-or-later
3+
4+
package agent
5+
6+
import (
7+
"encoding/json"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestNormalizeVnstatV1(t *testing.T) {
15+
// Simulated vnstat 1.18 JSON output
16+
v1JSON := `{
17+
"vnstatversion": "1.18",
18+
"jsonversion": "1",
19+
"interfaces": [{
20+
"id": "eth0",
21+
"nick": "",
22+
"traffic": {
23+
"total": {"rx": 1000000, "tx": 500000},
24+
"hours": [
25+
{"id": 0, "date": {"year": 2025, "month": 1, "day": 6}, "rx": 100, "tx": 50},
26+
{"id": 14, "date": {"year": 2025, "month": 1, "day": 6}, "rx": 200, "tx": 100}
27+
],
28+
"days": [
29+
{"id": 0, "date": {"year": 2025, "month": 1, "day": 6}, "rx": 5000, "tx": 2500}
30+
],
31+
"months": [
32+
{"id": 0, "date": {"year": 2025, "month": 1}, "rx": 100000, "tx": 50000}
33+
]
34+
}
35+
}]
36+
}`
37+
38+
var data map[string]any
39+
require.NoError(t, json.Unmarshal([]byte(v1JSON), &data))
40+
41+
normalizeVnstatV1(data)
42+
43+
// jsonversion should be updated to "2"
44+
assert.Equal(t, "2", data["jsonversion"])
45+
46+
interfaces := data["interfaces"].([]any)
47+
iface := interfaces[0].(map[string]any)
48+
traffic := iface["traffic"].(map[string]any)
49+
50+
// "hours" should be renamed to "hour"
51+
assert.Nil(t, traffic["hours"], "old 'hours' key should be removed")
52+
hourData, ok := traffic["hour"].([]any)
53+
require.True(t, ok, "should have 'hour' key")
54+
assert.Len(t, hourData, 2)
55+
56+
// Hour entries should have "time" field added
57+
firstHour := hourData[0].(map[string]any)
58+
timeField, ok := firstHour["time"].(map[string]any)
59+
require.True(t, ok, "hour entry should have 'time' field")
60+
assert.Equal(t, 0, timeField["hour"].(int))
61+
assert.Equal(t, 0, timeField["minute"].(int))
62+
63+
secondHour := hourData[1].(map[string]any)
64+
timeField2 := secondHour["time"].(map[string]any)
65+
assert.Equal(t, 14, timeField2["hour"].(int))
66+
67+
// "days" should be renamed to "day"
68+
assert.Nil(t, traffic["days"], "old 'days' key should be removed")
69+
dayData, ok := traffic["day"].([]any)
70+
require.True(t, ok, "should have 'day' key")
71+
assert.Len(t, dayData, 1)
72+
73+
// "months" should be renamed to "month"
74+
assert.Nil(t, traffic["months"], "old 'months' key should be removed")
75+
monthData, ok := traffic["month"].([]any)
76+
require.True(t, ok, "should have 'month' key")
77+
assert.Len(t, monthData, 1)
78+
}
79+
80+
func TestNormalizeVnstatV1_HourWithoutDay(t *testing.T) {
81+
// vnstat 1.18 may omit "day" from hour entries
82+
v1JSON := `{
83+
"vnstatversion": "1.18",
84+
"jsonversion": "1",
85+
"interfaces": [{
86+
"id": "eth0",
87+
"nick": "",
88+
"traffic": {
89+
"total": {"rx": 1000, "tx": 500},
90+
"hours": [
91+
{"id": 10, "date": {"year": 2025, "month": 1}, "rx": 100, "tx": 50}
92+
]
93+
}
94+
}]
95+
}`
96+
97+
var data map[string]any
98+
require.NoError(t, json.Unmarshal([]byte(v1JSON), &data))
99+
100+
normalizeVnstatV1(data)
101+
102+
interfaces := data["interfaces"].([]any)
103+
iface := interfaces[0].(map[string]any)
104+
traffic := iface["traffic"].(map[string]any)
105+
hourData := traffic["hour"].([]any)
106+
firstHour := hourData[0].(map[string]any)
107+
108+
timeField := firstHour["time"].(map[string]any)
109+
assert.Equal(t, 10, timeField["hour"].(int))
110+
111+
// Should have day added to date
112+
dateField := firstHour["date"].(map[string]any)
113+
assert.NotNil(t, dateField["day"], "day should be added to date when missing")
114+
}
115+
116+
func TestNormalizeVnstatV1_NoInterfaces(t *testing.T) {
117+
data := map[string]any{
118+
"vnstatversion": "1.18",
119+
"jsonversion": "1",
120+
}
121+
122+
// Should not panic
123+
normalizeVnstatV1(data)
124+
assert.Equal(t, "2", data["jsonversion"])
125+
}
126+
127+
func TestNormalizeVnstatV1_EmptyTraffic(t *testing.T) {
128+
data := map[string]any{
129+
"vnstatversion": "1.18",
130+
"jsonversion": "1",
131+
"interfaces": []any{
132+
map[string]any{
133+
"id": "eth0",
134+
"traffic": map[string]any{},
135+
},
136+
},
137+
}
138+
139+
// Should not panic
140+
normalizeVnstatV1(data)
141+
assert.Equal(t, "2", data["jsonversion"])
142+
}
143+
144+
func TestFormatBytesPerSecond(t *testing.T) {
145+
tests := []struct {
146+
name string
147+
input int
148+
expected string
149+
}{
150+
{"zero", 0, "0 B/s"},
151+
{"bytes", 512, "512.00 B/s"},
152+
{"kibibytes", 1024, "1.00 KiB/s"},
153+
{"mebibytes", 1048576, "1.00 MiB/s"},
154+
{"gibibytes", 1073741824, "1.00 GiB/s"},
155+
}
156+
157+
for _, tt := range tests {
158+
t.Run(tt.name, func(t *testing.T) {
159+
assert.Equal(t, tt.expected, formatBytesPerSecond(tt.input))
160+
})
161+
}
162+
}

0 commit comments

Comments
 (0)