Merge pull request #999 from unpoller/upgrade/unifi-v5.26.0

feat: upgrade unifi to v5.26.0 with Integration/v1 + new legacy metrics
This commit is contained in:
Cody Lee 2026-05-08 16:48:11 -05:00 committed by GitHub
commit bbc33006ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 2794 additions and 38 deletions

4
go.mod
View File

@ -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.25.0
github.com/unpoller/unifi/v5 v5.26.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0
@ -36,7 +36,7 @@ require (
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect

8
go.sum
View File

@ -87,8 +87,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/unpoller/unifi/v5 v5.25.0 h1:smX4nXSnCoZ7JenZdD9fktpja/yVUHUlXojYqQ7Be+Q=
github.com/unpoller/unifi/v5 v5.25.0/go.mod h1:0R6t/SKaS8eoOrTkSYwzVb292KG5eQfbKEuevuES0So=
github.com/unpoller/unifi/v5 v5.26.0 h1:W4wGiWsw+BnA+8Ozykwv7zneQkbq5ozTtLJnxjA/mbI=
github.com/unpoller/unifi/v5 v5.26.0/go.mod h1:G1PKfGQsflTd4nVgoLFUkvce+wcdd8tu5ICS42X3MJY=
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=
@ -120,8 +120,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

View File

@ -337,6 +337,90 @@ func (u *DatadogUnifi) loopPoints(r report) {
for _, v := range m.VPNMeshes {
u.switchExport(r, v)
}
for _, v := range m.PortForwards {
u.switchExport(r, v)
}
for _, v := range m.SSLCertificates {
u.switchExport(r, v)
}
for _, v := range m.UPSDevices {
u.switchExport(r, v)
}
for _, v := range m.WANStatuses {
u.switchExport(r, v)
}
for _, v := range m.IntegrationDevStats {
u.switchExport(r, v)
}
for _, v := range m.WifiBroadcasts {
u.switchExport(r, v)
}
for _, v := range m.FirewallZones {
u.switchExport(r, v)
}
for _, v := range m.ACLRules {
u.switchExport(r, v)
}
for _, v := range m.VPNServers {
u.switchExport(r, v)
}
for _, v := range m.SiteToSiteTunnels {
u.switchExport(r, v)
}
for _, v := range m.LAGs {
u.switchExport(r, v)
}
for _, v := range m.MCLAGDomains {
u.switchExport(r, v)
}
for _, v := range m.SwitchStacks {
u.switchExport(r, v)
}
for _, v := range m.DNSPolicies {
u.switchExport(r, v)
}
for _, v := range m.RADIUSProfiles {
u.switchExport(r, v)
}
for _, v := range m.TrafficMatchingLists {
u.switchExport(r, v)
}
for _, v := range m.HotspotVouchers {
u.switchExport(r, v)
}
for _, v := range m.DPIApplications {
u.switchExport(r, v)
}
for _, v := range m.DPICategories {
u.switchExport(r, v)
}
for _, v := range m.PendingDevices {
u.switchExport(r, v)
}
for _, v := range m.Countries {
u.switchExport(r, v)
}
}
func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
@ -385,6 +469,48 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
u.batchPortAnomaly(r, v)
case *unifi.MagicSiteToSiteVPN:
u.batchMagicSiteToSiteVPN(r, v)
case *unifi.PortForward:
u.batchPortForward(r, v)
case *unifi.SSLCertificate:
u.batchSSLCertificate(r, v)
case *unifi.UPSDeviceSelector:
u.batchUPSDevice(r, v)
case *unifi.WANStatus:
u.batchWANStatus(r, v)
case *unifi.IntegrationDeviceStats:
u.batchIntegrationDevStats(r, v)
case *unifi.WifiBroadcast:
u.batchWifiBroadcast(r, v)
case *unifi.FirewallZone:
u.batchFirewallZone(r, v)
case *unifi.ACLRule:
u.batchACLRule(r, v)
case *unifi.VPNServer:
u.batchVPNServer(r, v)
case *unifi.SiteToSiteTunnel:
u.batchSiteToSiteTunnel(r, v)
case *unifi.LAG:
u.batchLAG(r, v)
case *unifi.MCLAGDomain:
u.batchMCLAGDomain(r, v)
case *unifi.SwitchStack:
u.batchSwitchStack(r, v)
case *unifi.DNSPolicy:
u.batchDNSPolicy(r, v)
case *unifi.RADIUSProfile:
u.batchRADIUSProfile(r, v)
case *unifi.TrafficMatchingList:
u.batchTrafficMatchingList(r, v)
case *unifi.HotspotVoucher:
u.batchHotspotVoucher(r, v)
case *unifi.DPIApplication:
u.batchDPIApplication(r, v)
case *unifi.DPICategory:
u.batchDPICategory(r, v)
case *unifi.PendingDevice:
u.batchPendingDevice(r, v)
case *unifi.Country:
u.batchCountry(r, v)
default:
if u.Collector != nil && u.Collector.Poller().LogUnknownTypes {
u.LogDebugf("unknown export type: %T", v)

View File

@ -0,0 +1,52 @@
package datadogunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchIntegrationDevStats generates integration device statistics datapoints for Datadog.
func (u *DatadogUnifi) batchIntegrationDevStats(r report, ids *unifi.IntegrationDeviceStats) {
if ids == nil {
return
}
metricName := metricNamespace("integration_device")
tags := cleanTags(map[string]string{
"device_id": ids.DeviceID,
})
tagSlice := tagMapToTags(tags)
_ = r.reportGauge(metricName("cpu_utilization_pct"), ids.CPUUtilizationPct.Val, tagSlice)
_ = r.reportGauge(metricName("memory_utilization_pct"), ids.MemoryUtilizationPct.Val, tagSlice)
_ = r.reportGauge(metricName("uptime_sec"), ids.UptimeSec.Val, tagSlice)
_ = r.reportGauge(metricName("load_average_1min"), ids.LoadAverage1Min.Val, tagSlice)
_ = r.reportGauge(metricName("load_average_5min"), ids.LoadAverage5Min.Val, tagSlice)
_ = r.reportGauge(metricName("load_average_15min"), ids.LoadAverage15Min.Val, tagSlice)
radioMetric := metricNamespace("integration_device_radio")
for i := range ids.Radios {
radio := &ids.Radios[i]
radioTags := append(tagSlice,
tag("frequency_ghz", radio.FrequencyGHz.Val),
)
_ = r.reportGauge(radioMetric("tx_retries_pct"), radio.TxRetriesPct.Val, radioTags)
}
uplinkMetric := metricNamespace("integration_device_uplink")
for i := range ids.Uplinks {
uplink := &ids.Uplinks[i]
uplinkTags := append(tagSlice,
tag("uplink_index", i),
)
_ = r.reportGauge(uplinkMetric("rx_rate_bps"), uplink.RxRateBps.Val, uplinkTags)
_ = r.reportGauge(uplinkMetric("tx_rate_bps"), uplink.TxRateBps.Val, uplinkTags)
}
}

View File

@ -0,0 +1,75 @@
package datadogunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchDPIApplication generates DPI application catalogue datapoints for Datadog.
// DPIApplications are global (no site scope).
func (u *DatadogUnifi) batchDPIApplication(r report, da *unifi.DPIApplication) {
if da == nil {
return
}
metricName := metricNamespace("dpi_application")
tags := cleanTags(map[string]string{
"name": da.Name,
})
_ = r.reportGauge(metricName("id"), da.ID.Val, tagMapToTags(tags))
}
// batchDPICategory generates DPI category catalogue datapoints for Datadog.
// DPICategories are global (no site scope).
func (u *DatadogUnifi) batchDPICategory(r report, dc *unifi.DPICategory) {
if dc == nil {
return
}
metricName := metricNamespace("dpi_category")
tags := cleanTags(map[string]string{
"name": dc.Name,
})
_ = r.reportGauge(metricName("id"), dc.ID.Val, tagMapToTags(tags))
}
// batchPendingDevice generates PendingDevice (adoption queue) datapoints for Datadog.
// PendingDevices are global (no site scope).
func (u *DatadogUnifi) batchPendingDevice(r report, pd *unifi.PendingDevice) {
if pd == nil {
return
}
metricName := metricNamespace("pending_device")
tags := cleanTags(map[string]string{
"mac_address": pd.MACAddress,
"ip_address": pd.IPAddress,
"model": pd.Model,
"state": pd.State,
})
_ = r.reportGauge(metricName("supported"), boolToFloat64(pd.Supported), tagMapToTags(tags))
_ = r.reportGauge(metricName("firmware_updatable"), boolToFloat64(pd.FirmwareUpdatable), tagMapToTags(tags))
}
// batchCountry generates Country datapoints for Datadog.
// Countries are global (no site scope).
func (u *DatadogUnifi) batchCountry(r report, co *unifi.Country) {
if co == nil {
return
}
metricName := metricNamespace("country")
tags := cleanTags(map[string]string{
"code": co.Code,
"name": co.Name,
})
// Emit a presence gauge (1.0 = country entry exists in the catalogue).
_ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags))
}

View File

@ -0,0 +1,235 @@
package datadogunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchWifiBroadcast generates WifiBroadcast (SSID) datapoints for Datadog.
func (u *DatadogUnifi) batchWifiBroadcast(r report, wb *unifi.WifiBroadcast) {
if wb == nil {
return
}
metricName := metricNamespace("wifi_broadcast")
tags := cleanTags(map[string]string{
"site_name": wb.SiteName,
"id": wb.ID,
"name": wb.Name,
"network": wb.Network,
"security_type": wb.SecurityConfiguration.Type,
})
_ = r.reportGauge(metricName("enabled"), boolToFloat64(wb.Enabled), tagMapToTags(tags))
}
// batchFirewallZone generates FirewallZone datapoints for Datadog.
func (u *DatadogUnifi) batchFirewallZone(r report, fz *unifi.FirewallZone) {
if fz == nil {
return
}
metricName := metricNamespace("firewall_zone")
tags := cleanTags(map[string]string{
"site_name": fz.SiteName,
"id": fz.ID,
"name": fz.Name,
"origin": fz.Metadata.Origin,
})
_ = r.reportGauge(metricName("network_count"), float64(len(fz.NetworkIDs)), tagMapToTags(tags))
}
// batchACLRule generates ACLRule datapoints for Datadog.
func (u *DatadogUnifi) batchACLRule(r report, acl *unifi.ACLRule) {
if acl == nil {
return
}
metricName := metricNamespace("acl_rule")
tags := cleanTags(map[string]string{
"site_name": acl.SiteName,
"id": acl.ID,
"name": acl.Name,
"action": acl.Action,
"source_filter": acl.SourceFilter,
})
_ = r.reportGauge(metricName("enabled"), boolToFloat64(acl.Enabled), tagMapToTags(tags))
_ = r.reportGauge(metricName("index"), acl.Index.Val, tagMapToTags(tags))
_ = r.reportGauge(metricName("enforcing_device_count"), float64(len(acl.EnforcingDeviceFilter)), tagMapToTags(tags))
}
// batchVPNServer generates VPNServer datapoints for Datadog.
func (u *DatadogUnifi) batchVPNServer(r report, vs *unifi.VPNServer) {
if vs == nil {
return
}
metricName := metricNamespace("vpn_server")
tags := cleanTags(map[string]string{
"site_name": vs.SiteName,
"id": vs.ID,
"name": vs.Name,
"type": vs.Type,
"origin": vs.Metadata.Origin,
})
_ = r.reportGauge(metricName("enabled"), boolToFloat64(vs.Enabled), tagMapToTags(tags))
}
// batchSiteToSiteTunnel generates SiteToSiteTunnel datapoints for Datadog.
func (u *DatadogUnifi) batchSiteToSiteTunnel(r report, tun *unifi.SiteToSiteTunnel) {
if tun == nil {
return
}
metricName := metricNamespace("site_to_site_tunnel")
tags := cleanTags(map[string]string{
"site_name": tun.SiteName,
"id": tun.ID,
"name": tun.Name,
"type": tun.Type,
"origin": tun.Metadata.Origin,
})
// Emit a presence gauge (1.0 = tunnel is configured).
_ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags))
}
// batchLAG generates LAG (link aggregation group) datapoints for Datadog.
func (u *DatadogUnifi) batchLAG(r report, lag *unifi.LAG) {
if lag == nil {
return
}
metricName := metricNamespace("lag")
tags := cleanTags(map[string]string{
"site_name": lag.SiteName,
"id": lag.ID,
"type": lag.Type,
"origin": lag.Metadata.Origin,
})
_ = r.reportGauge(metricName("member_count"), float64(len(lag.Members)), tagMapToTags(tags))
}
// batchMCLAGDomain generates MCLAGDomain datapoints for Datadog.
func (u *DatadogUnifi) batchMCLAGDomain(r report, mcd *unifi.MCLAGDomain) {
if mcd == nil {
return
}
metricName := metricNamespace("mclag_domain")
tags := cleanTags(map[string]string{
"site_name": mcd.SiteName,
"id": mcd.ID,
"name": mcd.Name,
"origin": mcd.Metadata.Origin,
})
_ = r.reportGauge(metricName("peer_count"), float64(len(mcd.Peers)), tagMapToTags(tags))
_ = r.reportGauge(metricName("lag_count"), float64(len(mcd.LAGs)), tagMapToTags(tags))
}
// batchSwitchStack generates SwitchStack datapoints for Datadog.
func (u *DatadogUnifi) batchSwitchStack(r report, ss *unifi.SwitchStack) {
if ss == nil {
return
}
metricName := metricNamespace("switch_stack")
tags := cleanTags(map[string]string{
"site_name": ss.SiteName,
"id": ss.ID,
"name": ss.Name,
"origin": ss.Metadata.Origin,
})
_ = r.reportGauge(metricName("member_count"), float64(len(ss.Members)), tagMapToTags(tags))
_ = r.reportGauge(metricName("lag_count"), float64(len(ss.LAGs)), tagMapToTags(tags))
}
// batchDNSPolicy generates DNSPolicy datapoints for Datadog.
func (u *DatadogUnifi) batchDNSPolicy(r report, dp *unifi.DNSPolicy) {
if dp == nil {
return
}
metricName := metricNamespace("dns_policy")
tags := cleanTags(map[string]string{
"site_name": dp.SiteName,
"id": dp.ID,
"type": dp.Type,
"domain": dp.Domain,
})
_ = r.reportGauge(metricName("enabled"), boolToFloat64(dp.Enabled), tagMapToTags(tags))
}
// batchRADIUSProfile generates RADIUSProfile datapoints for Datadog.
func (u *DatadogUnifi) batchRADIUSProfile(r report, rp *unifi.RADIUSProfile) {
if rp == nil {
return
}
metricName := metricNamespace("radius_profile")
tags := cleanTags(map[string]string{
"site_name": rp.SiteName,
"id": rp.ID,
"name": rp.Name,
"origin": rp.Metadata.Origin,
})
// Emit a presence gauge (1.0 = profile exists).
_ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags))
}
// batchTrafficMatchingList generates TrafficMatchingList datapoints for Datadog.
func (u *DatadogUnifi) batchTrafficMatchingList(r report, tml *unifi.TrafficMatchingList) {
if tml == nil {
return
}
metricName := metricNamespace("traffic_matching_list")
tags := cleanTags(map[string]string{
"site_name": tml.SiteName,
"id": tml.ID,
"name": tml.Name,
"type": tml.Type,
})
// Emit a presence gauge (1.0 = list exists).
_ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags))
}
// batchHotspotVoucher generates HotspotVoucher datapoints for Datadog.
func (u *DatadogUnifi) batchHotspotVoucher(r report, hv *unifi.HotspotVoucher) {
if hv == nil {
return
}
metricName := metricNamespace("hotspot_voucher")
tags := cleanTags(map[string]string{
"site_name": hv.SiteName,
"id": hv.ID,
"name": hv.Name,
"expires_at": hv.ExpiresAt,
})
_ = r.reportGauge(metricName("authorized_guest_count"), hv.AuthorizedGuestCount.Val, tagMapToTags(tags))
_ = r.reportGauge(metricName("authorized_guest_limit"), hv.AuthorizedGuestLimit.Val, tagMapToTags(tags))
_ = r.reportGauge(metricName("data_usage_limit_mbytes"), hv.DataUsageLimitMBytes.Val, tagMapToTags(tags))
_ = r.reportGauge(metricName("time_limit_minutes"), hv.TimeLimitMinutes.Val, tagMapToTags(tags))
}

View File

@ -0,0 +1,54 @@
package datadogunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchPortForward generates PortForward datapoints for Datadog.
func (u *DatadogUnifi) batchPortForward(r report, pf *unifi.PortForward) {
if pf == nil {
return
}
metricName := metricNamespace("port_forward")
tags := cleanTags(map[string]string{
"site_name": pf.SiteName,
"source": pf.SourceName,
"id": pf.ID,
"name": pf.Name,
"proto": pf.Proto,
"fwd_ip": pf.FwdIP,
"fwd_port": pf.FwdPort,
"dst_port": pf.DstPort,
"pf_iface": pf.PfwdPf,
})
_ = r.reportGauge(metricName("enabled"), boolToFloat64(pf.Enabled.Val), tagMapToTags(tags))
_ = r.reportGauge(metricName("log"), boolToFloat64(pf.Log.Val), tagMapToTags(tags))
}
// batchSSLCertificate generates SSLCertificate datapoints for Datadog.
func (u *DatadogUnifi) batchSSLCertificate(r report, cert *unifi.SSLCertificate) {
if cert == nil || cert.ID == "" {
return
}
metricName := metricNamespace("ssl_cert")
tags := cleanTags(map[string]string{
"site_name": cert.SiteName,
"id": cert.ID,
"cert_type": cert.CertType,
"status": cert.Status,
"issuer": cert.Issuer,
"subject": cert.Subject,
"fingerprint": cert.Fingerprint,
})
_ = r.reportGauge(metricName("is_active"), boolToFloat64(cert.IsActive.Val), tagMapToTags(tags))
_ = r.reportGauge(metricName("is_valid"), boolToFloat64(cert.IsValid.Val), tagMapToTags(tags))
_ = r.reportGauge(metricName("valid_from"), cert.ValidFrom.Val, tagMapToTags(tags))
_ = r.reportGauge(metricName("valid_to"), cert.ValidTo.Val, tagMapToTags(tags))
_ = r.reportGauge(metricName("chain_length"), float64(len(cert.Chain)), tagMapToTags(tags))
}

52
pkg/datadogunifi/ups.go Normal file
View File

@ -0,0 +1,52 @@
package datadogunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchUPSDevice generates UPS device selector datapoints for Datadog.
func (u *DatadogUnifi) batchUPSDevice(r report, ups *unifi.UPSDeviceSelector) {
if ups == nil {
return
}
metricName := metricNamespace("ups_device")
tags := cleanTags(map[string]string{
"site_name": ups.SiteName,
"source": ups.SourceName,
"id": ups.ID,
"mac": ups.MAC,
"label": ups.Label,
})
// Emit a presence gauge (1.0 = device exists in the adoption list).
_ = r.reportGauge(metricName("present"), 1.0, tagMapToTags(tags))
}
// batchWANStatus generates WAN status datapoints for Datadog.
func (u *DatadogUnifi) batchWANStatus(r report, ws *unifi.WANStatus) {
if ws == nil {
return
}
metricName := metricNamespace("wan_status")
for i := range ws.WANInterfaces {
iface := &ws.WANInterfaces[i]
active := 0.0
if iface.State == "ACTIVE" {
active = 1.0
}
tags := cleanTags(map[string]string{
"site_name": ws.SiteName,
"wan_name": iface.Name,
"wan_networkgroup": iface.WANNetworkgroup,
"state": iface.State,
})
_ = r.reportGauge(metricName("active"), active, tagMapToTags(tags))
}
}

View File

@ -450,6 +450,133 @@ func (u *InfluxUnifi) loopPoints(r report) {
for _, v := range m.VPNMeshes {
u.switchExport(r, v)
}
// v5.26.0 additions.
for _, ds := range m.IntegrationDevStats {
if d, ok := ds.(*unifi.IntegrationDeviceStats); ok {
u.batchIntegrationDeviceStats(r, d)
}
}
for _, ws := range m.WANStatuses {
if w, ok := ws.(*unifi.WANStatus); ok {
u.batchWANStatus(r, w)
}
}
for _, pf := range m.PortForwards {
if v, ok := pf.(*unifi.PortForward); ok {
u.batchPortForward(r, v)
}
}
for _, sc := range m.SSLCertificates {
if v, ok := sc.(*unifi.SSLCertificate); ok {
u.batchSSLCertificate(r, v)
}
}
for _, ud := range m.UPSDevices {
if v, ok := ud.(*unifi.UPSDeviceSelector); ok {
u.batchUPSDevice(r, v)
}
}
for _, wb := range m.WifiBroadcasts {
if v, ok := wb.(*unifi.WifiBroadcast); ok {
u.batchWifiBroadcast(r, v)
}
}
for _, fz := range m.FirewallZones {
if v, ok := fz.(*unifi.FirewallZone); ok {
u.batchFirewallZone(r, v)
}
}
for _, ar := range m.ACLRules {
if v, ok := ar.(*unifi.ACLRule); ok {
u.batchACLRule(r, v)
}
}
for _, vs := range m.VPNServers {
if v, ok := vs.(*unifi.VPNServer); ok {
u.batchVPNServer(r, v)
}
}
for _, st := range m.SiteToSiteTunnels {
if v, ok := st.(*unifi.SiteToSiteTunnel); ok {
u.batchSiteToSiteTunnel(r, v)
}
}
for _, lag := range m.LAGs {
if v, ok := lag.(*unifi.LAG); ok {
u.batchLAG(r, v)
}
}
for _, md := range m.MCLAGDomains {
if v, ok := md.(*unifi.MCLAGDomain); ok {
u.batchMCLAGDomain(r, v)
}
}
for _, ss := range m.SwitchStacks {
if v, ok := ss.(*unifi.SwitchStack); ok {
u.batchSwitchStack(r, v)
}
}
for _, dp := range m.DNSPolicies {
if v, ok := dp.(*unifi.DNSPolicy); ok {
u.batchDNSPolicy(r, v)
}
}
for _, rp := range m.RADIUSProfiles {
if v, ok := rp.(*unifi.RADIUSProfile); ok {
u.batchRADIUSProfile(r, v)
}
}
for _, tml := range m.TrafficMatchingLists {
if v, ok := tml.(*unifi.TrafficMatchingList); ok {
u.batchTrafficMatchingList(r, v)
}
}
for _, hv := range m.HotspotVouchers {
if v, ok := hv.(*unifi.HotspotVoucher); ok {
u.batchHotspotVoucher(r, v)
}
}
for _, da := range m.DPIApplications {
if v, ok := da.(*unifi.DPIApplication); ok {
u.batchDPIApplication(r, v)
}
}
for _, dc := range m.DPICategories {
if v, ok := dc.(*unifi.DPICategory); ok {
u.batchDPICategory(r, v)
}
}
for _, pd := range m.PendingDevices {
if v, ok := pd.(*unifi.PendingDevice); ok {
u.batchPendingDevice(r, v)
}
}
for _, co := range m.Countries {
if v, ok := co.(*unifi.Country); ok {
u.batchCountry(r, v)
}
}
}
func (u *InfluxUnifi) switchExport(r report, v any) { //nolint:cyclop

View File

@ -0,0 +1,60 @@
package influxunifi
import (
"fmt"
"github.com/unpoller/unifi/v5"
)
// batchIntegrationDeviceStats generates InfluxDB points for Integration/v1 device statistics.
func (u *InfluxUnifi) batchIntegrationDeviceStats(r report, ds *unifi.IntegrationDeviceStats) {
if ds == nil {
return
}
tags := map[string]string{
"device_id": ds.DeviceID,
}
fields := map[string]any{
"cpu_utilization_pct": ds.CPUUtilizationPct.Val,
"memory_utilization_pct": ds.MemoryUtilizationPct.Val,
"load_average_1min": ds.LoadAverage1Min.Val,
"load_average_5min": ds.LoadAverage5Min.Val,
"load_average_15min": ds.LoadAverage15Min.Val,
"uptime_sec": ds.UptimeSec.Val,
}
r.send(&metric{Table: "integration_device_stats", Tags: tags, Fields: fields})
for _, radio := range ds.Radios {
radioTags := map[string]string{
"device_id": ds.DeviceID,
"frequency_ghz": radio.FrequencyGHz.Txt,
}
r.send(&metric{
Table: "integration_device_radio",
Tags: radioTags,
Fields: map[string]any{
"tx_retries_pct": radio.TxRetriesPct.Val,
},
})
}
for i, uplink := range ds.Uplinks {
uplinkTags := map[string]string{
"device_id": ds.DeviceID,
"uplink_index": fmt.Sprint(i),
}
r.send(&metric{
Table: "integration_device_uplink",
Tags: uplinkTags,
Fields: map[string]any{
"rx_rate_bps": uplink.RxRateBps.Val,
"tx_rate_bps": uplink.TxRateBps.Val,
},
})
}
}

View File

@ -0,0 +1,93 @@
package influxunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchDPIApplication generates InfluxDB points for a DPI application catalogue entry.
// These are global (no site) reference records; we emit a presence gauge keyed by ID.
func (u *InfluxUnifi) batchDPIApplication(r report, app *unifi.DPIApplication) {
if app == nil {
return
}
tags := map[string]string{
"app_id": app.ID.Txt,
"app_name": app.Name,
}
r.send(&metric{
Table: "dpi_application",
Tags: tags,
Fields: map[string]any{"app_id_val": app.ID.Val},
})
}
// batchDPICategory generates InfluxDB points for a DPI category catalogue entry.
func (u *InfluxUnifi) batchDPICategory(r report, cat *unifi.DPICategory) {
if cat == nil {
return
}
tags := map[string]string{
"cat_id": cat.ID.Txt,
"cat_name": cat.Name,
}
r.send(&metric{
Table: "dpi_category",
Tags: tags,
Fields: map[string]any{"cat_id_val": cat.ID.Val},
})
}
// batchPendingDevice generates InfluxDB points for a device awaiting adoption.
func (u *InfluxUnifi) batchPendingDevice(r report, d *unifi.PendingDevice) {
if d == nil {
return
}
tags := map[string]string{
"mac": d.MACAddress,
"model": d.Model,
"state": d.State,
"ip": d.IPAddress,
}
firmwareUpdatable := 0
if d.FirmwareUpdatable {
firmwareUpdatable = 1
}
supported := 0
if d.Supported {
supported = 1
}
fields := map[string]any{
"firmware_updatable": firmwareUpdatable,
"supported": supported,
"feature_count": len(d.Features),
}
r.send(&metric{Table: "pending_device", Tags: tags, Fields: fields})
}
// batchCountry generates InfluxDB points for a country entry used in geo-based firewall filters.
// Countries are global reference data with no numeric metrics; we emit a presence gauge.
func (u *InfluxUnifi) batchCountry(r report, c *unifi.Country) {
if c == nil {
return
}
tags := map[string]string{
"code": c.Code,
"name": c.Name,
}
r.send(&metric{
Table: "country",
Tags: tags,
Fields: map[string]any{"present": 1},
})
}

View File

@ -0,0 +1,302 @@
package influxunifi
import (
"fmt"
"github.com/unpoller/unifi/v5"
)
// batchWifiBroadcast generates InfluxDB points for a WiFi SSID broadcast configuration.
func (u *InfluxUnifi) batchWifiBroadcast(r report, wb *unifi.WifiBroadcast) {
if wb == nil {
return
}
tags := map[string]string{
"site_name": wb.SiteName,
"broadcast_id": wb.ID,
"broadcast_name": wb.Name,
"network": wb.Network,
"security_type": wb.SecurityConfiguration.Type,
}
enabled := 0
if wb.Enabled {
enabled = 1
}
fields := map[string]any{
"enabled": enabled,
"broadcasting_device_count": len(wb.BroadcastingDeviceFilter),
}
r.send(&metric{Table: "wifi_broadcast", Tags: tags, Fields: fields})
}
// batchFirewallZone generates InfluxDB points for a firewall zone.
func (u *InfluxUnifi) batchFirewallZone(r report, fz *unifi.FirewallZone) {
if fz == nil {
return
}
tags := map[string]string{
"site_name": fz.SiteName,
"zone_id": fz.ID,
"zone_name": fz.Name,
"origin": fz.Metadata.Origin,
}
fields := map[string]any{
"network_count": len(fz.NetworkIDs),
}
r.send(&metric{Table: "firewall_zone", Tags: tags, Fields: fields})
}
// batchACLRule generates InfluxDB points for an ACL rule.
func (u *InfluxUnifi) batchACLRule(r report, rule *unifi.ACLRule) {
if rule == nil {
return
}
tags := map[string]string{
"site_name": rule.SiteName,
"rule_id": rule.ID,
"rule_name": rule.Name,
"action": rule.Action,
"source_filter": rule.SourceFilter,
}
enabled := 0
if rule.Enabled {
enabled = 1
}
fields := map[string]any{
"enabled": enabled,
"index": rule.Index.Val,
"enforcing_device_count": len(rule.EnforcingDeviceFilter),
}
r.send(&metric{Table: "acl_rule", Tags: tags, Fields: fields})
}
// batchVPNServer generates InfluxDB points for a VPN server configuration.
func (u *InfluxUnifi) batchVPNServer(r report, vs *unifi.VPNServer) {
if vs == nil {
return
}
tags := map[string]string{
"site_name": vs.SiteName,
"server_id": vs.ID,
"server_name": vs.Name,
"vpn_type": vs.Type,
"origin": vs.Metadata.Origin,
}
enabled := 0
if vs.Enabled {
enabled = 1
}
fields := map[string]any{
"enabled": enabled,
}
r.send(&metric{Table: "vpn_server", Tags: tags, Fields: fields})
}
// batchSiteToSiteTunnel generates InfluxDB points for a site-to-site VPN tunnel.
func (u *InfluxUnifi) batchSiteToSiteTunnel(r report, t *unifi.SiteToSiteTunnel) {
if t == nil {
return
}
tags := map[string]string{
"site_name": t.SiteName,
"tunnel_id": t.ID,
"tunnel_name": t.Name,
"tunnel_type": t.Type,
"origin": t.Metadata.Origin,
}
r.send(&metric{
Table: "site_to_site_tunnel",
Tags: tags,
Fields: map[string]any{"present": 1},
})
}
// batchLAG generates InfluxDB points for a link aggregation group.
func (u *InfluxUnifi) batchLAG(r report, lag *unifi.LAG) {
if lag == nil {
return
}
tags := map[string]string{
"site_name": lag.SiteName,
"lag_id": lag.ID,
"lag_type": lag.Type,
"origin": lag.Metadata.Origin,
}
totalPorts := 0
for _, m := range lag.Members {
totalPorts += len(m.PortIndexes)
}
fields := map[string]any{
"member_count": len(lag.Members),
"port_count": totalPorts,
}
r.send(&metric{Table: "lag", Tags: tags, Fields: fields})
}
// batchMCLAGDomain generates InfluxDB points for a multi-chassis LAG domain.
func (u *InfluxUnifi) batchMCLAGDomain(r report, d *unifi.MCLAGDomain) {
if d == nil {
return
}
tags := map[string]string{
"site_name": d.SiteName,
"domain_id": d.ID,
"domain_name": d.Name,
"origin": d.Metadata.Origin,
}
fields := map[string]any{
"peer_count": len(d.Peers),
"lag_count": len(d.LAGs),
}
r.send(&metric{Table: "mclag_domain", Tags: tags, Fields: fields})
for i, peer := range d.Peers {
peerTags := map[string]string{
"site_name": d.SiteName,
"domain_id": d.ID,
"domain_name": d.Name,
"peer_index": fmt.Sprint(i),
"device_id": peer.DeviceID,
"role": peer.Role,
}
r.send(&metric{
Table: "mclag_peer",
Tags: peerTags,
Fields: map[string]any{"link_port_count": len(peer.LinkPorts)},
})
}
}
// batchSwitchStack generates InfluxDB points for a switch stack.
func (u *InfluxUnifi) batchSwitchStack(r report, ss *unifi.SwitchStack) {
if ss == nil {
return
}
tags := map[string]string{
"site_name": ss.SiteName,
"stack_id": ss.ID,
"stack_name": ss.Name,
"origin": ss.Metadata.Origin,
}
fields := map[string]any{
"member_count": len(ss.Members),
"lag_count": len(ss.LAGs),
}
r.send(&metric{Table: "switch_stack", Tags: tags, Fields: fields})
}
// batchDNSPolicy generates InfluxDB points for a DNS policy.
func (u *InfluxUnifi) batchDNSPolicy(r report, p *unifi.DNSPolicy) {
if p == nil {
return
}
tags := map[string]string{
"site_name": p.SiteName,
"policy_id": p.ID,
"policy_type": p.Type,
"domain": p.Domain,
}
enabled := 0
if p.Enabled {
enabled = 1
}
fields := map[string]any{
"enabled": enabled,
}
r.send(&metric{Table: "dns_policy", Tags: tags, Fields: fields})
}
// batchRADIUSProfile generates InfluxDB points for a RADIUS profile.
func (u *InfluxUnifi) batchRADIUSProfile(r report, p *unifi.RADIUSProfile) {
if p == nil {
return
}
tags := map[string]string{
"site_name": p.SiteName,
"profile_id": p.ID,
"profile_name": p.Name,
"origin": p.Metadata.Origin,
}
r.send(&metric{
Table: "radius_profile",
Tags: tags,
Fields: map[string]any{"present": 1},
})
}
// batchTrafficMatchingList generates InfluxDB points for a traffic matching list.
func (u *InfluxUnifi) batchTrafficMatchingList(r report, l *unifi.TrafficMatchingList) {
if l == nil {
return
}
tags := map[string]string{
"site_name": l.SiteName,
"list_id": l.ID,
"list_name": l.Name,
"list_type": l.Type,
}
r.send(&metric{
Table: "traffic_matching_list",
Tags: tags,
Fields: map[string]any{"present": 1},
})
}
// batchHotspotVoucher generates InfluxDB points for a hotspot voucher.
func (u *InfluxUnifi) batchHotspotVoucher(r report, v *unifi.HotspotVoucher) {
if v == nil {
return
}
tags := map[string]string{
"site_name": v.SiteName,
"voucher_id": v.ID,
"voucher_name": v.Name,
}
fields := map[string]any{
"authorized_guest_count": v.AuthorizedGuestCount.Val,
"authorized_guest_limit": v.AuthorizedGuestLimit.Val,
"data_usage_limit_mbytes": v.DataUsageLimitMBytes.Val,
"time_limit_minutes": v.TimeLimitMinutes.Val,
}
r.send(&metric{Table: "hotspot_voucher", Tags: tags, Fields: fields})
}

View File

@ -0,0 +1,72 @@
package influxunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchPortForward generates InfluxDB points for a port forwarding rule.
func (u *InfluxUnifi) batchPortForward(r report, pf *unifi.PortForward) {
if pf == nil {
return
}
tags := map[string]string{
"site_name": pf.SiteName,
"source": pf.SourceName,
"rule_id": pf.ID,
"rule_name": pf.Name,
"proto": pf.Proto,
}
enabled := 0
if pf.Enabled.Val {
enabled = 1
}
logged := 0
if pf.Log.Val {
logged = 1
}
fields := map[string]any{
"enabled": enabled,
"logged": logged,
}
r.send(&metric{Table: "port_forward", Tags: tags, Fields: fields})
}
// batchSSLCertificate generates InfluxDB points for an SSL certificate.
func (u *InfluxUnifi) batchSSLCertificate(r report, cert *unifi.SSLCertificate) {
if cert == nil || cert.ID == "" {
return
}
tags := map[string]string{
"site_name": cert.SiteName,
"cert_id": cert.ID,
"cert_type": cert.CertType,
"status": cert.Status,
"fingerprint": cert.Fingerprint,
}
isActive := 0
if cert.IsActive.Val {
isActive = 1
}
isValid := 0
if cert.IsValid.Val {
isValid = 1
}
fields := map[string]any{
"is_active": isActive,
"is_valid": isValid,
"valid_from": cert.ValidFrom.Val,
"valid_to": cert.ValidTo.Val,
"chain_len": len(cert.Chain),
}
r.send(&metric{Table: "ssl_certificate", Tags: tags, Fields: fields})
}

28
pkg/influxunifi/ups.go Normal file
View File

@ -0,0 +1,28 @@
package influxunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchUPSDevice generates InfluxDB points for a UPS device selector entry.
// UPSDeviceSelector is a lightweight inventory record; the only meaningful
// time-series value is a presence/count gauge (1 per device per poll cycle).
func (u *InfluxUnifi) batchUPSDevice(r report, d *unifi.UPSDeviceSelector) {
if d == nil {
return
}
tags := map[string]string{
"site_name": d.SiteName,
"source": d.SourceName,
"device_id": d.ID,
"mac": d.MAC,
"label": d.Label,
}
r.send(&metric{
Table: "ups_device",
Tags: tags,
Fields: map[string]any{"present": 1},
})
}

View File

@ -0,0 +1,26 @@
package influxunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchWANStatus generates InfluxDB points for WAN interface state.
func (u *InfluxUnifi) batchWANStatus(r report, ws *unifi.WANStatus) {
if ws == nil {
return
}
for _, iface := range ws.WANInterfaces {
tags := map[string]string{
"site_name": ws.SiteName,
"wan_interface": iface.Name,
"wan_networkgroup": iface.WANNetworkgroup,
}
r.send(&metric{
Table: "wan_status",
Tags: tags,
Fields: map[string]any{"state": iface.State},
})
}
}

View File

@ -39,9 +39,15 @@ func (u *InputUnifi) collectControllerEvents(c *Controller) ([]any, error) {
for _, call := range []caller{u.collectIDs, u.collectAnomalies, u.collectAlarms, u.collectEvents, u.collectSyslog, u.collectProtectLogs} {
if newLogs, err = call(logs, sites, c); err != nil {
if c.Remote && errors.Is(err, unifi.ErrInvalidStatusCode) {
// The remote API (api.ui.com) does not support all event endpoints (e.g. /stat/event
// returns 404). Log a warning and continue so other collectors still run.
if c.Remote && (errors.Is(err, unifi.ErrInvalidStatusCode) || errors.Is(err, unifi.ErrEndpointNotFound)) {
// The remote API (api.ui.com) does not support all event endpoints.
// ErrInvalidStatusCode is retained for backward compatibility: before
// ErrEndpointNotFound was split out, all non-200 remote responses were
// soft-skipped via ErrInvalidStatusCode.
// Note: most inner callers (collectAlarms, collectAnomalies, collectEvents,
// collectIDs, collectProtectLogs) handle ErrEndpointNotFound internally and
// return nil, so this branch fires primarily for collectSyslog and for
// ErrInvalidStatusCode cases on remote controllers.
u.Logf("Failed to collect events from controller %s: %v (endpoint may not be supported by the remote API)", c.URL, err)
continue
@ -121,6 +127,14 @@ func (u *InputUnifi) collectAlarms(logs []any, sites []*unifi.Site, c *Controlle
for _, s := range sites {
events, err := c.Unifi.GetAlarmsSite(s)
if errors.Is(err, unifi.ErrEndpointNotFound) {
// /stat/alarm is removed controller-wide in Network 10.x+; once the first
// site returns 404 all remaining sites on the same controller will too.
u.LogDebugf("[%s] Alarms endpoint not available (Network 10.x+): %v", c.URL, err)
return logs, nil
}
if err != nil {
return logs, fmt.Errorf("unifi.GetAlarms(): %w", err)
}
@ -150,17 +164,21 @@ func (u *InputUnifi) collectAnomalies(logs []any, sites []*unifi.Site, c *Contro
for _, s := range sites {
events, err := c.Unifi.GetAnomaliesSite(s)
if errors.Is(err, unifi.ErrEndpointNotFound) {
// /stat/anomaly is removed controller-wide in Network 10.x+; once the first
// site returns 404 all remaining sites on the same controller will too.
u.LogDebugf("[%s] Anomalies endpoint not available (Network 10.x+): %v", c.URL, err)
return logs, nil
}
if err != nil {
return logs, fmt.Errorf("unifi.GetAnomalies(): %w", err)
}
for _, e := range events {
// Apply site name override for anomalies if configured
if c.DefaultSiteNameOverride != "" {
lower := strings.ToLower(e.SiteName)
if lower == "default" || strings.Contains(lower, "default") {
e.SiteName = c.DefaultSiteNameOverride
}
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(e.SiteName) {
e.SiteName = c.DefaultSiteNameOverride
}
logs = append(logs, e)
@ -183,6 +201,15 @@ func (u *InputUnifi) collectEvents(logs []any, sites []*unifi.Site, c *Controlle
for _, s := range sites {
events, err := c.Unifi.GetSiteEvents(s, time.Hour)
if errors.Is(err, unifi.ErrEndpointNotFound) {
// stat/event was removed in Network 10.x. The path is per-site but the
// removal is controller-wide: if the first site returns 404, every site
// on the same controller will too. No replacement exists in integration/v1.
u.Logf("[%s] Events endpoint removed (Network 10.x+): use save_syslog instead", c.URL)
return logs, nil
}
if err != nil {
return logs, fmt.Errorf("unifi.GetEvents(): %w", err)
}
@ -242,6 +269,12 @@ func (u *InputUnifi) collectProtectLogs(logs []any, _ []*unifi.Site, c *Controll
entries, err := c.Unifi.GetProtectLogs(req)
if err != nil {
if errors.Is(err, unifi.ErrEndpointNotFound) {
u.Logf("[%s] Protect logs endpoint not available (404) — ensure UniFi Protect is installed, or disable save_protect_logs", c.URL)
return logs, nil
}
return logs, fmt.Errorf("unifi.GetProtectLogs(): %w", err)
}
@ -257,10 +290,11 @@ func (u *InputUnifi) collectProtectLogs(logs []any, _ []*unifi.Site, c *Controll
thumbID = thumbID[2:]
}
if thumbData, err := c.Unifi.GetProtectEventThumbnail(thumbID); err == nil {
e.ThumbnailBase64 = base64.StdEncoding.EncodeToString(thumbData)
thumbData, thumbErr := c.Unifi.GetProtectEventThumbnail(thumbID)
if thumbErr != nil {
u.LogDebugf("Failed to fetch thumbnail for event %s (thumb: %s): %v", e.ID, thumbID, thumbErr)
} else {
u.LogDebugf("Failed to fetch thumbnail for event %s (thumb: %s): %v", e.ID, thumbID, err)
e.ThumbnailBase64 = base64.StdEncoding.EncodeToString(thumbData)
}
}
@ -289,6 +323,15 @@ func (u *InputUnifi) collectIDs(logs []any, sites []*unifi.Site, c *Controller)
for _, s := range sites {
events, err := c.Unifi.GetIDSSite(s)
if errors.Is(err, unifi.ErrEndpointNotFound) {
// stat/ips/event was removed in Network 10.x. The path is per-site but
// the removal is controller-wide: if the first site returns 404, every
// site on the same controller will too. No replacement exists.
u.Logf("[%s] IDS/IPS endpoint removed (Network 10.x+): no replacement available", c.URL)
return logs, nil
}
if err != nil {
return logs, fmt.Errorf("unifi.GetIDS(): %w", err)
}

View File

@ -3,6 +3,7 @@ package inputunifi
// nolint: gosec
import (
"crypto/md5"
"errors"
"fmt"
"strings"
"time"
@ -78,7 +79,7 @@ func (u *InputUnifi) collectController(c *Controller) (*poller.Metrics, error) {
metrics, err := u.pollController(c)
if err != nil {
u.Logf("Re-authenticating to UniFi Controller: %s", c.URL)
u.Logf("Re-authenticating to UniFi Controller %s (poll error: %v)", c.URL, err)
if authErr := u.getUnifi(c); authErr != nil {
return metrics, fmt.Errorf("re-authenticating to %s: %w", c.URL, authErr)
@ -262,6 +263,14 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) {
u.LogDebugf("Found %d VPNMeshes entries", len(m.VPNMeshes))
}
// Legacy API additions (v5.26.0) — available on most firmware, no API key required.
u.collectLegacyPerSite(c, sites, m)
// Integration/v1 API additions (v5.26.0) — require API key and Network 9.3.43+.
if c.APIKey != "" {
u.collectIntegrationV1(c, sites, m)
}
// 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 {
@ -458,6 +467,182 @@ func (u *InputUnifi) convertToSiteDPI(clientUsageByApp []*unifi.ClientUsageByApp
}
}
// collectLegacyPerSite collects v5.26.0 additions that use the legacy API (no API key needed).
// Failures are non-fatal: older firmware may not expose these endpoints.
func (u *InputUnifi) collectLegacyPerSite(c *Controller, sites []*unifi.Site, m *Metrics) {
for _, site := range sites {
if wan, err := c.Unifi.GetWANStatus(site); err != nil {
u.LogDebugf("unifi.GetWANStatus(%s, %s): %v (continuing)", c.URL, site.Name, err)
} else {
m.WANStatuses = append(m.WANStatuses, wan)
}
if forwards, err := c.Unifi.GetPortForwards(site); err != nil {
u.LogDebugf("unifi.GetPortForwards(%s, %s): %v (continuing)", c.URL, site.Name, err)
} else {
m.PortForwards = append(m.PortForwards, forwards...)
}
if cert, err := c.Unifi.GetSSLCertificate(site); err != nil {
u.LogDebugf("unifi.GetSSLCertificate(%s, %s): %v (continuing)", c.URL, site.Name, err)
} else if cert.ID != "" {
m.SSLCertificates = append(m.SSLCertificates, cert)
}
if upsList, err := c.Unifi.GetUPSDeviceList(site); err != nil {
u.LogDebugf("unifi.GetUPSDeviceList(%s, %s): %v (continuing)", c.URL, site.Name, err)
} else {
m.UPSDevices = append(m.UPSDevices, upsList...)
}
}
}
// collectIntegrationV1 collects all Integration/v1 endpoints (Network 9.3.43+, API key required).
// Only called when c.APIKey != "", so ErrAPIKeyRequired will not be returned.
// ErrEndpointNotFound is expected on firmware older than Network 9.3.43.
//
//nolint:cyclop,funlen
func (u *InputUnifi) collectIntegrationV1(c *Controller, sites []*unifi.Site, m *Metrics) {
// Fetch integration sites — required for all per-site Integration/v1 calls.
integrationSites, err := c.Unifi.GetIntegrationSites()
if err != nil {
if errors.Is(err, unifi.ErrEndpointNotFound) {
// Integration/v1 requires Network 9.3.43+. Controllers below that return 404.
u.LogDebugf("unifi.GetIntegrationSites(%s): Integration/v1 not available (Network 9.3.43+ required)", c.URL)
} else {
// Unexpected failure (auth expiry, network error, 500) while an API key is configured.
// All per-site Integration/v1 data will be absent until this resolves.
u.Logf("unifi.GetIntegrationSites(%s): %v (skipping Integration/v1 per-site collection)", c.URL, err)
}
return
}
u.LogDebugf("Found %d IntegrationSites", len(integrationSites))
// Build a map from legacy site name → IntegrationSite to match user-configured sites.
// IntegrationSite.InternalReference is the same short name used in the legacy API (e.g. "default").
intSiteByName := make(map[string]*unifi.IntegrationSite, len(integrationSites))
for _, is := range integrationSites {
intSiteByName[is.InternalReference] = is
}
// Per-site Integration/v1 collections — only for user-configured sites.
for _, site := range sites {
is, ok := intSiteByName[site.Name]
if !ok {
continue
}
if devStats, err := c.Unifi.GetAllIntegrationDeviceStats(is); err != nil {
u.LogDebugf("unifi.GetAllIntegrationDeviceStats(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.IntegrationDevStats = append(m.IntegrationDevStats, devStats...)
}
if broadcasts, err := c.Unifi.GetWifiBroadcasts(is); err != nil {
u.LogDebugf("unifi.GetWifiBroadcasts(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.WifiBroadcasts = append(m.WifiBroadcasts, broadcasts...)
}
if zones, err := c.Unifi.GetFirewallZones(is); err != nil {
u.LogDebugf("unifi.GetFirewallZones(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.FirewallZones = append(m.FirewallZones, zones...)
}
if rules, err := c.Unifi.GetACLRules(is); err != nil {
u.LogDebugf("unifi.GetACLRules(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.ACLRules = append(m.ACLRules, rules...)
}
if servers, err := c.Unifi.GetVPNServers(is); err != nil {
u.LogDebugf("unifi.GetVPNServers(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.VPNServers = append(m.VPNServers, servers...)
}
if tunnels, err := c.Unifi.GetSiteToSiteTunnels(is); err != nil {
u.LogDebugf("unifi.GetSiteToSiteTunnels(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.SiteToSiteTunnels = append(m.SiteToSiteTunnels, tunnels...)
}
if lags, err := c.Unifi.GetLAGs(is); err != nil {
u.LogDebugf("unifi.GetLAGs(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.LAGs = append(m.LAGs, lags...)
}
if mclags, err := c.Unifi.GetMCLAGDomains(is); err != nil {
u.LogDebugf("unifi.GetMCLAGDomains(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.MCLAGDomains = append(m.MCLAGDomains, mclags...)
}
if stacks, err := c.Unifi.GetSwitchStacks(is); err != nil {
u.LogDebugf("unifi.GetSwitchStacks(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.SwitchStacks = append(m.SwitchStacks, stacks...)
}
if policies, err := c.Unifi.GetDNSPolicies(is); err != nil {
u.LogDebugf("unifi.GetDNSPolicies(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.DNSPolicies = append(m.DNSPolicies, policies...)
}
if profiles, err := c.Unifi.GetRADIUSProfiles(is); err != nil {
u.LogDebugf("unifi.GetRADIUSProfiles(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.RADIUSProfiles = append(m.RADIUSProfiles, profiles...)
}
if lists, err := c.Unifi.GetTrafficMatchingLists(is); err != nil {
u.LogDebugf("unifi.GetTrafficMatchingLists(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.TrafficMatchingLists = append(m.TrafficMatchingLists, lists...)
}
if vouchers, err := c.Unifi.GetHotspotVouchers(is); err != nil {
u.LogDebugf("unifi.GetHotspotVouchers(%s, %s): %v (continuing)", c.URL, is.Name, err)
} else {
m.HotspotVouchers = append(m.HotspotVouchers, vouchers...)
}
}
// Global Integration/v1 collections (not per-site).
if apps, err := c.Unifi.GetDPIApplications(); err != nil {
u.LogDebugf("unifi.GetDPIApplications(%s): %v (continuing)", c.URL, err)
} else {
m.DPIApplications = append(m.DPIApplications, apps...)
u.LogDebugf("Found %d DPIApplications", len(apps))
}
if cats, err := c.Unifi.GetDPICategories(); err != nil {
u.LogDebugf("unifi.GetDPICategories(%s): %v (continuing)", c.URL, err)
} else {
m.DPICategories = append(m.DPICategories, cats...)
u.LogDebugf("Found %d DPICategories", len(cats))
}
if pending, err := c.Unifi.GetPendingDevices(); err != nil {
u.LogDebugf("unifi.GetPendingDevices(%s): %v (continuing)", c.URL, err)
} else {
m.PendingDevices = append(m.PendingDevices, pending...)
u.LogDebugf("Found %d PendingDevices", len(pending))
}
if countries, err := c.Unifi.GetCountries(); err != nil {
u.LogDebugf("unifi.GetCountries(%s): %v (continuing)", c.URL, err)
} else {
m.Countries = append(m.Countries, countries...)
u.LogDebugf("Found %d Countries", len(countries))
}
}
// augmentMetrics is our middleware layer between collecting metrics and writing them.
// This is where we can manipuate the returned data or make arbitrary decisions.
// This method currently adds parent device names to client metrics and hashes PII.
@ -612,6 +797,156 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
m.VPNMeshes = append(m.VPNMeshes, mesh)
}
// v5.26.0 additions — pass through with site name override applied.
for _, ws := range metrics.WANStatuses {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(ws.SiteName) {
ws.SiteName = c.DefaultSiteNameOverride
}
m.WANStatuses = append(m.WANStatuses, ws)
}
for _, pf := range metrics.PortForwards {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(pf.SiteName) {
pf.SiteName = c.DefaultSiteNameOverride
}
m.PortForwards = append(m.PortForwards, pf)
}
for _, cert := range metrics.SSLCertificates {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(cert.SiteName) {
cert.SiteName = c.DefaultSiteNameOverride
}
m.SSLCertificates = append(m.SSLCertificates, cert)
}
for _, ups := range metrics.UPSDevices {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(ups.SiteName) {
ups.SiteName = c.DefaultSiteNameOverride
}
m.UPSDevices = append(m.UPSDevices, ups)
}
for _, ds := range metrics.IntegrationDevStats {
m.IntegrationDevStats = append(m.IntegrationDevStats, ds)
}
for _, wb := range metrics.WifiBroadcasts {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(wb.SiteName) {
wb.SiteName = c.DefaultSiteNameOverride
}
m.WifiBroadcasts = append(m.WifiBroadcasts, wb)
}
for _, fz := range metrics.FirewallZones {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(fz.SiteName) {
fz.SiteName = c.DefaultSiteNameOverride
}
m.FirewallZones = append(m.FirewallZones, fz)
}
for _, rule := range metrics.ACLRules {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(rule.SiteName) {
rule.SiteName = c.DefaultSiteNameOverride
}
m.ACLRules = append(m.ACLRules, rule)
}
for _, vs := range metrics.VPNServers {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(vs.SiteName) {
vs.SiteName = c.DefaultSiteNameOverride
}
m.VPNServers = append(m.VPNServers, vs)
}
for _, t := range metrics.SiteToSiteTunnels {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(t.SiteName) {
t.SiteName = c.DefaultSiteNameOverride
}
m.SiteToSiteTunnels = append(m.SiteToSiteTunnels, t)
}
for _, lag := range metrics.LAGs {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(lag.SiteName) {
lag.SiteName = c.DefaultSiteNameOverride
}
m.LAGs = append(m.LAGs, lag)
}
for _, mc := range metrics.MCLAGDomains {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(mc.SiteName) {
mc.SiteName = c.DefaultSiteNameOverride
}
m.MCLAGDomains = append(m.MCLAGDomains, mc)
}
for _, ss := range metrics.SwitchStacks {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(ss.SiteName) {
ss.SiteName = c.DefaultSiteNameOverride
}
m.SwitchStacks = append(m.SwitchStacks, ss)
}
for _, dp := range metrics.DNSPolicies {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(dp.SiteName) {
dp.SiteName = c.DefaultSiteNameOverride
}
m.DNSPolicies = append(m.DNSPolicies, dp)
}
for _, rp := range metrics.RADIUSProfiles {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(rp.SiteName) {
rp.SiteName = c.DefaultSiteNameOverride
}
m.RADIUSProfiles = append(m.RADIUSProfiles, rp)
}
for _, tl := range metrics.TrafficMatchingLists {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(tl.SiteName) {
tl.SiteName = c.DefaultSiteNameOverride
}
m.TrafficMatchingLists = append(m.TrafficMatchingLists, tl)
}
for _, hv := range metrics.HotspotVouchers {
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(hv.SiteName) {
hv.SiteName = c.DefaultSiteNameOverride
}
m.HotspotVouchers = append(m.HotspotVouchers, hv)
}
// Global types — no site name to override.
for _, app := range metrics.DPIApplications {
m.DPIApplications = append(m.DPIApplications, app)
}
for _, cat := range metrics.DPICategories {
m.DPICategories = append(m.DPICategories, cat)
}
for _, pd := range metrics.PendingDevices {
m.PendingDevices = append(m.PendingDevices, pd)
}
for _, co := range metrics.Countries {
m.Countries = append(m.Countries, co)
}
// 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
@ -772,6 +1107,103 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) {
}
}
}
// v5.26.0 additions.
for i := range m.WANStatuses {
if ws, ok := m.WANStatuses[i].(*unifi.WANStatus); ok && isDefaultSiteName(ws.SiteName) {
ws.SiteName = overrideName
}
}
for i := range m.PortForwards {
if pf, ok := m.PortForwards[i].(*unifi.PortForward); ok && isDefaultSiteName(pf.SiteName) {
pf.SiteName = overrideName
}
}
for i := range m.SSLCertificates {
if cert, ok := m.SSLCertificates[i].(*unifi.SSLCertificate); ok && isDefaultSiteName(cert.SiteName) {
cert.SiteName = overrideName
}
}
for i := range m.UPSDevices {
if ups, ok := m.UPSDevices[i].(*unifi.UPSDeviceSelector); ok && isDefaultSiteName(ups.SiteName) {
ups.SiteName = overrideName
}
}
for i := range m.WifiBroadcasts {
if wb, ok := m.WifiBroadcasts[i].(*unifi.WifiBroadcast); ok && isDefaultSiteName(wb.SiteName) {
wb.SiteName = overrideName
}
}
for i := range m.FirewallZones {
if fz, ok := m.FirewallZones[i].(*unifi.FirewallZone); ok && isDefaultSiteName(fz.SiteName) {
fz.SiteName = overrideName
}
}
for i := range m.ACLRules {
if r, ok := m.ACLRules[i].(*unifi.ACLRule); ok && isDefaultSiteName(r.SiteName) {
r.SiteName = overrideName
}
}
for i := range m.VPNServers {
if vs, ok := m.VPNServers[i].(*unifi.VPNServer); ok && isDefaultSiteName(vs.SiteName) {
vs.SiteName = overrideName
}
}
for i := range m.SiteToSiteTunnels {
if t, ok := m.SiteToSiteTunnels[i].(*unifi.SiteToSiteTunnel); ok && isDefaultSiteName(t.SiteName) {
t.SiteName = overrideName
}
}
for i := range m.LAGs {
if lag, ok := m.LAGs[i].(*unifi.LAG); ok && isDefaultSiteName(lag.SiteName) {
lag.SiteName = overrideName
}
}
for i := range m.MCLAGDomains {
if mc, ok := m.MCLAGDomains[i].(*unifi.MCLAGDomain); ok && isDefaultSiteName(mc.SiteName) {
mc.SiteName = overrideName
}
}
for i := range m.SwitchStacks {
if ss, ok := m.SwitchStacks[i].(*unifi.SwitchStack); ok && isDefaultSiteName(ss.SiteName) {
ss.SiteName = overrideName
}
}
for i := range m.DNSPolicies {
if dp, ok := m.DNSPolicies[i].(*unifi.DNSPolicy); ok && isDefaultSiteName(dp.SiteName) {
dp.SiteName = overrideName
}
}
for i := range m.RADIUSProfiles {
if rp, ok := m.RADIUSProfiles[i].(*unifi.RADIUSProfile); ok && isDefaultSiteName(rp.SiteName) {
rp.SiteName = overrideName
}
}
for i := range m.TrafficMatchingLists {
if tl, ok := m.TrafficMatchingLists[i].(*unifi.TrafficMatchingList); ok && isDefaultSiteName(tl.SiteName) {
tl.SiteName = overrideName
}
}
for i := range m.HotspotVouchers {
if hv, ok := m.HotspotVouchers[i].(*unifi.HotspotVoucher); ok && isDefaultSiteName(hv.SiteName) {
hv.SiteName = overrideName
}
}
}
// this is a helper function for augmentMetrics.

View File

@ -94,6 +94,28 @@ type Metrics struct {
Topologies []*unifi.Topology
PortAnomalies []*unifi.PortAnomaly
VPNMeshes []*unifi.MagicSiteToSiteVPN
// Added in v5.26.0 integration:
WANStatuses []*unifi.WANStatus
PortForwards []*unifi.PortForward
SSLCertificates []*unifi.SSLCertificate
UPSDevices []*unifi.UPSDeviceSelector
IntegrationDevStats []*unifi.IntegrationDeviceStats
WifiBroadcasts []*unifi.WifiBroadcast
FirewallZones []*unifi.FirewallZone
ACLRules []*unifi.ACLRule
VPNServers []*unifi.VPNServer
SiteToSiteTunnels []*unifi.SiteToSiteTunnel
LAGs []*unifi.LAG
MCLAGDomains []*unifi.MCLAGDomain
SwitchStacks []*unifi.SwitchStack
DNSPolicies []*unifi.DNSPolicy
RADIUSProfiles []*unifi.RADIUSProfile
TrafficMatchingLists []*unifi.TrafficMatchingList
HotspotVouchers []*unifi.HotspotVoucher
DPIApplications []*unifi.DPIApplication
DPICategories []*unifi.DPICategory
PendingDevices []*unifi.PendingDevice
Countries []*unifi.Country
}
func init() { // nolint: gochecknoinits

View File

@ -107,6 +107,28 @@ type Metrics struct {
PortAnomalies []any
VPNMeshes []any
ControllerStatuses []ControllerStatus
// Added in v5.26.0 integration:
WANStatuses []any // *unifi.WANStatus — per-site WAN failover state
PortForwards []any // *unifi.PortForward — port forwarding rules
SSLCertificates []any // *unifi.SSLCertificate — controller SSL cert info
UPSDevices []any // *unifi.UPSDeviceSelector — UPS selector list
IntegrationDevStats []any // *unifi.IntegrationDeviceStats — CPU/mem/radio/uplink per device
WifiBroadcasts []any // *unifi.WifiBroadcast — WiFi SSID broadcasts
FirewallZones []any // *unifi.FirewallZone — firewall zones
ACLRules []any // *unifi.ACLRule — access control rules
VPNServers []any // *unifi.VPNServer — VPN server inventory
SiteToSiteTunnels []any // *unifi.SiteToSiteTunnel — site-to-site VPN tunnels
LAGs []any // *unifi.LAG — link aggregation groups
MCLAGDomains []any // *unifi.MCLAGDomain — multi-chassis LAG domains
SwitchStacks []any // *unifi.SwitchStack — switch stacks
DNSPolicies []any // *unifi.DNSPolicy — DNS policies
RADIUSProfiles []any // *unifi.RADIUSProfile — RADIUS profiles
TrafficMatchingLists []any // *unifi.TrafficMatchingList — traffic matching lists
HotspotVouchers []any // *unifi.HotspotVoucher — hotspot vouchers
DPIApplications []any // *unifi.DPIApplication — DPI app catalogue (global)
DPICategories []any // *unifi.DPICategory — DPI category catalogue (global)
PendingDevices []any // *unifi.PendingDevice — devices awaiting adoption (global)
Countries []any // *unifi.Country — country list for geo-filters (global)
}
// Events defines the type for log entries.

View File

@ -282,6 +282,27 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics {
existing.PortAnomalies = append(existing.PortAnomalies, m.PortAnomalies...)
existing.VPNMeshes = append(existing.VPNMeshes, m.VPNMeshes...)
existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...)
existing.WANStatuses = append(existing.WANStatuses, m.WANStatuses...)
existing.PortForwards = append(existing.PortForwards, m.PortForwards...)
existing.SSLCertificates = append(existing.SSLCertificates, m.SSLCertificates...)
existing.UPSDevices = append(existing.UPSDevices, m.UPSDevices...)
existing.IntegrationDevStats = append(existing.IntegrationDevStats, m.IntegrationDevStats...)
existing.WifiBroadcasts = append(existing.WifiBroadcasts, m.WifiBroadcasts...)
existing.FirewallZones = append(existing.FirewallZones, m.FirewallZones...)
existing.ACLRules = append(existing.ACLRules, m.ACLRules...)
existing.VPNServers = append(existing.VPNServers, m.VPNServers...)
existing.SiteToSiteTunnels = append(existing.SiteToSiteTunnels, m.SiteToSiteTunnels...)
existing.LAGs = append(existing.LAGs, m.LAGs...)
existing.MCLAGDomains = append(existing.MCLAGDomains, m.MCLAGDomains...)
existing.SwitchStacks = append(existing.SwitchStacks, m.SwitchStacks...)
existing.DNSPolicies = append(existing.DNSPolicies, m.DNSPolicies...)
existing.RADIUSProfiles = append(existing.RADIUSProfiles, m.RADIUSProfiles...)
existing.TrafficMatchingLists = append(existing.TrafficMatchingLists, m.TrafficMatchingLists...)
existing.HotspotVouchers = append(existing.HotspotVouchers, m.HotspotVouchers...)
existing.DPIApplications = append(existing.DPIApplications, m.DPIApplications...)
existing.DPICategories = append(existing.DPICategories, m.DPICategories...)
existing.PendingDevices = append(existing.PendingDevices, m.PendingDevices...)
existing.Countries = append(existing.Countries, m.Countries...)
return existing
}

View File

@ -36,24 +36,45 @@ const (
var ErrMetricFetchFailed = fmt.Errorf("metric fetch failed")
type promUnifi struct {
*Config `json:"prometheus" toml:"prometheus" xml:"prometheus" yaml:"prometheus"`
Client *uclient
Device *unifiDevice
UAP *uap
USG *usg
USW *usw
PDU *pdu
Site *site
RogueAP *rogueap
SpeedTest *speedtest
CountryTraffic *ucountrytraffic
DHCPLease *dhcplease
WAN *wan
Controller *controller
FirewallPolicy *firewallpolicy
Topology *topology
PortAnomaly *portanomaly
VPNMesh *vpnmesh
*Config `json:"prometheus" toml:"prometheus" xml:"prometheus" yaml:"prometheus"`
Client *uclient
Device *unifiDevice
UAP *uap
USG *usg
USW *usw
PDU *pdu
Site *site
RogueAP *rogueap
SpeedTest *speedtest
CountryTraffic *ucountrytraffic
DHCPLease *dhcplease
WAN *wan
Controller *controller
FirewallPolicy *firewallpolicy
Topology *topology
PortAnomaly *portanomaly
VPNMesh *vpnmesh
IntegrationDevice *integrationDevice
WANStatus *wanStatus
PortForward *portForward
SSLCertificate *sslCertificate
UPSDevice *upsDevice
WifiBroadcast *wifiBroadcast
FirewallZone *firewallZone
ACLRule *aclRule
VPNServer *vpnServer
SiteToSiteTunnel *siteToSiteTunnel
LAG *lag
MCLAGDomain *mclagDomain
SwitchStack *switchStack
DNSPolicy *dnsPolicy
RADIUSProfile *radiusProfile
TrafficMatchingList *trafficMatchingList
HotspotVoucher *hotspotVoucher
DPIApplication *dpiApplication
DPICategory *dpiCategory
PendingDevice *pendingDevice
Country *country
// 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
@ -223,6 +244,27 @@ func (u *promUnifi) Run(c poller.Collect) error {
u.Topology = descTopology(u.Namespace + "_")
u.PortAnomaly = descPortAnomaly(u.Namespace + "_")
u.VPNMesh = descVPNMesh(u.Namespace + "_")
u.IntegrationDevice = descIntegrationDevice(u.Namespace + "_device_")
u.WANStatus = descWANStatus(u.Namespace + "_")
u.PortForward = descPortForward(u.Namespace + "_")
u.SSLCertificate = descSSLCertificate(u.Namespace + "_")
u.UPSDevice = descUPSDevice(u.Namespace + "_")
u.WifiBroadcast = descWifiBroadcast(u.Namespace + "_")
u.FirewallZone = descFirewallZone(u.Namespace + "_")
u.ACLRule = descACLRule(u.Namespace + "_")
u.VPNServer = descVPNServer(u.Namespace + "_")
u.SiteToSiteTunnel = descSiteToSiteTunnel(u.Namespace + "_")
u.LAG = descLAG(u.Namespace + "_")
u.MCLAGDomain = descMCLAGDomain(u.Namespace + "_")
u.SwitchStack = descSwitchStack(u.Namespace + "_")
u.DNSPolicy = descDNSPolicy(u.Namespace + "_")
u.RADIUSProfile = descRADIUSProfile(u.Namespace + "_")
u.TrafficMatchingList = descTrafficMatchingList(u.Namespace + "_")
u.HotspotVoucher = descHotspotVoucher(u.Namespace + "_")
u.DPIApplication = descDPIApplication(u.Namespace + "_")
u.DPICategory = descDPICategory(u.Namespace + "_")
u.PendingDevice = descPendingDevice(u.Namespace + "_")
u.Country = descCountry(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).",
@ -311,7 +353,16 @@ 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, u.Topology, u.PortAnomaly, u.VPNMesh} {
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, u.PortAnomaly, u.VPNMesh,
u.IntegrationDevice, u.WANStatus,
u.PortForward, u.SSLCertificate, u.UPSDevice, u.WifiBroadcast,
u.FirewallZone, u.ACLRule, u.VPNServer, u.SiteToSiteTunnel,
u.LAG, u.MCLAGDomain, u.SwitchStack, u.DNSPolicy, u.RADIUSProfile,
u.TrafficMatchingList, u.HotspotVoucher,
u.DPIApplication, u.DPICategory, u.PendingDevice, u.Country,
} {
v := reflect.Indirect(reflect.ValueOf(f))
// Loop each struct member and send it to the provided channel.
@ -510,6 +561,133 @@ func (u *promUnifi) loopExports(r report) {
}
}
// v5.26.0 additions.
for _, ds := range m.IntegrationDevStats {
if d, ok := ds.(*unifi.IntegrationDeviceStats); ok {
u.exportIntegrationDeviceStats(r, d)
}
}
for _, ws := range m.WANStatuses {
if w, ok := ws.(*unifi.WANStatus); ok {
u.exportWANStatus(r, w)
}
}
for _, pf := range m.PortForwards {
if v, ok := pf.(*unifi.PortForward); ok {
u.exportPortForward(r, v)
}
}
for _, sc := range m.SSLCertificates {
if v, ok := sc.(*unifi.SSLCertificate); ok {
u.exportSSLCertificate(r, v)
}
}
for _, ud := range m.UPSDevices {
if v, ok := ud.(*unifi.UPSDeviceSelector); ok {
u.exportUPSDevice(r, v)
}
}
for _, wb := range m.WifiBroadcasts {
if v, ok := wb.(*unifi.WifiBroadcast); ok {
u.exportWifiBroadcast(r, v)
}
}
for _, fz := range m.FirewallZones {
if v, ok := fz.(*unifi.FirewallZone); ok {
u.exportFirewallZone(r, v)
}
}
for _, ar := range m.ACLRules {
if v, ok := ar.(*unifi.ACLRule); ok {
u.exportACLRule(r, v)
}
}
for _, vs := range m.VPNServers {
if v, ok := vs.(*unifi.VPNServer); ok {
u.exportVPNServer(r, v)
}
}
for _, st := range m.SiteToSiteTunnels {
if v, ok := st.(*unifi.SiteToSiteTunnel); ok {
u.exportSiteToSiteTunnel(r, v)
}
}
for _, l := range m.LAGs {
if v, ok := l.(*unifi.LAG); ok {
u.exportLAG(r, v)
}
}
for _, md := range m.MCLAGDomains {
if v, ok := md.(*unifi.MCLAGDomain); ok {
u.exportMCLAGDomain(r, v)
}
}
for _, ss := range m.SwitchStacks {
if v, ok := ss.(*unifi.SwitchStack); ok {
u.exportSwitchStack(r, v)
}
}
for _, dp := range m.DNSPolicies {
if v, ok := dp.(*unifi.DNSPolicy); ok {
u.exportDNSPolicy(r, v)
}
}
for _, rp := range m.RADIUSProfiles {
if v, ok := rp.(*unifi.RADIUSProfile); ok {
u.exportRADIUSProfile(r, v)
}
}
for _, tml := range m.TrafficMatchingLists {
if v, ok := tml.(*unifi.TrafficMatchingList); ok {
u.exportTrafficMatchingList(r, v)
}
}
for _, hv := range m.HotspotVouchers {
if v, ok := hv.(*unifi.HotspotVoucher); ok {
u.exportHotspotVoucher(r, v)
}
}
for _, app := range m.DPIApplications {
if v, ok := app.(*unifi.DPIApplication); ok {
u.exportDPIApplication(r, v)
}
}
for _, cat := range m.DPICategories {
if v, ok := cat.(*unifi.DPICategory); ok {
u.exportDPICategory(r, v)
}
}
for _, pd := range m.PendingDevices {
if v, ok := pd.(*unifi.PendingDevice); ok {
u.exportPendingDevice(r, v)
}
}
for _, c := range m.Countries {
if v, ok := c.(*unifi.Country); ok {
u.exportCountry(r, v)
}
}
u.exportClientDPItotals(r, appTotal, catTotal)
}

View File

@ -0,0 +1,72 @@
package promunifi
import (
"fmt"
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
type integrationDevice struct {
CPUUtilizationPct *prometheus.Desc
MemoryUtilizationPct *prometheus.Desc
LoadAverage1Min *prometheus.Desc
LoadAverage5Min *prometheus.Desc
LoadAverage15Min *prometheus.Desc
UptimeSec *prometheus.Desc
RadioTxRetriesPct *prometheus.Desc
UplinkRxRateBps *prometheus.Desc
UplinkTxRateBps *prometheus.Desc
}
func descIntegrationDevice(ns string) *integrationDevice {
labels := []string{"device_id"}
radioLabels := []string{"device_id", "frequency_ghz"}
uplinkLabels := []string{"device_id", "uplink_index"}
return &integrationDevice{
CPUUtilizationPct: prometheus.NewDesc(ns+"cpu_utilization_pct", "Device CPU utilization percentage (Integration/v1)", labels, nil),
MemoryUtilizationPct: prometheus.NewDesc(ns+"memory_utilization_pct", "Device memory utilization percentage (Integration/v1)", labels, nil),
LoadAverage1Min: prometheus.NewDesc(ns+"load_average_1min", "Device 1-minute load average (Integration/v1)", labels, nil),
LoadAverage5Min: prometheus.NewDesc(ns+"load_average_5min", "Device 5-minute load average (Integration/v1)", labels, nil),
LoadAverage15Min: prometheus.NewDesc(ns+"load_average_15min", "Device 15-minute load average (Integration/v1)", labels, nil),
UptimeSec: prometheus.NewDesc(ns+"uptime_seconds", "Device uptime in seconds (Integration/v1)", labels, nil),
RadioTxRetriesPct: prometheus.NewDesc(ns+"radio_tx_retries_pct", "Per-radio TX retry percentage (Integration/v1)", radioLabels, nil),
UplinkRxRateBps: prometheus.NewDesc(ns+"uplink_rx_rate_bps", "Per-uplink receive rate in bps (Integration/v1)", uplinkLabels, nil),
UplinkTxRateBps: prometheus.NewDesc(ns+"uplink_tx_rate_bps", "Per-uplink transmit rate in bps (Integration/v1)", uplinkLabels, nil),
}
}
func (u *promUnifi) exportIntegrationDeviceStats(r report, ds *unifi.IntegrationDeviceStats) {
if ds == nil {
return
}
labels := []string{ds.DeviceID}
r.send([]*metric{
{u.IntegrationDevice.CPUUtilizationPct, gauge, ds.CPUUtilizationPct, labels},
{u.IntegrationDevice.MemoryUtilizationPct, gauge, ds.MemoryUtilizationPct, labels},
{u.IntegrationDevice.LoadAverage1Min, gauge, ds.LoadAverage1Min, labels},
{u.IntegrationDevice.LoadAverage5Min, gauge, ds.LoadAverage5Min, labels},
{u.IntegrationDevice.LoadAverage15Min, gauge, ds.LoadAverage15Min, labels},
{u.IntegrationDevice.UptimeSec, gauge, ds.UptimeSec, labels},
})
for _, radio := range ds.Radios {
radioLabels := []string{ds.DeviceID, radio.FrequencyGHz.Txt}
r.send([]*metric{
{u.IntegrationDevice.RadioTxRetriesPct, gauge, radio.TxRetriesPct, radioLabels},
})
}
for i, uplink := range ds.Uplinks {
uplinkLabels := []string{ds.DeviceID, fmt.Sprint(i)}
r.send([]*metric{
{u.IntegrationDevice.UplinkRxRateBps, gauge, uplink.RxRateBps, uplinkLabels},
{u.IntegrationDevice.UplinkTxRateBps, gauge, uplink.TxRateBps, uplinkLabels},
})
}
}

View File

@ -0,0 +1,133 @@
package promunifi
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
// dpiApplication holds Prometheus descriptors for DPI application catalogue metrics.
// No site label — this is a global catalogue.
type dpiApplication struct {
Presence *prometheus.Desc
}
func descDPIApplication(ns string) *dpiApplication {
labels := []string{"app_id", "name"}
return &dpiApplication{
Presence: prometheus.NewDesc(ns+"dpi_application_present",
"DPI application catalogue entry present (always 1)",
labels, nil),
}
}
func (u *promUnifi) exportDPIApplication(r report, app *unifi.DPIApplication) {
if app == nil {
return
}
labels := []string{app.ID.Txt, app.Name}
r.send([]*metric{
{u.DPIApplication.Presence, gauge, 1.0, labels},
})
}
// dpiCategory holds Prometheus descriptors for DPI category catalogue metrics.
// No site label — this is a global catalogue.
type dpiCategory struct {
Presence *prometheus.Desc
}
func descDPICategory(ns string) *dpiCategory {
labels := []string{"cat_id", "name"}
return &dpiCategory{
Presence: prometheus.NewDesc(ns+"dpi_category_present",
"DPI category catalogue entry present (always 1)",
labels, nil),
}
}
func (u *promUnifi) exportDPICategory(r report, cat *unifi.DPICategory) {
if cat == nil {
return
}
labels := []string{cat.ID.Txt, cat.Name}
r.send([]*metric{
{u.DPICategory.Presence, gauge, 1.0, labels},
})
}
// pendingDevice holds Prometheus descriptors for pending-adoption device metrics.
// No site label — these are controller-global.
type pendingDevice struct {
FirmwareUpdatable *prometheus.Desc
Supported *prometheus.Desc
}
func descPendingDevice(ns string) *pendingDevice {
labels := []string{"mac_address", "model", "state", "firmware_version"}
return &pendingDevice{
FirmwareUpdatable: prometheus.NewDesc(ns+"pending_device_firmware_updatable",
"Pending device has a firmware update available (1=yes, 0=no)",
labels, nil),
Supported: prometheus.NewDesc(ns+"pending_device_supported",
"Pending device model is supported by the controller (1=yes, 0=no)",
labels, nil),
}
}
func (u *promUnifi) exportPendingDevice(r report, pd *unifi.PendingDevice) {
if pd == nil {
return
}
firmwareUpdatable := 0.0
if pd.FirmwareUpdatable {
firmwareUpdatable = 1.0
}
supported := 0.0
if pd.Supported {
supported = 1.0
}
labels := []string{pd.MACAddress, pd.Model, pd.State, pd.FirmwareVersion}
r.send([]*metric{
{u.PendingDevice.FirmwareUpdatable, gauge, firmwareUpdatable, labels},
{u.PendingDevice.Supported, gauge, supported, labels},
})
}
// country holds Prometheus descriptors for country list metrics.
// No site label — this is a global geo-filter catalogue.
type country struct {
Presence *prometheus.Desc
}
func descCountry(ns string) *country {
labels := []string{"code", "name"}
return &country{
Presence: prometheus.NewDesc(ns+"country_present",
"Country entry present in geo-filter catalogue (always 1)",
labels, nil),
}
}
func (u *promUnifi) exportCountry(r report, c *unifi.Country) {
if c == nil {
return
}
labels := []string{c.Code, c.Name}
r.send([]*metric{
{u.Country.Presence, gauge, 1.0, labels},
})
}

View File

@ -0,0 +1,375 @@
package promunifi
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
// wifiBroadcast holds Prometheus descriptors for WiFi SSID broadcast metrics.
type wifiBroadcast struct {
Enabled *prometheus.Desc
}
func descWifiBroadcast(ns string) *wifiBroadcast {
labels := []string{"site_name", "name", "network", "security_type"}
return &wifiBroadcast{
Enabled: prometheus.NewDesc(ns+"wifi_broadcast_enabled",
"WiFi SSID broadcast enabled (1=enabled, 0=disabled)",
labels, nil),
}
}
func (u *promUnifi) exportWifiBroadcast(r report, wb *unifi.WifiBroadcast) {
if wb == nil {
return
}
enabled := 0.0
if wb.Enabled {
enabled = 1.0
}
labels := []string{wb.SiteName, wb.Name, wb.Network, wb.SecurityConfiguration.Type}
r.send([]*metric{
{u.WifiBroadcast.Enabled, gauge, enabled, labels},
})
}
// firewallZone holds Prometheus descriptors for firewall zone metrics.
type firewallZone struct {
NetworkCount *prometheus.Desc
}
func descFirewallZone(ns string) *firewallZone {
labels := []string{"site_name", "name", "origin"}
return &firewallZone{
NetworkCount: prometheus.NewDesc(ns+"firewall_zone_network_count",
"Number of networks assigned to the firewall zone",
labels, nil),
}
}
func (u *promUnifi) exportFirewallZone(r report, fz *unifi.FirewallZone) {
if fz == nil {
return
}
labels := []string{fz.SiteName, fz.Name, fz.Metadata.Origin}
r.send([]*metric{
{u.FirewallZone.NetworkCount, gauge, float64(len(fz.NetworkIDs)), labels},
})
}
// aclRule holds Prometheus descriptors for ACL rule metrics.
type aclRule struct {
Enabled *prometheus.Desc
Index *prometheus.Desc
}
func descACLRule(ns string) *aclRule {
labels := []string{"site_name", "name", "action"}
return &aclRule{
Enabled: prometheus.NewDesc(ns+"acl_rule_enabled",
"ACL rule enabled (1=enabled, 0=disabled)",
labels, nil),
Index: prometheus.NewDesc(ns+"acl_rule_index",
"ACL rule evaluation order index",
labels, nil),
}
}
func (u *promUnifi) exportACLRule(r report, ar *unifi.ACLRule) {
if ar == nil {
return
}
enabled := 0.0
if ar.Enabled {
enabled = 1.0
}
labels := []string{ar.SiteName, ar.Name, ar.Action}
r.send([]*metric{
{u.ACLRule.Enabled, gauge, enabled, labels},
{u.ACLRule.Index, gauge, ar.Index, labels},
})
}
// vpnServer holds Prometheus descriptors for VPN server metrics.
type vpnServer struct {
Enabled *prometheus.Desc
}
func descVPNServer(ns string) *vpnServer {
labels := []string{"site_name", "name", "vpn_type", "origin"}
return &vpnServer{
Enabled: prometheus.NewDesc(ns+"vpn_server_enabled",
"VPN server enabled (1=enabled, 0=disabled)",
labels, nil),
}
}
func (u *promUnifi) exportVPNServer(r report, vs *unifi.VPNServer) {
if vs == nil {
return
}
enabled := 0.0
if vs.Enabled {
enabled = 1.0
}
labels := []string{vs.SiteName, vs.Name, vs.Type, vs.Metadata.Origin}
r.send([]*metric{
{u.VPNServer.Enabled, gauge, enabled, labels},
})
}
// siteToSiteTunnel holds Prometheus descriptors for site-to-site VPN tunnel metrics.
type siteToSiteTunnel struct {
Presence *prometheus.Desc
}
func descSiteToSiteTunnel(ns string) *siteToSiteTunnel {
labels := []string{"site_name", "name", "tunnel_type", "origin"}
return &siteToSiteTunnel{
Presence: prometheus.NewDesc(ns+"site_to_site_tunnel_present",
"Site-to-site VPN tunnel configured (always 1 when present)",
labels, nil),
}
}
func (u *promUnifi) exportSiteToSiteTunnel(r report, t *unifi.SiteToSiteTunnel) {
if t == nil {
return
}
labels := []string{t.SiteName, t.Name, t.Type, t.Metadata.Origin}
r.send([]*metric{
{u.SiteToSiteTunnel.Presence, gauge, 1.0, labels},
})
}
// lag holds Prometheus descriptors for Link Aggregation Group metrics.
type lag struct {
MemberCount *prometheus.Desc
}
func descLAG(ns string) *lag {
labels := []string{"site_name", "lag_id", "lag_type", "origin"}
return &lag{
MemberCount: prometheus.NewDesc(ns+"lag_member_count",
"Number of member entries in the LAG",
labels, nil),
}
}
func (u *promUnifi) exportLAG(r report, l *unifi.LAG) {
if l == nil {
return
}
labels := []string{l.SiteName, l.ID, l.Type, l.Metadata.Origin}
r.send([]*metric{
{u.LAG.MemberCount, gauge, float64(len(l.Members)), labels},
})
}
// mclagDomain holds Prometheus descriptors for MC-LAG domain metrics.
type mclagDomain struct {
LAGCount *prometheus.Desc
PeerCount *prometheus.Desc
}
func descMCLAGDomain(ns string) *mclagDomain {
labels := []string{"site_name", "name", "origin"}
return &mclagDomain{
LAGCount: prometheus.NewDesc(ns+"mclag_domain_lag_count",
"Number of LAGs in the MC-LAG domain",
labels, nil),
PeerCount: prometheus.NewDesc(ns+"mclag_domain_peer_count",
"Number of peer devices in the MC-LAG domain",
labels, nil),
}
}
func (u *promUnifi) exportMCLAGDomain(r report, d *unifi.MCLAGDomain) {
if d == nil {
return
}
labels := []string{d.SiteName, d.Name, d.Metadata.Origin}
r.send([]*metric{
{u.MCLAGDomain.LAGCount, gauge, float64(len(d.LAGs)), labels},
{u.MCLAGDomain.PeerCount, gauge, float64(len(d.Peers)), labels},
})
}
// switchStack holds Prometheus descriptors for switch stack metrics.
type switchStack struct {
MemberCount *prometheus.Desc
}
func descSwitchStack(ns string) *switchStack {
labels := []string{"site_name", "name", "origin"}
return &switchStack{
MemberCount: prometheus.NewDesc(ns+"switch_stack_member_count",
"Number of member devices in the switch stack",
labels, nil),
}
}
func (u *promUnifi) exportSwitchStack(r report, s *unifi.SwitchStack) {
if s == nil {
return
}
labels := []string{s.SiteName, s.Name, s.Metadata.Origin}
r.send([]*metric{
{u.SwitchStack.MemberCount, gauge, float64(len(s.Members)), labels},
})
}
// dnsPolicy holds Prometheus descriptors for DNS policy metrics.
type dnsPolicy struct {
Enabled *prometheus.Desc
}
func descDNSPolicy(ns string) *dnsPolicy {
labels := []string{"site_name", "domain", "policy_type"}
return &dnsPolicy{
Enabled: prometheus.NewDesc(ns+"dns_policy_enabled",
"DNS policy enabled (1=enabled, 0=disabled)",
labels, nil),
}
}
func (u *promUnifi) exportDNSPolicy(r report, dp *unifi.DNSPolicy) {
if dp == nil {
return
}
enabled := 0.0
if dp.Enabled {
enabled = 1.0
}
labels := []string{dp.SiteName, dp.Domain, dp.Type}
r.send([]*metric{
{u.DNSPolicy.Enabled, gauge, enabled, labels},
})
}
// radiusProfile holds Prometheus descriptors for RADIUS profile metrics.
type radiusProfile struct {
Presence *prometheus.Desc
}
func descRADIUSProfile(ns string) *radiusProfile {
labels := []string{"site_name", "name", "origin"}
return &radiusProfile{
Presence: prometheus.NewDesc(ns+"radius_profile_present",
"RADIUS profile configured (always 1 when present)",
labels, nil),
}
}
func (u *promUnifi) exportRADIUSProfile(r report, rp *unifi.RADIUSProfile) {
if rp == nil {
return
}
labels := []string{rp.SiteName, rp.Name, rp.Metadata.Origin}
r.send([]*metric{
{u.RADIUSProfile.Presence, gauge, 1.0, labels},
})
}
// trafficMatchingList holds Prometheus descriptors for traffic matching list metrics.
type trafficMatchingList struct {
Presence *prometheus.Desc
}
func descTrafficMatchingList(ns string) *trafficMatchingList {
labels := []string{"site_name", "name", "list_type"}
return &trafficMatchingList{
Presence: prometheus.NewDesc(ns+"traffic_matching_list_present",
"Traffic matching list configured (always 1 when present)",
labels, nil),
}
}
func (u *promUnifi) exportTrafficMatchingList(r report, tml *unifi.TrafficMatchingList) {
if tml == nil {
return
}
labels := []string{tml.SiteName, tml.Name, tml.Type}
r.send([]*metric{
{u.TrafficMatchingList.Presence, gauge, 1.0, labels},
})
}
// hotspotVoucher holds Prometheus descriptors for hotspot voucher metrics.
type hotspotVoucher struct {
AuthorizedGuestCount *prometheus.Desc
AuthorizedGuestLimit *prometheus.Desc
DataUsageLimitMBytes *prometheus.Desc
TimeLimitMinutes *prometheus.Desc
}
func descHotspotVoucher(ns string) *hotspotVoucher {
labels := []string{"site_name", "name", "code"}
return &hotspotVoucher{
AuthorizedGuestCount: prometheus.NewDesc(ns+"hotspot_voucher_authorized_guest_count",
"Number of guests currently authorized with this voucher",
labels, nil),
AuthorizedGuestLimit: prometheus.NewDesc(ns+"hotspot_voucher_authorized_guest_limit",
"Maximum number of guests allowed with this voucher (0=unlimited)",
labels, nil),
DataUsageLimitMBytes: prometheus.NewDesc(ns+"hotspot_voucher_data_usage_limit_mbytes",
"Data usage cap for the voucher in MBytes (0=no limit)",
labels, nil),
TimeLimitMinutes: prometheus.NewDesc(ns+"hotspot_voucher_time_limit_minutes",
"Time limit for the voucher in minutes (0=no limit)",
labels, nil),
}
}
func (u *promUnifi) exportHotspotVoucher(r report, hv *unifi.HotspotVoucher) {
if hv == nil {
return
}
labels := []string{hv.SiteName, hv.Name, hv.Code}
r.send([]*metric{
{u.HotspotVoucher.AuthorizedGuestCount, gauge, hv.AuthorizedGuestCount, labels},
{u.HotspotVoucher.AuthorizedGuestLimit, gauge, hv.AuthorizedGuestLimit, labels},
{u.HotspotVoucher.DataUsageLimitMBytes, gauge, hv.DataUsageLimitMBytes, labels},
{u.HotspotVoucher.TimeLimitMinutes, gauge, hv.TimeLimitMinutes, labels},
})
}

View File

@ -0,0 +1,79 @@
package promunifi
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
type portForward struct {
Enabled *prometheus.Desc
Log *prometheus.Desc
}
func descPortForward(ns string) *portForward {
labels := []string{"site_name", "name", "proto", "fwd_ip", "fwd_port", "dst_port"}
return &portForward{
Enabled: prometheus.NewDesc(ns+"port_forward_enabled",
"Port forward rule enabled (1=enabled, 0=disabled)",
labels, nil),
Log: prometheus.NewDesc(ns+"port_forward_log",
"Port forward rule logging enabled (1=on, 0=off)",
labels, nil),
}
}
func (u *promUnifi) exportPortForward(r report, pf *unifi.PortForward) {
if pf == nil {
return
}
labels := []string{pf.SiteName, pf.Name, pf.Proto, pf.FwdIP, pf.FwdPort, pf.DstPort}
r.send([]*metric{
{u.PortForward.Enabled, gauge, pf.Enabled.Val, labels},
{u.PortForward.Log, gauge, pf.Log.Val, labels},
})
}
// sslCertificate holds Prometheus descriptors for SSL certificate metrics.
type sslCertificate struct {
IsActive *prometheus.Desc
IsValid *prometheus.Desc
ValidFrom *prometheus.Desc
ValidTo *prometheus.Desc
}
func descSSLCertificate(ns string) *sslCertificate {
labels := []string{"site_name", "cert_type", "subject", "issuer", "status"}
return &sslCertificate{
IsActive: prometheus.NewDesc(ns+"ssl_cert_active",
"SSL certificate is the active certificate (1=active, 0=inactive)",
labels, nil),
IsValid: prometheus.NewDesc(ns+"ssl_cert_valid",
"SSL certificate passes validity checks (1=valid, 0=invalid)",
labels, nil),
ValidFrom: prometheus.NewDesc(ns+"ssl_cert_valid_from_seconds",
"SSL certificate validity start time (Unix epoch)",
labels, nil),
ValidTo: prometheus.NewDesc(ns+"ssl_cert_valid_to_seconds",
"SSL certificate expiry time (Unix epoch)",
labels, nil),
}
}
func (u *promUnifi) exportSSLCertificate(r report, cert *unifi.SSLCertificate) {
if cert == nil || cert.ID == "" {
return
}
labels := []string{cert.SiteName, cert.CertType, cert.Subject, cert.Issuer, cert.Status}
r.send([]*metric{
{u.SSLCertificate.IsActive, gauge, cert.IsActive.Val, labels},
{u.SSLCertificate.IsValid, gauge, cert.IsValid.Val, labels},
{u.SSLCertificate.ValidFrom, gauge, cert.ValidFrom, labels},
{u.SSLCertificate.ValidTo, gauge, cert.ValidTo, labels},
})
}

35
pkg/promunifi/ups.go Normal file
View File

@ -0,0 +1,35 @@
package promunifi
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
// upsDevice holds Prometheus descriptors for UPS device selector metrics.
// UPSDeviceSelector has no numeric fields — we emit a presence gauge so the
// device appears in Prometheus at all.
type upsDevice struct {
Presence *prometheus.Desc
}
func descUPSDevice(ns string) *upsDevice {
labels := []string{"site_name", "mac", "label"}
return &upsDevice{
Presence: prometheus.NewDesc(ns+"ups_device_present",
"UPS device detected on site (always 1 when present)",
labels, nil),
}
}
func (u *promUnifi) exportUPSDevice(r report, d *unifi.UPSDeviceSelector) {
if d == nil {
return
}
labels := []string{d.SiteName, d.MAC, d.Label}
r.send([]*metric{
{u.UPSDevice.Presence, gauge, 1.0, labels},
})
}

View File

@ -0,0 +1,42 @@
package promunifi
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
type wanStatus struct {
InterfaceState *prometheus.Desc
}
func descWANStatus(ns string) *wanStatus {
labels := []string{"site_name", "wan_interface", "wan_networkgroup"}
return &wanStatus{
InterfaceState: prometheus.NewDesc(ns+"wan_interface_state",
"WAN interface state: 1=ACTIVE, 0=other",
labels, nil),
}
}
func wanStateValue(state string) float64 {
if state == "ACTIVE" {
return 1
}
return 0
}
func (u *promUnifi) exportWANStatus(r report, ws *unifi.WANStatus) {
if ws == nil {
return
}
for _, iface := range ws.WANInterfaces {
labels := []string{ws.SiteName, iface.Name, iface.WANNetworkgroup}
r.send([]*metric{
{u.WANStatus.InterfaceState, gauge, wanStateValue(iface.State), labels},
})
}
}