Merge pull request #924 from brngates98/feat/dhcp-client-monitoring

feat: Add DHCP lease metrics export to Prometheus
This commit is contained in:
Cody Lee
2026-01-29 16:12:31 -06:00
committed by GitHub
20 changed files with 677 additions and 249 deletions

2
go.mod
View File

@@ -12,7 +12,7 @@ require (
github.com/prometheus/common v0.67.5 github.com/prometheus/common v0.67.5
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/unpoller/unifi/v5 v5.8.0 github.com/unpoller/unifi/v5 v5.10.0
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.47.0
golang.org/x/term v0.39.0 golang.org/x/term v0.39.0
golift.io/cnfg v0.2.3 golift.io/cnfg v0.2.3

4
go.sum
View File

@@ -77,8 +77,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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/unpoller/unifi/v5 v5.8.0 h1:FbP0+4eC4T4lI/sacgwG+erRVHcyujioz8w5HWtqTJw= github.com/unpoller/unifi/v5 v5.10.0 h1:GzurmJqXBYLsxMtwMzejXdOlajbsxV7FLghu0cOcXG8=
github.com/unpoller/unifi/v5 v5.8.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo= github.com/unpoller/unifi/v5 v5.10.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=

View File

@@ -4,7 +4,6 @@ package datadogunifi
import ( import (
"fmt" "fmt"
"reflect"
"time" "time"
"github.com/DataDog/datadog-go/v5/statsd" "github.com/DataDog/datadog-go/v5/statsd"
@@ -355,7 +354,9 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
case *unifi.SpeedTestResult: case *unifi.SpeedTestResult:
u.batchSpeedTest(r, v) u.batchSpeedTest(r, v)
default: default:
u.LogErrorf("invalid export, type=%+v", reflect.TypeOf(v)) if u.Collector != nil && u.Collector.Poller().LogUnknownTypes {
u.LogDebugf("unknown export type: %T", v)
}
} }
} }

View File

@@ -182,6 +182,14 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) {
u.LogDebugf("Found %d SpeedTests entries", len(m.SpeedTests)) u.LogDebugf("Found %d SpeedTests entries", len(m.SpeedTests))
} }
// Get DHCP leases with associations
if m.DHCPLeases, err = c.Unifi.GetActiveDHCPLeasesWithAssociations(sites); err != nil {
// Don't fail collection if DHCP leases fail - older controllers may not have this endpoint
u.LogDebugf("unifi.GetActiveDHCPLeasesWithAssociations(%s): %v (continuing)", c.URL, err)
} else {
u.LogDebugf("Found %d DHCPLeases entries", len(m.DHCPLeases))
}
return u.augmentMetrics(c, m), nil return u.augmentMetrics(c, m), nil
} }
@@ -367,6 +375,14 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met
m.CountryTraffic = append(m.CountryTraffic, traffic) m.CountryTraffic = append(m.CountryTraffic, traffic)
} }
for _, lease := range metrics.DHCPLeases {
// Apply site name override for DHCP leases if configured
if c.DefaultSiteNameOverride != "" && isDefaultSiteName(lease.SiteName) {
lease.SiteName = c.DefaultSiteNameOverride
}
m.DHCPLeases = append(m.DHCPLeases, lease)
}
// Apply default_site_name_override to all metrics if configured. // 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 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 // This allows us to use the console name for Cloud Gateways while keeping
@@ -462,6 +478,15 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) {
} }
} }
} }
// Apply to DHCP leases
for i := range m.DHCPLeases {
if lease, ok := m.DHCPLeases[i].(*unifi.DHCPLease); ok {
if isDefaultSiteName(lease.SiteName) {
lease.SiteName = overrideName
}
}
}
} }
// this is a helper function for augmentMetrics. // this is a helper function for augmentMetrics.

View File

@@ -86,6 +86,7 @@ type Metrics struct {
RogueAPs []*unifi.RogueAP RogueAPs []*unifi.RogueAP
SpeedTests []*unifi.SpeedTestResult SpeedTests []*unifi.SpeedTestResult
Devices *unifi.Devices Devices *unifi.Devices
DHCPLeases []*unifi.DHCPLease
} }
func init() { // nolint: gochecknoinits func init() { // nolint: gochecknoinits

View File

@@ -25,6 +25,7 @@ type Logs struct {
type Report struct { type Report struct {
Start time.Time Start time.Time
Oldest time.Time Oldest time.Time
Collect poller.Collect
poller.Logger poller.Logger
Counts map[string]int Counts map[string]int
} }
@@ -34,6 +35,7 @@ func (l *Loki) NewReport(start time.Time) *Report {
return &Report{ return &Report{
Start: start, Start: start,
Oldest: l.last, Oldest: l.last,
Collect: l.Collect,
Logger: l, Logger: l,
Counts: make(map[string]int), Counts: make(map[string]int),
} }
@@ -60,7 +62,9 @@ func (r *Report) ProcessEventLogs(events *poller.Events) *Logs {
case *unifi.ProtectLogEntry: case *unifi.ProtectLogEntry:
r.ProtectLogEvent(event, logs) r.ProtectLogEvent(event, logs)
default: // unlikely. default: // unlikely.
r.LogErrorf("unknown event type: %T", e) if r.Collect != nil && r.Collect.Poller().LogUnknownTypes {
r.LogDebugf("unknown event type: %T", e)
}
} }
} }

View File

@@ -88,6 +88,7 @@ type Metrics struct {
RogueAPs []any RogueAPs []any
SpeedTests []any SpeedTests []any
CountryTraffic []any CountryTraffic []any
DHCPLeases []any
} }
// Events defines the type for log entries. // Events defines the type for log entries.

View File

@@ -269,6 +269,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics {
existing.Clients = append(existing.Clients, m.Clients...) existing.Clients = append(existing.Clients, m.Clients...)
existing.Devices = append(existing.Devices, m.Devices...) existing.Devices = append(existing.Devices, m.Devices...)
existing.CountryTraffic = append(existing.CountryTraffic, m.CountryTraffic...) existing.CountryTraffic = append(existing.CountryTraffic, m.CountryTraffic...)
existing.DHCPLeases = append(existing.DHCPLeases, m.DHCPLeases...)
return existing return existing
} }

View File

@@ -3,7 +3,6 @@ package promunifi
import ( import (
"fmt" "fmt"
"github.com/prometheus/client_golang/prometheus/collectors"
"net" "net"
"net/http" "net/http"
"reflect" "reflect"
@@ -11,6 +10,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
promver "github.com/prometheus/common/version" promver "github.com/prometheus/common/version"
@@ -46,6 +47,7 @@ type promUnifi struct {
RogueAP *rogueap RogueAP *rogueap
SpeedTest *speedtest SpeedTest *speedtest
CountryTraffic *ucountrytraffic CountryTraffic *ucountrytraffic
DHCPLease *dhcplease
// This interface is passed to the Collect() method. The Collect method uses // This interface is passed to the Collect() method. The Collect method uses
// this interface to retrieve the latest UniFi measurements and export them. // this interface to retrieve the latest UniFi measurements and export them.
Collector poller.Collect Collector poller.Collect
@@ -205,6 +207,7 @@ func (u *promUnifi) Run(c poller.Collect) error {
u.RogueAP = descRogueAP(u.Namespace + "_rogueap_") u.RogueAP = descRogueAP(u.Namespace + "_rogueap_")
u.SpeedTest = descSpeedTest(u.Namespace + "_speedtest_") u.SpeedTest = descSpeedTest(u.Namespace + "_speedtest_")
u.CountryTraffic = descCountryTraffic(u.Namespace + "_countrytraffic_") u.CountryTraffic = descCountryTraffic(u.Namespace + "_countrytraffic_")
u.DHCPLease = descDHCPLease(u.Namespace + "_")
mux := http.NewServeMux() mux := http.NewServeMux()
promver.Version = version.Version promver.Version = version.Version
@@ -288,7 +291,7 @@ func (t *target) Describe(ch chan<- *prometheus.Desc) {
// Describe satisfies the prometheus Collector. This returns all of the // Describe satisfies the prometheus Collector. This returns all of the
// metric descriptions that this packages produces. // metric descriptions that this packages produces.
func (u *promUnifi) Describe(ch chan<- *prometheus.Desc) { 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} { for _, f := range []any{u.Client, u.Device, u.UAP, u.USG, u.USW, u.PDU, u.Site, u.SpeedTest, u.DHCPLease} {
v := reflect.Indirect(reflect.ValueOf(f)) v := reflect.Indirect(reflect.ValueOf(f))
// Loop each struct member and send it to the provided channel. // Loop each struct member and send it to the provided channel.
@@ -411,6 +414,24 @@ func (u *promUnifi) loopExports(r report) {
u.exportCountryTraffic(r, ct) u.exportCountryTraffic(r, ct)
} }
// Export network-level pool metrics first (once per network)
dhcpLeases := make([]*unifi.DHCPLease, 0, len(m.DHCPLeases))
for _, lease := range m.DHCPLeases {
if l, ok := lease.(*unifi.DHCPLease); ok {
dhcpLeases = append(dhcpLeases, l)
}
}
if len(dhcpLeases) > 0 {
u.exportDHCPNetworkPool(r, dhcpLeases)
}
// Export per-lease metrics
for _, lease := range m.DHCPLeases {
if l, ok := lease.(*unifi.DHCPLease); ok {
u.exportDHCPLease(r, l)
}
}
u.exportClientDPItotals(r, appTotal, catTotal) u.exportClientDPItotals(r, appTotal, catTotal)
} }

View File

@@ -0,0 +1,158 @@
package promunifi
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
type dhcplease struct {
// Network-level pool metrics (exported once per network)
ActiveLeases *prometheus.Desc
PoolSize *prometheus.Desc
UtilizationPercent *prometheus.Desc
FreePercent *prometheus.Desc
AvailableIPs *prometheus.Desc
// Per-lease metrics
LeaseStart *prometheus.Desc
LeaseEnd *prometheus.Desc
LeaseTime *prometheus.Desc
IsStatic *prometheus.Desc
}
func descDHCPLease(ns string) *dhcplease {
// Network-level labels (for pool metrics)
networkLabels := []string{
"network",
"network_id",
"site_name",
"source",
}
// Per-lease labels
leaseLabels := []string{
"ip",
"mac",
"hostname",
"network",
"network_id",
"client_name",
"site_name",
"source",
}
nd := prometheus.NewDesc
return &dhcplease{
ActiveLeases: nd(ns+"dhcp_active_leases", "Number of active DHCP leases for this network", networkLabels, nil),
PoolSize: nd(ns+"dhcp_pool_size", "Total number of IPs in DHCP pool range", networkLabels, nil),
UtilizationPercent: nd(ns+"dhcp_utilization_percent", "DHCP pool utilization percentage (used)", networkLabels, nil),
FreePercent: nd(ns+"dhcp_free_percent", "DHCP pool free percentage (available)", networkLabels, nil),
AvailableIPs: nd(ns+"dhcp_available_ips", "Number of available IPs in DHCP pool", networkLabels, nil),
LeaseStart: nd(ns+"dhcp_lease_start", "DHCP lease start timestamp", leaseLabels, nil),
LeaseEnd: nd(ns+"dhcp_lease_end", "DHCP lease end timestamp", leaseLabels, nil),
LeaseTime: nd(ns+"dhcp_lease_time", "DHCP lease duration in seconds", leaseLabels, nil),
IsStatic: nd(ns+"dhcp_is_static", "Whether this is a static DHCP lease (1) or dynamic (0)", leaseLabels, nil),
}
}
func (u *promUnifi) exportDHCPLease(r report, l *unifi.DHCPLease) {
// Per-lease labels
leaseLabels := []string{
l.IP,
l.Mac,
l.Hostname,
l.Network,
l.NetworkID,
l.ClientName,
l.SiteName,
l.SourceName,
}
// Convert FlexBool to float64 (1.0 for true, 0.0 for false)
isStaticVal := 0.0
if l.IsStatic.Val {
isStaticVal = 1.0
}
metrics := []*metric{
{u.DHCPLease.IsStatic, gauge, isStaticVal, leaseLabels},
}
// Add lease time metrics if available
if l.LeaseStart.Val > 0 {
metrics = append(metrics, &metric{u.DHCPLease.LeaseStart, gauge, l.LeaseStart.Val, leaseLabels})
}
if l.LeaseEnd.Val > 0 {
metrics = append(metrics, &metric{u.DHCPLease.LeaseEnd, gauge, l.LeaseEnd.Val, leaseLabels})
}
if l.LeaseTime.Val > 0 {
metrics = append(metrics, &metric{u.DHCPLease.LeaseTime, gauge, l.LeaseTime.Val, leaseLabels})
}
r.send(metrics)
}
// exportDHCPNetworkPool exports network-level DHCP pool metrics (once per network).
func (u *promUnifi) exportDHCPNetworkPool(r report, leases []*unifi.DHCPLease) {
// Group leases by network_id to export pool metrics once per network
networkMetrics := make(map[string]*networkPoolData)
for _, lease := range leases {
if lease.NetworkTableEntry == nil {
continue
}
networkID := lease.NetworkID
if networkID == "" {
continue
}
// Use the first lease for each network to get pool data
if _, exists := networkMetrics[networkID]; !exists {
poolSize := lease.GetPoolSize()
if poolSize > 0 {
networkMetrics[networkID] = &networkPoolData{
Network: lease.Network,
NetworkID: networkID,
SiteName: lease.SiteName,
SourceName: lease.SourceName,
PoolSize: poolSize,
ActiveLeases: lease.GetActiveLeaseCount(),
Utilization: lease.GetUtilizationPercentage(),
FreePercent: 100.0 - lease.GetUtilizationPercentage(),
AvailableIPs: lease.GetAvailableIPs(),
}
}
}
}
// Export metrics for each unique network
for _, data := range networkMetrics {
networkLabels := []string{
data.Network,
data.NetworkID,
data.SiteName,
data.SourceName,
}
r.send([]*metric{
{u.DHCPLease.PoolSize, gauge, float64(data.PoolSize), networkLabels},
{u.DHCPLease.ActiveLeases, gauge, float64(data.ActiveLeases), networkLabels},
{u.DHCPLease.UtilizationPercent, gauge, data.Utilization, networkLabels},
{u.DHCPLease.FreePercent, gauge, data.FreePercent, networkLabels},
{u.DHCPLease.AvailableIPs, gauge, float64(data.AvailableIPs), networkLabels},
})
}
}
type networkPoolData struct {
Network string
NetworkID string
SiteName string
SourceName string
PoolSize int
ActiveLeases int
Utilization float64
FreePercent float64
AvailableIPs int
}

View File

@@ -64,14 +64,14 @@ func descPDU(ns string) *pdu {
outlet := ns + "outlet_" outlet := ns + "outlet_"
pns := ns + "port_" pns := ns + "port_"
sfp := pns + "sfp_" sfp := pns + "sfp_"
labelS := []string{"site_name", "name", "source"} labelS := []string{"site_name", "name", "source", "tag"}
labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source"} labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "tag"}
labelF := []string{ labelF := []string{
"sfp_part", "sfp_vendor", "sfp_serial", "sfp_compliance", "sfp_part", "sfp_vendor", "sfp_serial", "sfp_compliance",
"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "tag",
} }
labelO := []string{ labelO := []string{
"outlet_description", "outlet_index", "outlet_name", "site_name", "name", "source", "outlet_description", "outlet_index", "outlet_name", "site_name", "name", "source", "tag",
} }
nd := prometheus.NewDesc nd := prometheus.NewDesc
@@ -136,8 +136,13 @@ func (u *promUnifi) exportPDU(r report, d *unifi.PDU) {
return return
} }
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName} baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID} baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
u.exportPDUstats(r, labels, d.Stat.Sw) u.exportPDUstats(r, labels, d.Stat.Sw)
u.exportPDUPrtTable(r, labels, d.PortTable) u.exportPDUPrtTable(r, labels, d.PortTable)
@@ -146,7 +151,7 @@ func (u *promUnifi) exportPDU(r report, d *unifi.PDU) {
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats) u.exportSYSstats(r, labels, d.SysStats, d.SystemStats)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta) u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta)
r.send([]*metric{ r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)}, {u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels}, {u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradeable.Val, labels}, {u.Device.Upgradeable, gauge, d.Upgradeable.Val, labels},
}) })
@@ -163,6 +168,7 @@ func (u *promUnifi) exportPDU(r report, d *unifi.PDU) {
if d.TotalMaxPower.Txt != "" { if d.TotalMaxPower.Txt != "" {
r.send([]*metric{{u.Device.TotalMaxPower, gauge, d.TotalMaxPower, labels}}) r.send([]*metric{{u.Device.TotalMaxPower, gauge, d.TotalMaxPower, labels}})
} }
})
} }
// Switch Stats. // Switch Stats.
@@ -204,7 +210,7 @@ func (u *promUnifi) exportPDUPrtTable(r report, labels []string, pt []unifi.Port
// Copy labels, and add four new ones. // Copy labels, and add four new ones.
labelP := []string{ labelP := []string{
labels[2] + " Port " + p.PortIdx.Txt, p.PortIdx.Txt, labels[2] + " Port " + p.PortIdx.Txt, p.PortIdx.Txt,
p.Name, p.Mac, p.IP, labels[1], labels[2], labels[3], p.Name, p.Mac, p.IP, labels[1], labels[2], labels[3], labels[4],
} }
if p.PoeEnable.Val && p.PortPoe.Val { if p.PoeEnable.Val && p.PortPoe.Val {
@@ -218,7 +224,7 @@ func (u *promUnifi) exportPDUPrtTable(r report, labels []string, pt []unifi.Port
if p.SFPFound.Val { if p.SFPFound.Val {
labelF := []string{ labelF := []string{
p.SFPPart, p.SFPVendor, p.SFPSerial, p.SFPCompliance, p.SFPPart, p.SFPVendor, p.SFPSerial, p.SFPCompliance,
labelP[0], labelP[1], labelP[2], labelP[3], labelP[4], labelP[5], labelP[6], labelP[7], labelP[0], labelP[1], labelP[2], labelP[3], labelP[4], labelP[5], labelP[6], labelP[7], labelP[8],
} }
r.send([]*metric{ r.send([]*metric{
@@ -258,7 +264,7 @@ func (u *promUnifi) exportPDUOutletTable(r report, labels []string, ot []unifi.O
// Copy labels, and add four new ones. // Copy labels, and add four new ones.
labelOutlet := []string{ labelOutlet := []string{
labels[2] + " Outlet " + o.Index.Txt, o.Index.Txt, labels[2] + " Outlet " + o.Index.Txt, o.Index.Txt,
o.Name, labels[1], labels[2], labels[3], o.Name, labels[1], labels[2], labels[3], labels[4],
} }
r.send([]*metric{ r.send([]*metric{
@@ -277,7 +283,7 @@ func (u *promUnifi) exportPDUOutletTable(r report, labels []string, ot []unifi.O
// Copy labels, and add four new ones. // Copy labels, and add four new ones.
labelOutlet := []string{ labelOutlet := []string{
labels[2] + " Outlet Override " + o.Index.Txt, o.Index.Txt, labels[2] + " Outlet Override " + o.Index.Txt, o.Index.Txt,
o.Name, labels[1], labels[2], labels[3], o.Name, labels[1], labels[2], labels[3], labels[4],
} }
r.send([]*metric{ r.send([]*metric{

View File

@@ -111,9 +111,9 @@ func descRogueAP(ns string) *rogueap {
} }
func descUAP(ns string) *uap { // nolint: funlen func descUAP(ns string) *uap { // nolint: funlen
labelA := []string{"stat", "site_name", "name", "source"} // stat + labels[1:] labelA := []string{"stat", "site_name", "name", "source", "tag"} // stat + labels[1:]
labelV := []string{"vap_name", "bssid", "radio", "radio_name", "essid", "usage", "site_name", "name", "source"} labelV := []string{"vap_name", "bssid", "radio", "radio_name", "essid", "usage", "site_name", "name", "source", "tag"}
labelR := []string{"radio_name", "radio", "site_name", "name", "source"} labelR := []string{"radio_name", "radio", "site_name", "name", "source", "tag"}
nd := prometheus.NewDesc nd := prometheus.NewDesc
return &uap{ return &uap{
@@ -219,8 +219,14 @@ func (u *promUnifi) exportUAP(r report, d *unifi.UAP) {
return return
} }
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName} baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID} baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
u.exportUAPstats(r, labels, d.Stat.Ap, d.BytesD, d.TxBytesD, d.RxBytesD, d.BytesR) u.exportUAPstats(r, labels, d.Stat.Ap, d.BytesD, d.TxBytesD, d.RxBytesD, d.BytesR)
u.exportVAPtable(r, labels, d.VapTable) u.exportVAPtable(r, labels, d.VapTable)
u.exportPRTtable(r, labels, d.PortTable) u.exportPRTtable(r, labels, d.PortTable)
@@ -229,10 +235,11 @@ func (u *promUnifi) exportUAP(r report, d *unifi.UAP) {
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta) u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta)
u.exportRADtable(r, labels, d.RadioTable, d.RadioTableStats) u.exportRADtable(r, labels, d.RadioTable, d.RadioTableStats)
r.send([]*metric{ r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)}, {u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels}, {u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradable.Val, labels}, {u.Device.Upgradeable, gauge, d.Upgradable.Val, labels},
}) })
})
} }
// udm doesn't have these stats exposed yet, so pass 2 or 6 metrics. // udm doesn't have these stats exposed yet, so pass 2 or 6 metrics.
@@ -241,8 +248,8 @@ func (u *promUnifi) exportUAPstats(r report, labels []string, ap *unifi.Ap, byte
return return
} }
labelU := []string{"user", labels[1], labels[2], labels[3]} labelU := []string{"user", labels[1], labels[2], labels[3], labels[4]}
labelG := []string{"guest", labels[1], labels[2], labels[3]} labelG := []string{"guest", labels[1], labels[2], labels[3], labels[4]}
r.send([]*metric{ r.send([]*metric{
// ap only stuff. // ap only stuff.
{u.Device.BytesD, counter, bytes[0], labels}, // not sure if these 3 Ds are counters or gauges. {u.Device.BytesD, counter, bytes[0], labels}, // not sure if these 3 Ds are counters or gauges.
@@ -290,7 +297,7 @@ func (u *promUnifi) exportVAPtable(r report, labels []string, vt unifi.VapTable)
continue continue
} }
labelV := []string{v.Name, v.Bssid, v.Radio, v.RadioName, v.Essid, v.Usage, labels[1], labels[2], labels[3]} labelV := []string{v.Name, v.Bssid, v.Radio, v.RadioName, v.Essid, v.Usage, labels[1], labels[2], labels[3], labels[4]}
r.send([]*metric{ r.send([]*metric{
{u.UAP.VAPCcq, gauge, float64(v.Ccq) / 1000.0, labelV}, {u.UAP.VAPCcq, gauge, float64(v.Ccq) / 1000.0, labelV},
{u.UAP.VAPMacFilterRejections, counter, v.MacFilterRejections, labelV}, {u.UAP.VAPMacFilterRejections, counter, v.MacFilterRejections, labelV},
@@ -337,7 +344,7 @@ func (u *promUnifi) exportVAPtable(r report, labels []string, vt unifi.VapTable)
func (u *promUnifi) exportRADtable(r report, labels []string, rt unifi.RadioTable, rts unifi.RadioTableStats) { func (u *promUnifi) exportRADtable(r report, labels []string, rt unifi.RadioTable, rts unifi.RadioTableStats) {
// radio table // radio table
for _, p := range rt { for _, p := range rt {
labelR := []string{p.Name, p.Radio, labels[1], labels[2], labels[3]} labelR := []string{p.Name, p.Radio, labels[1], labels[2], labels[3], labels[4]}
labelRUser := append(labelR, "user") labelRUser := append(labelR, "user")
labelRGuest := append(labelR, "guest") labelRGuest := append(labelR, "guest")

View File

@@ -13,8 +13,13 @@ func (u *promUnifi) exportUBB(r report, d *unifi.UBB) {
return return
} }
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName} baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID} baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
// Export UBB-specific stats if available // Export UBB-specific stats if available
u.exportUBBstats(r, labels, d.Stat) u.exportUBBstats(r, labels, d.Stat)
@@ -34,7 +39,7 @@ func (u *promUnifi) exportUBB(r report, d *unifi.UBB) {
// Device info, uptime, and temperature // Device info, uptime, and temperature
r.send([]*metric{ r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)}, {u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels}, {u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Temperature, gauge, d.GeneralTemperature.Val, append(labels, d.Name, "general")}, {u.Device.Temperature, gauge, d.GeneralTemperature.Val, append(labels, d.Name, "general")},
}) })
@@ -50,6 +55,7 @@ func (u *promUnifi) exportUBB(r report, d *unifi.UBB) {
{u.Device.Counter, gauge, d.LinkQualityCurrent.Val, append(labels, "link_quality_current")}, {u.Device.Counter, gauge, d.LinkQualityCurrent.Val, append(labels, "link_quality_current")},
{u.Device.Counter, gauge, d.LinkCapacity.Val, append(labels, "link_capacity")}, {u.Device.Counter, gauge, d.LinkCapacity.Val, append(labels, "link_capacity")},
}) })
})
} }
// exportUBBstats exports UBB-specific stats from the Bb structure. // exportUBBstats exports UBB-specific stats from the Bb structure.
@@ -62,6 +68,7 @@ func (u *promUnifi) exportUBBstats(r report, labels []string, stat *unifi.UBBSta
bb := stat.Bb bb := stat.Bb
// Export aggregated stats (total across both radios) // Export aggregated stats (total across both radios)
// labels is [type, site_name, name, source, tag], so labels[1:] = [site_name, name, source, tag]
labelTotal := append([]string{"total"}, labels[1:]...) labelTotal := append([]string{"total"}, labels[1:]...)
r.send([]*metric{ r.send([]*metric{
{u.UAP.ApRxPackets, counter, bb.RxPackets, labelTotal}, {u.UAP.ApRxPackets, counter, bb.RxPackets, labelTotal},

View File

@@ -15,8 +15,14 @@ func (u *promUnifi) exportUCI(r report, d *unifi.UCI) {
sw = d.Stat.Sw sw = d.Stat.Sw
} }
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName} baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID} baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
// Shared data (all devices do this). // Shared data (all devices do this).
u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes) u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes)
@@ -28,7 +34,8 @@ func (u *promUnifi) exportUCI(r report, d *unifi.UCI) {
u.exportUSWstats(r, labels, sw) u.exportUSWstats(r, labels, sw)
// Dream Machine System Data. // Dream Machine System Data.
r.send([]*metric{ r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)}, {u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels}, {u.Device.Uptime, gauge, d.Uptime, labels},
}) })
})
} }

View File

@@ -36,36 +36,36 @@ type unifiDevice struct {
func descDevice(ns string) *unifiDevice { func descDevice(ns string) *unifiDevice {
labels := []string{"type", "site_name", "name", "source"} labels := []string{"type", "site_name", "name", "source"}
infoLabels := []string{"version", "model", "serial", "mac", "ip", "id"} infoLabels := []string{"version", "model", "serial", "mac", "ip", "id", "tag"}
return &unifiDevice{ return &unifiDevice{
Info: prometheus.NewDesc(ns+"info", "Device Information", append(labels, infoLabels...), nil), Info: prometheus.NewDesc(ns+"info", "Device Information", append(labels, infoLabels...), nil),
Uptime: prometheus.NewDesc(ns+"uptime_seconds", "Device Uptime", labels, nil), Uptime: prometheus.NewDesc(ns+"uptime_seconds", "Device Uptime", append(labels, "tag"), nil),
Temperature: prometheus.NewDesc(ns+"temperature_celsius", "Temperature", Temperature: prometheus.NewDesc(ns+"temperature_celsius", "Temperature",
append(labels, "temp_area", "temp_type"), nil), append(labels, "temp_area", "temp_type", "tag"), nil),
Storage: prometheus.NewDesc(ns+"storage", "Storage", Storage: prometheus.NewDesc(ns+"storage", "Storage",
append(labels, "mountpoint", "storage_name", "storage_reading"), nil), append(labels, "mountpoint", "storage_name", "storage_reading", "tag"), nil),
TotalMaxPower: prometheus.NewDesc(ns+"max_power_total", "Total Max Power", labels, nil), TotalMaxPower: prometheus.NewDesc(ns+"max_power_total", "Total Max Power", append(labels, "tag"), nil),
OutletACPowerConsumption: prometheus.NewDesc(ns+"outlet_ac_power_consumption", "Outlet AC Power Consumption", labels, nil), OutletACPowerConsumption: prometheus.NewDesc(ns+"outlet_ac_power_consumption", "Outlet AC Power Consumption", append(labels, "tag"), nil),
PowerSource: prometheus.NewDesc(ns+"power_source", "Power Source", labels, nil), PowerSource: prometheus.NewDesc(ns+"power_source", "Power Source", append(labels, "tag"), nil),
FanLevel: prometheus.NewDesc(ns+"fan_level", "Fan Level", labels, nil), FanLevel: prometheus.NewDesc(ns+"fan_level", "Fan Level", append(labels, "tag"), nil),
TotalTxBytes: prometheus.NewDesc(ns+"transmit_bytes_total", "Total Transmitted Bytes", labels, nil), TotalTxBytes: prometheus.NewDesc(ns+"transmit_bytes_total", "Total Transmitted Bytes", append(labels, "tag"), nil),
TotalRxBytes: prometheus.NewDesc(ns+"receive_bytes_total", "Total Received Bytes", labels, nil), TotalRxBytes: prometheus.NewDesc(ns+"receive_bytes_total", "Total Received Bytes", append(labels, "tag"), nil),
TotalBytes: prometheus.NewDesc(ns+"bytes_total", "Total Bytes Transferred", labels, nil), TotalBytes: prometheus.NewDesc(ns+"bytes_total", "Total Bytes Transferred", append(labels, "tag"), nil),
BytesR: prometheus.NewDesc(ns+"rate_bytes", "Transfer Rate", labels, nil), BytesR: prometheus.NewDesc(ns+"rate_bytes", "Transfer Rate", append(labels, "tag"), nil),
BytesD: prometheus.NewDesc(ns+"d_bytes", "Total Bytes D???", labels, nil), BytesD: prometheus.NewDesc(ns+"d_bytes", "Total Bytes D???", append(labels, "tag"), nil),
TxBytesD: prometheus.NewDesc(ns+"d_tranmsit_bytes", "Transmit Bytes D???", labels, nil), TxBytesD: prometheus.NewDesc(ns+"d_tranmsit_bytes", "Transmit Bytes D???", append(labels, "tag"), nil),
RxBytesD: prometheus.NewDesc(ns+"d_receive_bytes", "Receive Bytes D???", labels, nil), RxBytesD: prometheus.NewDesc(ns+"d_receive_bytes", "Receive Bytes D???", append(labels, "tag"), nil),
Counter: prometheus.NewDesc(ns+"stations", "Number of Stations", append(labels, "station_type"), nil), Counter: prometheus.NewDesc(ns+"stations", "Number of Stations", append(labels, "station_type", "tag"), nil),
Loadavg1: prometheus.NewDesc(ns+"load_average_1", "System Load Average 1 Minute", labels, nil), Loadavg1: prometheus.NewDesc(ns+"load_average_1", "System Load Average 1 Minute", append(labels, "tag"), nil),
Loadavg5: prometheus.NewDesc(ns+"load_average_5", "System Load Average 5 Minutes", labels, nil), Loadavg5: prometheus.NewDesc(ns+"load_average_5", "System Load Average 5 Minutes", append(labels, "tag"), nil),
Loadavg15: prometheus.NewDesc(ns+"load_average_15", "System Load Average 15 Minutes", labels, nil), Loadavg15: prometheus.NewDesc(ns+"load_average_15", "System Load Average 15 Minutes", append(labels, "tag"), nil),
MemUsed: prometheus.NewDesc(ns+"memory_used_bytes", "System Memory Used", labels, nil), MemUsed: prometheus.NewDesc(ns+"memory_used_bytes", "System Memory Used", append(labels, "tag"), nil),
MemTotal: prometheus.NewDesc(ns+"memory_installed_bytes", "System Installed Memory", labels, nil), MemTotal: prometheus.NewDesc(ns+"memory_installed_bytes", "System Installed Memory", append(labels, "tag"), nil),
MemBuffer: prometheus.NewDesc(ns+"memory_buffer_bytes", "System Memory Buffer", labels, nil), MemBuffer: prometheus.NewDesc(ns+"memory_buffer_bytes", "System Memory Buffer", append(labels, "tag"), nil),
CPU: prometheus.NewDesc(ns+"cpu_utilization_ratio", "System CPU % Utilized", labels, nil), CPU: prometheus.NewDesc(ns+"cpu_utilization_ratio", "System CPU % Utilized", append(labels, "tag"), nil),
Mem: prometheus.NewDesc(ns+"memory_utilization_ratio", "System Memory % Utilized", labels, nil), Mem: prometheus.NewDesc(ns+"memory_utilization_ratio", "System Memory % Utilized", append(labels, "tag"), nil),
Upgradeable: prometheus.NewDesc(ns+"upgradable", "Upgrade-able", labels, nil), Upgradeable: prometheus.NewDesc(ns+"upgradable", "Upgrade-able", append(labels, "tag"), nil),
} }
} }
@@ -75,43 +75,71 @@ func (u *promUnifi) exportUDM(r report, d *unifi.UDM) {
return return
} }
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName} baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID} baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
// Export metrics with tags - create separate series for each tag
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := baseLabels
infoLabels := append(baseInfoLabels, tag)
// Shared data (all devices do this). // Shared data (all devices do this).
u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes) u.exportBYTstats(r, append(labels, tag), d.TxBytes, d.RxBytes)
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats) u.exportSYSstats(r, append(labels, tag), d.SysStats, d.SystemStats)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.NumMobile, d.NumHandheld) u.exportSTAcount(r, append(labels, tag), d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.NumMobile, d.NumHandheld)
// Switch Data // Switch Data
u.exportUSWstats(r, labels, d.Stat.Sw) u.exportUSWstats(r, append(labels, tag), d.Stat.Sw)
u.exportPRTtable(r, labels, d.PortTable) u.exportPRTtable(r, append(labels, tag), d.PortTable)
// Gateway Data // Gateway Data
u.exportWANPorts(r, labels, d.Wan1, d.Wan2) u.exportWANPorts(r, append(labels, tag), d.Wan1, d.Wan2)
u.exportUSGstats(r, labels, d.Stat.Gw, d.SpeedtestStatus, d.Uplink) u.exportUSGstats(r, append(labels, tag), d.Stat.Gw, d.SpeedtestStatus, d.Uplink)
// Dream Machine System Data. // Dream Machine System Data.
r.send([]*metric{ r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)}, {u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels}, {u.Device.Uptime, gauge, d.Uptime, append(labels, tag)},
{u.Device.Upgradeable, gauge, d.Upgradeable.Val, labels}, {u.Device.Upgradeable, gauge, d.Upgradeable.Val, append(labels, tag)},
}) })
// UDM pro has special temp sensors. UDM non-pro may not have temp; not sure. // UDM pro has special temp sensors. UDM non-pro may not have temp; not sure.
for _, t := range d.Temperatures { for _, t := range d.Temperatures {
r.send([]*metric{{u.Device.Temperature, gauge, t.Value, append(labels, t.Name, t.Type)}}) r.send([]*metric{{u.Device.Temperature, gauge, t.Value, append(labels, t.Name, t.Type, tag)}})
} }
// UDM pro and UXG have hard drives. // UDM pro and UXG have hard drives.
for _, t := range d.Storage { for _, t := range d.Storage {
r.send([]*metric{ r.send([]*metric{
{u.Device.Storage, gauge, t.Size.Val, append(labels, t.MountPoint, t.Name, "size")}, {u.Device.Storage, gauge, t.Size.Val, append(labels, t.MountPoint, t.Name, "size", tag)},
{u.Device.Storage, gauge, t.Used.Val, append(labels, t.MountPoint, t.Name, "used")}, {u.Device.Storage, gauge, t.Used.Val, append(labels, t.MountPoint, t.Name, "used", tag)},
}) })
} }
})
// Wireless Data - UDM (non-pro) only // Wireless Data - UDM (non-pro) only
if d.Stat.Ap != nil && d.VapTable != nil { if d.Stat.Ap != nil && d.VapTable != nil {
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
u.exportUAPstats(r, labels, d.Stat.Ap, d.BytesD, d.TxBytesD, d.RxBytesD, d.BytesR) u.exportUAPstats(r, labels, d.Stat.Ap, d.BytesD, d.TxBytesD, d.RxBytesD, d.BytesR)
u.exportVAPtable(r, labels, *d.VapTable) u.exportVAPtable(r, labels, *d.VapTable)
u.exportRADtable(r, labels, *d.RadioTable, *d.RadioTableStats) u.exportRADtable(r, labels, *d.RadioTable, *d.RadioTableStats)
})
}
}
// exportWithTags exports metrics with tag support. If device has multiple tags,
// each tag creates a separate metric series. If no tags, exports with tag="".
func (u *promUnifi) exportWithTags(_ report, tags []string, fn func([]string)) {
if len(tags) == 0 {
// No tags - export once with empty tag
fn([]string{""})
return
}
// Multiple tags - export once per tag
for _, tag := range tags {
fn([]string{tag})
} }
} }

View File

@@ -41,7 +41,7 @@ type usg struct {
} }
func descUSG(ns string) *usg { func descUSG(ns string) *usg {
labels := []string{"port", "site_name", "name", "source"} labels := []string{"port", "site_name", "name", "source", "tag"}
return &usg{ return &usg{
WanRxPackets: prometheus.NewDesc(ns+"wan_receive_packets_total", "WAN Receive Packets Total", labels, nil), WanRxPackets: prometheus.NewDesc(ns+"wan_receive_packets_total", "WAN Receive Packets Total", labels, nil),
@@ -82,8 +82,13 @@ func (u *promUnifi) exportUSG(r report, d *unifi.USG) {
return return
} }
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName} baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID} baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
for _, t := range d.Temperatures { for _, t := range d.Temperatures {
r.send([]*metric{{u.Device.Temperature, gauge, t.Value, append(labels, t.Name, t.Type)}}) r.send([]*metric{{u.Device.Temperature, gauge, t.Value, append(labels, t.Name, t.Type)}})
@@ -105,10 +110,11 @@ func (u *promUnifi) exportUSG(r report, d *unifi.USG) {
u.exportUSGstats(r, labels, d.Stat.Gw, d.SpeedtestStatus, d.Uplink) u.exportUSGstats(r, labels, d.Stat.Gw, d.SpeedtestStatus, d.Uplink)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.UserNumSta, d.GuestNumSta) u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.UserNumSta, d.GuestNumSta)
r.send([]*metric{ r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)}, {u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels}, {u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradable.Val, labels}, {u.Device.Upgradeable, gauge, d.Upgradable.Val, labels},
}) })
})
} }
// Gateway Stats. // Gateway Stats.
@@ -125,8 +131,8 @@ func (u *promUnifi) exportUSGstats(r report, labels []string, gw *unifi.Gw, st u
return return
} }
labelLan := []string{"lan", labels[1], labels[2], labels[3]} labelLan := []string{"lan", labels[1], labels[2], labels[3], labels[4]}
labelWan := []string{sourceInterface, labels[1], labels[2], labels[3]} labelWan := []string{sourceInterface, labels[1], labels[2], labels[3], labels[4]}
r.send([]*metric{ r.send([]*metric{
{u.USG.LanRxPackets, counter, gw.LanRxPackets, labelLan}, {u.USG.LanRxPackets, counter, gw.LanRxPackets, labelLan},
@@ -154,7 +160,7 @@ func (u *promUnifi) exportWANPorts(r report, labels []string, wans ...unifi.Wan)
continue // only record UP interfaces. continue // only record UP interfaces.
} }
labelWan := []string{wan.Name, labels[1], labels[2], labels[3]} labelWan := []string{wan.Name, labels[1], labels[2], labels[3], labels[4]}
r.send([]*metric{ r.send([]*metric{
{u.USG.WanRxPackets, counter, wan.RxPackets, labelWan}, {u.USG.WanRxPackets, counter, wan.RxPackets, labelWan},

View File

@@ -55,11 +55,11 @@ type usw struct {
func descUSW(ns string) *usw { func descUSW(ns string) *usw {
pns := ns + "port_" pns := ns + "port_"
sfp := pns + "sfp_" sfp := pns + "sfp_"
labelS := []string{"site_name", "name", "source"} labelS := []string{"site_name", "name", "source", "tag"}
labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source"} labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "tag"}
labelF := []string{ labelF := []string{
"sfp_part", "sfp_vendor", "sfp_serial", "sfp_compliance", "sfp_part", "sfp_vendor", "sfp_serial", "sfp_compliance",
"port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "port_id", "port_num", "port_name", "port_mac", "port_ip", "site_name", "name", "source", "tag",
} }
nd := prometheus.NewDesc nd := prometheus.NewDesc
@@ -116,8 +116,13 @@ func (u *promUnifi) exportUSW(r report, d *unifi.USW) {
return return
} }
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName} baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID} baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
u.exportUSWstats(r, labels, d.Stat.Sw) u.exportUSWstats(r, labels, d.Stat.Sw)
u.exportPRTtable(r, labels, d.PortTable) u.exportPRTtable(r, labels, d.PortTable)
@@ -125,7 +130,7 @@ func (u *promUnifi) exportUSW(r report, d *unifi.USW) {
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats) u.exportSYSstats(r, labels, d.SysStats, d.SystemStats)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta) u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta)
r.send([]*metric{ r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)}, {u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels}, {u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradable.Val, labels}, {u.Device.Upgradeable, gauge, d.Upgradable.Val, labels},
}) })
@@ -142,6 +147,7 @@ func (u *promUnifi) exportUSW(r report, d *unifi.USW) {
if d.TotalMaxPower.Txt != "" { if d.TotalMaxPower.Txt != "" {
r.send([]*metric{{u.Device.TotalMaxPower, gauge, d.TotalMaxPower, labels}}) r.send([]*metric{{u.Device.TotalMaxPower, gauge, d.TotalMaxPower, labels}})
} }
})
} }
// Switch Stats. // Switch Stats.
@@ -183,7 +189,7 @@ func (u *promUnifi) exportPRTtable(r report, labels []string, pt []unifi.Port) {
// Copy labels, and add four new ones. // Copy labels, and add four new ones.
labelP := []string{ labelP := []string{
labels[2] + " Port " + p.PortIdx.Txt, p.PortIdx.Txt, labels[2] + " Port " + p.PortIdx.Txt, p.PortIdx.Txt,
p.Name, p.Mac, p.IP, labels[1], labels[2], labels[3], p.Name, p.Mac, p.IP, labels[1], labels[2], labels[3], labels[4],
} }
if p.PoeEnable.Val && p.PortPoe.Val { if p.PoeEnable.Val && p.PortPoe.Val {
@@ -197,7 +203,7 @@ func (u *promUnifi) exportPRTtable(r report, labels []string, pt []unifi.Port) {
if p.SFPFound.Val { if p.SFPFound.Val {
labelF := []string{ labelF := []string{
p.SFPPart, p.SFPVendor, p.SFPSerial, p.SFPCompliance, p.SFPPart, p.SFPVendor, p.SFPSerial, p.SFPCompliance,
labelP[0], labelP[1], labelP[2], labelP[3], labelP[4], labelP[5], labelP[6], labelP[7], labelP[0], labelP[1], labelP[2], labelP[3], labelP[4], labelP[5], labelP[6], labelP[7], labelP[8],
} }
r.send([]*metric{ r.send([]*metric{

View File

@@ -20,8 +20,14 @@ func (u *promUnifi) exportUXG(r report, d *unifi.UXG) {
sw = d.Stat.Sw sw = d.Stat.Sw
} }
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName} baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID} baseInfoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
u.exportWithTags(r, d.Tags, func(tagLabels []string) {
tag := tagLabels[0]
labels := append(baseLabels, tag)
infoLabels := append(baseInfoLabels, tag)
// Shared data (all devices do this). // Shared data (all devices do this).
u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes) u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes)
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats) u.exportSYSstats(r, labels, d.SysStats, d.SystemStats)
@@ -34,7 +40,7 @@ func (u *promUnifi) exportUXG(r report, d *unifi.UXG) {
u.exportUSGstats(r, labels, gw, d.SpeedtestStatus, d.Uplink) u.exportUSGstats(r, labels, gw, d.SpeedtestStatus, d.Uplink)
// Dream Machine System Data. // Dream Machine System Data.
r.send([]*metric{ r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)}, {u.Device.Info, gauge, 1.0, append(baseLabels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels}, {u.Device.Uptime, gauge, d.Uptime, labels},
}) })
@@ -49,4 +55,5 @@ func (u *promUnifi) exportUXG(r report, d *unifi.UXG) {
{u.Device.Storage, gauge, t.Used.Val, append(labels, t.MountPoint, t.Name, "used")}, {u.Device.Storage, gauge, t.Used.Val, append(labels, t.MountPoint, t.Name, "used")},
}) })
} }
})
} }

View File

@@ -0,0 +1,42 @@
# Saving UniFi API Output
Ways to save API responses and explorer output for discovery or debugging.
## Single endpoint → file
Redirect `unpoller -j "other <path>"` to a file:
```bash
unpoller -c up.conf -j "other /api/s/default/stat/device" > device.json
unpoller -c up.conf -j "other /api/s/default/stat/sta" > clients.json
```
Use `jq` to inspect: `jq . device.json`
## Bulk dump → directory
Use the dump script to request many known endpoints and save each to a JSON file:
```bash
./scripts/dump_unifi_api.sh -c up.conf -s default -o ./api_dump
```
Output goes to `./api_dump` by default. See `./scripts/dump_unifi_api.sh -h` for options.
Note: some endpoints (e.g. `sitedpi`, `stadpi`) require POST with a body; the script only issues GETs, so those may fail or return errors. You can still inspect the saved responses.
## Saving the API explorer UI
If you're using the developer UI (e.g. [developer.ui.com](https://developer.ui.com) or another API explorer) and want to save the **list of endpoints and their details**:
1. **OpenAPI / Swagger spec**
Open DevTools → **Network**, (re)load the explorer, and look for requests to `openapi.json`, `swagger.json`, or similar. Rightclick the response → **Copy****Save as**, or use **Save all as HAR** and extract the spec from the HAR.
2. **Save page**
Use **File → Save As** (HTML) or **Print → Save as PDF** to capture the visible explorer structure. This wont persist dynamically loaded data unless the page embeds it.
3. **Export**
If the explorer has an **Export** or **Download** button (e.g. for OpenAPI YAML/JSON), use that to save the full spec.
4. **Community specs**
Community OpenAPI specs for the UniFi API exist (e.g. [ubiquiti-community/unifi-api](https://github.com/ubiquiti-community/unifi-api), [ringods/unifi-api-spec](https://github.com/ringods/unifi-api-spec)). Clone or download those repos to get machinereadable API definitions.

100
scripts/dump_unifi_api.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
#
# Dump raw JSON from UniFi Controller API endpoints to files.
# Uses unpoller -j "other <path>" for each path and saves to OUTDIR.
#
# Prerequisites: unpoller on PATH, valid config with controller auth.
#
# Usage:
# ./scripts/dump_unifi_api.sh [-c CONFIG] [-s SITE] [-o OUTDIR]
#
# Options:
# -c CONFIG Config file (default: unpoller default locations)
# -s SITE Site name for /api/s/<site>/... paths (default: default)
# -o OUTDIR Output directory (default: ./api_dump)
#
# Examples:
# ./scripts/dump_unifi_api.sh -c up.conf -o ./my_dump
# SITE=my-site ./scripts/dump_unifi_api.sh -o ./api_dump
#
set -euo pipefail
CONFIG=""
SITE="${SITE:-default}"
OUTDIR="${OUTDIR:-./api_dump}"
while getopts "c:s:o:h" opt; do
case "$opt" in
c) CONFIG="$OPTARG" ;;
s) SITE="$OPTARG" ;;
o) OUTDIR="$OPTARG" ;;
h) grep -E '^# (Usage|Options|Examples)' "$0" | sed 's/^# //'; exit 0 ;;
*) exit 1 ;;
esac
done
# Paths that need site substitution use %s
PATHS=(
"/api/stat/sites"
"/api/s/%s/stat/device"
"/api/s/%s/stat/sta"
"/api/s/%s/stat/event"
"/api/s/%s/stat/rogueap"
"/api/s/%s/stat/sitedpi"
"/api/s/%s/stat/stadpi"
"/api/s/%s/stat/alluser"
"/api/s/%s/rest/networkconf"
"/api/s/%s/list/alarm"
"/api/s/%s/stat/ips/event"
"/api/s/%s/stat/anomalies"
"/api/s/%s/stat/admins"
"/api/s/%s/stat/session"
"/api/s/%s/stat/dashboard"
"/api/s/%s/stat/health"
"/v2/api/site/%s/aggregated-dashboard?historySeconds=3600"
)
# Optional: add traffic endpoints with fixed time window (last hour)
NOW_MS=$(($(date +%s) * 1000))
START_MS=$((NOW_MS - 3600000))
PATHS+=(
"/v2/api/site/%s/traffic?start=${START_MS}&end=${NOW_MS}&includeUnidentified=false"
"/v2/api/site/%s/country-traffic?start=${START_MS}&end=${NOW_MS}"
)
UNPOLLER="${UNPOLLER:-unpoller}"
if ! command -v "$UNPOLLER" &>/dev/null; then
echo "error: $UNPOLLER not found (set UNPOLLER to path of unpoller binary)" >&2
exit 1
fi
mkdir -p "$OUTDIR"
CONF_ARGS=()
if [[ -n "$CONFIG" ]]; then
CONF_ARGS=(-c "$CONFIG")
fi
dump_one() {
local path="$1"
local sub
sub=$(echo "$path" | sed "s|%s|$SITE|g")
local fname
fname=$(echo "$sub" | sed 's|^/||; s|[/?=&]|_|g')
[[ -z "$fname" ]] && fname="root"
fname="${fname}.json"
local out="$OUTDIR/$fname"
if out_err=$("$UNPOLLER" "${CONF_ARGS[@]}" -j "other $sub" 2>&1); then
echo "$out_err" > "$out"
echo "ok $sub -> $out"
else
echo "fail $sub ($out_err)" >&2
fi
}
echo "Dumping UniFi API responses to $OUTDIR (site=$SITE)"
for p in "${PATHS[@]}"; do
dump_one "$p"
done
echo "Done. Output in $OUTDIR"