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/spf13/pflag v1.0.10
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/term v0.39.0
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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.8.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
github.com/unpoller/unifi/v5 v5.10.0 h1:GzurmJqXBYLsxMtwMzejXdOlajbsxV7FLghu0cOcXG8=
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=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=

View File

@@ -4,7 +4,6 @@ package datadogunifi
import (
"fmt"
"reflect"
"time"
"github.com/DataDog/datadog-go/v5/statsd"
@@ -355,7 +354,9 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
case *unifi.SpeedTestResult:
u.batchSpeedTest(r, v)
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))
}
// 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
}
@@ -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.

View File

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

View File

@@ -25,6 +25,7 @@ type Logs struct {
type Report struct {
Start time.Time
Oldest time.Time
Collect poller.Collect
poller.Logger
Counts map[string]int
}
@@ -34,6 +35,7 @@ func (l *Loki) NewReport(start time.Time) *Report {
return &Report{
Start: start,
Oldest: l.last,
Collect: l.Collect,
Logger: l,
Counts: make(map[string]int),
}
@@ -60,7 +62,9 @@ func (r *Report) ProcessEventLogs(events *poller.Events) *Logs {
case *unifi.ProtectLogEntry:
r.ProtectLogEvent(event, logs)
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
SpeedTests []any
CountryTraffic []any
DHCPLeases []any
}
// 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.Devices = append(existing.Devices, m.Devices...)
existing.CountryTraffic = append(existing.CountryTraffic, m.CountryTraffic...)
existing.DHCPLeases = append(existing.DHCPLeases, m.DHCPLeases...)
return existing
}

View File

@@ -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)
}

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_"
pns := ns + "port_"
sfp := pns + "sfp_"
labelS := []string{"site_name", "name", "source"}
labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "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", "tag"}
labelF := []string{
"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{
"outlet_description", "outlet_index", "outlet_name", "site_name", "name", "source",
"outlet_description", "outlet_index", "outlet_name", "site_name", "name", "source", "tag",
}
nd := prometheus.NewDesc
@@ -136,8 +136,13 @@ func (u *promUnifi) exportPDU(r report, d *unifi.PDU) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
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.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.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta)
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.Upgradeable, gauge, d.Upgradeable.Val, labels},
})
@@ -163,6 +168,7 @@ func (u *promUnifi) exportPDU(r report, d *unifi.PDU) {
if d.TotalMaxPower.Txt != "" {
r.send([]*metric{{u.Device.TotalMaxPower, gauge, d.TotalMaxPower, labels}})
}
})
}
// Switch Stats.
@@ -204,7 +210,7 @@ func (u *promUnifi) exportPDUPrtTable(r report, labels []string, pt []unifi.Port
// Copy labels, and add four new ones.
labelP := []string{
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 {
@@ -218,7 +224,7 @@ func (u *promUnifi) exportPDUPrtTable(r report, labels []string, pt []unifi.Port
if p.SFPFound.Val {
labelF := []string{
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{
@@ -258,7 +264,7 @@ func (u *promUnifi) exportPDUOutletTable(r report, labels []string, ot []unifi.O
// Copy labels, and add four new ones.
labelOutlet := []string{
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{
@@ -277,7 +283,7 @@ func (u *promUnifi) exportPDUOutletTable(r report, labels []string, ot []unifi.O
// Copy labels, and add four new ones.
labelOutlet := []string{
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{

View File

@@ -111,9 +111,9 @@ func descRogueAP(ns string) *rogueap {
}
func descUAP(ns string) *uap { // nolint: funlen
labelA := []string{"stat", "site_name", "name", "source"} // stat + labels[1:]
labelV := []string{"vap_name", "bssid", "radio", "radio_name", "essid", "usage", "site_name", "name", "source"}
labelR := []string{"radio_name", "radio", "site_name", "name", "source"}
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", "tag"}
labelR := []string{"radio_name", "radio", "site_name", "name", "source", "tag"}
nd := prometheus.NewDesc
return &uap{
@@ -219,8 +219,14 @@ func (u *promUnifi) exportUAP(r report, d *unifi.UAP) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
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.exportVAPtable(r, labels, d.VapTable)
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.exportRADtable(r, labels, d.RadioTable, d.RadioTableStats)
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.Upgradeable, gauge, d.Upgradable.Val, labels},
})
})
}
// 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
}
labelU := []string{"user", labels[1], labels[2], labels[3]}
labelG := []string{"guest", 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], labels[4]}
r.send([]*metric{
// ap only stuff.
{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
}
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{
{u.UAP.VAPCcq, gauge, float64(v.Ccq) / 1000.0, 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) {
// radio table
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")
labelRGuest := append(labelR, "guest")

View File

@@ -13,8 +13,13 @@ func (u *promUnifi) exportUBB(r report, d *unifi.UBB) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
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
u.exportUBBstats(r, labels, d.Stat)
@@ -34,7 +39,7 @@ func (u *promUnifi) exportUBB(r report, d *unifi.UBB) {
// Device info, uptime, and temperature
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.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.LinkCapacity.Val, append(labels, "link_capacity")},
})
})
}
// 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
// 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:]...)
r.send([]*metric{
{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
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
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).
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)
// Dream Machine System Data.
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},
})
})
}

View File

@@ -36,36 +36,36 @@ type unifiDevice struct {
func descDevice(ns string) *unifiDevice {
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{
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",
append(labels, "temp_area", "temp_type"), nil),
append(labels, "temp_area", "temp_type", "tag"), nil),
Storage: prometheus.NewDesc(ns+"storage", "Storage",
append(labels, "mountpoint", "storage_name", "storage_reading"), nil),
TotalMaxPower: prometheus.NewDesc(ns+"max_power_total", "Total Max Power", labels, nil),
OutletACPowerConsumption: prometheus.NewDesc(ns+"outlet_ac_power_consumption", "Outlet AC Power Consumption", labels, nil),
PowerSource: prometheus.NewDesc(ns+"power_source", "Power Source", labels, nil),
FanLevel: prometheus.NewDesc(ns+"fan_level", "Fan Level", labels, nil),
TotalTxBytes: prometheus.NewDesc(ns+"transmit_bytes_total", "Total Transmitted Bytes", labels, nil),
TotalRxBytes: prometheus.NewDesc(ns+"receive_bytes_total", "Total Received Bytes", labels, nil),
TotalBytes: prometheus.NewDesc(ns+"bytes_total", "Total Bytes Transferred", labels, nil),
BytesR: prometheus.NewDesc(ns+"rate_bytes", "Transfer Rate", labels, nil),
BytesD: prometheus.NewDesc(ns+"d_bytes", "Total Bytes D???", labels, nil),
TxBytesD: prometheus.NewDesc(ns+"d_tranmsit_bytes", "Transmit Bytes D???", labels, nil),
RxBytesD: prometheus.NewDesc(ns+"d_receive_bytes", "Receive Bytes D???", labels, nil),
Counter: prometheus.NewDesc(ns+"stations", "Number of Stations", append(labels, "station_type"), nil),
Loadavg1: prometheus.NewDesc(ns+"load_average_1", "System Load Average 1 Minute", labels, nil),
Loadavg5: prometheus.NewDesc(ns+"load_average_5", "System Load Average 5 Minutes", labels, nil),
Loadavg15: prometheus.NewDesc(ns+"load_average_15", "System Load Average 15 Minutes", labels, nil),
MemUsed: prometheus.NewDesc(ns+"memory_used_bytes", "System Memory Used", labels, nil),
MemTotal: prometheus.NewDesc(ns+"memory_installed_bytes", "System Installed Memory", labels, nil),
MemBuffer: prometheus.NewDesc(ns+"memory_buffer_bytes", "System Memory Buffer", labels, nil),
CPU: prometheus.NewDesc(ns+"cpu_utilization_ratio", "System CPU % Utilized", labels, nil),
Mem: prometheus.NewDesc(ns+"memory_utilization_ratio", "System Memory % Utilized", labels, nil),
Upgradeable: prometheus.NewDesc(ns+"upgradable", "Upgrade-able", labels, nil),
append(labels, "mountpoint", "storage_name", "storage_reading", "tag"), 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", append(labels, "tag"), nil),
PowerSource: prometheus.NewDesc(ns+"power_source", "Power Source", append(labels, "tag"), nil),
FanLevel: prometheus.NewDesc(ns+"fan_level", "Fan Level", append(labels, "tag"), 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", append(labels, "tag"), nil),
TotalBytes: prometheus.NewDesc(ns+"bytes_total", "Total Bytes Transferred", append(labels, "tag"), nil),
BytesR: prometheus.NewDesc(ns+"rate_bytes", "Transfer Rate", append(labels, "tag"), nil),
BytesD: prometheus.NewDesc(ns+"d_bytes", "Total Bytes D???", append(labels, "tag"), 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???", append(labels, "tag"), 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", append(labels, "tag"), 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", append(labels, "tag"), 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", append(labels, "tag"), 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", append(labels, "tag"), nil),
Mem: prometheus.NewDesc(ns+"memory_utilization_ratio", "System Memory % Utilized", append(labels, "tag"), 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
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
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).
u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes)
u.exportSYSstats(r, labels, d.SysStats, d.SystemStats)
u.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.NumMobile, d.NumHandheld)
u.exportBYTstats(r, append(labels, tag), d.TxBytes, d.RxBytes)
u.exportSYSstats(r, append(labels, tag), d.SysStats, d.SystemStats)
u.exportSTAcount(r, append(labels, tag), d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.NumMobile, d.NumHandheld)
// Switch Data
u.exportUSWstats(r, labels, d.Stat.Sw)
u.exportPRTtable(r, labels, d.PortTable)
u.exportUSWstats(r, append(labels, tag), d.Stat.Sw)
u.exportPRTtable(r, append(labels, tag), d.PortTable)
// Gateway Data
u.exportWANPorts(r, labels, d.Wan1, d.Wan2)
u.exportUSGstats(r, labels, d.Stat.Gw, d.SpeedtestStatus, d.Uplink)
u.exportWANPorts(r, append(labels, tag), d.Wan1, d.Wan2)
u.exportUSGstats(r, append(labels, tag), d.Stat.Gw, d.SpeedtestStatus, d.Uplink)
// Dream Machine System Data.
r.send([]*metric{
{u.Device.Info, gauge, 1.0, append(labels, infoLabels...)},
{u.Device.Uptime, gauge, d.Uptime, labels},
{u.Device.Upgradeable, gauge, d.Upgradeable.Val, labels},
{u.Device.Uptime, gauge, d.Uptime, append(labels, tag)},
{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.
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.
for _, t := range d.Storage {
r.send([]*metric{
{u.Device.Storage, gauge, t.Size.Val, append(labels, t.MountPoint, t.Name, "size")},
{u.Device.Storage, gauge, t.Used.Val, append(labels, t.MountPoint, t.Name, "used")},
{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", tag)},
})
}
})
// Wireless Data - UDM (non-pro) only
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.exportVAPtable(r, labels, *d.VapTable)
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 {
labels := []string{"port", "site_name", "name", "source"}
labels := []string{"port", "site_name", "name", "source", "tag"}
return &usg{
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
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
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 {
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.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta, d.NumDesktop, d.UserNumSta, d.GuestNumSta)
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.Upgradeable, gauge, d.Upgradable.Val, labels},
})
})
}
// Gateway Stats.
@@ -125,8 +131,8 @@ func (u *promUnifi) exportUSGstats(r report, labels []string, gw *unifi.Gw, st u
return
}
labelLan := []string{"lan", labels[1], labels[2], labels[3]}
labelWan := []string{sourceInterface, 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], labels[4]}
r.send([]*metric{
{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.
}
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{
{u.USG.WanRxPackets, counter, wan.RxPackets, labelWan},

View File

@@ -55,11 +55,11 @@ type usw struct {
func descUSW(ns string) *usw {
pns := ns + "port_"
sfp := pns + "sfp_"
labelS := []string{"site_name", "name", "source"}
labelP := []string{"port_id", "port_num", "port_name", "port_mac", "port_ip", "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", "tag"}
labelF := []string{
"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
@@ -116,8 +116,13 @@ func (u *promUnifi) exportUSW(r report, d *unifi.USW) {
return
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
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.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.exportSTAcount(r, labels, d.UserNumSta, d.GuestNumSta)
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.Upgradeable, gauge, d.Upgradable.Val, labels},
})
@@ -142,6 +147,7 @@ func (u *promUnifi) exportUSW(r report, d *unifi.USW) {
if d.TotalMaxPower.Txt != "" {
r.send([]*metric{{u.Device.TotalMaxPower, gauge, d.TotalMaxPower, labels}})
}
})
}
// Switch Stats.
@@ -183,7 +189,7 @@ func (u *promUnifi) exportPRTtable(r report, labels []string, pt []unifi.Port) {
// Copy labels, and add four new ones.
labelP := []string{
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 {
@@ -197,7 +203,7 @@ func (u *promUnifi) exportPRTtable(r report, labels []string, pt []unifi.Port) {
if p.SFPFound.Val {
labelF := []string{
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{

View File

@@ -20,8 +20,14 @@ func (u *promUnifi) exportUXG(r report, d *unifi.UXG) {
sw = d.Stat.Sw
}
labels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
infoLabels := []string{d.Version, d.Model, d.Serial, d.Mac, d.IP, d.ID}
baseLabels := []string{d.Type, d.SiteName, d.Name, d.SourceName}
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).
u.exportBYTstats(r, labels, d.TxBytes, d.RxBytes)
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)
// Dream Machine System Data.
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},
})
@@ -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")},
})
}
})
}

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"