Add device tag support to Prometheus metrics

- Add 'tag' label to all device metric descriptors
- Update exportWithTags helper to create separate metric series per tag
- Update all device export functions (UAP, USW, UDM, USG, UXG, PDU, UBB, UCI) to include tags
- Update all label arrays (VAP, Radio, Port, etc.) to include tag label
- Devices with multiple tags create multiple metric series (one per tag)
- Devices without tags export with tag=""

Requires unpoller/unifi#92
This commit is contained in:
brngates98
2026-01-28 20:48:10 -05:00
parent 2a44b2f0be
commit 6d85ea76ab
14 changed files with 462 additions and 245 deletions

2
go.mod
View File

@@ -47,4 +47,4 @@ require (
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
// replace github.com/unpoller/unifi/v5 => ../unifi replace github.com/unpoller/unifi/v5 => ../unifi

2
go.sum
View File

@@ -77,8 +77,6 @@ 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.7.0 h1:mGdHLOvmeQqnyB8mgI/ZzMMd/7kaGm+zd9p6iIF4W6g=
github.com/unpoller/unifi/v5 v5.7.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

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

@@ -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,69 @@ 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(r 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"