diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index adc3a6e3..a86e827d 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -182,6 +182,14 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) { 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 } @@ -295,12 +303,12 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met client.ApName = devices[client.ApMac] client.GwName = devices[client.GwMac] client.RadioDescription = bssdIDs[client.Bssid] + client.RadioProto - + // Apply site name override for clients if configured if c.DefaultSiteNameOverride != "" && isDefaultSiteName(client.SiteName) { client.SiteName = c.DefaultSiteNameOverride } - + m.Clients = append(m.Clients, client) } @@ -313,12 +321,12 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met client.Name = RedactNamePII(client.Name, c.HashPII, c.DropPII) client.MAC = RedactMacPII(client.MAC, c.HashPII, c.DropPII) - + // Apply site name override for DPI clients if configured if c.DefaultSiteNameOverride != "" && isDefaultSiteName(client.SiteName) { client.SiteName = c.DefaultSiteNameOverride } - + m.ClientsDPI = append(m.ClientsDPI, client) } @@ -367,6 +375,14 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met 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. // 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 @@ -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. diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index 2c8746fb..9fe762c5 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -86,6 +86,7 @@ type Metrics struct { RogueAPs []*unifi.RogueAP SpeedTests []*unifi.SpeedTestResult Devices *unifi.Devices + DHCPLeases []*unifi.DHCPLease } func init() { // nolint: gochecknoinits diff --git a/pkg/poller/config.go b/pkg/poller/config.go index f7caf535..2e622329 100644 --- a/pkg/poller/config.go +++ b/pkg/poller/config.go @@ -88,6 +88,7 @@ type Metrics struct { RogueAPs []any SpeedTests []any CountryTraffic []any + DHCPLeases []any } // Events defines the type for log entries. diff --git a/pkg/poller/inputs.go b/pkg/poller/inputs.go index 7c6fdf23..9822ff69 100644 --- a/pkg/poller/inputs.go +++ b/pkg/poller/inputs.go @@ -269,6 +269,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics { existing.Clients = append(existing.Clients, m.Clients...) existing.Devices = append(existing.Devices, m.Devices...) existing.CountryTraffic = append(existing.CountryTraffic, m.CountryTraffic...) + existing.DHCPLeases = append(existing.DHCPLeases, m.DHCPLeases...) return existing } diff --git a/pkg/promunifi/collector.go b/pkg/promunifi/collector.go index 7d0934de..05e799fe 100644 --- a/pkg/promunifi/collector.go +++ b/pkg/promunifi/collector.go @@ -3,7 +3,6 @@ package promunifi import ( "fmt" - "github.com/prometheus/client_golang/prometheus/collectors" "net" "net/http" "reflect" @@ -11,6 +10,8 @@ import ( "sync" "time" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" promver "github.com/prometheus/common/version" @@ -46,6 +47,7 @@ type promUnifi struct { RogueAP *rogueap SpeedTest *speedtest CountryTraffic *ucountrytraffic + DHCPLease *dhcplease // This interface is passed to the Collect() method. The Collect method uses // this interface to retrieve the latest UniFi measurements and export them. Collector poller.Collect @@ -205,6 +207,7 @@ func (u *promUnifi) Run(c poller.Collect) error { u.RogueAP = descRogueAP(u.Namespace + "_rogueap_") u.SpeedTest = descSpeedTest(u.Namespace + "_speedtest_") u.CountryTraffic = descCountryTraffic(u.Namespace + "_countrytraffic_") + u.DHCPLease = descDHCPLease(u.Namespace + "_") mux := http.NewServeMux() 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 // 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} { + 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)) // 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) } + // 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) } diff --git a/pkg/promunifi/dhcp_leases.go b/pkg/promunifi/dhcp_leases.go new file mode 100644 index 00000000..d3d2fa4c --- /dev/null +++ b/pkg/promunifi/dhcp_leases.go @@ -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 +}