@cryptotaxi247 / netdata-1 / commits / 16d6869d7

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