From ae1ab40386bcec66780eec7a8577adf11b06b4ad Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Sun, 25 Jan 2026 12:09:01 -0600 Subject: [PATCH] Populate num_user field for VPN subsystem metrics Fixes #417 UniFi controllers populate RemoteUserNumActive for VPN connections but leave NumUser at 0 for the VPN subsystem. This caused dashboard queries looking for num_user in the VPN subsystem to always show 0 active users, even when VPN connections were active. Root Cause: For most subsystems (wlan, lan, www), the controller populates NumUser directly. However, for the VPN subsystem, the controller uses the RemoteUserNumActive field instead, leaving NumUser at 0. The Prometheus exporter had special handling for VPN (lines 148-156 in pkg/promunifi/site.go) and exported RemoteUserNumActive, but did not export NumUser. The InfluxDB and Datadog exporters exported all fields for all subsystems without special handling, resulting in num_user always being 0 for VPN. Existing Grafana dashboards query: SELECT "num_user" FROM "subsystems" WHERE subsystem='vpn' This always returned 0 even with active VPN users. Solution: For all three exporters (InfluxDB, Datadog, Prometheus), when the subsystem is 'vpn' and NumUser is 0 but RemoteUserNumActive has a value, populate num_user with RemoteUserNumActive. Changes: - pkg/influxunifi/site.go: Add VPN-specific num_user fallback logic - pkg/datadogunifi/site.go: Add VPN-specific num_user fallback logic - pkg/promunifi/site.go: Add NumUser metric to VPN case with fallback This maintains backward compatibility - existing queries for num_user will now work correctly, and the remote_user_num_active field is still available for those who updated their dashboards. Co-Authored-By: Claude Sonnet 4.5 --- pkg/datadogunifi/site.go | 8 +++++++- pkg/influxunifi/site.go | 9 ++++++++- pkg/promunifi/site.go | 7 +++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pkg/datadogunifi/site.go b/pkg/datadogunifi/site.go index 2a9f9c32..8b417cf6 100644 --- a/pkg/datadogunifi/site.go +++ b/pkg/datadogunifi/site.go @@ -22,8 +22,14 @@ func (u *DatadogUnifi) reportSite(r report, s *unifi.Site) { tag("lan_ip", h.LanIP), } + // For VPN subsystem, use RemoteUserNumActive for num_user if NumUser is not set + numUser := h.NumUser.Val + if h.Subsystem == "vpn" && numUser == 0 && h.RemoteUserNumActive.Val > 0 { + numUser = h.RemoteUserNumActive.Val + } + data := map[string]float64{ - "num_user": h.NumUser.Val, + "num_user": numUser, "num_guest": h.NumGuest.Val, "num_iot": h.NumIot.Val, "tx_bytes_r": h.TxBytesR.Val, diff --git a/pkg/influxunifi/site.go b/pkg/influxunifi/site.go index 5d102bec..01138c5e 100644 --- a/pkg/influxunifi/site.go +++ b/pkg/influxunifi/site.go @@ -19,8 +19,15 @@ func (u *InfluxUnifi) batchSite(r report, s *unifi.Site) { "gw_name": h.GwName, "lan_ip": h.LanIP, } + + // For VPN subsystem, use RemoteUserNumActive for num_user if NumUser is not set + numUser := h.NumUser.Val + if h.Subsystem == "vpn" && numUser == 0 && h.RemoteUserNumActive.Val > 0 { + numUser = h.RemoteUserNumActive.Val + } + fields := map[string]any{ - "num_user": h.NumUser.Val, + "num_user": numUser, "num_guest": h.NumGuest.Val, "num_iot": h.NumIot.Val, "tx_bytes-r": h.TxBytesR.Int64(), diff --git a/pkg/promunifi/site.go b/pkg/promunifi/site.go index 11e4f6c9..95842f4c 100644 --- a/pkg/promunifi/site.go +++ b/pkg/promunifi/site.go @@ -146,7 +146,14 @@ func (u *promUnifi) exportSite(r report, s *unifi.Site) { {u.Site.NumSw, gauge, h.NumSw, labels}, }) case "vpn": + // For VPN subsystem, use RemoteUserNumActive for NumUser if NumUser is not set + numUser := h.NumUser.Val + if numUser == 0 && h.RemoteUserNumActive.Val > 0 { + numUser = h.RemoteUserNumActive.Val + } + r.send([]*metric{ + {u.Site.NumUser, gauge, numUser, labels}, {u.Site.RemoteUserNumActive, gauge, h.RemoteUserNumActive, labels}, {u.Site.RemoteUserNumInactive, gauge, h.RemoteUserNumInactive, labels}, {u.Site.RemoteUserRxBytes, counter, h.RemoteUserRxBytes, labels},