mirror of
https://github.com/unpoller/unpoller.git
synced 2026-10-18 04:35:53 +02:00
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:
co-authored by
Claude Opus 5
parent
ccc6ad5dae
commit
d5dfc771d5
@@ -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.
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user