From 643c1086749359c58d064f230e49dde933546c0c Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Mon, 23 Mar 2026 18:44:51 -0500 Subject: [PATCH] feat: add network topology metrics (closes #931) (#981) Bumps github.com/unpoller/unifi/v5 to v5.23.0 which adds GetTopology() fetching vertices (devices/clients) and edges (wired/wireless connections) from /proxy/network/v2/api/site/{site}/topology. Changes across the stack: - poller.Metrics: add Topologies []any field + AppendMetrics support - inputunifi: collect topology per-site (non-fatal on older controllers), pass through augmentMetrics with site name override support - promunifi: new topology.go with summary, connection-type, link-quality, and band-distribution gauges - influxunifi: new topology.go with topology_summary and topology_edge measurements - datadogunifi: new topology.go with equivalent Datadog gauges - otelunifi: new topology.go with OpenTelemetry gauge observations Co-authored-by: Claude Sonnet 4.6 (1M context) --- go.mod | 2 +- go.sum | 2 + pkg/datadogunifi/datadog.go | 6 ++ pkg/datadogunifi/topology.go | 101 +++++++++++++++++++++++++++++ pkg/influxunifi/influxdb.go | 6 ++ pkg/influxunifi/topology.go | 93 +++++++++++++++++++++++++++ pkg/inputunifi/collector.go | 25 ++++++++ pkg/inputunifi/input.go | 1 + pkg/otelunifi/report.go | 1 + pkg/otelunifi/topology.go | 114 +++++++++++++++++++++++++++++++++ pkg/poller/config.go | 1 + pkg/poller/inputs.go | 1 + pkg/promunifi/collector.go | 10 ++- pkg/promunifi/topology.go | 121 +++++++++++++++++++++++++++++++++++ 14 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 pkg/datadogunifi/topology.go create mode 100644 pkg/influxunifi/topology.go create mode 100644 pkg/otelunifi/topology.go create mode 100644 pkg/promunifi/topology.go diff --git a/go.mod b/go.mod index 80f63053..f6ef7868 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/prometheus/common v0.67.5 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/unpoller/unifi/v5 v5.22.0 + github.com/unpoller/unifi/v5 v5.23.0 go.opentelemetry.io/otel v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 diff --git a/go.sum b/go.sum index d85ed289..e010c50e 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/unpoller/unifi/v5 v5.22.0 h1:ftLZcdXCtSfmd1a9nytajVCPuUoDxB1JyOPqoxPt8cI= github.com/unpoller/unifi/v5 v5.22.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So= +github.com/unpoller/unifi/v5 v5.23.0 h1:aJ7qM/UNtNNa9+iCfd6Quom8F7riFPQOe5g9rMsX8os= +github.com/unpoller/unifi/v5 v5.23.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= diff --git a/pkg/datadogunifi/datadog.go b/pkg/datadogunifi/datadog.go index 20952072..f28bb2da 100644 --- a/pkg/datadogunifi/datadog.go +++ b/pkg/datadogunifi/datadog.go @@ -325,6 +325,10 @@ func (u *DatadogUnifi) loopPoints(r report) { for _, p := range m.FirewallPolicies { u.switchExport(r, p) } + + for _, t := range m.Topologies { + u.switchExport(r, t) + } } func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop @@ -367,6 +371,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop u.batchWAN(r, v) case *unifi.FirewallPolicy: u.batchFirewallPolicy(r, v) + case *unifi.Topology: + u.batchTopology(r, v) default: if u.Collector != nil && u.Collector.Poller().LogUnknownTypes { u.LogDebugf("unknown export type: %T", v) diff --git a/pkg/datadogunifi/topology.go b/pkg/datadogunifi/topology.go new file mode 100644 index 00000000..3bd574d3 --- /dev/null +++ b/pkg/datadogunifi/topology.go @@ -0,0 +1,101 @@ +package datadogunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchTopology generates topology datapoints for Datadog. +func (u *DatadogUnifi) batchTopology(r report, t *unifi.Topology) { + if t == nil { + return + } + + metricName := metricNamespace("topology") + + siteTags := []string{ + tag("site_name", t.SiteName), + tag("source", t.SourceName), + } + + var ( + devices int + clients int + wired int + wireless int + fullDuplex int + ) + + unknownSwitch := 0.0 + if t.HasUnknownSwitch { + unknownSwitch = 1.0 + } + + for i := range t.Vertices { + switch t.Vertices[i].Type { + case "DEVICE": + devices++ + case "CLIENT": + clients++ + } + } + + bandCounts := make(map[string]int) + + for i := range t.Edges { + e := &t.Edges[i] + + edgeTags := []string{ + tag("uplink_mac", e.UplinkMac), + tag("downlink_mac", e.DownlinkMac), + tag("link_type", e.Type), + tag("site_name", t.SiteName), + tag("source", t.SourceName), + } + + switch e.Type { + case "WIRED": + wired++ + + if e.Duplex == "FULL_DUPLEX" { + fullDuplex++ + } + + _ = r.reportGauge(metricName("link_rate_mbps"), e.RateMbps.Val, edgeTags) + + case "WIRELESS": + wireless++ + + if e.RadioBand != "" { + bandCounts[e.RadioBand]++ + } + + if e.ExperienceScore.Val > 0 { + _ = r.reportGauge(metricName("link_experience_score"), e.ExperienceScore.Val, edgeTags) + } + } + } + + summary := map[string]float64{ + "vertices_total": float64(len(t.Vertices)), + "edges_total": float64(len(t.Edges)), + "devices_total": float64(devices), + "clients_total": float64(clients), + "connections_wired": float64(wired), + "connections_wireless": float64(wireless), + "wired_full_duplex": float64(fullDuplex), + "has_unknown_switch": unknownSwitch, + } + + for name, value := range summary { + _ = r.reportGauge(metricName(name), value, siteTags) + } + + for band, count := range bandCounts { + bandTags := []string{ + tag("band", band), + tag("site_name", t.SiteName), + tag("source", t.SourceName), + } + _ = r.reportGauge(metricName("connections_by_band"), float64(count), bandTags) + } +} diff --git a/pkg/influxunifi/influxdb.go b/pkg/influxunifi/influxdb.go index 0fd5aaf4..2b64ad0a 100644 --- a/pkg/influxunifi/influxdb.go +++ b/pkg/influxunifi/influxdb.go @@ -438,6 +438,10 @@ func (u *InfluxUnifi) loopPoints(r report) { for _, p := range m.FirewallPolicies { u.switchExport(r, p) } + + for _, t := range m.Topologies { + u.switchExport(r, t) + } } func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop @@ -480,6 +484,8 @@ func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop u.batchWAN(r, v) case *unifi.FirewallPolicy: u.batchFirewallPolicy(r, v) + case *unifi.Topology: + u.batchTopology(r, v) default: if u.Collector.Poller().LogUnknownTypes { u.LogDebugf("unknown export type: %T", v) diff --git a/pkg/influxunifi/topology.go b/pkg/influxunifi/topology.go new file mode 100644 index 00000000..fd04baba --- /dev/null +++ b/pkg/influxunifi/topology.go @@ -0,0 +1,93 @@ +package influxunifi + +import ( + "github.com/unpoller/unifi/v5" +) + +// batchTopology generates topology datapoints for InfluxDB. +func (u *InfluxUnifi) batchTopology(r report, t *unifi.Topology) { + if t == nil { + return + } + + var ( + devices int + clients int + wired int + wireless int + fullDuplex int + ) + + unknownSwitch := 0 + if t.HasUnknownSwitch { + unknownSwitch = 1 + } + + for i := range t.Vertices { + switch t.Vertices[i].Type { + case "DEVICE": + devices++ + case "CLIENT": + clients++ + } + } + + for i := range t.Edges { + e := &t.Edges[i] + + edgeTags := map[string]string{ + "uplink_mac": e.UplinkMac, + "downlink_mac": e.DownlinkMac, + "link_type": e.Type, + "site_name": t.SiteName, + "source": t.SourceName, + } + + switch e.Type { + case "WIRED": + wired++ + + if e.Duplex == "FULL_DUPLEX" { + fullDuplex++ + } + + edgeFields := map[string]any{ + "rate_mbps": e.RateMbps.Val, + } + + r.send(&metric{Table: "topology_edge", Tags: edgeTags, Fields: edgeFields}) + + case "WIRELESS": + wireless++ + + edgeTags["essid"] = e.Essid + edgeTags["radio_band"] = e.RadioBand + edgeTags["protocol"] = e.Protocol + + edgeFields := map[string]any{ + "experience_score": e.ExperienceScore.Val, + "channel": e.Channel.Val, + } + + r.send(&metric{Table: "topology_edge", Tags: edgeTags, Fields: edgeFields}) + } + } + + summaryTags := map[string]string{ + "site_name": t.SiteName, + "source": t.SourceName, + } + + summaryFields := map[string]any{ + "vertices_total": len(t.Vertices), + "edges_total": len(t.Edges), + "devices_total": devices, + "clients_total": clients, + "connections_wired": wired, + "connections_wireless": wireless, + "wired_full_duplex": fullDuplex, + "has_unknown_switch": unknownSwitch, + } + + r.send(&metric{Table: "topology_summary", Tags: summaryTags, Fields: summaryFields}) +} diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index 380e132b..f5411106 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -230,6 +230,14 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) { u.LogDebugf("Found %d Sysinfo entries", len(m.Sysinfos)) } + // Get network topology + if m.Topologies, err = c.Unifi.GetTopology(sites); err != nil { + // Don't fail collection if topology fails - older controllers may not have this endpoint + u.LogDebugf("unifi.GetTopology(%s): %v (continuing)", c.URL, err) + } else { + u.LogDebugf("Found %d Topology entries", len(m.Topologies)) + } + // Update web UI only on success; call explicitly so we never run with nil c/c.Unifi (no defer). // Recover so a panic in updateWeb (e.g. old image, race) never kills the poller. if c != nil && c.Unifi != nil { @@ -454,6 +462,15 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met m.FirewallPolicies = append(m.FirewallPolicies, policy) } + for _, topo := range metrics.Topologies { + // Apply site name override for topology if configured + if c.DefaultSiteNameOverride != "" && isDefaultSiteName(topo.SiteName) { + topo.SiteName = c.DefaultSiteNameOverride + } + + m.Topologies = append(m.Topologies, topo) + } + // Apply default_site_name_override to all metrics if configured. // This must be done AFTER all metrics are added to m, so everything is included. // This allows us to use the console name for Cloud Gateways while keeping @@ -588,6 +605,14 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) { } } } + + for i := range m.Topologies { + if topo, ok := m.Topologies[i].(*unifi.Topology); ok { + if isDefaultSiteName(topo.SiteName) { + topo.SiteName = overrideName + } + } + } } // this is a helper function for augmentMetrics. diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index 71261f25..90b7a746 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -91,6 +91,7 @@ type Metrics struct { WANConfigs []*unifi.WANEnrichedConfiguration Sysinfos []*unifi.Sysinfo FirewallPolicies []*unifi.FirewallPolicy + Topologies []*unifi.Topology } func init() { // nolint: gochecknoinits diff --git a/pkg/otelunifi/report.go b/pkg/otelunifi/report.go index f1f9a67e..f40bcf98 100644 --- a/pkg/otelunifi/report.go +++ b/pkg/otelunifi/report.go @@ -48,6 +48,7 @@ func (u *OtelOutput) reportMetrics(m *poller.Metrics, _ *poller.Events) (*Report u.exportClients(ctx, meter, m, r) u.exportDevices(ctx, meter, m, r) u.exportFirewallPolicies(ctx, meter, m, r) + u.exportTopology(ctx, meter, m, r) r.Elapsed = time.Since(start) diff --git a/pkg/otelunifi/topology.go b/pkg/otelunifi/topology.go new file mode 100644 index 00000000..27d77a95 --- /dev/null +++ b/pkg/otelunifi/topology.go @@ -0,0 +1,114 @@ +package otelunifi + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/unpoller/unifi/v5" + "github.com/unpoller/unpoller/pkg/poller" +) + +// exportTopology emits network topology metrics. +func (u *OtelOutput) exportTopology(ctx context.Context, meter metric.Meter, m *poller.Metrics, r *Report) { + for _, item := range m.Topologies { + t, ok := item.(*unifi.Topology) + if !ok { + continue + } + + siteAttrs := attribute.NewSet( + attribute.String("site_name", t.SiteName), + attribute.String("source", t.SourceName), + ) + + var ( + devices int + clients int + wired int + wireless int + fullDuplex int + ) + + unknownSwitch := 0.0 + if t.HasUnknownSwitch { + unknownSwitch = 1.0 + } + + for i := range t.Vertices { + switch t.Vertices[i].Type { + case "DEVICE": + devices++ + case "CLIENT": + clients++ + } + } + + bandCounts := make(map[string]int) + + for i := range t.Edges { + e := &t.Edges[i] + + edgeAttrs := attribute.NewSet( + attribute.String("uplink_mac", e.UplinkMac), + attribute.String("downlink_mac", e.DownlinkMac), + attribute.String("link_type", e.Type), + attribute.String("site_name", t.SiteName), + attribute.String("source", t.SourceName), + ) + + switch e.Type { + case "WIRED": + wired++ + + if e.Duplex == "FULL_DUPLEX" { + fullDuplex++ + } + + u.recordGauge(ctx, meter, r, "unifi_topology_link_rate_mbps", + "Wired link rate in Mbps", e.RateMbps.Val, edgeAttrs) + + case "WIRELESS": + wireless++ + + if e.RadioBand != "" { + bandCounts[e.RadioBand]++ + } + + if e.ExperienceScore.Val > 0 { + u.recordGauge(ctx, meter, r, "unifi_topology_link_experience_score", + "Wireless link experience score (0-100)", e.ExperienceScore.Val, edgeAttrs) + } + } + } + + u.recordGauge(ctx, meter, r, "unifi_topology_vertices_total", + "Total vertices in topology", float64(len(t.Vertices)), siteAttrs) + u.recordGauge(ctx, meter, r, "unifi_topology_edges_total", + "Total edges/connections in topology", float64(len(t.Edges)), siteAttrs) + u.recordGauge(ctx, meter, r, "unifi_topology_devices_total", + "UniFi devices in topology", float64(devices), siteAttrs) + u.recordGauge(ctx, meter, r, "unifi_topology_clients_total", + "Clients in topology", float64(clients), siteAttrs) + u.recordGauge(ctx, meter, r, "unifi_topology_has_unknown_switch", + "Unknown switch detected in topology (1/0)", unknownSwitch, siteAttrs) + u.recordGauge(ctx, meter, r, "unifi_topology_connections_wired", + "Number of wired connections", float64(wired), siteAttrs) + u.recordGauge(ctx, meter, r, "unifi_topology_connections_wireless", + "Number of wireless connections", float64(wireless), siteAttrs) + u.recordGauge(ctx, meter, r, "unifi_topology_wired_full_duplex", + "Number of full-duplex wired links", float64(fullDuplex), siteAttrs) + + for band, count := range bandCounts { + bandAttrs := attribute.NewSet( + attribute.String("band", band), + attribute.String("site_name", t.SiteName), + attribute.String("source", t.SourceName), + ) + + u.recordGauge(ctx, meter, r, "unifi_topology_connections_by_band", + "Number of wireless connections by radio band", float64(count), bandAttrs) + } + } +} diff --git a/pkg/poller/config.go b/pkg/poller/config.go index 7ee38d2f..ee68bc77 100644 --- a/pkg/poller/config.go +++ b/pkg/poller/config.go @@ -103,6 +103,7 @@ type Metrics struct { WANConfigs []any Sysinfos []any FirewallPolicies []any + Topologies []any ControllerStatuses []ControllerStatus } diff --git a/pkg/poller/inputs.go b/pkg/poller/inputs.go index 36f8daa4..77bd5c3e 100644 --- a/pkg/poller/inputs.go +++ b/pkg/poller/inputs.go @@ -278,6 +278,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics { existing.WANConfigs = append(existing.WANConfigs, m.WANConfigs...) existing.Sysinfos = append(existing.Sysinfos, m.Sysinfos...) existing.FirewallPolicies = append(existing.FirewallPolicies, m.FirewallPolicies...) + existing.Topologies = append(existing.Topologies, m.Topologies...) existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...) return existing diff --git a/pkg/promunifi/collector.go b/pkg/promunifi/collector.go index 0eb2a13e..a346d42e 100644 --- a/pkg/promunifi/collector.go +++ b/pkg/promunifi/collector.go @@ -51,6 +51,7 @@ type promUnifi struct { WAN *wan Controller *controller FirewallPolicy *firewallpolicy + Topology *topology // controllerUp tracks per-controller poll success (1) or failure (0). controllerUp *prometheus.GaugeVec // This interface is passed to the Collect() method. The Collect method uses @@ -217,6 +218,7 @@ func (u *promUnifi) Run(c poller.Collect) error { u.WAN = descWAN(u.Namespace + "_") u.Controller = descController(u.Namespace + "_") u.FirewallPolicy = descFirewallPolicy(u.Namespace + "_") + u.Topology = descTopology(u.Namespace + "_") u.controllerUp = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: u.Namespace + "_controller_up", Help: "Whether the last poll of the UniFi controller succeeded (1) or failed (0).", @@ -305,7 +307,7 @@ func (t *target) Describe(ch chan<- *prometheus.Desc) { // Describe satisfies the prometheus Collector. This returns all of the // metric descriptions that this packages produces. func (u *promUnifi) Describe(ch chan<- *prometheus.Desc) { - for _, f := range []any{u.Client, u.Device, u.UAP, u.USG, u.USW, u.PDU, u.Site, u.SpeedTest, u.DHCPLease, u.WAN, u.FirewallPolicy} { + for _, f := range []any{u.Client, u.Device, u.UAP, u.USG, u.USW, u.PDU, u.Site, u.SpeedTest, u.DHCPLease, u.WAN, u.FirewallPolicy, u.Topology} { v := reflect.Indirect(reflect.ValueOf(f)) // Loop each struct member and send it to the provided channel. @@ -482,6 +484,12 @@ func (u *promUnifi) loopExports(r report) { u.exportFirewallPolicies(r, firewallPolicies) + for _, t := range m.Topologies { + if topo, ok := t.(*unifi.Topology); ok { + u.exportTopology(r, topo) + } + } + u.exportClientDPItotals(r, appTotal, catTotal) } diff --git a/pkg/promunifi/topology.go b/pkg/promunifi/topology.go new file mode 100644 index 00000000..ba04260c --- /dev/null +++ b/pkg/promunifi/topology.go @@ -0,0 +1,121 @@ +package promunifi + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/unpoller/unifi/v5" +) + +type topology struct { + // Summary metrics + VerticesTotal *prometheus.Desc + EdgesTotal *prometheus.Desc + DevicesTotal *prometheus.Desc + ClientsTotal *prometheus.Desc + HasUnknownSwitch *prometheus.Desc + + // Connection type metrics + ConnectionsWired *prometheus.Desc + ConnectionsWireless *prometheus.Desc + ConnectionsByBand *prometheus.Desc + + // Link quality metrics + LinkExperienceScore *prometheus.Desc + LinkRateMbps *prometheus.Desc + WiredFullDuplex *prometheus.Desc +} + +func descTopology(ns string) *topology { + siteLabels := []string{"site_name", "source"} + linkLabels := []string{"uplink_mac", "downlink_mac", "link_type", "site_name", "source"} + bandLabels := []string{"band", "site_name", "source"} + + nd := prometheus.NewDesc + + return &topology{ + VerticesTotal: nd(ns+"topology_vertices_total", "Total vertices in topology", siteLabels, nil), + EdgesTotal: nd(ns+"topology_edges_total", "Total edges/connections in topology", siteLabels, nil), + DevicesTotal: nd(ns+"topology_devices_total", "UniFi devices in topology", siteLabels, nil), + ClientsTotal: nd(ns+"topology_clients_total", "Clients in topology", siteLabels, nil), + HasUnknownSwitch: nd(ns+"topology_has_unknown_switch", "Unknown switch detected in topology (1/0)", siteLabels, nil), + ConnectionsWired: nd(ns+"topology_connections_wired", "Number of wired connections", siteLabels, nil), + ConnectionsWireless: nd(ns+"topology_connections_wireless", "Number of wireless connections", siteLabels, nil), + ConnectionsByBand: nd(ns+"topology_connections_by_band", "Number of wireless connections by radio band", bandLabels, nil), + LinkExperienceScore: nd(ns+"topology_link_experience_score", "Link experience score (0-100)", linkLabels, nil), + LinkRateMbps: nd(ns+"topology_link_rate_mbps", "Link rate in Mbps", linkLabels, nil), + WiredFullDuplex: nd(ns+"topology_wired_full_duplex", "Number of full-duplex wired links", siteLabels, nil), + } +} + +func (u *promUnifi) exportTopology(r report, t *unifi.Topology) { + if t == nil { + return + } + + siteLabels := []string{t.SiteName, t.SourceName} + + var ( + devices int + clients int + wired int + wireless int + fullDuplex int + bandCounts = make(map[string]int) + unknownSwitch float64 + ) + + if t.HasUnknownSwitch { + unknownSwitch = 1 + } + + for i := range t.Vertices { + switch t.Vertices[i].Type { + case "DEVICE": + devices++ + case "CLIENT": + clients++ + } + } + + for i := range t.Edges { + e := &t.Edges[i] + linkLabels := []string{e.UplinkMac, e.DownlinkMac, e.Type, t.SiteName, t.SourceName} + + switch e.Type { + case "WIRED": + wired++ + + if e.Duplex == "FULL_DUPLEX" { + fullDuplex++ + } + + if e.RateMbps.Val > 0 { + r.send([]*metric{{u.Topology.LinkRateMbps, gauge, e.RateMbps.Val, linkLabels}}) + } + case "WIRELESS": + wireless++ + + if e.RadioBand != "" { + bandCounts[e.RadioBand]++ + } + + if e.ExperienceScore.Val > 0 { + r.send([]*metric{{u.Topology.LinkExperienceScore, gauge, e.ExperienceScore.Val, linkLabels}}) + } + } + } + + r.send([]*metric{ + {u.Topology.VerticesTotal, gauge, float64(len(t.Vertices)), siteLabels}, + {u.Topology.EdgesTotal, gauge, float64(len(t.Edges)), siteLabels}, + {u.Topology.DevicesTotal, gauge, float64(devices), siteLabels}, + {u.Topology.ClientsTotal, gauge, float64(clients), siteLabels}, + {u.Topology.HasUnknownSwitch, gauge, unknownSwitch, siteLabels}, + {u.Topology.ConnectionsWired, gauge, float64(wired), siteLabels}, + {u.Topology.ConnectionsWireless, gauge, float64(wireless), siteLabels}, + {u.Topology.WiredFullDuplex, gauge, float64(fullDuplex), siteLabels}, + }) + + for band, count := range bandCounts { + r.send([]*metric{{u.Topology.ConnectionsByBand, gauge, float64(count), []string{band, t.SiteName, t.SourceName}}}) + } +}