From 6c5ff5482d7fd38b3118f46b9efd35b983e9e417 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Mon, 23 Mar 2026 15:25:00 -0500 Subject: [PATCH] feat(promunifi): add unifi_controller_up gauge metric (closes #356) (#974) Add a per-controller `_controller_up` Prometheus GaugeVec with a `source` label (controller URL or configured ID). The gauge is set to 1 after each successful poll and 0 on failure, giving operators a standard metric to alert on controller connectivity issues. Changes: - pkg/poller/config.go: add ControllerStatus type and ControllerStatuses field to Metrics so any output plugin can consume per-controller health. - pkg/poller/inputs.go: merge ControllerStatuses when AppendMetrics is called (multiple input sources). - pkg/inputunifi/interface.go: populate ControllerStatuses with Up=true on success and Up=false (while still continuing) on per-controller error. - pkg/promunifi/collector.go: declare and register a prometheus.GaugeVec `_controller_up`; set the gauge for each controller status after every Collect cycle. Co-authored-by: Claude Sonnet 4.6 (1M context) --- pkg/inputunifi/interface.go | 25 +++++++++++++++++++++++++ pkg/poller/config.go | 34 ++++++++++++++++++++++------------ pkg/poller/inputs.go | 1 + pkg/promunifi/collector.go | 19 +++++++++++++++++++ 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/pkg/inputunifi/interface.go b/pkg/inputunifi/interface.go index 8ba5ae2c..896ca7ba 100644 --- a/pkg/inputunifi/interface.go +++ b/pkg/inputunifi/interface.go @@ -256,9 +256,34 @@ func (u *InputUnifi) Metrics(filter *poller.Filter) (*poller.Metrics, error) { // Log error but continue to next controller u.LogErrorf("Failed to collect metrics from controller %s: %v", c.URL, err) collectionErrors = append(collectionErrors, fmt.Errorf("%s: %w", c.URL, err)) + + // Record controller as down so output plugins can expose the status. + source := c.URL + if c.ID != "" { + source = c.ID + } + + metrics.ControllerStatuses = append(metrics.ControllerStatuses, poller.ControllerStatus{ + Source: source, + Up: false, + }) + continue } + // Record controller as up. + source := c.URL + if c.ID != "" { + source = c.ID + } + + if m != nil { + m.ControllerStatuses = append(m.ControllerStatuses, poller.ControllerStatus{ + Source: source, + Up: true, + }) + } + metrics = poller.AppendMetrics(metrics, m) } diff --git a/pkg/poller/config.go b/pkg/poller/config.go index d53fd464..ed326999 100644 --- a/pkg/poller/config.go +++ b/pkg/poller/config.go @@ -79,20 +79,30 @@ type Flags struct { *pflag.FlagSet } +// ControllerStatus carries the per-controller poll result (up/down) so that +// output plugins can expose a health gauge without knowing UniFi internals. +type ControllerStatus struct { + // Source is a stable identifier for the controller (URL or configured ID). + Source string + // Up is true when the last poll of this controller succeeded. + Up bool +} + // Metrics is a type shared by the exporting and reporting packages. type Metrics struct { - TS time.Time - Sites []any - Clients []any - SitesDPI []any - ClientsDPI []any - Devices []any - RogueAPs []any - SpeedTests []any - CountryTraffic []any - DHCPLeases []any - WANConfigs []any - Sysinfos []any + TS time.Time + Sites []any + Clients []any + SitesDPI []any + ClientsDPI []any + Devices []any + RogueAPs []any + SpeedTests []any + CountryTraffic []any + DHCPLeases []any + WANConfigs []any + Sysinfos []any + ControllerStatuses []ControllerStatus } // Events defines the type for log entries. diff --git a/pkg/poller/inputs.go b/pkg/poller/inputs.go index c15ad043..bf07eb7f 100644 --- a/pkg/poller/inputs.go +++ b/pkg/poller/inputs.go @@ -277,6 +277,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics { existing.DHCPLeases = append(existing.DHCPLeases, m.DHCPLeases...) existing.WANConfigs = append(existing.WANConfigs, m.WANConfigs...) existing.Sysinfos = append(existing.Sysinfos, m.Sysinfos...) + existing.ControllerStatuses = append(existing.ControllerStatuses, m.ControllerStatuses...) return existing } diff --git a/pkg/promunifi/collector.go b/pkg/promunifi/collector.go index 8e6a203f..818d5412 100644 --- a/pkg/promunifi/collector.go +++ b/pkg/promunifi/collector.go @@ -50,6 +50,8 @@ type promUnifi struct { DHCPLease *dhcplease WAN *wan Controller *controller + // controllerUp tracks per-controller poll success (1) or failure (0). + controllerUp *prometheus.GaugeVec // This interface is passed to the Collect() method. The Collect method uses // this interface to retrieve the latest UniFi measurements and export them. Collector poller.Collect @@ -213,6 +215,10 @@ func (u *promUnifi) Run(c poller.Collect) error { u.DHCPLease = descDHCPLease(u.Namespace + "_") u.WAN = descWAN(u.Namespace + "_") u.Controller = descController(u.Namespace + "_") + u.controllerUp = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: u.Namespace + "_controller_up", + Help: "Whether the last poll of the UniFi controller succeeded (1) or failed (0).", + }, []string{"source"}) mux := http.NewServeMux() promver.Version = version.Version @@ -221,6 +227,7 @@ func (u *promUnifi) Run(c poller.Collect) error { webserver.UpdateOutput(&webserver.Output{Name: PluginName, Config: u.Config}) prometheus.MustRegister(collectors.NewBuildInfoCollector()) + prometheus.MustRegister(u.controllerUp) prometheus.MustRegister(u) mux.Handle("/metrics", promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}, @@ -340,6 +347,18 @@ func (u *promUnifi) collect(ch chan<- prometheus.Metric, filter *poller.Filter) return } + // Export per-controller up/down gauge values. + if u.controllerUp != nil && r.Metrics != nil { + for _, cs := range r.Metrics.ControllerStatuses { + val := 0.0 + if cs.Up { + val = 1.0 + } + + u.controllerUp.WithLabelValues(cs.Source).Set(val) + } + } + // Pass Report interface into our collecting and reporting methods. go u.exportMetrics(r, ch, r.ch)