feat: add opt-in UNAS Pro support (closes #785)

Adds a new `unas` input plugin that polls UNAS Pro storage consoles and
exports console health, storage pools, disks and shares to Prometheus,
InfluxDB and DataDog.

UNAS is a separate plugin rather than a device type inside inputunifi
because a storage-only console has no Network application: it cannot
answer /status, has no sites, and shares none of the UniFi device schema.

The plugin is opt-in and inert until an operator names a console. Opt-in
is expressed as "no devices configured" rather than a `disable` flag,
because a bool named `disable` zero-values to false and so cannot make a
plugin default-off. Initialize returns silently on an empty device list
and, unlike inputunifi, nothing synthesizes a default URL.

Two behaviours are worth calling out for reviewers:

  - Metrics returns (metrics, nil) whenever any console was collected.
    poller.collectMetrics uses `if err != nil {} else if metric != nil`,
    so returning both would discard every healthy console because one
    failed. Only a total failure returns an error.

  - Re-auth fires on total failure, not on a 401. A mid-session 401 from
    GetData surfaces as ErrInvalidStatusCode, not ErrAuthenticationFailed,
    so there is no sentinel to match on. Session expiry fails all four
    endpoints at once, which is exactly the total-failure case.

Prometheus metrics use the `unifi_unas_` prefix, which diverges from the
`unas_` prefix used by the reference implementation; dashboards built
against that will need query edits.

Requires unifi/v5 v5.31.0 for the UNAS client and structs.

Credit to alexgreenbank/unaspoller for mapping the endpoints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cody Lee
2026-08-19 07:52:37 -05:00
co-authored by Claude Opus 5
parent ccc6ad5dae
commit d5dfc771d5
20 changed files with 1800 additions and 21 deletions
+238
View File
@@ -0,0 +1,238 @@
# Plan: opt-in UNAS Pro support (issue #785)
Reference implementation: https://github.com/alexgreenbank/unaspoller (MIT, David Newhall II
copyright header — same lineage as unpoller, so the code is cleanly upstreamable). Credit
alexgreenbank in the package header and README.
## Design decision: separate input plugin, not a `save_unas` toggle
UNAS Pro is a standalone UniFi OS console with **no Network application**: no sites, no
`/status`, its own credentials, its own host. `inputunifi`'s sites → devices → per-site flow
has nothing to offer it, and folding it in would mean bolting a second credential set onto
`Controller`. So: a new `pkg/inputunas` plugin with its own `[unas]` config block, disabled
by default and a no-op when unconfigured.
### The one real blocker
`unifi.NewUnifi()` is unusable against a UNAS target. It ends in `GetServerData()` → GET
`APIStatusPath` (`/status`), which `path()` rewrites to `/proxy/network/status` when
`u.new` is set. A storage-only console has no Network app to answer that, and unaspoller's
endpoint list never touches `/status`, so we have no evidence it responds. API-key auth is
out for the same reason — `Login()` uses `/status` as its login path when `APIKey != ""`.
Fix by construction, not by testing against hardware we don't have: a login-only
constructor that stops after `Login()`.
What already works in our favour: `path()` (types.go:234) deliberately passes anything
starting with `/proxy/` through untouched, so `GetData`/`GetJSON` work on `/proxy/drive/…`
with zero changes. UniFi OS login, the cookie jar, csrf capture
(`x-csrf-token`/`x-updated-csrf-token`), and 429 `Retry-After` handling all already exist.
## Scope for v1
**In** — the four endpoints unaspoller actually polls:
| Endpoint | Yields |
|---|---|
| `/proxy/drive/api/v2/systems/device-info` | name, model, version, firmware, uptime, cpu load/temp, memory free/total/avail |
| `/proxy/drive/api/v2/storage` | pools (capacity/usage/status/raid groups) and disks (health score, temp, power-on hours, rpm, size, bad/uncorrectable sectors, read/write KBPS) |
| `/proxy/users/drive/api/v2/drives` | per-share id/name/type/status/quota/usage/member count |
| `/proxy/drive/api/v2/systems/network-io` | receive/transmit KBPS |
**Explicitly cut from v1**, to be revisited once community data exists:
- `/proxy/drive/api/v2/systems/disk-stats` — needs `?start=&end=&interval=`, returns
time-series arrays that don't map onto gauges, and its per-disk `readKBPS`/`writeKBPS`
are already in `/storage`.
- Cache slots, expansions (unaspoller has them as empty placeholder structs).
- `/proxy/users/drive/api/v1/systems/{identity,info}`, `/proxy/users/drive/api/v2/{groups,storage}`,
`/proxy/drive/api/v1/systems/performance/file-operations`.
## Part 1 — `../unifi` (github.com/unpoller/unifi/v5) — **done**
Landed as `unas.go` + `unas_test.go`, four path constants in `types.go`, two sentinels
(`ErrNilConfig`, `ErrAPIKeyUnsupported`) in `unifi.go`, synthetic fixtures under
`endpoints_data/unas-*.json`, and a UNAS section in `endpoints_data/ENDPOINTS.md`.
`go test ./...` and `golangci-lint run ./...` are clean.
Refinements over the plan below:
1. `NewUNASClient` sets `u.new = true` directly instead of calling `checkNewStyleAPI()`. A
UNAS Pro is always a UniFi OS console, so probing a GET of `/` only adds a way for login
to resolve to the wrong path. This removes the "does the console answer GET `/`?" open
item entirely.
2. **`GetUNASDevice` does not return an error on partial failure.** Failing endpoints are
logged to `ErrorLog` and their fields left nil; only a total failure returns an error
(and then a nil device). Returning `(device, err)` together was the original design, but
it made the reflexive `if err != nil { return }` in `pkg/inputunas` silently drop every
metric from a console whenever one endpoint 404s — exactly the case the partial path
exists to serve. The footgun is removed at the source rather than documented around.
3. `NewUnifi` now returns `ErrNilConfig` too, instead of its own `fmt.Errorf("config is
nil")` for the identical condition. Callers gain `errors.Is`; the lib stops having two
errors for one condition in one file.
### 1a. `unas.go`
Path constants alongside the existing `/proxy/protect/…` block in `types.go` (or local to
`unas.go`, matching however `protect_log.go` does it):
```go
APIUNASDeviceInfoPath = "/proxy/drive/api/v2/systems/device-info"
APIUNASStoragePath = "/proxy/drive/api/v2/storage"
APIUNASDrivesPath = "/proxy/users/drive/api/v2/drives"
APIUNASNetworkIOPath = "/proxy/drive/api/v2/systems/network-io"
```
Login-only client:
```go
type UNASClient struct{ *Unifi }
func NewUNASClient(config *Config) (*UNASClient, error) // newUnifi → checkNewStyleAPI → Login, stop
```
`newUnifi` is unexported but we're in-package; this is ~10 lines lifted from `NewUnifi`
plus the SSL-fingerprint loop, minus `GetServerData()`. Reject a non-empty `APIKey` with a
clear error (`ErrAPIKeyUnsupported` or reuse an existing sentinel) since key auth routes
through `/status`.
Getters on `*UNASClient` — `GetUNASDeviceInfo`, `GetUNASStorage`, `GetUNASDrives`,
`GetUNASNetworkIO`, plus one aggregate `GetUNASDevice()` returning the composed
`*UNASDevice`. **Do not add these to the `UnifiClient` interface**: CONVENTIONS.md requires
`mocks.MockUnifi` implement every method, so widening it breaks the mocks build for no
gain — `inputunifi` holds `*unifi.Unifi` directly and nothing consumes the interface here.
### 1b. Structs — port with two bug fixes
Port unaspoller's `drivetypes.go`, renaming `DriveApiV2*` → `UNAS*` to match lib style.
Two dead fields to fix rather than inherit:
1. `DriveApiV2SystemsDeviceInfo.NetworkInterfaces` has an **empty** tag (`json:""`) — it
never unmarshals today. Confirm the real key (likely `networkInterfaces`) from a probe
dump before relying on it; leave it out of v1 metrics either way.
2. `DriveApiV2Pool.activeRaidGroupId` is **unexported** with a json tag — also dead.
Export as `ActiveRaidGroupID`.
Use `FlexInt`/`FlexBool` for every numeric/boolean field. unaspoller's own comments flag
several as "float64? only ever seen ints" (temperatures, sector counts) — the flex types
are the lib convention and pre-empt exactly the unmarshal breakage its author predicted.
### 1c. Tests
JSON fixtures under `endpoints_data/`, table tests in the existing lib style.
## Part 2 — `unpoller` — **done**
Landed as `pkg/inputunas/{config.go,input.go,input_test.go,README.md}`, the `UNASDevices`
pair in `pkg/poller`, a blank import in `main.go`, `unas.go` in promunifi/influxunifi/
datadogunifi with their dispatch wiring, `pkg/promunifi/unas_test.go`, and an `[unas]` section
in all three example configs. `go test ./...` and `golangci-lint run` are clean.
Refinements over the plan below:
1. **The opt-in mechanism is "no devices configured", not `disable`.** `Disable` zero-values
to false, so a field named `disable` cannot make a plugin default-off. `Initialize` returns
silently when the device list is empty, and unlike `inputunifi` it does **not** synthesize
a default device from `[unas.defaults]` — doing so would poll a host the operator never
named. `disable` remains as an explicit off switch.
2. **`Metrics` returns `(metrics, nil)` whenever any console was collected.** Not politeness:
`poller.collectMetrics` uses `if result.err != nil { ... } else if result.metric != nil`,
so returning both discards every metric in that cycle. One dead console out of three would
have thrown away the two that worked. (`inputunifi.Metrics` hits this at
`interface.go:303`; left alone as out of scope.)
3. **Re-auth fires on any total failure, not on a 401.** A mid-session 401 from `GetData`
surfaces as `ErrInvalidStatusCode`, not `ErrAuthenticationFailed` (`unifi.go:603`), so
there is no sentinel to match. Session expiry fails all four endpoints at once, which is
exactly the total-failure case, and one wasted re-login against an unreachable console is
cheaper than never recovering from an expired session.
4. **Env var names come from the `xml` tags, not `json`** — verified by binding a real config:
`UP_UNAS_DEFAULT_TIMEOUT` (singular) and `UP_UNAS_DEVICE_0_URL`.
### Original Part 2 plan
### 2a. `pkg/inputunas/`
`config.go` + `input.go` + `README.md`. Config shape:
```toml
[unas]
disable = false
[unas.defaults]
user = "unpoller"
pass = ""
verify_ssl = false
timeout = "60s"
[[unas.device]]
url = "https://192.168.1.10"
user = "..."
pass = "..."
```
`json`/`toml`/`xml`/`yaml` tags on every field; env vars map as `UP_UNAS_*` via cnfg.
Implement all five `poller.Input` methods — `Events` returns empty, `RawMetrics` and
`DebugInput` as stubs; `Metrics` returns `&poller.Metrics{UNASDevices: …}`.
**Do not re-log endpoint failures.** `GetUNASDevice` already reports each failing endpoint
through `ErrorLog` once per poll — accepted deliberately: a 404ing endpoint is something an
operator should see, and `inputunifi` logs per-cycle problems the same way. The plugin must
not add a second layer of the same message.
**Own re-auth-on-401 loop.** We do not inherit `inputunifi`'s. UNAS session tokens expire
around 2h, and re-login must re-attach fresh cookies — this is the bug alexgreenbank burned
days on. One re-login attempt per request, then fail.
### 2b. Plumbing (the easy-to-miss half)
3. `main.go` — blank import `_ "github.com/unpoller/unpoller/pkg/inputunas"`.
4. `pkg/poller/config.go` — add `UNASDevices []any` to `Metrics` **and** the matching
append in `AppendMetrics` (`pkg/poller/inputs.go` ~336). Omitting the second is silent
metric loss when two inputs run, and nothing errors.
**One aggregate family, not five.** `UNASDevices []any` carrying `*unifi.UNASDevice`
(device-info + pools + disks + drives + net-io) keeps `AppendMetrics` to one line and lets
one export function per output emit every gauge family. Nothing needs pools or disks
correlated independently — no site name, no PII redaction — so flat-per-entity families buy
nothing and cost 5× the plumbing.
### 2c. Outputs — follow the `ups.go` pattern
- `pkg/promunifi/unas.go` + four edits in `collector.go`: descriptor struct field (~71),
`descUNASDevice()` call (~321), the `Describe` reflect list (~607), the collect loop (~836).
- `pkg/influxunifi/unas.go` + the type-switch loop in `influxdb.go`.
- `pkg/datadogunifi/unas.go` + `datadog.go` loop and type-switch (~476).
- `pkg/lokiunifi` — skip, events only.
- `pkg/otelunifi` — **skip in v1.** `report.go:178` walks only `m.Devices`; it's per-family
and UPSDevices is already absent there. Adding UNAS is a separate follow-up.
**Namespace divergence, stated deliberately:** promunifi prefixes with `u.Namespace + "_"`,
so these land as `unifi_unas_disk_temperature`, not unaspoller's `unas_disk_temperature`.
The Grafana dashboard in issue #785 is built on the latter and will need its queries
adjusted. Consistency with every other unpoller metric wins.
### 2d. Docs
`examples/up.conf.example` (new `[unas]` section after `[unifi]` at line 133),
`up.json.example`, `up.yaml.example`, `pkg/inputunas/README.md`, and the `UP_UNAS_*` env
mapping.
## Sequencing across the two repos
`go.mod` pins `github.com/unpoller/unifi/v5 v5.30.0` with **no** `replace` directive
(CLAUDE.md's `/Users/briangates/unifi` reference is stale). So:
1. Land the `../unifi` change, tag a release.
2. Bump `unifi/v5` in unpoller, land `pkg/inputunas`.
Both steps are done: Part 1 released as **v5.31.0**, and unpoller's `go.mod` now pins that
version. Because the tag is published and proxy-resolvable, the `replace` directive this plan
originally called for was never needed — which removes the "strip the replace before merging"
footgun entirely. The pin bump also pulled `testify` to v1.12.0 and `golang.org/x/net` to
v0.58.0, both required by the new lib version.
## Open items needing real hardware or community data
- Real json key and shape for `networkInterfaces`, `usbs`, `sfpAggregation`. The port assumes
`networkInterfaces` (the reference implementation's empty tag means this was never
verified); if a live console disagrees, that one tag is the fix.
- Shapes for `cacheSlots`, `riskReasons`, `incompatibleReasons`, `expansions` — unaspoller
left these as empty placeholders. Out of v1 scope.
+32
View File
@@ -267,3 +267,35 @@
# save_speedtest = true
# verify_ssl = false
# ssl_cert_paths = []
##############################
### UNAS Pro (opt-in)
##############################
# A UNAS Pro is a standalone UniFi OS console with no Network application, so it is polled
# by its own input plugin with its own credentials and host -- not as a UniFi controller.
#
# This section is entirely optional and does nothing unless you add at least one
# [[unas.device]] below: with no devices configured the plugin stays silent and inert.
# Metrics land under the unifi_unas_ prefix in Prometheus, and in the unas_device,
# unas_pool, unas_disk, and unas_share measurements in InfluxDB.
#
#[unas]
# disable = false
#
# Defaults applied to every device that does not set its own value.
#[unas.defaults]
# user = "unpoller"
# pass = ""
# verify_ssl = false
# timeout = "60s"
#
# Repeat the following stanza for each UNAS console you want to poll.
# Use the console's own local-account credentials. Do not add a path after the host.
#[[unas.device]]
# url = "https://192.168.1.10"
# user = "unpoller"
# pass = "unpoller"
# verify_ssl = false
# timeout = "60s"
# ssl_cert_paths = []
+20
View File
@@ -84,5 +84,25 @@
"verify_ssl": false
}
]
},
"unas": {
"//": "UNAS Pro consoles. JSON cannot carry comments, so this section ships disabled: it shows the shape, and flipping disable to false with a real url turns it on.",
"disable": true,
"defaults": {
"user": "unpoller",
"pass": "",
"verify_ssl": false,
"timeout": "60s"
},
"devices": [
{
"url": "https://192.168.1.10",
"user": "unpoller",
"pass": "unpoller",
"verify_ssl": false,
"timeout": "60s"
}
]
}
}
+18
View File
@@ -86,3 +86,21 @@ unifi:
save_sites: true
hash_pii: false
verify_ssl: false
# A UNAS Pro is a standalone UniFi OS console with no Network application, so it is polled
# by its own input plugin with its own credentials and host -- not as a UniFi controller.
# This section is optional: with no devices configured the plugin stays silent and inert.
#unas:
# disable: false
# defaults:
# user: "unpoller"
# pass: ""
# verify_ssl: false
# timeout: "60s"
# devices:
# # Repeat the following stanza to poll multiple UNAS consoles.
# - url: "https://192.168.1.10"
# user: "unpoller"
# pass: "unpoller"
# verify_ssl: false
# timeout: "60s"
+3 -3
View File
@@ -11,8 +11,8 @@ require (
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/common v0.70.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/unpoller/unifi/v5 v5.30.0
github.com/stretchr/testify v1.12.0
github.com/unpoller/unifi/v5 v5.31.0
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0
@@ -36,7 +36,7 @@ require (
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
+7
View File
@@ -80,6 +80,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -87,8 +88,12 @@ 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/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/unpoller/unifi/v5 v5.30.0 h1:M1L/VG4BkZKt+aoqaB/E7foEvDfVFgc5SdPeoyX/qx0=
github.com/unpoller/unifi/v5 v5.30.0/go.mod h1:j897mt84iizeLfvXL6KEGlfRdbAua8nTWm5PZjtif30=
github.com/unpoller/unifi/v5 v5.31.0 h1:WKrVxDP5YZetehFnqDoalb3+eALbR33zWFV0eyBhk08=
github.com/unpoller/unifi/v5 v5.31.0/go.mod h1:gbg5QyCqI7PFjSLAlm3IfEE1m0205LQF8nUYRtuOrAE=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
@@ -124,6 +129,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+1
View File
@@ -8,6 +8,7 @@ import (
"github.com/unpoller/unpoller/pkg/poller"
// Load input plugins!
_ "github.com/unpoller/unpoller/pkg/inputunas"
_ "github.com/unpoller/unpoller/pkg/inputunifi"
// Load output plugins!
_ "github.com/unpoller/unpoller/pkg/datadogunifi"
+6
View File
@@ -350,6 +350,10 @@ func (u *DatadogUnifi) loopPoints(r report) {
u.switchExport(r, v)
}
for _, v := range m.UNASDevices {
u.switchExport(r, v)
}
for _, v := range m.WANStatuses {
u.switchExport(r, v)
}
@@ -475,6 +479,8 @@ func (u *DatadogUnifi) switchExport(r report, v any) { //nolint:cyclop
u.batchSSLCertificate(r, v)
case *unifi.UPSDeviceSelector:
u.batchUPSDevice(r, v)
case *unifi.UNASDevice:
u.batchUNASDevice(r, v)
case *unifi.WANStatus:
u.batchWANStatus(r, v)
case *unifi.IntegrationDeviceStats:
+135
View File
@@ -0,0 +1,135 @@
package datadogunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchUNASDevice generates UNAS Pro storage console datapoints for Datadog.
//
// Each section is guarded independently because GetUNASDevice leaves the field for a failing
// endpoint nil rather than failing the whole poll -- a console that serves storage but not
// network-io still reports its disks.
func (u *DatadogUnifi) batchUNASDevice(r report, d *unifi.UNASDevice) {
if d == nil {
return
}
metricName := metricNamespace("unas_device")
tags := cleanTags(map[string]string{
"source": d.SourceName,
"name": d.Name(),
"model": d.Model(),
})
deviceTags := tagMapToTags(tags)
_ = r.reportGauge(metricName("present"), 1.0, deviceTags)
if info := d.DeviceInfo; info != nil {
_ = r.reportGauge(metricName("cpu_load"), info.CPU.CurrentLoad.Val, deviceTags)
_ = r.reportGauge(metricName("cpu_temperature"), info.CPU.Temperature.Val, deviceTags)
_ = r.reportGauge(metricName("memory_total"), info.Memory.Total.Val, deviceTags)
_ = r.reportGauge(metricName("memory_free"), info.Memory.Free.Val, deviceTags)
_ = r.reportGauge(metricName("memory_available"), info.Memory.Available.Val, deviceTags)
}
if io := d.NetworkIO; io != nil {
_ = r.reportGauge(metricName("receive_kbps"), io.ReceiveKBPS.Val, deviceTags)
_ = r.reportGauge(metricName("transmit_kbps"), io.TransmitKBPS.Val, deviceTags)
}
if s := d.Storage; s != nil {
u.batchUNASPools(r, d, s)
u.batchUNASDisks(r, d, s)
}
u.batchUNASShares(r, d)
}
func (u *DatadogUnifi) batchUNASPools(r report, d *unifi.UNASDevice, s *unifi.UNASStorage) {
metricName := metricNamespace("unas_pool")
raidName := metricNamespace("unas_raid_group")
for _, p := range s.Pools {
tags := tagMapToTags(cleanTags(map[string]string{
"source": d.SourceName,
"name": d.Name(),
"pool_id": p.ID,
"pool_type": p.Type,
"status": p.Status,
}))
_ = r.reportGauge(metricName("capacity"), p.Capacity.Val, tags)
_ = r.reportGauge(metricName("usage"), p.Usage.Val, tags)
for _, rg := range p.RaidGroups {
raidTags := tagMapToTags(cleanTags(map[string]string{
"source": d.SourceName,
"name": d.Name(),
"pool_id": p.ID,
"raid_group_id": rg.ID,
"current_level": rg.CurrentLevel,
"config_level": rg.ConfigLevel,
}))
_ = r.reportGauge(raidName("current_protection"), rg.CurrentProtection.Val, raidTags)
_ = r.reportGauge(raidName("expected_protection"), rg.ExpectedProtection.Val, raidTags)
_ = r.reportGauge(raidName("progress"), rg.Progress.Val, raidTags)
}
}
}
func (u *DatadogUnifi) batchUNASDisks(r report, d *unifi.UNASDevice, s *unifi.UNASStorage) {
metricName := metricNamespace("unas_disk")
for _, disk := range s.Disks {
tags := tagMapToTags(cleanTags(map[string]string{
"source": d.SourceName,
"name": d.Name(),
"slot_id": disk.SlotID,
"location": disk.Location,
"pool_id": disk.PoolID,
"disk_type": disk.Type,
"state": disk.State,
"model": disk.Model,
"serial": disk.Serial,
}))
_ = r.reportGauge(metricName("size"), disk.Size.Val, tags)
_ = r.reportGauge(metricName("temperature"), disk.Temperature.Val, tags)
_ = r.reportGauge(metricName("health_score"), disk.HealthScore.Val, tags)
_ = r.reportGauge(metricName("power_on_hours"), disk.PowerOnHours.Val, tags)
_ = r.reportGauge(metricName("rpm"), disk.RPM.Val, tags)
_ = r.reportGauge(metricName("bad_sector_count"), disk.BadSectorCount.Val, tags)
_ = r.reportGauge(metricName("uncorrectable_sector_count"), disk.UncorrectableSectorCount.Val, tags)
_ = r.reportGauge(metricName("read_error_rate"), disk.ReadErrorRate.Val, tags)
_ = r.reportGauge(metricName("smart_read_error_count"), disk.SmartReadErrorCount.Val, tags)
_ = r.reportGauge(metricName("read_kbps"), disk.ReadKBPS.Val, tags)
_ = r.reportGauge(metricName("write_kbps"), disk.WriteKBPS.Val, tags)
}
}
func (u *DatadogUnifi) batchUNASShares(r report, d *unifi.UNASDevice) {
metricName := metricNamespace("unas_share")
for _, share := range d.Drives {
if share == nil {
continue
}
tags := tagMapToTags(cleanTags(map[string]string{
"source": d.SourceName,
"name": d.Name(),
"share_id": share.ID,
"share_name": share.Name,
"share_type": share.Type,
"status": share.Status,
"role": share.Role,
}))
_ = r.reportGauge(metricName("quota"), share.Quota.Val, tags)
_ = r.reportGauge(metricName("usage"), share.Usage.Val, tags)
_ = r.reportGauge(metricName("member_count"), share.MemberCount.Val, tags)
}
}
+6
View File
@@ -507,6 +507,12 @@ func (u *InfluxUnifi) loopPoints(r report) {
}
}
for _, nd := range m.UNASDevices {
if v, ok := nd.(*unifi.UNASDevice); ok {
u.batchUNASDevice(r, v)
}
}
for _, wb := range m.WifiBroadcasts {
if v, ok := wb.(*unifi.WifiBroadcast); ok {
u.batchWifiBroadcast(r, v)
+151
View File
@@ -0,0 +1,151 @@
package influxunifi
import (
"github.com/unpoller/unifi/v5"
)
// batchUNASDevice generates InfluxDB points for one UNAS Pro storage console.
//
// Four measurements are emitted rather than one: the console, its storage pools, its physical
// disks, and its shares each have their own tag set, and folding them together would give
// every point the union of those tags with most of them empty.
//
// Each section is guarded independently because GetUNASDevice leaves the field for a failing
// endpoint nil rather than failing the whole poll -- a console that serves storage but not
// network-io still reports its disks.
func (u *InfluxUnifi) batchUNASDevice(r report, d *unifi.UNASDevice) { // nolint: funlen
if d == nil {
return
}
tags := map[string]string{
"source": d.SourceName,
"name": d.Name(),
"model": d.Model(),
}
fields := map[string]any{"present": 1}
if info := d.DeviceInfo; info != nil {
tags["version"] = info.Version
tags["firmware_version"] = info.FirmwareVersion
tags["status"] = info.Status
fields["cpu_load"] = info.CPU.CurrentLoad.Val
fields["cpu_temperature"] = info.CPU.Temperature.Val
fields["memory_total"] = info.Memory.Total.Val
fields["memory_free"] = info.Memory.Free.Val
fields["memory_available"] = info.Memory.Available.Val
fields["startup_time"] = info.StartupTime
}
if io := d.NetworkIO; io != nil {
fields["receive_kbps"] = io.ReceiveKBPS.Val
fields["transmit_kbps"] = io.TransmitKBPS.Val
}
r.send(&metric{Table: "unas_device", Tags: tags, Fields: fields})
if s := d.Storage; s != nil {
u.batchUNASStorage(r, d, s)
}
for _, share := range d.Drives {
if share == nil {
continue
}
r.send(&metric{
Table: "unas_share",
Tags: map[string]string{
"source": d.SourceName,
"name": d.Name(),
"share_id": share.ID,
"share_name": share.Name,
"share_type": share.Type,
"status": share.Status,
"storage_pool_id": share.StoragePoolID,
"role": share.Role,
"encryption_status": share.Protections.EncryptionStatus,
},
Fields: map[string]any{
"quota": share.Quota.Val,
"usage": share.Usage.Val,
"member_count": share.MemberCount.Val,
"snapshot_enabled": share.Protections.SnapshotEnabled.Val,
"remote_backup_enabled": share.Protections.RemoteBackupEnabled.Val,
"deduplication": share.Deduplication,
"compression_level": share.CompressionLevel,
},
})
}
}
func (u *InfluxUnifi) batchUNASStorage(r report, d *unifi.UNASDevice, s *unifi.UNASStorage) {
for _, p := range s.Pools {
fields := map[string]any{
"capacity": p.Capacity.Val,
"usage": p.Usage.Val,
"raid_group_count": len(p.RaidGroups),
"initializing_status": p.InitializingStatus,
}
// A pool has one active RAID group; its redundancy is the number an operator alerts
// on, so it is flattened onto the pool point rather than given its own measurement.
for _, rg := range p.RaidGroups {
if rg.ID != p.ActiveRaidGroupID {
continue
}
fields["current_protection"] = rg.CurrentProtection.Val
fields["expected_protection"] = rg.ExpectedProtection.Val
fields["progress"] = rg.Progress.Val
fields["current_level"] = rg.CurrentLevel
}
r.send(&metric{
Table: "unas_pool",
Tags: map[string]string{
"source": d.SourceName,
"name": d.Name(),
"pool_id": p.ID,
"pool_type": p.Type,
"status": p.Status,
"prefer_level": p.PreferLevel,
},
Fields: fields,
})
}
for _, disk := range s.Disks {
r.send(&metric{
Table: "unas_disk",
Tags: map[string]string{
"source": d.SourceName,
"name": d.Name(),
"slot_id": disk.SlotID,
"location": disk.Location,
"pool_id": disk.PoolID,
"disk_type": disk.Type,
"state": disk.State,
"model": disk.Model,
"serial": disk.Serial,
"firmware": disk.Firmware,
},
Fields: map[string]any{
"size": disk.Size.Val,
"temperature": disk.Temperature.Val,
"health_score": disk.HealthScore.Val,
"power_on_hours": disk.PowerOnHours.Val,
"rpm": disk.RPM.Val,
"bad_sector_count": disk.BadSectorCount.Val,
"uncorrectable_sector_count": disk.UncorrectableSectorCount.Val,
"read_error_rate": disk.ReadErrorRate.Val,
"smart_read_error_count": disk.SmartReadErrorCount.Val,
"read_kbps": disk.ReadKBPS.Val,
"write_kbps": disk.WriteKBPS.Val,
"is_global_hot_spare": disk.IsGlobalHotSpare.Val,
"is_local_hot_spare": disk.IsLocalHotSpare.Val,
},
})
}
}
+120
View File
@@ -0,0 +1,120 @@
# UNAS Pro Input Plugin
Polls UniFi UNAS Pro storage consoles and hands their metrics to every configured output.
**This plugin is opt-in and does nothing until you configure at least one device.** With no
`[unas]` section, or a section with no devices, it stays silent and inert — it logs nothing
and polls nothing.
## Why a separate plugin?
A UNAS Pro is a standalone UniFi OS console with **no Network application**: no sites, no
`/status` endpoint, its own credentials, and usually its own host. `inputunifi`'s
sites-then-devices flow has nothing to offer it, so UNAS consoles are configured separately
rather than as another UniFi controller.
## Configuration
```toml
[unas]
disable = false
# Applied to any device that does not set its own value.
[unas.defaults]
user = "unpoller"
pass = ""
verify_ssl = false
timeout = "60s"
# Repeat for each console. Use its own local-account credentials.
# Do not add a path after the host.
[[unas.device]]
url = "https://192.168.1.10"
user = "unpoller"
pass = "unpoller"
verify_ssl = false
timeout = "60s"
ssl_cert_paths = []
```
JSON and YAML use `devices` (plural) as the list key; TOML uses repeated `[[unas.device]]`
tables. See `examples/up.{conf,json,yaml}.example`.
### Environment variables
| Variable | Meaning |
|---|---|
| `UP_UNAS_DISABLE` | Disable the plugin outright. |
| `UP_UNAS_DEFAULT_USER` | Default username for every device. |
| `UP_UNAS_DEFAULT_PASS` | Default password. |
| `UP_UNAS_DEFAULT_VERIFY_SSL` | Default TLS verification. |
| `UP_UNAS_DEFAULT_TIMEOUT` | Default HTTP timeout, e.g. `60s`. |
| `UP_UNAS_DEVICE_0_URL` | First console's URL. |
| `UP_UNAS_DEVICE_0_USER` | First console's username. |
| `UP_UNAS_DEVICE_0_PASS` | First console's password. |
| `UP_UNAS_DEVICE_0_VERIFY_SSL` | First console's TLS verification. |
| `UP_UNAS_DEVICE_0_TIMEOUT` | First console's HTTP timeout. |
Increment the index for additional consoles. Note the defaults block is `DEFAULT`, singular.
### Authentication
Username and password only. API keys are **not** supported: the UniFi library routes key
auth through `/status`, which a storage-only console does not serve.
A UNAS session expires after roughly two hours. When it does, every endpoint starts failing
at once; the plugin logs back in and retries once per poll, so this recovers without a
restart.
## Metrics
Data comes from the UniFi Drive API. Four endpoints are polled per cycle:
| Endpoint | Yields |
|---|---|
| `/proxy/drive/api/v2/systems/device-info` | name, model, version, firmware, status, CPU load and temperature, memory |
| `/proxy/drive/api/v2/storage` | pools (capacity, usage, status, RAID groups) and disks (health score, temperature, power-on hours, RPM, size, bad and uncorrectable sectors, read/write KB/s) |
| `/proxy/users/drive/api/v2/drives` | per-share id, name, type, status, quota, usage, member count |
| `/proxy/drive/api/v2/systems/network-io` | receive and transmit KB/s |
An endpoint that fails is logged and its metrics omitted for that cycle; the rest are still
reported. A console that fails *every* endpoint is reported as an error, and one dead console
never suppresses the metrics of a healthy one.
### Prometheus
Metric names are prefixed with the configured namespace, so they read as
`unifi_unas_disk_temperature_celsius`, `unifi_unas_pool_usage_bytes`,
`unifi_unas_share_quota_bytes`, and so on. Note this differs from `unaspoller`, which uses a
bare `unas_` prefix — the Grafana dashboard in
[#785](https://github.com/unpoller/unpoller/issues/785) needs its queries adjusted
accordingly. Consistency with every other unpoller metric wins here.
### InfluxDB and DataDog
Four measurements / metric prefixes: `unas_device`, `unas_pool`, `unas_disk`, and
`unas_share` (DataDog also emits `unas_raid_group`). They are kept separate because each has
its own tag set; folding them together would give every point the union of those tags with
most of them empty.
Loki and OpenTelemetry are not wired up: Loki carries events only, and a UNAS console exposes
no event endpoints in this version.
## Credit
Endpoint discovery and the JSON shapes come from
[alexgreenbank/unaspoller](https://github.com/alexgreenbank/unaspoller) (MIT), which worked
out the UniFi Drive API by observing the console's own web UI. See
[#785](https://github.com/unpoller/unpoller/issues/785).
## Known gaps
These need data from real hardware:
- The `networkInterfaces` JSON key is an informed assumption — the reference implementation's
struct tag for it was empty, so it was never actually verified. Interface data is not
exported as metrics either way.
- `cacheSlots`, `expansions`, `usbs`, `riskReasons`, and `incompatibleReasons` are kept as
raw JSON in the library because no populated example exists.
- `/proxy/drive/api/v2/systems/disk-stats` is not polled: it returns time series that do not
map onto gauges, and its per-disk throughput is already in `/storage`.
+163
View File
@@ -0,0 +1,163 @@
// Package inputunas implements the poller.Input interface for UniFi UNAS Pro storage
// consoles. A UNAS Pro is a standalone UniFi OS console with no Network application: no
// sites, no /status endpoint, its own credentials and its own host. That is why it lives
// here rather than inside inputunifi, whose sites-then-devices flow has nothing to offer it.
//
// Endpoint discovery and the JSON shapes are derived from alexgreenbank's unaspoller
// (https://github.com/alexgreenbank/unaspoller, MIT).
package inputunas
import (
"fmt"
"os"
"sync"
"time"
"github.com/unpoller/unifi/v5"
"github.com/unpoller/unpoller/pkg/poller"
"golift.io/cnfg"
)
// PluginName is the name of this input plugin.
const PluginName = "unas"
const (
defaultUser = "unpoller"
defaultTimeout = 60 * time.Second
)
// InputUNAS contains the running data.
type InputUNAS struct {
*Config `json:"unas" toml:"unas" xml:"unas" yaml:"unas"`
Logger poller.Logger
}
// Device represents one UNAS Pro console to poll. Each may have its own credentials.
type Device struct {
URL string `json:"url" toml:"url" xml:"url" yaml:"url"`
User string `json:"user" toml:"user" xml:"user" yaml:"user"`
Pass string `json:"pass" toml:"pass" xml:"pass" yaml:"pass"`
VerifySSL *bool `json:"verify_ssl" toml:"verify_ssl" xml:"verify_ssl" yaml:"verify_ssl"`
CertPaths []string `json:"ssl_cert_paths" toml:"ssl_cert_paths" xml:"ssl_cert_path" yaml:"ssl_cert_paths"`
Timeout cnfg.Duration `json:"timeout" toml:"timeout" xml:"timeout" yaml:"timeout"`
unas *unifi.UNASClient `json:"-" toml:"-" xml:"-" yaml:"-"`
}
// Config contains our configuration data.
type Config struct {
sync.RWMutex // locks a Device's client while it re-authenticates.
Default Device `json:"defaults" toml:"defaults" xml:"default" yaml:"defaults"`
Disable bool `json:"disable" toml:"disable" xml:"disable,attr" yaml:"disable"`
Devices []*Device `json:"devices" toml:"device" xml:"device" yaml:"devices"`
}
func init() { // nolint: gochecknoinits
u := &InputUNAS{}
poller.NewInput(&poller.InputPlugin{
Name: PluginName,
Input: u, // this package implements poller.Input for Metrics().
Config: u, // defines our config data interface.
})
}
// setDefaults fills a device's unset fields from the defaults block, then from package
// defaults. It never invents a URL: an empty URL is how "not configured" is expressed, and
// synthesizing one (as inputunifi does with localhost:8443) would make the plugin poll a
// host the operator never named. That is what keeps UNAS support opt-in.
func (u *InputUNAS) setDefaults(d *Device) *Device {
if d.User == "" {
d.User = u.Default.User
}
if d.User == "" {
d.User = defaultUser
}
if d.Pass == "" {
d.Pass = u.Default.Pass
}
if len(d.CertPaths) == 0 {
d.CertPaths = u.Default.CertPaths
}
if d.Timeout.Duration == 0 {
d.Timeout = u.Default.Timeout
}
if d.Timeout.Duration == 0 {
d.Timeout.Duration = defaultTimeout
}
if d.VerifySSL == nil {
if u.Default.VerifySSL != nil {
d.VerifySSL = u.Default.VerifySSL
} else {
f := false
d.VerifySSL = &f
}
}
return d
}
// getCerts reads in cert files from disk and stores them as a slice of byte slices.
func (d *Device) getCerts() ([][]byte, error) {
if len(d.CertPaths) == 0 {
return nil, nil
}
b := make([][]byte, len(d.CertPaths))
for i, f := range d.CertPaths {
c, err := os.ReadFile(f)
if err != nil {
return nil, fmt.Errorf("reading SSL cert file: %w", err)
}
b[i] = c
}
return b, nil
}
// login (re-)authenticates to a UNAS console, replacing any existing client.
//
// NewUNASClient stops after Login: unlike NewUnifi it does not finish with GetServerData,
// because that requests /status, which path() rewrites to /proxy/network/status -- and a
// storage-only console has no Network application to answer it.
func (u *InputUNAS) login(d *Device) error {
u.Lock()
defer u.Unlock()
if d.unas != nil && d.unas.Unifi != nil {
d.unas.CloseIdleConnections()
}
certs, err := d.getCerts()
if err != nil {
return err
}
client, err := unifi.NewUNASClient(&unifi.Config{
User: d.User,
Pass: d.Pass,
URL: d.URL,
SSLCert: certs,
VerifySSL: *d.VerifySSL,
Timeout: d.Timeout.Duration,
ErrorLog: u.LogErrorf,
DebugLog: u.LogDebugf,
})
if err != nil {
d.unas = nil
return fmt.Errorf("unas console %s: %w", d.URL, err)
}
d.unas = client
u.LogDebugf("Authenticated with UNAS console successfully, %s", d.URL)
return nil
}
+339
View File
@@ -0,0 +1,339 @@
package inputunas
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/unpoller/unifi/v5"
"github.com/unpoller/unpoller/pkg/poller"
"github.com/unpoller/unpoller/pkg/webserver"
)
// ErrNoDevices is returned by DebugInput when the plugin is enabled but nothing is configured.
var ErrNoDevices = errors.New("no UNAS devices configured")
/* This file contains the poller.Input interface methods. */
// Initialize gets called one time when starting up.
// Satisfies poller.Input interface.
func (u *InputUNAS) Initialize(l poller.Logger) error {
if u.Config == nil {
u.Config = &Config{Disable: true}
}
if u.Logger = l; u.Disable {
return nil
}
u.Devices = u.configuredDevices()
// No [unas] block means no devices, which means nothing to say. Staying silent here is
// what makes the plugin opt-in: an operator who has never heard of UNAS should see no
// trace of it in the log.
if len(u.Devices) == 0 {
return nil
}
for i, d := range u.Devices {
if err := u.login(d); err != nil {
// Not fatal: a console that is down at startup gets another attempt every poll.
u.LogErrorf("UNAS console %d of %d auth or connection error, retrying next poll: %v",
i+1, len(u.Devices), err)
continue
}
u.Logf("Configured UNAS console %d of %d:", i+1, len(u.Devices))
u.logDevice(d)
}
webserver.UpdateInput(&webserver.Input{Name: PluginName, Config: formatConfig(u.Config)})
return nil
}
// configuredDevices returns the devices with defaults applied, dropping any without a URL.
// A device with no URL is a config typo (or an empty [[unas.device]] table); polling it
// would only produce a confusing error against the empty string every cycle.
func (u *InputUNAS) configuredDevices() []*Device {
devices := make([]*Device, 0, len(u.Devices))
for _, d := range u.Devices {
if d == nil {
continue
}
if d.URL == "" {
u.LogErrorf("Ignoring UNAS device with no url configured.")
continue
}
devices = append(devices, u.setDefaults(d))
}
return devices
}
func (u *InputUNAS) logDevice(d *Device) {
u.Logf(" => URL: %s (verify SSL: %v, timeout: %v)", d.URL, *d.VerifySSL, d.Timeout.Duration)
if len(d.CertPaths) > 0 {
u.Logf(" => Cert Files: %s", strings.Join(d.CertPaths, ", "))
}
u.Logf(" => Username: %s (has password: %v)", d.User, d.Pass != "")
}
// Metrics polls every configured UNAS console and returns the aggregate.
//
// A per-console failure is logged and skipped rather than returned, and an error comes back
// only when nothing at all was collected. This is not politeness: poller.collectMetrics
// discards the metrics entirely when an input returns both a result and an error, so
// returning `(metrics, err)` after one console of three fails would throw away the two that
// worked. Satisfies poller.Input interface.
func (u *InputUNAS) Metrics(filter *poller.Filter) (*poller.Metrics, error) {
if u.Disable {
return nil, nil
}
metrics := &poller.Metrics{TS: time.Now()}
if filter == nil {
filter = &poller.Filter{}
}
var errs []error
for _, d := range u.Devices {
if filter.Path != "" && !strings.EqualFold(d.URL, filter.Path) {
continue
}
device, err := u.collectDevice(d)
if err != nil {
u.LogErrorf("Failed to collect metrics from UNAS console %s: %v", d.URL, err)
errs = append(errs, fmt.Errorf("%s: %w", d.URL, err))
continue
}
metrics.UNASDevices = append(metrics.UNASDevices, device)
}
if len(metrics.UNASDevices) == 0 && len(errs) > 0 {
return nil, errors.Join(errs...)
}
return metrics, nil
}
// collectDevice fetches one console, re-authenticating once if the whole fetch failed.
//
// UNAS session cookies expire after roughly two hours, so a long-running poller must expect
// to be logged out mid-life and must re-attach fresh cookies rather than reuse the dead jar.
// We retry on any total failure rather than on a 401 specifically: a mid-session 401 from
// GetData surfaces as ErrInvalidStatusCode, not ErrAuthenticationFailed, so there is no
// sentinel to match on. Session expiry fails every endpoint at once, which is exactly the
// total-failure case, and one wasted re-login against a console that is simply unreachable
// is cheaper than never recovering from an expired session. Partial failures never reach
// here: GetUNASDevice logs those endpoints and leaves their fields nil.
func (u *InputUNAS) collectDevice(d *Device) (*unifi.UNASDevice, error) {
device, err := u.getUNASDevice(d)
if err == nil {
return device, nil
}
u.Logf("Re-authenticating to UNAS console: %s", d.URL)
if lerr := u.login(d); lerr != nil {
// Report the original failure too, so a console that is merely down does not look
// like a credentials problem.
return nil, fmt.Errorf("re-authenticating after %v: %w", err, lerr)
}
return u.getUNASDevice(d)
}
// getUNASDevice reads the client under the read lock and releases it before polling, so a
// slow console cannot block a concurrent re-auth (which takes the write lock).
func (u *InputUNAS) getUNASDevice(d *Device) (*unifi.UNASDevice, error) {
u.RLock()
client := d.unas
u.RUnlock()
if client == nil {
return nil, unifi.ErrNilUnifi
}
return client.GetUNASDevice()
}
// Events is a no-op: a UNAS console exposes no event or log endpoints in this version.
// Satisfies poller.Input interface.
func (u *InputUNAS) Events(_ *poller.Filter) (*poller.Events, error) {
return &poller.Events{}, nil
}
// RawMetrics returns the raw JSON from one UNAS endpoint, selected by filter.Kind.
// Adjust filter.Unit to pull from a console other than the first.
// Satisfies poller.Input interface.
func (u *InputUNAS) RawMetrics(filter *poller.Filter) ([]byte, error) {
if filter == nil {
filter = &poller.Filter{}
}
if l := len(u.Devices); filter.Unit >= l {
return nil, fmt.Errorf("%d UNAS console(s) configured, '%d': %w", l, filter.Unit, ErrNoDevices)
}
d := u.Devices[filter.Unit]
u.RLock()
client := d.unas
u.RUnlock()
if client == nil {
if err := u.login(d); err != nil {
return nil, err
}
u.RLock()
client = d.unas
u.RUnlock()
}
var path string
switch filter.Kind {
case "", "device", "device-info", "d":
path = unifi.APIUNASDeviceInfoPath
case "storage", "s":
path = unifi.APIUNASStoragePath
case "drives", "dr":
path = unifi.APIUNASDrivesPath
case "network-io", "n":
path = unifi.APIUNASNetworkIOPath
default:
return nil, fmt.Errorf("must provide filter: device-info, storage, drives, network-io: %w",
unifi.ErrEndpointNotFound)
}
return client.GetJSON(path)
}
// DebugInput checks that every configured console can be reached and authenticated against.
// Satisfies poller.Input interface.
func (u *InputUNAS) DebugInput() (bool, error) {
if u == nil || u.Config == nil || u.Disable {
return true, nil
}
// Safe to assign without holding the lock: --debugio is a one-shot mode that exits before
// Run() ever starts polling (see poller.Start), so this cannot race a Metrics call. It is
// not redundant with Initialize either -- that never runs in this mode.
u.Devices = u.configuredDevices()
if len(u.Devices) == 0 {
return true, nil
}
allOK := true
var errs []error
for i, d := range u.Devices {
if err := u.login(d); err != nil {
u.LogErrorf("UNAS console %d of %d auth or connection error: %v", i+1, len(u.Devices), err)
allOK = false
errs = append(errs, err)
continue
}
u.Logf("Valid UNAS console %d of %d:", i+1, len(u.Devices))
u.logDevice(d)
}
return allOK, errors.Join(errs...)
}
// formatConfig copies the config for display on the web interface, replacing the password
// with whether one is set.
func formatConfig(config *Config) *Config {
devices := make([]*Device, len(config.Devices))
for i, d := range config.Devices {
devices[i] = &Device{
URL: d.URL,
User: d.User,
Pass: strconv.FormatBool(d.Pass != ""),
VerifySSL: d.VerifySSL,
CertPaths: d.CertPaths,
Timeout: d.Timeout,
}
}
return &Config{
Default: Device{
URL: config.Default.URL,
User: config.Default.User,
Pass: strconv.FormatBool(config.Default.Pass != ""),
VerifySSL: config.Default.VerifySSL,
CertPaths: config.Default.CertPaths,
Timeout: config.Default.Timeout,
},
Disable: config.Disable,
Devices: devices,
}
}
// Logf logs a message.
func (u *InputUNAS) Logf(msg string, v ...any) {
webserver.NewInputEvent(PluginName, PluginName, &webserver.Event{
Ts: time.Now(),
Msg: fmt.Sprintf(msg, v...),
Tags: map[string]string{"type": "info"},
})
if u.Logger != nil {
u.Logger.Logf(msg, v...)
}
}
// LogErrorf logs an error message.
func (u *InputUNAS) LogErrorf(msg string, v ...any) {
webserver.NewInputEvent(PluginName, PluginName, &webserver.Event{
Ts: time.Now(),
Msg: fmt.Sprintf(msg, v...),
Tags: map[string]string{"type": "error"},
})
if u.Logger != nil {
u.Logger.LogErrorf(msg, v...)
}
}
// LogDebugf logs a debug message.
func (u *InputUNAS) LogDebugf(msg string, v ...any) {
webserver.NewInputEvent(PluginName, PluginName, &webserver.Event{
Ts: time.Now(),
Msg: fmt.Sprintf(msg, v...),
Tags: map[string]string{"type": "debug"},
})
if u.Logger != nil {
u.Logger.LogDebugf(msg, v...)
}
}
+219
View File
@@ -0,0 +1,219 @@
package inputunas_test
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/unpoller/unifi/v5"
"github.com/unpoller/unpoller/pkg/inputunas"
"github.com/unpoller/unpoller/pkg/poller"
)
const (
deviceInfoJSON = `{"name":"UNAS-Pro","model":"UNASPro","version":"4.0.6","firmwareVersion":"4.0.6",
"status":"HEALTHY","cpu":{"currentLoad":12,"temperature":45},
"memory":{"free":1000,"total":8000,"available":6000}}`
storageJSON = `{"pools":[{"number":1,"id":"pool-1","type":"RAID","status":"HEALTHY",
"capacity":1000,"usage":400,"activeRaidGroupId":"rg-1",
"raidGroups":[{"number":1,"id":"rg-1","currentLevel":"RAID5","configLevel":"RAID5",
"currentProtection":1,"expectedProtection":1,"progress":100}]}],
"disks":[{"slotId":"1","poolId":"pool-1","type":"HDD","state":"HEALTHY",
"model":"HAT5300","serial":"ABC123","temperature":38,"healthScore":100,"size":500}]}`
drivesJSON = `{"drives":[{"id":"d-1","name":"media","type":"SHARE","status":"HEALTHY","quota":100,"usage":50,"memberCount":3}]}`
networkIOJSON = `{"receiveKBPS":120,"transmitKBPS":340,"timestamp":"2026-01-01T00:00:00Z"}`
)
// unasServer is a fake UNAS Pro console. failData, when set, makes every data endpoint
// return 401 -- which is how an expired session presents itself.
type unasServer struct {
logins atomic.Int64
failData atomic.Bool
}
func (s *unasServer) start(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, _ *http.Request) {
s.logins.Add(1)
w.Header().Set("x-csrf-token", "csrf-token")
w.WriteHeader(http.StatusOK)
})
for path, body := range map[string]string{
unifi.APIUNASDeviceInfoPath: deviceInfoJSON,
unifi.APIUNASStoragePath: storageJSON,
unifi.APIUNASDrivesPath: drivesJSON,
unifi.APIUNASNetworkIOPath: networkIOJSON,
} {
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
if s.failData.Load() {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
})
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func newInput(t *testing.T, urls ...string) *inputunas.InputUNAS {
t.Helper()
devices := make([]*inputunas.Device, len(urls))
for i, u := range urls {
devices[i] = &inputunas.Device{URL: u, User: "unpoller", Pass: "secret"}
}
return &inputunas.InputUNAS{Config: &inputunas.Config{Devices: devices}}
}
func TestMetricsCollectsConsole(t *testing.T) {
t.Parallel()
srv := (&unasServer{}).start(t)
u := newInput(t, srv.URL)
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err)
require.NotNil(t, m)
require.Len(t, m.UNASDevices, 1)
d, ok := m.UNASDevices[0].(*unifi.UNASDevice)
require.True(t, ok, "outputs type-assert to *unifi.UNASDevice, so nothing else may be appended")
a := assert.New(t)
a.Equal("UNAS-Pro", d.Name())
a.Equal("UNASPro", d.Model())
a.Equal(srv.URL, d.SourceName)
require.NotNil(t, d.Storage)
a.Len(d.Storage.Pools, 1)
a.Len(d.Storage.Disks, 1)
a.Len(d.Drives, 1)
require.NotNil(t, d.NetworkIO)
a.InDelta(120, d.NetworkIO.ReceiveKBPS.Val, 0.01)
}
// A UNAS session cookie expires after roughly two hours. When it does, every endpoint fails
// at once, and the plugin must log back in and re-attach fresh cookies rather than reuse the
// dead jar -- otherwise a long-running poller reports nothing until it is restarted.
func TestMetricsReauthenticatesAfterSessionExpiry(t *testing.T) {
t.Parallel()
fake := &unasServer{}
srv := fake.start(t)
u := newInput(t, srv.URL)
require.NoError(t, u.Initialize(nil))
require.Equal(t, int64(1), fake.logins.Load())
fake.failData.Store(true)
m, err := u.Metrics(nil)
require.Error(t, err, "a console that fails every endpoint even after re-auth is a real error")
require.Nil(t, m)
require.Equal(t, int64(2), fake.logins.Load(), "a total failure must trigger exactly one re-login")
// Once the console answers again, the refreshed session works without another login.
fake.failData.Store(false)
m, err = u.Metrics(nil)
require.NoError(t, err)
require.Len(t, m.UNASDevices, 1)
require.Equal(t, int64(2), fake.logins.Load(), "a healthy poll must not re-authenticate")
}
// One dead console must not discard the metrics of a healthy one. poller.collectMetrics
// drops the result entirely when an input returns both metrics and an error, so returning
// them together here would throw away everything the working console reported.
func TestMetricsPartialFailureStillReportsHealthyConsole(t *testing.T) {
t.Parallel()
good := (&unasServer{}).start(t)
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer dead.Close()
u := newInput(t, dead.URL, good.URL)
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err, "one bad console out of two is not an error")
require.Len(t, m.UNASDevices, 1)
}
func TestMetricsDisabled(t *testing.T) {
t.Parallel()
u := &inputunas.InputUNAS{Config: &inputunas.Config{Disable: true}}
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err)
require.Nil(t, m)
}
// With no [unas] block the plugin must be silent and inert: that is what makes UNAS support
// opt-in. It must not synthesize a default device the way inputunifi does with localhost.
func TestUnconfiguredIsInert(t *testing.T) {
t.Parallel()
u := &inputunas.InputUNAS{}
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err)
require.Nil(t, m, "an unconfigured plugin disables itself")
ok, err := u.DebugInput()
require.NoError(t, err)
require.True(t, ok)
}
func TestDeviceWithNoURLIsIgnored(t *testing.T) {
t.Parallel()
u := &inputunas.InputUNAS{Config: &inputunas.Config{Devices: []*inputunas.Device{{}, nil}}}
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err)
require.Empty(t, m.UNASDevices)
}
func TestEventsAreEmpty(t *testing.T) {
t.Parallel()
e, err := (&inputunas.InputUNAS{}).Events(&poller.Filter{})
require.NoError(t, err)
require.NotNil(t, e)
require.Empty(t, e.Logs)
}
func TestRawMetrics(t *testing.T) {
t.Parallel()
srv := (&unasServer{}).start(t)
u := newInput(t, srv.URL)
require.NoError(t, u.Initialize(nil))
body, err := u.RawMetrics(&poller.Filter{Kind: "storage"})
require.NoError(t, err)
require.Contains(t, string(body), "pool-1")
_, err = u.RawMetrics(&poller.Filter{Unit: 9})
require.ErrorIs(t, err, inputunas.ErrNoDevices)
}
+2
View File
@@ -129,6 +129,8 @@ type Metrics struct {
DPICategories []any // *unifi.DPICategory — DPI category catalogue (global)
PendingDevices []any // *unifi.PendingDevice — devices awaiting adoption (global)
Countries []any // *unifi.Country — country list for geo-filters (global)
// Added by the inputunas plugin:
UNASDevices []any // *unifi.UNASDevice — UNAS Pro storage console (one per configured device)
}
// Events defines the type for log entries.
+1
View File
@@ -351,6 +351,7 @@ func AppendMetrics(existing *Metrics, m *Metrics) *Metrics {
existing.DPICategories = append(existing.DPICategories, m.DPICategories...)
existing.PendingDevices = append(existing.PendingDevices, m.PendingDevices...)
existing.Countries = append(existing.Countries, m.Countries...)
existing.UNASDevices = append(existing.UNASDevices, m.UNASDevices...)
return existing
}
+27 -18
View File
@@ -46,29 +46,30 @@ const (
var ErrMetricFetchFailed = fmt.Errorf("metric fetch failed")
type promUnifi struct {
*Config `json:"prometheus" toml:"prometheus" xml:"prometheus" yaml:"prometheus"`
Client *uclient
Device *unifiDevice
UAP *uap
USG *usg
USW *usw
PDU *pdu
Site *site
RogueAP *rogueap
SpeedTest *speedtest
CountryTraffic *ucountrytraffic
DHCPLease *dhcplease
WAN *wan
Controller *controller
FirewallPolicy *firewallpolicy
Topology *topology
PortAnomaly *portanomaly
VPNMesh *vpnmesh
*Config `json:"prometheus" toml:"prometheus" xml:"prometheus" yaml:"prometheus"`
Client *uclient
Device *unifiDevice
UAP *uap
USG *usg
USW *usw
PDU *pdu
Site *site
RogueAP *rogueap
SpeedTest *speedtest
CountryTraffic *ucountrytraffic
DHCPLease *dhcplease
WAN *wan
Controller *controller
FirewallPolicy *firewallpolicy
Topology *topology
PortAnomaly *portanomaly
VPNMesh *vpnmesh
IntegrationDevice *integrationDevice
WANStatus *wanStatus
PortForward *portForward
SSLCertificate *sslCertificate
UPSDevice *upsDevice
UNASDevice *unasDevice
WifiBroadcast *wifiBroadcast
FirewallZone *firewallZone
ACLRule *aclRule
@@ -319,6 +320,7 @@ func (u *promUnifi) Run(c poller.Collect) error {
u.PortForward = descPortForward(u.Namespace + "_")
u.SSLCertificate = descSSLCertificate(u.Namespace + "_")
u.UPSDevice = descUPSDevice(u.Namespace + "_")
u.UNASDevice = descUNASDevice(u.Namespace + "_")
u.WifiBroadcast = descWifiBroadcast(u.Namespace + "_")
u.FirewallZone = descFirewallZone(u.Namespace + "_")
u.ACLRule = descACLRule(u.Namespace + "_")
@@ -609,6 +611,7 @@ func (u *promUnifi) Describe(ch chan<- *prometheus.Desc) {
u.LAG, u.MCLAGDomain, u.SwitchStack, u.DNSPolicy, u.RADIUSProfile,
u.TrafficMatchingList, u.HotspotVoucher,
u.DPIApplication, u.DPICategory, u.PendingDevice, u.Country,
u.UNASDevice,
} {
v := reflect.Indirect(reflect.ValueOf(f))
@@ -839,6 +842,12 @@ func (u *promUnifi) loopExports(r report) {
}
}
for _, nd := range m.UNASDevices {
if v, ok := nd.(*unifi.UNASDevice); ok {
u.exportUNASDevice(r, v)
}
}
for _, wb := range m.WifiBroadcasts {
if v, ok := wb.(*unifi.WifiBroadcast); ok {
u.exportWifiBroadcast(r, v)
+209
View File
@@ -0,0 +1,209 @@
package promunifi
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/unpoller/unifi/v5"
)
// unasDevice holds Prometheus descriptors for UNAS Pro storage console metrics.
//
// The struct is deliberately flat even though the metrics come from five different entities
// (console, network IO, pool, RAID group, disk, share) with different label sets: Describe
// reflects over the members of this struct and sends each one to the channel, so a nested
// sub-struct would be sent as something that is not a *prometheus.Desc.
type unasDevice struct {
// Console.
Presence *prometheus.Desc
CPULoad *prometheus.Desc
CPUTemperature *prometheus.Desc
MemoryTotal *prometheus.Desc
MemoryFree *prometheus.Desc
MemoryAvail *prometheus.Desc
ReceiveKBPS *prometheus.Desc
TransmitKBPS *prometheus.Desc
// Storage pools and their RAID groups.
PoolCapacity *prometheus.Desc
PoolUsage *prometheus.Desc
RaidCurrentProtection *prometheus.Desc
RaidExpectedProtection *prometheus.Desc
RaidProgress *prometheus.Desc
// Physical disks.
DiskSize *prometheus.Desc
DiskTemperature *prometheus.Desc
DiskHealthScore *prometheus.Desc
DiskPowerOnHours *prometheus.Desc
DiskRPM *prometheus.Desc
DiskBadSectorCount *prometheus.Desc
DiskUncorrectableSectorCount *prometheus.Desc
DiskReadErrorRate *prometheus.Desc
DiskSmartReadErrorCount *prometheus.Desc
DiskReadKBPS *prometheus.Desc
DiskWriteKBPS *prometheus.Desc
// Shares ("drives").
ShareQuota *prometheus.Desc
ShareUsage *prometheus.Desc
ShareMemberCount *prometheus.Desc
}
func descUNASDevice(ns string) *unasDevice { // nolint: funlen
ns += "unas_"
device := []string{"source", "name", "model"}
console := []string{"source", "name"}
pool := []string{"source", "name", "pool_id", "pool_type", "status"}
raid := []string{"source", "name", "pool_id", "raid_group_id", "current_level", "config_level"}
disk := []string{"source", "name", "slot_id", "pool_id", "disk_type", "state", "model", "serial"}
share := []string{"source", "name", "share_id", "share_name", "share_type", "status"}
return &unasDevice{
Presence: prometheus.NewDesc(ns+"device_present",
"UNAS console reachable (always 1 when present)", device, nil),
CPULoad: prometheus.NewDesc(ns+"cpu_load_percent",
"UNAS console current CPU load", console, nil),
CPUTemperature: prometheus.NewDesc(ns+"cpu_temperature_celsius",
"UNAS console CPU temperature", console, nil),
MemoryTotal: prometheus.NewDesc(ns+"memory_total_bytes",
"UNAS console total memory", console, nil),
MemoryFree: prometheus.NewDesc(ns+"memory_free_bytes",
"UNAS console free memory", console, nil),
MemoryAvail: prometheus.NewDesc(ns+"memory_available_bytes",
"UNAS console available memory", console, nil),
ReceiveKBPS: prometheus.NewDesc(ns+"receive_kbps",
"UNAS console instantaneous receive throughput in KB/s", console, nil),
TransmitKBPS: prometheus.NewDesc(ns+"transmit_kbps",
"UNAS console instantaneous transmit throughput in KB/s", console, nil),
PoolCapacity: prometheus.NewDesc(ns+"pool_capacity_bytes",
"UNAS storage pool total capacity", pool, nil),
PoolUsage: prometheus.NewDesc(ns+"pool_usage_bytes",
"UNAS storage pool bytes in use", pool, nil),
RaidCurrentProtection: prometheus.NewDesc(ns+"raid_group_current_protection",
"UNAS RAID group current redundancy (compare to expected to spot a degraded array)",
raid, nil),
RaidExpectedProtection: prometheus.NewDesc(ns+"raid_group_expected_protection",
"UNAS RAID group expected redundancy", raid, nil),
RaidProgress: prometheus.NewDesc(ns+"raid_group_progress_percent",
"UNAS RAID group rebuild or expansion progress", raid, nil),
DiskSize: prometheus.NewDesc(ns+"disk_size_bytes",
"UNAS disk size", disk, nil),
DiskTemperature: prometheus.NewDesc(ns+"disk_temperature_celsius",
"UNAS disk temperature", disk, nil),
DiskHealthScore: prometheus.NewDesc(ns+"disk_health_score",
"UNAS disk health score as reported by the console", disk, nil),
DiskPowerOnHours: prometheus.NewDesc(ns+"disk_power_on_hours",
"UNAS disk SMART power-on hours", disk, nil),
DiskRPM: prometheus.NewDesc(ns+"disk_rpm",
"UNAS disk rotational speed (0 for solid state)", disk, nil),
DiskBadSectorCount: prometheus.NewDesc(ns+"disk_bad_sectors",
"UNAS disk bad sector count", disk, nil),
DiskUncorrectableSectorCount: prometheus.NewDesc(ns+"disk_uncorrectable_sectors",
"UNAS disk uncorrectable sector count", disk, nil),
DiskReadErrorRate: prometheus.NewDesc(ns+"disk_read_error_rate",
"UNAS disk SMART read error rate", disk, nil),
DiskSmartReadErrorCount: prometheus.NewDesc(ns+"disk_smart_read_errors",
"UNAS disk SMART read error count", disk, nil),
DiskReadKBPS: prometheus.NewDesc(ns+"disk_read_kbps",
"UNAS disk instantaneous read throughput in KB/s", disk, nil),
DiskWriteKBPS: prometheus.NewDesc(ns+"disk_write_kbps",
"UNAS disk instantaneous write throughput in KB/s", disk, nil),
ShareQuota: prometheus.NewDesc(ns+"share_quota_bytes",
"UNAS share quota (0 when unlimited)", share, nil),
ShareUsage: prometheus.NewDesc(ns+"share_usage_bytes",
"UNAS share bytes in use", share, nil),
ShareMemberCount: prometheus.NewDesc(ns+"share_members",
"UNAS share member count", share, nil),
}
}
// exportUNASDevice emits every metric family for one UNAS console.
//
// Each section is guarded independently because GetUNASDevice leaves the field for a failing
// endpoint nil rather than failing the whole poll -- a console that serves storage but not
// network-io still reports its disks.
func (u *promUnifi) exportUNASDevice(r report, d *unifi.UNASDevice) {
if d == nil {
return
}
name, model := d.Name(), d.Model()
console := []string{d.SourceName, name}
r.send([]*metric{{u.UNASDevice.Presence, gauge, 1.0, []string{d.SourceName, name, model}}})
if info := d.DeviceInfo; info != nil {
r.send([]*metric{
{u.UNASDevice.CPULoad, gauge, info.CPU.CurrentLoad, console},
{u.UNASDevice.CPUTemperature, gauge, info.CPU.Temperature, console},
{u.UNASDevice.MemoryTotal, gauge, info.Memory.Total, console},
{u.UNASDevice.MemoryFree, gauge, info.Memory.Free, console},
{u.UNASDevice.MemoryAvail, gauge, info.Memory.Available, console},
})
}
if io := d.NetworkIO; io != nil {
r.send([]*metric{
{u.UNASDevice.ReceiveKBPS, gauge, io.ReceiveKBPS, console},
{u.UNASDevice.TransmitKBPS, gauge, io.TransmitKBPS, console},
})
}
if s := d.Storage; s != nil {
u.exportUNASStorage(r, d, s)
}
for _, share := range d.Drives {
if share == nil {
continue
}
labels := []string{d.SourceName, name, share.ID, share.Name, share.Type, share.Status}
r.send([]*metric{
{u.UNASDevice.ShareQuota, gauge, share.Quota, labels},
{u.UNASDevice.ShareUsage, gauge, share.Usage, labels},
{u.UNASDevice.ShareMemberCount, gauge, share.MemberCount, labels},
})
}
}
func (u *promUnifi) exportUNASStorage(r report, d *unifi.UNASDevice, s *unifi.UNASStorage) {
name := d.Name()
for _, p := range s.Pools {
labels := []string{d.SourceName, name, p.ID, p.Type, p.Status}
r.send([]*metric{
{u.UNASDevice.PoolCapacity, gauge, p.Capacity, labels},
{u.UNASDevice.PoolUsage, gauge, p.Usage, labels},
})
for _, rg := range p.RaidGroups {
raid := []string{d.SourceName, name, p.ID, rg.ID, rg.CurrentLevel, rg.ConfigLevel}
r.send([]*metric{
{u.UNASDevice.RaidCurrentProtection, gauge, rg.CurrentProtection, raid},
{u.UNASDevice.RaidExpectedProtection, gauge, rg.ExpectedProtection, raid},
{u.UNASDevice.RaidProgress, gauge, rg.Progress, raid},
})
}
}
for _, disk := range s.Disks {
labels := []string{
d.SourceName, name, disk.SlotID, disk.PoolID,
disk.Type, disk.State, disk.Model, disk.Serial,
}
r.send([]*metric{
{u.UNASDevice.DiskSize, gauge, disk.Size, labels},
{u.UNASDevice.DiskTemperature, gauge, disk.Temperature, labels},
{u.UNASDevice.DiskHealthScore, gauge, disk.HealthScore, labels},
{u.UNASDevice.DiskPowerOnHours, gauge, disk.PowerOnHours, labels},
{u.UNASDevice.DiskRPM, gauge, disk.RPM, labels},
{u.UNASDevice.DiskBadSectorCount, gauge, disk.BadSectorCount, labels},
{u.UNASDevice.DiskUncorrectableSectorCount, gauge, disk.UncorrectableSectorCount, labels},
{u.UNASDevice.DiskReadErrorRate, gauge, disk.ReadErrorRate, labels},
{u.UNASDevice.DiskSmartReadErrorCount, gauge, disk.SmartReadErrorCount, labels},
{u.UNASDevice.DiskReadKBPS, gauge, disk.ReadKBPS, labels},
{u.UNASDevice.DiskWriteKBPS, gauge, disk.WriteKBPS, labels},
})
}
}
+103
View File
@@ -0,0 +1,103 @@
//nolint:testpackage // white-box: exercises the unexported descriptors and export path.
package promunifi
import (
"reflect"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/unpoller/unifi/v5"
)
// fakeReport collects the metrics an export function sends, and nothing else. It embeds
// *Report (nil) only to satisfy the rest of the report interface; the export path under test
// calls nothing but send.
type fakeReport struct {
*Report
sent []*metric
}
func (f *fakeReport) send(m []*metric) { f.sent = append(f.sent, m...) }
func testUNASDevice() *unifi.UNASDevice {
return &unifi.UNASDevice{
SourceName: "https://unas.example",
DeviceInfo: &unifi.UNASDeviceInfo{
Name: "UNAS-Pro",
Model: "UNASPro",
CPU: unifi.UNASCPU{CurrentLoad: unifi.FlexInt{Val: 12}, Temperature: unifi.FlexInt{Val: 45}},
Memory: unifi.UNASMemory{Total: unifi.FlexInt{Val: 8000}},
},
NetworkIO: &unifi.UNASNetworkIO{ReceiveKBPS: unifi.FlexInt{Val: 120}},
Storage: &unifi.UNASStorage{
Pools: []unifi.UNASPool{{
ID: "pool-1", RaidGroups: []unifi.UNASRaidGroup{{ID: "rg-1"}},
}},
Disks: []unifi.UNASDisk{{SlotID: "1", Temperature: unifi.FlexInt{Val: 38}}},
},
Drives: []*unifi.UNASDrive{{ID: "d-1", Name: "media"}, nil},
}
}
// TestUNASDescriptorsAreReachableByDescribe replicates the exact reflect loop Describe uses.
// That loop skips any member which is not a *prometheus.Desc, so a nested sub-struct would be
// dropped silently rather than noisily -- and an unassigned field would be a nil descriptor.
// The loop is duplicated rather than driven through Describe itself because Describe walks
// every metric family and the descriptors are assigned inline in Run, which also starts an
// HTTP listener; there is no way to build a collector holding only this family.
func TestUNASDescriptorsAreReachableByDescribe(t *testing.T) {
t.Parallel()
descs := descUNASDevice("unifi_")
v := reflect.Indirect(reflect.ValueOf(descs))
got := []*prometheus.Desc{}
for i := 0; i < v.NumField(); i++ {
desc, ok := v.Field(i).Interface().(*prometheus.Desc)
require.True(t, ok, "field %d of unasDevice is not a *prometheus.Desc; Describe would skip it",
i)
require.NotNil(t, desc, "field %d of unasDevice was never assigned by descUNASDevice", i)
got = append(got, desc)
}
assert.Len(t, got, v.NumField())
}
func TestExportUNASDevice(t *testing.T) {
t.Parallel()
r := &fakeReport{}
u := &promUnifi{UNASDevice: descUNASDevice("unifi_")}
u.exportUNASDevice(r, testUNASDevice())
// presence(1) + console(5) + net io(2) + pool(2) + raid(3) + disk(11) + one share(3).
// This is the flattened total across the seven send() calls -- it equals the descriptor
// struct's field count only because this fixture fires each descriptor exactly once. Do
// not "simplify" it to reflect.NumField(): add a second pool or disk and they diverge.
assert.Len(t, r.sent, 27, "every family is emitted for a fully populated console")
for i, m := range r.sent {
require.NotNil(t, m.Desc, "metric %d has no descriptor", i)
require.NotEmpty(t, m.Labels, "metric %d has no labels", i)
}
}
// GetUNASDevice leaves the field for a failing endpoint nil instead of failing the whole
// poll, so every section must be independently guarded.
func TestExportUNASDeviceTolerantOfNilSections(t *testing.T) {
t.Parallel()
u := &promUnifi{UNASDevice: descUNASDevice("unifi_")}
r := &fakeReport{}
u.exportUNASDevice(r, &unifi.UNASDevice{SourceName: "https://unas.example"})
assert.Len(t, r.sent, 1, "a console with no readable endpoints still reports its presence")
empty := &fakeReport{}
u.exportUNASDevice(empty, nil)
assert.Empty(t, empty.sent)
}