feat(go.d/snmp): add interfaces function (#21604)
Ilya Mashchenko committed
Jan 22, 2026 at 15:04 UTC
16d6869d700af32508f2f5a63b0010f395f1bf5e
6 files changed
+1962
-3
src/go/plugin/go.d/collector/snmp/collect_snmp.go
+8
@@ -21,10 +21,14 @@ func (c *Collector) collectSNMP(mx map[string]int64) error {
21
return err
22
}
23
24
+ c.resetIfaceCache()
25
+
26
c.collectProfileScalarMetrics(mx, pms)
27
c.collectProfileTableMetrics(mx, pms)
28
c.collectProfileStats(mx, pms)
29
30
+ c.finalizeIfaceCache()
31
+
32
return nil
33
}
34
@@ -83,6 +87,10 @@ func (c *Collector) collectProfileTableMetrics(mx map[string]int64, pms []*ddsnm
87
mx[id] = v
88
}
89
}
90
+
91
+ if isIfaceMetric(m.Name) {
92
+ c.updateIfaceCacheEntry(m)
93
+ }
94
}
95
}
96
src/go/plugin/go.d/collector/snmp/collector.go
+15
-3
@@ -29,13 +29,16 @@ func init() {
29
Defaults: module.Defaults{
30
UpdateEvery: 10,
31
},
32
- Create: func() module.Module { return New() },
33
- Config: func() any { return &Config{} },
32
+ Create: func() module.Module { return New() },
33
+ Config: func() any { return &Config{} },
34
+ Methods: snmpMethods,
35
+ MethodParams: snmpMethodParams,
36
+ HandleMethod: snmpHandleMethod,
37
})
38
}
39
40
func New() *Collector {
38
- return &Collector{
41
+ c := &Collector{
42
Config: Config{
43
CreateVnode: true,
44
VnodeDeviceDownThreshold: 3,
@@ -69,12 +72,18 @@ func New() *Collector {
72
seenTableMetrics: make(map[string]bool),
73
seenProfiles: make(map[string]bool),
74
75
+ ifaceCache: newIfaceCache(),
76
+
77
newProber: ping.NewProber,
78
newSnmpClient: gosnmp.NewHandler,
79
newDdSnmpColl: func(cfg ddsnmpcollector.Config) ddCollector {
80
return ddsnmpcollector.New(cfg)
81
},
82
}
83
+
84
+ c.funcIfaces = newFuncInterfaces(c.ifaceCache)
85
+
86
+ return c
87
}
88
89
type (
@@ -89,6 +98,9 @@ type (
98
seenTableMetrics map[string]bool
99
seenProfiles map[string]bool
100
101
+ ifaceCache *ifaceCache // interface metrics cache for functions
102
+ funcIfaces *funcInterfaces // interfaces function handler
103
+
104
prober ping.Prober
105
newProber func(ping.ProberConfig, *logger.Logger) ping.Prober
106
src/go/plugin/go.d/collector/snmp/func_interfaces.go
new
+623
@@ -0,0 +1,623 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmp
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "sort"
9
+
10
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
11
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
12
+)
13
+
14
+// funcInterfaces handles the "interfaces" function for SNMP devices.
15
+// It provides network interface traffic and status metrics from cached SNMP data.
16
+type funcInterfaces struct {
17
+ cache *ifaceCache
18
+}
19
+
20
+func newFuncInterfaces(cache *ifaceCache) *funcInterfaces {
21
+ return &funcInterfaces{cache: cache}
22
+}
23
+
24
+// methods returns the method configurations for this function.
25
+func (f *funcInterfaces) methods() []module.MethodConfig {
26
+ return []module.MethodConfig{{
27
+ ID: "interfaces",
28
+ Name: "Network Interfaces",
29
+ Help: "Network interface traffic and status metrics",
30
+ RequiredParams: []funcapi.ParamConfig{{
31
+ ID: funcIfacesParamTypeGroup,
32
+ Name: "Type Group",
33
+ Help: "Filter by interface type group",
34
+ Selection: funcapi.ParamSelect,
35
+ Options: []funcapi.ParamOption{
36
+ {ID: "ethernet", Name: "Ethernet", Default: true},
37
+ {ID: "aggregation", Name: "Aggregation"},
38
+ {ID: "virtual", Name: "Virtual"},
39
+ {ID: "other", Name: "Other"},
40
+ },
41
+ }},
42
+ }}
43
+}
44
+
45
+// methodParams returns params for the given method.
46
+func (f *funcInterfaces) methodParams(method string) ([]funcapi.ParamConfig, error) {
47
+ if method != "interfaces" {
48
+ return nil, fmt.Errorf("unknown method: %s", method)
49
+ }
50
+
51
+ methods := f.methods()
52
+ if len(methods) > 0 {
53
+ return methods[0].RequiredParams, nil
54
+ }
55
+ return nil, nil
56
+}
57
+
58
+// handle processes a function request and returns the response.
59
+func (f *funcInterfaces) handle(method string, params funcapi.ResolvedParams) *module.FunctionResponse {
60
+ if method != "interfaces" {
61
+ return &module.FunctionResponse{Status: 404, Message: fmt.Sprintf("unknown method: %s", method)}
62
+ }
63
+
64
+ if f.cache == nil {
65
+ return &module.FunctionResponse{
66
+ Status: 503,
67
+ Message: "interface data not available yet, please retry after data collection",
68
+ }
69
+ }
70
+
71
+ f.cache.mu.RLock()
72
+ defer f.cache.mu.RUnlock()
73
+
74
+ typeGroupFilter := params.GetOne(funcIfacesParamTypeGroup)
75
+ if typeGroupFilter == "" {
76
+ typeGroupFilter = "ethernet"
77
+ }
78
+
79
+ // Build data rows from cache
80
+ data := make([][]any, 0, len(f.cache.interfaces))
81
+ for _, entry := range f.cache.interfaces {
82
+ if !matchesTypeGroup(entry.ifTypeGroup, typeGroupFilter) {
83
+ continue
84
+ }
85
+ row := f.buildRow(entry)
86
+ data = append(data, row)
87
+ }
88
+
89
+ // Sort data based on params
90
+ f.sortData(data, f.defaultSortColumn())
91
+
92
+ return &module.FunctionResponse{
93
+ Status: 200,
94
+ Help: "Network interface traffic and status metrics",
95
+ Columns: f.buildColumns(),
96
+ Data: data,
97
+ DefaultSortColumn: f.defaultSortColumn(),
98
+
99
+ // Charts for aggregated visualization
100
+ Charts: map[string]module.ChartConfig{
101
+ "Traffic": {
102
+ Name: "Traffic",
103
+ Type: "stacked-bar",
104
+ Columns: []string{"Traffic In", "Traffic Out"},
105
+ },
106
+ "UnicastPackets": {
107
+ Name: "Unicast Packets",
108
+ Type: "stacked-bar",
109
+ Columns: []string{"Unicast In", "Unicast Out"},
110
+ },
111
+ "BroadcastPackets": {
112
+ Name: "Broadcast Packets",
113
+ Type: "stacked-bar",
114
+ Columns: []string{"Broadcast In", "Broadcast Out"},
115
+ },
116
+ "MulticastPackets": {
117
+ Name: "Multicast Packets",
118
+ Type: "stacked-bar",
119
+ Columns: []string{"Multicast In", "Multicast Out"},
120
+ },
121
+ "OperationalStatus": {
122
+ Name: "Operational Status",
123
+ Type: "stacked-bar",
124
+ Columns: []string{"Oper Status"},
125
+ },
126
+ },
127
+ DefaultCharts: [][]string{
128
+ {"Traffic", "Type"},
129
+ {"OperationalStatus", "Oper Status"},
130
+ },
131
+ GroupBy: map[string]module.GroupByConfig{
132
+ "Type": {
133
+ Name: "Group by Type",
134
+ Columns: []string{"Type"},
135
+ },
136
+ },
137
+ }
138
+}
139
+
140
+// buildColumns builds column definitions for the response.
141
+func (f *funcInterfaces) buildColumns() map[string]any {
142
+ columns := make(map[string]any)
143
+
144
+ for i, col := range funcIfacesColumns {
145
+ colDef := funcapi.Column{
146
+ Index: i,
147
+ Name: col.name,
148
+ Type: col.dataType,
149
+ Units: col.units,
150
+ Visualization: col.visual,
151
+ Sort: col.sortDir,
152
+ Sortable: true,
153
+ Sticky: col.sticky,
154
+ Summary: col.summary,
155
+ Filter: col.filter,
156
+ Visible: col.visible,
157
+ ValueOptions: funcapi.ValueOptions{
158
+ Transform: col.transform,
159
+ DecimalPoints: col.decimals,
160
+ DefaultValue: nil,
161
+ },
162
+ }
163
+ columns[col.key] = colDef.BuildColumn()
164
+ }
165
+
166
+ rowOptions := funcapi.Column{
167
+ Index: len(funcIfacesColumns),
168
+ Name: "rowOptions",
169
+ Type: funcapi.FieldTypeNone,
170
+ Visualization: funcapi.FieldVisualRowOptions,
171
+ Sort: funcapi.FieldSortAscending,
172
+ Sortable: false,
173
+ Sticky: false,
174
+ Summary: funcapi.FieldSummaryCount,
175
+ Filter: funcapi.FieldFilterNone,
176
+ Visible: false,
177
+ Dummy: true,
178
+ ValueOptions: funcapi.ValueOptions{
179
+ Transform: funcapi.FieldTransformNone,
180
+ DecimalPoints: 0,
181
+ DefaultValue: nil,
182
+ },
183
+ }
184
+ columns["rowOptions"] = rowOptions.BuildColumn()
185
+
186
+ return columns
187
+}
188
+
189
+// buildRow builds a data row from an interface entry.
190
+// Column order is determined by funcIfacesColumns - each column's value() extracts the data.
191
+func (f *funcInterfaces) buildRow(entry *ifaceEntry) []any {
192
+ row := make([]any, len(funcIfacesColumns)+1)
193
+ for i, col := range funcIfacesColumns {
194
+ row[i] = col.value(entry)
195
+ }
196
+ if isIfaceDown(entry) {
197
+ for i, col := range funcIfacesColumns {
198
+ if col.dataType == funcapi.FieldTypeFloat {
199
+ row[i] = nil
200
+ }
201
+ }
202
+ }
203
+ row[len(funcIfacesColumns)] = rowOptionsForIface(entry)
204
+ return row
205
+}
206
+
207
+// sortData sorts the data rows by the specified column.
208
+func (f *funcInterfaces) sortData(data [][]any, sortColumn string) {
209
+ if len(data) == 0 {
210
+ return
211
+ }
212
+
213
+ // Find column index and sort direction
214
+ colIdx := 0
215
+ sortDir := funcapi.FieldSortAscending
216
+
217
+ for i, col := range funcIfacesColumns {
218
+ if col.key == sortColumn {
219
+ colIdx = i
220
+ sortDir = col.sortDir
221
+ break
222
+ }
223
+ }
224
+
225
+ sort.Slice(data, func(i, j int) bool {
226
+ vi := data[i][colIdx]
227
+ vj := data[j][colIdx]
228
+
229
+ // Handle nil values - put them at the end
230
+ if vi == nil && vj == nil {
231
+ return false
232
+ }
233
+ if vi == nil {
234
+ return false
235
+ }
236
+ if vj == nil {
237
+ return true
238
+ }
239
+
240
+ // Compare based on type
241
+ switch a := vi.(type) {
242
+ case string:
243
+ b := vj.(string)
244
+ if sortDir == funcapi.FieldSortAscending {
245
+ return a < b
246
+ }
247
+ return a > b
248
+ case float64:
249
+ b := vj.(float64)
250
+ if sortDir == funcapi.FieldSortAscending {
251
+ return a < b
252
+ }
253
+ return a > b
254
+ default:
255
+ return false
256
+ }
257
+ })
258
+}
259
+
260
+// defaultSortColumn returns the default sort column key.
261
+func (f *funcInterfaces) defaultSortColumn() string {
262
+ for _, col := range funcIfacesColumns {
263
+ if col.defaultSort {
264
+ return col.key
265
+ }
266
+ }
267
+ return "Interface"
268
+}
269
+
270
+func rowOptionsForIface(entry *ifaceEntry) any {
271
+ // TODO: Re-enable row coloring once the UI supports more severity options.
272
+ return nil
273
+}
274
+
275
+func isIfaceDown(entry *ifaceEntry) bool {
276
+ if entry == nil {
277
+ return false
278
+ }
279
+ return entry.adminStatus != "up" || entry.operStatus != "up"
280
+}
281
+
282
+func matchesTypeGroup(group, filter string) bool {
283
+ knownGroups := map[string]bool{
284
+ "ethernet": true,
285
+ "aggregation": true,
286
+ "virtual": true,
287
+ }
288
+
289
+ if filter == "other" {
290
+ return !knownGroups[group]
291
+ }
292
+
293
+ return group == filter
294
+}
295
+
296
+// ptrToAny converts a *float64 to any, returning nil if the pointer is nil.
297
+func ptrToAny(p *float64) any {
298
+ if p == nil {
299
+ return nil
300
+ }
301
+ return *p
302
+}
303
+
304
+func ptrToAnyScale(p *float64, scale float64) any {
305
+ if p == nil {
306
+ return nil
307
+ }
308
+ return *p / scale
309
+}
310
+
311
+func sumRates(vals ...*float64) *float64 {
312
+ var sum float64
313
+ hasValue := false
314
+ for _, v := range vals {
315
+ if v == nil {
316
+ continue
317
+ }
318
+ sum += *v
319
+ hasValue = true
320
+ }
321
+ if !hasValue {
322
+ return nil
323
+ }
324
+ return &sum
325
+}
326
+
327
+// Package-level registration functions that delegate to funcInterfaces.
328
+
329
+func snmpMethods() []module.MethodConfig {
330
+ return (&funcInterfaces{}).methods()
331
+}
332
+
333
+func snmpMethodParams(_ context.Context, job *module.Job, method string) ([]funcapi.ParamConfig, error) {
334
+ c, ok := job.Module().(*Collector)
335
+ if !ok {
336
+ return nil, fmt.Errorf("invalid module type")
337
+ }
338
+ return c.funcIfaces.methodParams(method)
339
+}
340
+
341
+func snmpHandleMethod(_ context.Context, job *module.Job, method string, params funcapi.ResolvedParams) *module.FunctionResponse {
342
+ c, ok := job.Module().(*Collector)
343
+ if !ok {
344
+ return &module.FunctionResponse{Status: 500, Message: "internal error: invalid module type"}
345
+ }
346
+ return c.funcIfaces.handle(method, params)
347
+}
348
+
349
+// funcIfacesParamTypeGroup is the parameter ID for type group filtering.
350
+const funcIfacesParamTypeGroup = "if_type_group"
351
+
352
+// funcIfacesColumn defines a column with its metadata and value extractor.
353
+// The value function extracts the column's data from an ifaceEntry.
354
+type funcIfacesColumn struct {
355
+ key string // column header value (must be unique)
356
+ name string // tooltip value
357
+ value func(*ifaceEntry) any // extracts value from entry
358
+ dataType funcapi.FieldType // string, float, etc.
359
+ units string // display units (bytes/s, packets/s)
360
+ visual funcapi.FieldVisual // visualization type
361
+ visible bool // shown by default
362
+ transform funcapi.FieldTransform // number formatting
363
+ decimals int // decimal points
364
+ sortDir funcapi.FieldSort // asc or desc
365
+ summary funcapi.FieldSummary // count, sum, etc.
366
+ filter funcapi.FieldFilter // multiselect, range, etc.
367
+ sortOption string // if non-empty, appears in sort dropdown
368
+ defaultSort bool // is default sort column
369
+ sticky bool // sticky column
370
+}
371
+
372
+// funcIfacesColumns defines all columns for the interfaces function.
373
+// Each column includes its value extractor - single source of truth.
374
+var funcIfacesColumns = []funcIfacesColumn{
375
+ {
376
+ key: "Interface",
377
+ name: "",
378
+ value: func(e *ifaceEntry) any { return e.name },
379
+ dataType: funcapi.FieldTypeString,
380
+ visible: true,
381
+ sortDir: funcapi.FieldSortAscending,
382
+ summary: funcapi.FieldSummaryCount,
383
+ filter: funcapi.FieldFilterMultiselect,
384
+ defaultSort: true,
385
+ sticky: true,
386
+ },
387
+ {
388
+ key: "Type",
389
+ name: "IANA ifType (IF-MIB)",
390
+ value: func(e *ifaceEntry) any { return e.ifType },
391
+ dataType: funcapi.FieldTypeString,
392
+ visible: false,
393
+ sortDir: funcapi.FieldSortAscending,
394
+ summary: funcapi.FieldSummaryCount,
395
+ filter: funcapi.FieldFilterMultiselect,
396
+ },
397
+ {
398
+ key: "Type Group",
399
+ name: "Custom mapping of IANA ifType into groups",
400
+ value: func(e *ifaceEntry) any { return e.ifTypeGroup },
401
+ dataType: funcapi.FieldTypeString,
402
+ visible: true,
403
+ sortDir: funcapi.FieldSortAscending,
404
+ summary: funcapi.FieldSummaryCount,
405
+ filter: funcapi.FieldFilterMultiselect,
406
+ },
407
+ {
408
+ key: "Admin Status",
409
+ name: "Administrative status: up, down, testing",
410
+ value: func(e *ifaceEntry) any { return e.adminStatus },
411
+ dataType: funcapi.FieldTypeString,
412
+ visible: true,
413
+ sortDir: funcapi.FieldSortAscending,
414
+ summary: funcapi.FieldSummaryCount,
415
+ filter: funcapi.FieldFilterMultiselect,
416
+ },
417
+ {
418
+ key: "Oper Status",
419
+ name: "Operational status: up, down, testing, unknown, dormant, notPresent, lowerLayerDown",
420
+ value: func(e *ifaceEntry) any { return e.operStatus },
421
+ dataType: funcapi.FieldTypeString,
422
+ visible: true,
423
+ sortDir: funcapi.FieldSortAscending,
424
+ summary: funcapi.FieldSummaryCount,
425
+ filter: funcapi.FieldFilterMultiselect,
426
+ },
427
+ {
428
+ key: "Traffic In",
429
+ name: "",
430
+ value: func(e *ifaceEntry) any { return ptrToAnyScale(e.rates.trafficIn, 1_000_000) },
431
+ dataType: funcapi.FieldTypeFloat,
432
+ units: "Mbits",
433
+ visual: funcapi.FieldVisualBar,
434
+ visible: true,
435
+ transform: funcapi.FieldTransformNumber,
436
+ decimals: 2,
437
+ sortDir: funcapi.FieldSortDescending,
438
+ summary: funcapi.FieldSummarySum,
439
+ filter: funcapi.FieldFilterRange,
440
+ },
441
+ {
442
+ key: "Traffic Out",
443
+ name: "",
444
+ value: func(e *ifaceEntry) any { return ptrToAnyScale(e.rates.trafficOut, 1_000_000) },
445
+ dataType: funcapi.FieldTypeFloat,
446
+ units: "Mbits",
447
+ visual: funcapi.FieldVisualBar,
448
+ visible: true,
449
+ transform: funcapi.FieldTransformNumber,
450
+ decimals: 2,
451
+ sortDir: funcapi.FieldSortDescending,
452
+ summary: funcapi.FieldSummarySum,
453
+ filter: funcapi.FieldFilterRange,
454
+ },
455
+ {
456
+ key: "Unicast In",
457
+ name: "",
458
+ value: func(e *ifaceEntry) any { return ptrToAnyScale(e.rates.ucastPktsIn, 1_000) },
459
+ dataType: funcapi.FieldTypeFloat,
460
+ units: "Kpps",
461
+ visual: funcapi.FieldVisualBar,
462
+ visible: false,
463
+ transform: funcapi.FieldTransformNumber,
464
+ decimals: 2,
465
+ sortDir: funcapi.FieldSortDescending,
466
+ summary: funcapi.FieldSummarySum,
467
+ filter: funcapi.FieldFilterRange,
468
+ },
469
+ {
470
+ key: "Unicast Out",
471
+ name: "",
472
+ value: func(e *ifaceEntry) any { return ptrToAnyScale(e.rates.ucastPktsOut, 1_000) },
473
+ dataType: funcapi.FieldTypeFloat,
474
+ units: "Kpps",
475
+ visual: funcapi.FieldVisualBar,
476
+ visible: false,
477
+ transform: funcapi.FieldTransformNumber,
478
+ decimals: 2,
479
+ sortDir: funcapi.FieldSortDescending,
480
+ summary: funcapi.FieldSummarySum,
481
+ filter: funcapi.FieldFilterRange,
482
+ },
483
+ {
484
+ key: "Broadcast In",
485
+ name: "",
486
+ value: func(e *ifaceEntry) any { return ptrToAnyScale(e.rates.bcastPktsIn, 1_000) },
487
+ dataType: funcapi.FieldTypeFloat,
488
+ units: "Kpps",
489
+ visual: funcapi.FieldVisualBar,
490
+ visible: false,
491
+ transform: funcapi.FieldTransformNumber,
492
+ decimals: 2,
493
+ sortDir: funcapi.FieldSortDescending,
494
+ summary: funcapi.FieldSummarySum,
495
+ filter: funcapi.FieldFilterRange,
496
+ },
497
+ {
498
+ key: "Broadcast Out",
499
+ name: "",
500
+ value: func(e *ifaceEntry) any { return ptrToAnyScale(e.rates.bcastPktsOut, 1_000) },
501
+ dataType: funcapi.FieldTypeFloat,
502
+ units: "Kpps",
503
+ visual: funcapi.FieldVisualBar,
504
+ visible: false,
505
+ transform: funcapi.FieldTransformNumber,
506
+ decimals: 2,
507
+ sortDir: funcapi.FieldSortDescending,
508
+ summary: funcapi.FieldSummarySum,
509
+ filter: funcapi.FieldFilterRange,
510
+ },
511
+ {
512
+ key: "Packets In",
513
+ name: "",
514
+ value: func(e *ifaceEntry) any {
515
+ return ptrToAnyScale(sumRates(e.rates.ucastPktsIn, e.rates.bcastPktsIn, e.rates.mcastPktsIn), 1_000)
516
+ },
517
+ dataType: funcapi.FieldTypeFloat,
518
+ units: "Kpps",
519
+ visual: funcapi.FieldVisualBar,
520
+ visible: true,
521
+ transform: funcapi.FieldTransformNumber,
522
+ decimals: 2,
523
+ sortDir: funcapi.FieldSortDescending,
524
+ summary: funcapi.FieldSummarySum,
525
+ filter: funcapi.FieldFilterRange,
526
+ },
527
+ {
528
+ key: "Packets Out",
529
+ name: "",
530
+ value: func(e *ifaceEntry) any {
531
+ return ptrToAnyScale(sumRates(e.rates.ucastPktsOut, e.rates.bcastPktsOut, e.rates.mcastPktsOut), 1_000)
532
+ },
533
+ dataType: funcapi.FieldTypeFloat,
534
+ units: "Kpps",
535
+ visual: funcapi.FieldVisualBar,
536
+ visible: true,
537
+ transform: funcapi.FieldTransformNumber,
538
+ decimals: 2,
539
+ sortDir: funcapi.FieldSortDescending,
540
+ summary: funcapi.FieldSummarySum,
541
+ filter: funcapi.FieldFilterRange,
542
+ },
543
+ {
544
+ key: "Errors In",
545
+ name: "",
546
+ value: func(e *ifaceEntry) any { return ptrToAny(e.rates.errorsIn) },
547
+ dataType: funcapi.FieldTypeFloat,
548
+ units: "packets/s",
549
+ visual: funcapi.FieldVisualBar,
550
+ visible: false,
551
+ transform: funcapi.FieldTransformNumber,
552
+ sortDir: funcapi.FieldSortDescending,
553
+ summary: funcapi.FieldSummarySum,
554
+ filter: funcapi.FieldFilterRange,
555
+ },
556
+ {
557
+ key: "Errors Out",
558
+ name: "",
559
+ value: func(e *ifaceEntry) any { return ptrToAny(e.rates.errorsOut) },
560
+ dataType: funcapi.FieldTypeFloat,
561
+ units: "packets/s",
562
+ visual: funcapi.FieldVisualBar,
563
+ visible: false,
564
+ transform: funcapi.FieldTransformNumber,
565
+ sortDir: funcapi.FieldSortDescending,
566
+ summary: funcapi.FieldSummarySum,
567
+ filter: funcapi.FieldFilterRange,
568
+ },
569
+ {
570
+ key: "Discards In",
571
+ name: "",
572
+ value: func(e *ifaceEntry) any { return ptrToAny(e.rates.discardsIn) },
573
+ dataType: funcapi.FieldTypeFloat,
574
+ units: "packets/s",
575
+ visual: funcapi.FieldVisualBar,
576
+ visible: true,
577
+ transform: funcapi.FieldTransformNumber,
578
+ sortDir: funcapi.FieldSortDescending,
579
+ summary: funcapi.FieldSummarySum,
580
+ filter: funcapi.FieldFilterRange,
581
+ },
582
+ {
583
+ key: "Discards Out",
584
+ name: "",
585
+ value: func(e *ifaceEntry) any { return ptrToAny(e.rates.discardsOut) },
586
+ dataType: funcapi.FieldTypeFloat,
587
+ units: "packets/s",
588
+ visual: funcapi.FieldVisualBar,
589
+ visible: true,
590
+ transform: funcapi.FieldTransformNumber,
591
+ sortDir: funcapi.FieldSortDescending,
592
+ summary: funcapi.FieldSummarySum,
593
+ filter: funcapi.FieldFilterRange,
594
+ },
595
+ {
596
+ key: "Multicast In",
597
+ name: "",
598
+ value: func(e *ifaceEntry) any { return ptrToAnyScale(e.rates.mcastPktsIn, 1_000) },
599
+ dataType: funcapi.FieldTypeFloat,
600
+ units: "Kpps",
601
+ visual: funcapi.FieldVisualBar,
602
+ visible: false,
603
+ transform: funcapi.FieldTransformNumber,
604
+ decimals: 2,
605
+ sortDir: funcapi.FieldSortDescending,
606
+ summary: funcapi.FieldSummarySum,
607
+ filter: funcapi.FieldFilterRange,
608
+ },
609
+ {
610
+ key: "Multicast Out",
611
+ name: "",
612
+ value: func(e *ifaceEntry) any { return ptrToAnyScale(e.rates.mcastPktsOut, 1_000) },
613
+ dataType: funcapi.FieldTypeFloat,
614
+ units: "Kpps",
615
+ visual: funcapi.FieldVisualBar,
616
+ visible: false,
617
+ transform: funcapi.FieldTransformNumber,
618
+ decimals: 2,
619
+ sortDir: funcapi.FieldSortDescending,
620
+ summary: funcapi.FieldSummarySum,
621
+ filter: funcapi.FieldFilterRange,
622
+ },
623
+}
src/go/plugin/go.d/collector/snmp/func_interfaces_cache.go
new
+285
@@ -0,0 +1,285 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmp
4
+
5
+import (
6
+ "math"
7
+ "sync"
8
+ "time"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
11
+)
12
+
13
+// Interface metric names we track for the function.
14
+var ifaceMetricNames = map[string]bool{
15
+ "ifTraffic": true,
16
+ "ifPacketsUcast": true,
17
+ "ifPacketsBroadcast": true,
18
+ "ifPacketsMulticast": true,
19
+ "ifErrors": true,
20
+ "ifDiscards": true,
21
+ "ifAdminStatus": true,
22
+ "ifOperStatus": true,
23
+}
24
+
25
+// Tag keys used to identify interfaces.
26
+const (
27
+ tagInterface = "interface"
28
+ tagIfType = "_if_type"
29
+ tagIfTypeGrp = "_if_type_group"
30
+)
31
+
32
+// ifaceCache holds interface metrics between collections for function queries.
33
+type ifaceCache struct {
34
+ mu sync.RWMutex
35
+ lastUpdate time.Time
36
+ updateTime time.Time // current collection cycle time
37
+ interfaces map[string]*ifaceEntry // key: interface name from m.Tags["interface"]
38
+}
39
+
40
+// ifaceEntry holds metrics for a single network interface.
41
+type ifaceEntry struct {
42
+ // Identity
43
+ name string // interface name (from Tags["interface"])
44
+ ifType string // interface type (from Tags["_if_type"])
45
+ ifTypeGroup string // interface type group (from Tags["_if_type_group"])
46
+
47
+ // Status (text values extracted from MultiValue)
48
+ adminStatus string
49
+ operStatus string
50
+
51
+ // Raw counter values (cumulative, stored for next delta calculation)
52
+ counters ifaceCounters
53
+
54
+ // Previous counter values (for delta calculation)
55
+ prevCounters ifaceCounters
56
+ prevTime time.Time
57
+ hasPrev bool // true if we have previous values for delta calculation
58
+
59
+ // Computed rates (per-second, nil if not yet calculable)
60
+ rates ifaceRates
61
+
62
+ // Tracking
63
+ updated bool // true if seen in current collection cycle
64
+}
65
+
66
+// ifaceCounters holds raw cumulative counter values.
67
+type ifaceCounters struct {
68
+ trafficIn int64
69
+ trafficOut int64
70
+ ucastPktsIn int64
71
+ ucastPktsOut int64
72
+ bcastPktsIn int64
73
+ bcastPktsOut int64
74
+ mcastPktsIn int64
75
+ mcastPktsOut int64
76
+ errorsIn int64
77
+ errorsOut int64
78
+ discardsIn int64
79
+ discardsOut int64
80
+}
81
+
82
+// ifaceRates holds computed per-second rates.
83
+type ifaceRates struct {
84
+ trafficIn *float64
85
+ trafficOut *float64
86
+ ucastPktsIn *float64
87
+ ucastPktsOut *float64
88
+ bcastPktsIn *float64
89
+ bcastPktsOut *float64
90
+ mcastPktsIn *float64
91
+ mcastPktsOut *float64
92
+ errorsIn *float64
93
+ errorsOut *float64
94
+ discardsIn *float64
95
+ discardsOut *float64
96
+}
97
+
98
+// newIfaceCache creates a new interface cache.
99
+func newIfaceCache() *ifaceCache {
100
+ return &ifaceCache{
101
+ interfaces: make(map[string]*ifaceEntry),
102
+ }
103
+}
104
+
105
+// isIfaceMetric returns true if the metric name is one we track for interface function.
106
+func isIfaceMetric(name string) bool {
107
+ return ifaceMetricNames[name]
108
+}
109
+
110
+// resetIfaceCache prepares the cache for a new collection cycle.
111
+// Must be called before processing metrics.
112
+func (c *Collector) resetIfaceCache() {
113
+ if c.ifaceCache == nil {
114
+ return
115
+ }
116
+
117
+ c.ifaceCache.mu.Lock()
118
+ defer c.ifaceCache.mu.Unlock()
119
+
120
+ c.ifaceCache.updateTime = time.Now()
121
+
122
+ for _, entry := range c.ifaceCache.interfaces {
123
+ entry.updated = false
124
+ }
125
+}
126
+
127
+// updateIfaceCacheEntry updates the cache with a single interface metric.
128
+// Called during collectProfileTableMetrics for matching metrics.
129
+// Caller must ensure m.IsTable is true and m.Tags["interface"] is not empty.
130
+func (c *Collector) updateIfaceCacheEntry(m ddsnmp.Metric) {
131
+ if c.ifaceCache == nil {
132
+ return
133
+ }
134
+
135
+ ifaceName := m.Tags[tagInterface]
136
+ if ifaceName == "" {
137
+ return
138
+ }
139
+
140
+ c.ifaceCache.mu.Lock()
141
+ defer c.ifaceCache.mu.Unlock()
142
+
143
+ entry := c.ifaceCache.interfaces[ifaceName]
144
+ if entry == nil {
145
+ entry = &ifaceEntry{
146
+ name: ifaceName,
147
+ }
148
+ c.ifaceCache.interfaces[ifaceName] = entry
149
+ }
150
+
151
+ if ifType := m.Tags[tagIfType]; ifType != "" {
152
+ entry.ifType = ifType
153
+ }
154
+ if ifTypeGroup := m.Tags[tagIfTypeGrp]; ifTypeGroup != "" {
155
+ entry.ifTypeGroup = ifTypeGroup
156
+ }
157
+
158
+ switch m.Name {
159
+ case "ifTraffic":
160
+ if v, ok := m.MultiValue["in"]; ok {
161
+ entry.counters.trafficIn = v
162
+ }
163
+ if v, ok := m.MultiValue["out"]; ok {
164
+ entry.counters.trafficOut = v
165
+ }
166
+ case "ifPacketsUcast":
167
+ if v, ok := m.MultiValue["in"]; ok {
168
+ entry.counters.ucastPktsIn = v
169
+ }
170
+ if v, ok := m.MultiValue["out"]; ok {
171
+ entry.counters.ucastPktsOut = v
172
+ }
173
+ case "ifPacketsBroadcast":
174
+ if v, ok := m.MultiValue["in"]; ok {
175
+ entry.counters.bcastPktsIn = v
176
+ }
177
+ if v, ok := m.MultiValue["out"]; ok {
178
+ entry.counters.bcastPktsOut = v
179
+ }
180
+ case "ifPacketsMulticast":
181
+ if v, ok := m.MultiValue["in"]; ok {
182
+ entry.counters.mcastPktsIn = v
183
+ }
184
+ if v, ok := m.MultiValue["out"]; ok {
185
+ entry.counters.mcastPktsOut = v
186
+ }
187
+ case "ifErrors":
188
+ if v, ok := m.MultiValue["in"]; ok {
189
+ entry.counters.errorsIn = v
190
+ }
191
+ if v, ok := m.MultiValue["out"]; ok {
192
+ entry.counters.errorsOut = v
193
+ }
194
+ case "ifDiscards":
195
+ if v, ok := m.MultiValue["in"]; ok {
196
+ entry.counters.discardsIn = v
197
+ }
198
+ if v, ok := m.MultiValue["out"]; ok {
199
+ entry.counters.discardsOut = v
200
+ }
201
+ case "ifAdminStatus":
202
+ entry.adminStatus = extractStatus(m.MultiValue)
203
+ case "ifOperStatus":
204
+ entry.operStatus = extractStatus(m.MultiValue)
205
+ }
206
+
207
+ entry.updated = true
208
+}
209
+
210
+// finalizeIfaceCache removes stale entries and calculates rates.
211
+// Must be called after all metrics have been processed.
212
+func (c *Collector) finalizeIfaceCache() {
213
+ if c.ifaceCache == nil {
214
+ return
215
+ }
216
+
217
+ c.ifaceCache.mu.Lock()
218
+ defer c.ifaceCache.mu.Unlock()
219
+
220
+ now := c.ifaceCache.updateTime
221
+
222
+ for name, entry := range c.ifaceCache.interfaces {
223
+ if !entry.updated {
224
+ delete(c.ifaceCache.interfaces, name)
225
+ continue
226
+ }
227
+
228
+ if entry.hasPrev {
229
+ elapsed := now.Sub(entry.prevTime)
230
+ entry.rates.trafficIn = calcRate(entry.counters.trafficIn, entry.prevCounters.trafficIn, elapsed)
231
+ entry.rates.trafficOut = calcRate(entry.counters.trafficOut, entry.prevCounters.trafficOut, elapsed)
232
+ entry.rates.ucastPktsIn = calcRate(entry.counters.ucastPktsIn, entry.prevCounters.ucastPktsIn, elapsed)
233
+ entry.rates.ucastPktsOut = calcRate(entry.counters.ucastPktsOut, entry.prevCounters.ucastPktsOut, elapsed)
234
+ entry.rates.bcastPktsIn = calcRate(entry.counters.bcastPktsIn, entry.prevCounters.bcastPktsIn, elapsed)
235
+ entry.rates.bcastPktsOut = calcRate(entry.counters.bcastPktsOut, entry.prevCounters.bcastPktsOut, elapsed)
236
+ entry.rates.mcastPktsIn = calcRate(entry.counters.mcastPktsIn, entry.prevCounters.mcastPktsIn, elapsed)
237
+ entry.rates.mcastPktsOut = calcRate(entry.counters.mcastPktsOut, entry.prevCounters.mcastPktsOut, elapsed)
238
+ entry.rates.errorsIn = calcRate(entry.counters.errorsIn, entry.prevCounters.errorsIn, elapsed)
239
+ entry.rates.errorsOut = calcRate(entry.counters.errorsOut, entry.prevCounters.errorsOut, elapsed)
240
+ entry.rates.discardsIn = calcRate(entry.counters.discardsIn, entry.prevCounters.discardsIn, elapsed)
241
+ entry.rates.discardsOut = calcRate(entry.counters.discardsOut, entry.prevCounters.discardsOut, elapsed)
242
+ }
243
+
244
+ entry.prevCounters = entry.counters
245
+ entry.prevTime = now
246
+ entry.hasPrev = true
247
+ }
248
+
249
+ c.ifaceCache.lastUpdate = now
250
+}
251
+
252
+// calcRate computes per-second rate from counter delta.
253
+// Returns nil if rate cannot be calculated (zero or negative elapsed time).
254
+// Handles counter wrap by treating values as unsigned.
255
+func calcRate(current, previous int64, elapsed time.Duration) *float64 {
256
+ if elapsed <= 0 {
257
+ return nil
258
+ }
259
+
260
+ // Treat as unsigned for proper counter wrap handling
261
+ ucurrent := uint64(current)
262
+ uprevious := uint64(previous)
263
+
264
+ var delta uint64
265
+ if ucurrent >= uprevious {
266
+ delta = ucurrent - uprevious
267
+ } else {
268
+ // Counter wrap - calculate wrapped delta
269
+ delta = (math.MaxUint64 - uprevious) + ucurrent + 1
270
+ }
271
+
272
+ rate := float64(delta) / elapsed.Seconds()
273
+ return &rate
274
+}
275
+
276
+// extractStatus finds the active status from a MultiValue map.
277
+// Returns the key where value == 1, or "unknown" if none found.
278
+func extractStatus(mv map[string]int64) string {
279
+ for k, v := range mv {
280
+ if v == 1 {
281
+ return k
282
+ }
283
+ }
284
+ return "unknown"
285
+}
src/go/plugin/go.d/collector/snmp/func_interfaces_cache_test.go
new
+400
@@ -0,0 +1,400 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmp
4
+
5
+import (
6
+ "math"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+
13
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
14
+)
15
+
16
+func TestCalcRate(t *testing.T) {
17
+ tests := map[string]struct {
18
+ current int64
19
+ previous int64
20
+ elapsed time.Duration
21
+ wantNil bool
22
+ wantRate float64
23
+ wantPositive bool // for wrap cases where exact value is hard to predict
24
+ }{
25
+ "simple rate": {
26
+ current: 1000,
27
+ previous: 0,
28
+ elapsed: time.Second,
29
+ wantRate: 1000.0,
30
+ },
31
+ "rate over 10 seconds": {
32
+ current: 1000,
33
+ previous: 0,
34
+ elapsed: 10 * time.Second,
35
+ wantRate: 100.0,
36
+ },
37
+ "rate with non-zero previous": {
38
+ current: 5000,
39
+ previous: 1000,
40
+ elapsed: 2 * time.Second,
41
+ wantRate: 2000.0,
42
+ },
43
+ "zero delta": {
44
+ current: 1000,
45
+ previous: 1000,
46
+ elapsed: time.Second,
47
+ wantRate: 0.0,
48
+ },
49
+ "counter wrap small": {
50
+ current: 100,
51
+ previous: math.MaxInt64 - 100,
52
+ elapsed: time.Second,
53
+ wantPositive: true,
54
+ },
55
+ "counter wrap from max to zero": {
56
+ current: 0,
57
+ previous: math.MaxInt64,
58
+ elapsed: time.Second,
59
+ wantPositive: true,
60
+ },
61
+ "zero elapsed": {
62
+ current: 1000,
63
+ previous: 0,
64
+ elapsed: 0,
65
+ wantNil: true,
66
+ },
67
+ "negative elapsed": {
68
+ current: 1000,
69
+ previous: 0,
70
+ elapsed: -time.Second,
71
+ wantNil: true,
72
+ },
73
+ }
74
+
75
+ for name, tc := range tests {
76
+ t.Run(name, func(t *testing.T) {
77
+ result := calcRate(tc.current, tc.previous, tc.elapsed)
78
+ if tc.wantNil {
79
+ assert.Nil(t, result)
80
+ } else {
81
+ require.NotNil(t, result)
82
+ if tc.wantPositive {
83
+ assert.Greater(t, *result, 0.0)
84
+ } else {
85
+ assert.InDelta(t, tc.wantRate, *result, 0.001)
86
+ }
87
+ }
88
+ })
89
+ }
90
+}
91
+
92
+func TestExtractStatus(t *testing.T) {
93
+ tests := map[string]struct {
94
+ mv map[string]int64
95
+ expected string
96
+ }{
97
+ "single active up": {
98
+ mv: map[string]int64{"up": 1, "down": 0, "testing": 0},
99
+ expected: "up",
100
+ },
101
+ "single active down": {
102
+ mv: map[string]int64{"up": 0, "down": 1, "testing": 0},
103
+ expected: "down",
104
+ },
105
+ "none active": {
106
+ mv: map[string]int64{"up": 0, "down": 0, "testing": 0},
107
+ expected: "unknown",
108
+ },
109
+ "empty map": {
110
+ mv: map[string]int64{},
111
+ expected: "unknown",
112
+ },
113
+ "nil map": {
114
+ mv: nil,
115
+ expected: "unknown",
116
+ },
117
+ }
118
+
119
+ for name, tc := range tests {
120
+ t.Run(name, func(t *testing.T) {
121
+ assert.Equal(t, tc.expected, extractStatus(tc.mv))
122
+ })
123
+ }
124
+}
125
+
126
+func TestIsIfaceMetric(t *testing.T) {
127
+ tests := map[string]struct {
128
+ name string
129
+ expected bool
130
+ }{
131
+ "ifTraffic": {name: "ifTraffic", expected: true},
132
+ "ifPacketsUcast": {name: "ifPacketsUcast", expected: true},
133
+ "ifPacketsBroadcast": {name: "ifPacketsBroadcast", expected: true},
134
+ "ifPacketsMulticast": {name: "ifPacketsMulticast", expected: true},
135
+ "ifErrors": {name: "ifErrors", expected: true},
136
+ "ifDiscards": {name: "ifDiscards", expected: true},
137
+ "ifAdminStatus": {name: "ifAdminStatus", expected: true},
138
+ "ifOperStatus": {name: "ifOperStatus", expected: true},
139
+ "sysUptime": {name: "sysUptime", expected: false},
140
+ "empty": {name: "", expected: false},
141
+ "random": {name: "someOtherMetric", expected: false},
142
+ }
143
+
144
+ for name, tc := range tests {
145
+ t.Run(name, func(t *testing.T) {
146
+ assert.Equal(t, tc.expected, isIfaceMetric(tc.name))
147
+ })
148
+ }
149
+}
150
+
151
+func TestIfaceCache(t *testing.T) {
152
+ tests := map[string]struct {
153
+ setup func(c *Collector)
154
+ validate func(t *testing.T, c *Collector)
155
+ }{
156
+ "new interface": {
157
+ setup: func(c *Collector) {
158
+ c.resetIfaceCache()
159
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
160
+ Name: "ifTraffic",
161
+ IsTable: true,
162
+ Tags: map[string]string{tagInterface: "eth0", tagIfType: "ethernetCsmacd"},
163
+ MultiValue: map[string]int64{"in": 1000, "out": 2000},
164
+ })
165
+ c.finalizeIfaceCache()
166
+ },
167
+ validate: func(t *testing.T, c *Collector) {
168
+ c.ifaceCache.mu.RLock()
169
+ defer c.ifaceCache.mu.RUnlock()
170
+
171
+ require.Len(t, c.ifaceCache.interfaces, 1)
172
+ entry := c.ifaceCache.interfaces["eth0"]
173
+ require.NotNil(t, entry)
174
+
175
+ assert.Equal(t, "eth0", entry.name)
176
+ assert.Equal(t, "ethernetCsmacd", entry.ifType)
177
+ assert.Equal(t, int64(1000), entry.counters.trafficIn)
178
+ assert.Equal(t, int64(2000), entry.counters.trafficOut)
179
+ assert.True(t, entry.hasPrev)
180
+ assert.Nil(t, entry.rates.trafficIn)
181
+ assert.Nil(t, entry.rates.trafficOut)
182
+ },
183
+ },
184
+ "update existing with rates": {
185
+ setup: func(c *Collector) {
186
+ // First collection
187
+ c.resetIfaceCache()
188
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
189
+ Name: "ifTraffic",
190
+ IsTable: true,
191
+ Tags: map[string]string{tagInterface: "eth0"},
192
+ MultiValue: map[string]int64{"in": 1000, "out": 2000},
193
+ })
194
+ c.finalizeIfaceCache()
195
+
196
+ time.Sleep(10 * time.Millisecond)
197
+
198
+ // Second collection
199
+ c.resetIfaceCache()
200
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
201
+ Name: "ifTraffic",
202
+ IsTable: true,
203
+ Tags: map[string]string{tagInterface: "eth0"},
204
+ MultiValue: map[string]int64{"in": 2000, "out": 4000},
205
+ })
206
+ c.finalizeIfaceCache()
207
+ },
208
+ validate: func(t *testing.T, c *Collector) {
209
+ c.ifaceCache.mu.RLock()
210
+ defer c.ifaceCache.mu.RUnlock()
211
+
212
+ entry := c.ifaceCache.interfaces["eth0"]
213
+ require.NotNil(t, entry)
214
+
215
+ assert.Equal(t, int64(2000), entry.counters.trafficIn)
216
+ assert.Equal(t, int64(4000), entry.counters.trafficOut)
217
+ require.NotNil(t, entry.rates.trafficIn)
218
+ require.NotNil(t, entry.rates.trafficOut)
219
+ assert.Greater(t, *entry.rates.trafficIn, 0.0)
220
+ assert.Greater(t, *entry.rates.trafficOut, 0.0)
221
+ },
222
+ },
223
+ "remove stale interface": {
224
+ setup: func(c *Collector) {
225
+ // First collection with two interfaces
226
+ c.resetIfaceCache()
227
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
228
+ Name: "ifTraffic",
229
+ IsTable: true,
230
+ Tags: map[string]string{tagInterface: "eth0"},
231
+ MultiValue: map[string]int64{"in": 1000, "out": 2000},
232
+ })
233
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
234
+ Name: "ifTraffic",
235
+ IsTable: true,
236
+ Tags: map[string]string{tagInterface: "eth1"},
237
+ MultiValue: map[string]int64{"in": 3000, "out": 4000},
238
+ })
239
+ c.finalizeIfaceCache()
240
+
241
+ // Second collection with only eth0
242
+ c.resetIfaceCache()
243
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
244
+ Name: "ifTraffic",
245
+ IsTable: true,
246
+ Tags: map[string]string{tagInterface: "eth0"},
247
+ MultiValue: map[string]int64{"in": 2000, "out": 3000},
248
+ })
249
+ c.finalizeIfaceCache()
250
+ },
251
+ validate: func(t *testing.T, c *Collector) {
252
+ c.ifaceCache.mu.RLock()
253
+ defer c.ifaceCache.mu.RUnlock()
254
+
255
+ assert.Len(t, c.ifaceCache.interfaces, 1)
256
+ _, ok := c.ifaceCache.interfaces["eth0"]
257
+ assert.True(t, ok)
258
+ _, ok = c.ifaceCache.interfaces["eth1"]
259
+ assert.False(t, ok)
260
+ },
261
+ },
262
+ "multiple metrics for same interface": {
263
+ setup: func(c *Collector) {
264
+ c.resetIfaceCache()
265
+ metrics := []ddsnmp.Metric{
266
+ {Name: "ifTraffic", IsTable: true, Tags: map[string]string{tagInterface: "eth0", tagIfType: "ethernetCsmacd"}, MultiValue: map[string]int64{"in": 1000, "out": 2000}},
267
+ {Name: "ifPacketsUcast", IsTable: true, Tags: map[string]string{tagInterface: "eth0"}, MultiValue: map[string]int64{"in": 100, "out": 200}},
268
+ {Name: "ifPacketsBroadcast", IsTable: true, Tags: map[string]string{tagInterface: "eth0"}, MultiValue: map[string]int64{"in": 10, "out": 20}},
269
+ {Name: "ifPacketsMulticast", IsTable: true, Tags: map[string]string{tagInterface: "eth0"}, MultiValue: map[string]int64{"in": 5, "out": 15}},
270
+ {Name: "ifAdminStatus", IsTable: true, Tags: map[string]string{tagInterface: "eth0"}, MultiValue: map[string]int64{"up": 1, "down": 0}},
271
+ {Name: "ifOperStatus", IsTable: true, Tags: map[string]string{tagInterface: "eth0"}, MultiValue: map[string]int64{"up": 1, "down": 0}},
272
+ }
273
+ for _, m := range metrics {
274
+ c.updateIfaceCacheEntry(m)
275
+ }
276
+ c.finalizeIfaceCache()
277
+ },
278
+ validate: func(t *testing.T, c *Collector) {
279
+ c.ifaceCache.mu.RLock()
280
+ defer c.ifaceCache.mu.RUnlock()
281
+
282
+ require.Len(t, c.ifaceCache.interfaces, 1)
283
+ entry := c.ifaceCache.interfaces["eth0"]
284
+ require.NotNil(t, entry)
285
+
286
+ assert.Equal(t, int64(1000), entry.counters.trafficIn)
287
+ assert.Equal(t, int64(2000), entry.counters.trafficOut)
288
+ assert.Equal(t, int64(100), entry.counters.ucastPktsIn)
289
+ assert.Equal(t, int64(200), entry.counters.ucastPktsOut)
290
+ assert.Equal(t, int64(10), entry.counters.bcastPktsIn)
291
+ assert.Equal(t, int64(20), entry.counters.bcastPktsOut)
292
+ assert.Equal(t, int64(5), entry.counters.mcastPktsIn)
293
+ assert.Equal(t, int64(15), entry.counters.mcastPktsOut)
294
+ assert.Equal(t, "up", entry.adminStatus)
295
+ assert.Equal(t, "up", entry.operStatus)
296
+ assert.Equal(t, "ethernetCsmacd", entry.ifType)
297
+ },
298
+ },
299
+ "first collection rates nil": {
300
+ setup: func(c *Collector) {
301
+ c.resetIfaceCache()
302
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
303
+ Name: "ifTraffic",
304
+ IsTable: true,
305
+ Tags: map[string]string{tagInterface: "eth0"},
306
+ MultiValue: map[string]int64{"in": 1000, "out": 2000},
307
+ })
308
+ c.finalizeIfaceCache()
309
+ },
310
+ validate: func(t *testing.T, c *Collector) {
311
+ c.ifaceCache.mu.RLock()
312
+ defer c.ifaceCache.mu.RUnlock()
313
+
314
+ entry := c.ifaceCache.interfaces["eth0"]
315
+ require.NotNil(t, entry)
316
+
317
+ assert.Nil(t, entry.rates.trafficIn)
318
+ assert.Nil(t, entry.rates.trafficOut)
319
+ assert.Nil(t, entry.rates.ucastPktsIn)
320
+ assert.Nil(t, entry.rates.ucastPktsOut)
321
+ assert.Nil(t, entry.rates.bcastPktsIn)
322
+ assert.Nil(t, entry.rates.bcastPktsOut)
323
+ assert.Nil(t, entry.rates.mcastPktsIn)
324
+ assert.Nil(t, entry.rates.mcastPktsOut)
325
+ },
326
+ },
327
+ "missing interface tag ignored": {
328
+ setup: func(c *Collector) {
329
+ c.resetIfaceCache()
330
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
331
+ Name: "ifTraffic",
332
+ IsTable: true,
333
+ Tags: map[string]string{},
334
+ MultiValue: map[string]int64{"in": 1000, "out": 2000},
335
+ })
336
+ c.finalizeIfaceCache()
337
+ },
338
+ validate: func(t *testing.T, c *Collector) {
339
+ c.ifaceCache.mu.RLock()
340
+ defer c.ifaceCache.mu.RUnlock()
341
+
342
+ assert.Len(t, c.ifaceCache.interfaces, 0)
343
+ },
344
+ },
345
+ "reset marks all as not updated": {
346
+ setup: func(c *Collector) {
347
+ c.resetIfaceCache()
348
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
349
+ Name: "ifTraffic",
350
+ IsTable: true,
351
+ Tags: map[string]string{tagInterface: "eth0"},
352
+ MultiValue: map[string]int64{"in": 1000, "out": 2000},
353
+ })
354
+ c.finalizeIfaceCache()
355
+ c.resetIfaceCache() // reset again without finalize
356
+ },
357
+ validate: func(t *testing.T, c *Collector) {
358
+ c.ifaceCache.mu.RLock()
359
+ defer c.ifaceCache.mu.RUnlock()
360
+
361
+ entry := c.ifaceCache.interfaces["eth0"]
362
+ require.NotNil(t, entry)
363
+ assert.False(t, entry.updated)
364
+ },
365
+ },
366
+ }
367
+
368
+ for name, tc := range tests {
369
+ t.Run(name, func(t *testing.T) {
370
+ c := &Collector{
371
+ ifaceCache: newIfaceCache(),
372
+ }
373
+ tc.setup(c)
374
+ tc.validate(t, c)
375
+ })
376
+ }
377
+}
378
+
379
+func TestIfaceCacheNilSafety(t *testing.T) {
380
+ c := &Collector{
381
+ ifaceCache: nil,
382
+ }
383
+
384
+ // None of these should panic
385
+ c.resetIfaceCache()
386
+ c.updateIfaceCacheEntry(ddsnmp.Metric{
387
+ Name: "ifTraffic",
388
+ IsTable: true,
389
+ Tags: map[string]string{tagInterface: "eth0"},
390
+ MultiValue: map[string]int64{"in": 1000, "out": 2000},
391
+ })
392
+ c.finalizeIfaceCache()
393
+}
394
+
395
+func TestNewIfaceCache(t *testing.T) {
396
+ cache := newIfaceCache()
397
+ require.NotNil(t, cache)
398
+ require.NotNil(t, cache.interfaces)
399
+ assert.Len(t, cache.interfaces, 0)
400
+}
src/go/plugin/go.d/collector/snmp/func_interfaces_test.go
new
+631
@@ -0,0 +1,631 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmp
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
9
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+)
13
+
14
+func TestSnmpMethods(t *testing.T) {
15
+ methods := snmpMethods()
16
+
17
+ require.Len(t, methods, 1)
18
+ assert.Equal(t, "interfaces", methods[0].ID)
19
+ assert.Equal(t, "Network Interfaces", methods[0].Name)
20
+ require.NotEmpty(t, methods[0].RequiredParams)
21
+
22
+ // Verify type group param exists
23
+ var typeGroupParam *funcapi.ParamConfig
24
+ for i := range methods[0].RequiredParams {
25
+ if methods[0].RequiredParams[i].ID == "if_type_group" {
26
+ typeGroupParam = &methods[0].RequiredParams[i]
27
+ break
28
+ }
29
+ }
30
+ require.NotNil(t, typeGroupParam, "expected if_type_group required param")
31
+ require.NotEmpty(t, typeGroupParam.Options)
32
+
33
+ // Verify default type group option exists
34
+ hasDefault := false
35
+ for _, opt := range typeGroupParam.Options {
36
+ if opt.Default {
37
+ hasDefault = true
38
+ assert.Equal(t, "ethernet", opt.ID)
39
+ break
40
+ }
41
+ }
42
+ assert.True(t, hasDefault, "should have a default type group option")
43
+}
44
+
45
+func TestFuncIfacesColumns(t *testing.T) {
46
+ tests := map[string]struct {
47
+ validate func(t *testing.T)
48
+ }{
49
+ "has required columns": {
50
+ validate: func(t *testing.T) {
51
+ requiredKeys := []string{
52
+ "Interface", "Type", "Type Group",
53
+ "Admin Status", "Oper Status",
54
+ "Traffic In", "Traffic Out",
55
+ "Unicast In", "Unicast Out",
56
+ "Broadcast In", "Broadcast Out",
57
+ "Packets In", "Packets Out",
58
+ "Errors In", "Errors Out",
59
+ "Discards In", "Discards Out",
60
+ "Multicast In", "Multicast Out",
61
+ }
62
+
63
+ keys := make(map[string]bool)
64
+ for _, col := range funcIfacesColumns {
65
+ keys[col.key] = true
66
+ }
67
+
68
+ for _, key := range requiredKeys {
69
+ assert.True(t, keys[key], "column %s should be defined", key)
70
+ }
71
+ },
72
+ },
73
+ "has valid metadata": {
74
+ validate: func(t *testing.T) {
75
+ for _, col := range funcIfacesColumns {
76
+ assert.NotEmpty(t, col.key, "column must have key")
77
+ assert.NotEqual(t, funcapi.FieldTypeNone, col.dataType, "column %s must have dataType", col.key)
78
+ assert.NotNil(t, col.value, "column %s must have value extractor", col.key)
79
+
80
+ if col.sortOption != "" {
81
+ assert.NotEmpty(t, col.sortOption, "sort option column %s must have sortOption label", col.key)
82
+ }
83
+ }
84
+ },
85
+ },
86
+ "value extractors work correctly": {
87
+ validate: func(t *testing.T) {
88
+ rate := 100.0
89
+ entry := &ifaceEntry{
90
+ name: "eth0",
91
+ ifType: "ethernetCsmacd",
92
+ ifTypeGroup: "ethernet",
93
+ adminStatus: "up",
94
+ operStatus: "up",
95
+ rates: ifaceRates{
96
+ trafficIn: &rate,
97
+ ucastPktsIn: &rate,
98
+ errorsIn: &rate,
99
+ discardsIn: &rate,
100
+ },
101
+ }
102
+
103
+ // Test each column's value extractor
104
+ for _, col := range funcIfacesColumns {
105
+ // Should not panic
106
+ _ = col.value(entry)
107
+ }
108
+
109
+ // Verify specific values
110
+ for _, col := range funcIfacesColumns {
111
+ switch col.key {
112
+ case "Interface":
113
+ assert.Equal(t, "eth0", col.value(entry))
114
+ case "Type":
115
+ assert.Equal(t, "ethernetCsmacd", col.value(entry))
116
+ case "Type Group":
117
+ assert.Equal(t, "ethernet", col.value(entry))
118
+ case "Traffic In":
119
+ assert.Equal(t, rate/1_000_000, col.value(entry))
120
+ case "Packets In":
121
+ assert.Equal(t, rate/1_000, col.value(entry))
122
+ case "Errors In":
123
+ assert.Equal(t, rate, col.value(entry))
124
+ case "Discards In":
125
+ assert.Equal(t, rate, col.value(entry))
126
+ case "Admin Status":
127
+ assert.Equal(t, "up", col.value(entry))
128
+ }
129
+ }
130
+ },
131
+ },
132
+ "column count matches row length": {
133
+ validate: func(t *testing.T) {
134
+ entry := &ifaceEntry{
135
+ name: "eth0",
136
+ ifType: "ethernetCsmacd",
137
+ ifTypeGroup: "ethernet",
138
+ adminStatus: "up",
139
+ operStatus: "up",
140
+ }
141
+ f := &funcInterfaces{}
142
+ row := f.buildRow(entry)
143
+ assert.Len(t, row, len(funcIfacesColumns)+1, "row length must match column count")
144
+ },
145
+ },
146
+ }
147
+
148
+ for name, tc := range tests {
149
+ t.Run(name, func(t *testing.T) {
150
+ tc.validate(t)
151
+ })
152
+ }
153
+}
154
+
155
+func TestFuncInterfaces_buildColumns(t *testing.T) {
156
+ f := &funcInterfaces{}
157
+ columns := f.buildColumns()
158
+
159
+ require.NotEmpty(t, columns)
160
+ assert.Len(t, columns, len(funcIfacesColumns)+1)
161
+
162
+ // Verify all columns are present
163
+ for _, col := range funcIfacesColumns {
164
+ colDef, ok := columns[col.key]
165
+ assert.True(t, ok, "column %s should be in result", col.key)
166
+ assert.NotNil(t, colDef)
167
+
168
+ // Verify column is a map with expected fields
169
+ colMap, ok := colDef.(map[string]any)
170
+ require.True(t, ok, "column %s should be a map", col.key)
171
+ assert.Equal(t, col.name, colMap["name"])
172
+ }
173
+
174
+ rowOptions, ok := columns["rowOptions"]
175
+ require.True(t, ok, "rowOptions column should be in result")
176
+ rowOptionsMap, ok := rowOptions.(map[string]any)
177
+ require.True(t, ok, "rowOptions column should be a map")
178
+ assert.Equal(t, "rowOptions", rowOptionsMap["name"])
179
+ assert.Equal(t, "none", rowOptionsMap["type"])
180
+ assert.Equal(t, "rowOptions", rowOptionsMap["visualization"])
181
+
182
+}
183
+
184
+func TestFuncInterfaces_buildRow(t *testing.T) {
185
+ rate1 := 1000.5
186
+ rate2 := 2000.5
187
+
188
+ tests := map[string]struct {
189
+ entry *ifaceEntry
190
+ validate func(t *testing.T, row []any)
191
+ }{
192
+ "all fields populated": {
193
+ entry: &ifaceEntry{
194
+ name: "eth0",
195
+ ifType: "ethernetCsmacd",
196
+ ifTypeGroup: "ethernet",
197
+ adminStatus: "up",
198
+ operStatus: "up",
199
+ rates: ifaceRates{
200
+ trafficIn: &rate1,
201
+ trafficOut: &rate2,
202
+ ucastPktsIn: &rate1,
203
+ ucastPktsOut: &rate2,
204
+ bcastPktsIn: &rate1,
205
+ bcastPktsOut: &rate2,
206
+ errorsIn: &rate1,
207
+ errorsOut: &rate2,
208
+ discardsIn: &rate1,
209
+ discardsOut: &rate2,
210
+ mcastPktsIn: &rate1,
211
+ mcastPktsOut: &rate2,
212
+ },
213
+ },
214
+ validate: func(t *testing.T, row []any) {
215
+ // Find column indices by key
216
+ nameIdx := findColIdx("Interface")
217
+ typeIdx := findColIdx("Type")
218
+ typeGroupIdx := findColIdx("Type Group")
219
+ trafficInIdx := findColIdx("Traffic In")
220
+ trafficOutIdx := findColIdx("Traffic Out")
221
+ packetsInIdx := findColIdx("Packets In")
222
+ packetsOutIdx := findColIdx("Packets Out")
223
+ adminIdx := findColIdx("Admin Status")
224
+ operIdx := findColIdx("Oper Status")
225
+ rowOptionsIdx := len(funcIfacesColumns)
226
+
227
+ assert.Equal(t, "eth0", row[nameIdx])
228
+ assert.Equal(t, "ethernetCsmacd", row[typeIdx])
229
+ assert.Equal(t, "ethernet", row[typeGroupIdx])
230
+ assert.Equal(t, rate1/1_000_000, row[trafficInIdx])
231
+ assert.Equal(t, rate2/1_000_000, row[trafficOutIdx])
232
+ assert.Equal(t, (rate1*3)/1_000, row[packetsInIdx])
233
+ assert.Equal(t, (rate2*3)/1_000, row[packetsOutIdx])
234
+ assert.Equal(t, "up", row[adminIdx])
235
+ assert.Equal(t, "up", row[operIdx])
236
+ assert.Nil(t, row[rowOptionsIdx])
237
+ },
238
+ },
239
+ "nil rates produce nil values": {
240
+ entry: &ifaceEntry{
241
+ name: "eth1",
242
+ ifType: "other",
243
+ ifTypeGroup: "other",
244
+ adminStatus: "down",
245
+ operStatus: "down",
246
+ rates: ifaceRates{}, // all nil
247
+ },
248
+ validate: func(t *testing.T, row []any) {
249
+ nameIdx := findColIdx("Interface")
250
+ typeIdx := findColIdx("Type")
251
+ trafficInIdx := findColIdx("Traffic In")
252
+ trafficOutIdx := findColIdx("Traffic Out")
253
+ adminIdx := findColIdx("Admin Status")
254
+ operIdx := findColIdx("Oper Status")
255
+ rowOptionsIdx := len(funcIfacesColumns)
256
+
257
+ assert.Equal(t, "eth1", row[nameIdx])
258
+ assert.Equal(t, "other", row[typeIdx])
259
+ assert.Nil(t, row[trafficInIdx])
260
+ assert.Nil(t, row[trafficOutIdx])
261
+ assert.Equal(t, "down", row[adminIdx])
262
+ assert.Equal(t, "down", row[operIdx])
263
+ assert.Nil(t, row[rowOptionsIdx])
264
+ },
265
+ },
266
+ "down interface hides metrics": {
267
+ entry: &ifaceEntry{
268
+ name: "eth3",
269
+ ifType: "ethernetCsmacd",
270
+ ifTypeGroup: "ethernet",
271
+ adminStatus: "up",
272
+ operStatus: "down",
273
+ rates: ifaceRates{
274
+ trafficIn: &rate1,
275
+ trafficOut: &rate2,
276
+ errorsIn: &rate1,
277
+ },
278
+ },
279
+ validate: func(t *testing.T, row []any) {
280
+ trafficInIdx := findColIdx("Traffic In")
281
+ trafficOutIdx := findColIdx("Traffic Out")
282
+ errorsInIdx := findColIdx("Errors In")
283
+ rowOptionsIdx := len(funcIfacesColumns)
284
+
285
+ assert.Nil(t, row[trafficInIdx])
286
+ assert.Nil(t, row[trafficOutIdx])
287
+ assert.Nil(t, row[errorsInIdx])
288
+ assert.Nil(t, row[rowOptionsIdx])
289
+ },
290
+ },
291
+ "partial rates": {
292
+ entry: &ifaceEntry{
293
+ name: "eth2",
294
+ ifType: "",
295
+ ifTypeGroup: "",
296
+ adminStatus: "up",
297
+ operStatus: "unknown",
298
+ rates: ifaceRates{
299
+ trafficIn: &rate1,
300
+ trafficOut: &rate2,
301
+ // rest nil
302
+ },
303
+ },
304
+ validate: func(t *testing.T, row []any) {
305
+ nameIdx := findColIdx("Interface")
306
+ trafficInIdx := findColIdx("Traffic In")
307
+ trafficOutIdx := findColIdx("Traffic Out")
308
+ ucastInIdx := findColIdx("Unicast In")
309
+ rowOptionsIdx := len(funcIfacesColumns)
310
+
311
+ assert.Equal(t, "eth2", row[nameIdx])
312
+ assert.Nil(t, row[trafficInIdx])
313
+ assert.Nil(t, row[trafficOutIdx])
314
+ assert.Nil(t, row[ucastInIdx])
315
+ assert.Nil(t, row[rowOptionsIdx])
316
+ },
317
+ },
318
+ }
319
+
320
+ for name, tc := range tests {
321
+ t.Run(name, func(t *testing.T) {
322
+ f := &funcInterfaces{}
323
+ row := f.buildRow(tc.entry)
324
+ require.Len(t, row, len(funcIfacesColumns)+1)
325
+ tc.validate(t, row)
326
+ })
327
+ }
328
+}
329
+
330
+func TestFuncInterfaces_sortData(t *testing.T) {
331
+ rate100 := 100.0
332
+ rate200 := 200.0
333
+ rate300 := 300.0
334
+
335
+ // Helper to build a test row with name and trafficIn
336
+ buildTestRow := func(name string, trafficIn *float64) []any {
337
+ row := make([]any, len(funcIfacesColumns)+1)
338
+ for i, col := range funcIfacesColumns {
339
+ switch col.key {
340
+ case "Interface":
341
+ row[i] = name
342
+ case "Traffic In":
343
+ row[i] = ptrToAny(trafficIn)
344
+ case "Traffic Out":
345
+ row[i] = ptrToAny(trafficIn) // reuse for simplicity
346
+ default:
347
+ if col.dataType == funcapi.FieldTypeString {
348
+ row[i] = "test"
349
+ } else {
350
+ row[i] = nil
351
+ }
352
+ }
353
+ }
354
+ row[len(funcIfacesColumns)] = nil
355
+ return row
356
+ }
357
+
358
+ tests := map[string]struct {
359
+ data [][]any
360
+ sortColumn string
361
+ expected []string // expected order of names
362
+ }{
363
+ "sort by name ascending": {
364
+ data: [][]any{
365
+ buildTestRow("eth2", nil),
366
+ buildTestRow("eth0", nil),
367
+ buildTestRow("eth1", nil),
368
+ },
369
+ sortColumn: "Interface",
370
+ expected: []string{"eth0", "eth1", "eth2"},
371
+ },
372
+ "sort by trafficIn descending": {
373
+ data: [][]any{
374
+ buildTestRow("eth0", &rate100),
375
+ buildTestRow("eth1", &rate300),
376
+ buildTestRow("eth2", &rate200),
377
+ },
378
+ sortColumn: "Traffic In",
379
+ expected: []string{"eth1", "eth2", "eth0"},
380
+ },
381
+ "nil values go to end": {
382
+ data: [][]any{
383
+ buildTestRow("eth0", nil),
384
+ buildTestRow("eth1", &rate300),
385
+ buildTestRow("eth2", &rate100),
386
+ },
387
+ sortColumn: "Traffic In",
388
+ expected: []string{"eth1", "eth2", "eth0"},
389
+ },
390
+ "unknown column defaults to name": {
391
+ data: [][]any{
392
+ buildTestRow("eth2", nil),
393
+ buildTestRow("eth0", nil),
394
+ buildTestRow("eth1", nil),
395
+ },
396
+ sortColumn: "unknown_column",
397
+ expected: []string{"eth0", "eth1", "eth2"},
398
+ },
399
+ "empty data": {
400
+ data: [][]any{},
401
+ sortColumn: "Interface",
402
+ expected: nil,
403
+ },
404
+ }
405
+
406
+ for name, tc := range tests {
407
+ t.Run(name, func(t *testing.T) {
408
+ f := &funcInterfaces{}
409
+ f.sortData(tc.data, tc.sortColumn)
410
+
411
+ nameIdx := findColIdx("Interface")
412
+ var names []string
413
+ for _, row := range tc.data {
414
+ names = append(names, row[nameIdx].(string))
415
+ }
416
+ assert.Equal(t, tc.expected, names)
417
+ })
418
+ }
419
+}
420
+
421
+func TestFuncInterfaces_handle(t *testing.T) {
422
+ rate100 := 100.0
423
+ rate200 := 200.0
424
+
425
+ tests := map[string]struct {
426
+ setup func() *funcInterfaces
427
+ method string
428
+ params funcapi.ResolvedParams
429
+ validate func(t *testing.T, resp *module.FunctionResponse)
430
+ }{
431
+ "unknown method returns 404": {
432
+ setup: func() *funcInterfaces {
433
+ return newFuncInterfaces(newIfaceCache())
434
+ },
435
+ method: "unknown",
436
+ params: funcapi.ResolvedParams{},
437
+ validate: func(t *testing.T, resp *module.FunctionResponse) {
438
+ assert.Equal(t, 404, resp.Status)
439
+ assert.Contains(t, resp.Message, "unknown method")
440
+ },
441
+ },
442
+ "nil cache returns 503": {
443
+ setup: func() *funcInterfaces {
444
+ return &funcInterfaces{cache: nil}
445
+ },
446
+ method: "interfaces",
447
+ params: funcapi.ResolvedParams{},
448
+ validate: func(t *testing.T, resp *module.FunctionResponse) {
449
+ assert.Equal(t, 503, resp.Status)
450
+ assert.Contains(t, resp.Message, "not available")
451
+ },
452
+ },
453
+ "empty cache returns 200 with empty data": {
454
+ setup: func() *funcInterfaces {
455
+ return newFuncInterfaces(newIfaceCache())
456
+ },
457
+ method: "interfaces",
458
+ params: resolveIfaceParams(nil),
459
+ validate: func(t *testing.T, resp *module.FunctionResponse) {
460
+ assert.Equal(t, 200, resp.Status)
461
+ assert.NotNil(t, resp.Columns)
462
+ assert.Len(t, resp.Columns, len(funcIfacesColumns)+1)
463
+
464
+ data, ok := resp.Data.([][]any)
465
+ require.True(t, ok)
466
+ assert.Len(t, data, 0)
467
+ },
468
+ },
469
+ "cache with data returns correct rows": {
470
+ setup: func() *funcInterfaces {
471
+ cache := newIfaceCache()
472
+ cache.interfaces["eth0"] = &ifaceEntry{
473
+ name: "eth0",
474
+ ifType: "ethernetCsmacd",
475
+ ifTypeGroup: "ethernet",
476
+ adminStatus: "up",
477
+ operStatus: "up",
478
+ rates: ifaceRates{
479
+ trafficIn: &rate100,
480
+ trafficOut: &rate200,
481
+ },
482
+ }
483
+ cache.interfaces["eth1"] = &ifaceEntry{
484
+ name: "eth1",
485
+ ifType: "other",
486
+ ifTypeGroup: "virtual",
487
+ adminStatus: "down",
488
+ operStatus: "down",
489
+ }
490
+ return newFuncInterfaces(cache)
491
+ },
492
+ method: "interfaces",
493
+ params: resolveIfaceParams(nil),
494
+ validate: func(t *testing.T, resp *module.FunctionResponse) {
495
+ assert.Equal(t, 200, resp.Status)
496
+ assert.Equal(t, "Interface", resp.DefaultSortColumn)
497
+
498
+ data, ok := resp.Data.([][]any)
499
+ require.True(t, ok)
500
+ assert.Len(t, data, 1)
501
+
502
+ // Verify Charts
503
+ require.NotNil(t, resp.Charts)
504
+ assert.Contains(t, resp.Charts, "Traffic")
505
+ assert.Contains(t, resp.Charts, "UnicastPackets")
506
+ assert.Equal(t, []string{"Traffic In", "Traffic Out"}, resp.Charts["Traffic"].Columns)
507
+
508
+ // Verify DefaultCharts
509
+ require.NotEmpty(t, resp.DefaultCharts)
510
+ assert.Equal(t, [][]string{{"Traffic", "Type"}, {"OperationalStatus", "Oper Status"}}, resp.DefaultCharts)
511
+
512
+ // Verify GroupBy
513
+ require.NotNil(t, resp.GroupBy)
514
+ assert.Contains(t, resp.GroupBy, "Type")
515
+ },
516
+ },
517
+ "filter other includes non-target groups": {
518
+ setup: func() *funcInterfaces {
519
+ cache := newIfaceCache()
520
+ cache.interfaces["eth0"] = &ifaceEntry{
521
+ name: "eth0",
522
+ ifType: "ethernetCsmacd",
523
+ ifTypeGroup: "ethernet",
524
+ adminStatus: "up",
525
+ operStatus: "up",
526
+ }
527
+ cache.interfaces["lo0"] = &ifaceEntry{
528
+ name: "lo0",
529
+ ifType: "loopback",
530
+ ifTypeGroup: "virtual",
531
+ adminStatus: "up",
532
+ operStatus: "up",
533
+ }
534
+ cache.interfaces["vlan0"] = &ifaceEntry{
535
+ name: "vlan0",
536
+ ifType: "l2vlan",
537
+ ifTypeGroup: "virtual",
538
+ adminStatus: "up",
539
+ operStatus: "up",
540
+ }
541
+ cache.interfaces["tun0"] = &ifaceEntry{
542
+ name: "tun0",
543
+ ifType: "tunnel",
544
+ ifTypeGroup: "",
545
+ adminStatus: "up",
546
+ operStatus: "up",
547
+ }
548
+ cache.interfaces["wlan0"] = &ifaceEntry{
549
+ name: "wlan0",
550
+ ifType: "ieee80211",
551
+ ifTypeGroup: "wireless",
552
+ adminStatus: "up",
553
+ operStatus: "up",
554
+ }
555
+ return newFuncInterfaces(cache)
556
+ },
557
+ method: "interfaces",
558
+ params: resolveIfaceParams(map[string][]string{"if_type_group": {"other"}}),
559
+ validate: func(t *testing.T, resp *module.FunctionResponse) {
560
+ assert.Equal(t, 200, resp.Status)
561
+
562
+ data, ok := resp.Data.([][]any)
563
+ require.True(t, ok)
564
+ assert.Len(t, data, 2)
565
+
566
+ nameIdx := findColIdx("Interface")
567
+ names := []string{data[0][nameIdx].(string), data[1][nameIdx].(string)}
568
+ assert.ElementsMatch(t, []string{"tun0", "wlan0"}, names)
569
+ },
570
+ },
571
+ }
572
+
573
+ for name, tc := range tests {
574
+ t.Run(name, func(t *testing.T) {
575
+ f := tc.setup()
576
+ resp := f.handle(tc.method, tc.params)
577
+ tc.validate(t, resp)
578
+ })
579
+ }
580
+}
581
+
582
+func TestFuncInterfaces_defaultSortColumn(t *testing.T) {
583
+ f := &funcInterfaces{}
584
+ assert.Equal(t, "Interface", f.defaultSortColumn())
585
+}
586
+
587
+func TestPtrToAny(t *testing.T) {
588
+ tests := map[string]struct {
589
+ input *float64
590
+ expected any
591
+ }{
592
+ "nil pointer": {
593
+ input: nil,
594
+ expected: nil,
595
+ },
596
+ "non-nil pointer": {
597
+ input: func() *float64 { v := 123.45; return &v }(),
598
+ expected: 123.45,
599
+ },
600
+ "zero value pointer": {
601
+ input: func() *float64 { v := 0.0; return &v }(),
602
+ expected: 0.0,
603
+ },
604
+ }
605
+
606
+ for name, tc := range tests {
607
+ t.Run(name, func(t *testing.T) {
608
+ result := ptrToAny(tc.input)
609
+ assert.Equal(t, tc.expected, result)
610
+ })
611
+ }
612
+}
613
+
614
+// findColIdx finds the index of a column by key.
615
+func findColIdx(key string) int {
616
+ for i, col := range funcIfacesColumns {
617
+ if col.key == key {
618
+ return i
619
+ }
620
+ }
621
+ return -1
622
+}
623
+
624
+func resolveIfaceParams(values map[string][]string) funcapi.ResolvedParams {
625
+ f := &funcInterfaces{}
626
+ params, err := f.methodParams("interfaces")
627
+ if err != nil {
628
+ return nil
629
+ }
630
+ return funcapi.ResolveParams(params, values)
631
+}