From 20ee82b3e63e0f558dc1364194a63e7b20efa69f Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:13:08 -0400 Subject: [PATCH] feat(unas): export the SSD cache and its cache-bay SSDs (#1109) --- examples/up.conf.example | 2 +- go.mod | 2 + go.sum | 4 +- pkg/datadogunifi/unas.go | 87 +++++++++++++++++++++++++--------- pkg/influxunifi/unas.go | 95 ++++++++++++++++++++++++++------------ pkg/inputunas/README.md | 16 +++++-- pkg/promunifi/unas.go | 83 ++++++++++++++++++++++++++------- pkg/promunifi/unas_test.go | 41 ++++++++++++++++ 8 files changed, 253 insertions(+), 77 deletions(-) diff --git a/examples/up.conf.example b/examples/up.conf.example index e2436495..cdab527a 100644 --- a/examples/up.conf.example +++ b/examples/up.conf.example @@ -310,7 +310,7 @@ # This section is entirely optional. The plugin is off unless you set enable = true and # add at least one [[unas.device]] below; otherwise it 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_pool, unas_pool_cache, unas_disk, and unas_share measurements in InfluxDB. # #[unas] # enable = true diff --git a/go.mod b/go.mod index e942982b..23cbf6ba 100644 --- a/go.mod +++ b/go.mod @@ -72,3 +72,5 @@ require ( golang.org/x/sys v0.48.0 // indirect google.golang.org/protobuf v1.36.12 // indirect ) + +replace github.com/unpoller/unifi/v6 => github.com/cpcloud/unifi/v6 v6.0.0-20261017135054-b7989cc3fe67 diff --git a/go.sum b/go.sum index ca5d543f..bf2ecb03 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpcloud/unifi/v6 v6.0.0-20261017135054-b7989cc3fe67 h1:rEiPcEkXizLqLkEKgaND9ozy9WJZMNjSp6on85/PHNY= +github.com/cpcloud/unifi/v6 v6.0.0-20261017135054-b7989cc3fe67/go.mod h1:fJ1lU/mpKrQ8Jv3b2yq399ATlrUuiOZAMbKWcw/1GaU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -121,8 +123,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/unpoller/unifi/v6 v6.3.1 h1:gUBOYmSl/kmTAlSVfTEaDYqGU6iao5biMDeuQcCZmLE= -github.com/unpoller/unifi/v6 v6.3.1/go.mod h1:fJ1lU/mpKrQ8Jv3b2yq399ATlrUuiOZAMbKWcw/1GaU= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= diff --git a/pkg/datadogunifi/unas.go b/pkg/datadogunifi/unas.go index 4587c20c..dba46d6b 100644 --- a/pkg/datadogunifi/unas.go +++ b/pkg/datadogunifi/unas.go @@ -77,37 +77,78 @@ func (u *DatadogUnifi) batchUNASPools(r report, d *unifi.UNASDevice, s *unifi.UN _ = r.reportGauge(raidName("expected_protection"), rg.ExpectedProtection.Val, raidTags) _ = r.reportGauge(raidName("progress"), rg.Progress.Val, raidTags) } + + if c := p.Cache; c != nil { + u.batchUNASPoolCache(r, d, p.ID, c) + } } } +func (u *DatadogUnifi) batchUNASPoolCache(r report, d *unifi.UNASDevice, poolID string, c *unifi.UNASPoolCache) { + metricName := metricNamespace("unas_pool_cache") + + tags := tagMapToTags(cleanTags(map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "pool_id": poolID, + "mode": c.Mode, + "status": c.Status, + })) + + _ = r.reportGauge(metricName("size"), c.Size.Val, tags) + _ = r.reportGauge(metricName("used"), c.Composition.Used.Val, tags) + _ = r.reportGauge(metricName("dirty"), c.Composition.Dirty.Val, tags) + _ = r.reportGauge(metricName("read_hit_rate"), c.Composition.ReadHitRate.Val, tags) + _ = r.reportGauge(metricName("write_hit_rate"), c.Composition.WriteHitRate.Val, tags) +} + 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) + u.batchUNASDisk(r, d, disk) } + + // Cache bays hold SSDs with the same fields as pool disks; disk_type tells them apart. + for _, disk := range s.CacheSlots { + if disk.State == "empty" { + continue + } + + tags := u.batchUNASDisk(r, d, disk) + _ = r.reportGauge(metricName("life_span"), disk.LifeSpan.Val, tags) + } +} + +// batchUNASDisk reports one disk's gauges and returns its tags. +func (u *DatadogUnifi) batchUNASDisk(r report, d *unifi.UNASDevice, disk unifi.UNASDisk) []string { + metricName := metricNamespace("unas_disk") + + 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) + + return tags } func (u *DatadogUnifi) batchUNASShares(r report, d *unifi.UNASDevice) { diff --git a/pkg/influxunifi/unas.go b/pkg/influxunifi/unas.go index 0873e720..00d76380 100644 --- a/pkg/influxunifi/unas.go +++ b/pkg/influxunifi/unas.go @@ -114,38 +114,73 @@ func (u *InfluxUnifi) batchUNASStorage(r report, d *unifi.UNASDevice, s *unifi.U }, Fields: fields, }) + + if c := p.Cache; c != nil { + r.send(&metric{ + Table: "unas_pool_cache", + Tags: map[string]string{ + "source": d.SourceName, + "name": d.Name(), + "pool_id": p.ID, + "mode": c.Mode, + "status": c.Status, + }, + Fields: map[string]any{ + "size": c.Size.Val, + "used": c.Composition.Used.Val, + "dirty": c.Composition.Dirty.Val, + "read_hit_rate": c.Composition.ReadHitRate.Val, + "write_hit_rate": c.Composition.WriteHitRate.Val, + }, + }) + } } 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, - }, - }) + r.send(unasDiskMetric(d, disk)) + } + + // Cache bays hold SSDs with the same fields as pool disks; disk_type tells them apart. + for _, disk := range s.CacheSlots { + if disk.State == "empty" { + continue + } + + m := unasDiskMetric(d, disk) + m.Fields["life_span"] = disk.LifeSpan.Val + r.send(m) + } +} + +func unasDiskMetric(d *unifi.UNASDevice, disk unifi.UNASDisk) *metric { + return &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, + }, } } diff --git a/pkg/inputunas/README.md b/pkg/inputunas/README.md index c4039c25..bd8a0de3 100644 --- a/pkg/inputunas/README.md +++ b/pkg/inputunas/README.md @@ -74,7 +74,7 @@ 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/drive/api/v2/storage` | pools (capacity, usage, status, RAID groups, SSD cache size, fill, dirty data, and hit rates), disks (health score, temperature, power-on hours, RPM, size, bad and uncorrectable sectors, read/write KB/s), and the SSDs in the cache bays (the same disk fields plus remaining endurance) | | `/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 | @@ -91,12 +91,18 @@ 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. +The SSD cache in front of a pool reports as `unifi_unas_pool_cache_*`. Cache-bay SSDs report +as `unifi_unas_disk_*` with `disk_type` set to the SSD's type, plus +`unifi_unas_disk_life_span_percent`. A cache bay and a drive bay can share a `slot_id`, so +filter on `disk_type` to tell them apart. + ### 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. +Five measurements / metric prefixes: `unas_device`, `unas_pool`, `unas_pool_cache`, +`unas_disk`, and `unas_share` (DataDog also emits `unas_raid_group`). Cache-bay SSDs report +under `unas_disk` with `disk_type` set to the SSD's type and an extra `life_span` field. The +measurements 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. diff --git a/pkg/promunifi/unas.go b/pkg/promunifi/unas.go index 649179b5..54c54ec2 100644 --- a/pkg/promunifi/unas.go +++ b/pkg/promunifi/unas.go @@ -27,6 +27,12 @@ type unasDevice struct { RaidCurrentProtection *prometheus.Desc RaidExpectedProtection *prometheus.Desc RaidProgress *prometheus.Desc + // SSD caches in front of storage pools. + PoolCacheSize *prometheus.Desc + PoolCacheUsed *prometheus.Desc + PoolCacheDirty *prometheus.Desc + PoolCacheReadHitRate *prometheus.Desc + PoolCacheWriteHitRate *prometheus.Desc // Physical disks. DiskSize *prometheus.Desc DiskTemperature *prometheus.Desc @@ -39,6 +45,7 @@ type unasDevice struct { DiskSmartReadErrorCount *prometheus.Desc DiskReadKBPS *prometheus.Desc DiskWriteKBPS *prometheus.Desc + DiskLifeSpan *prometheus.Desc // Shares ("drives"). ShareQuota *prometheus.Desc ShareUsage *prometheus.Desc @@ -51,6 +58,7 @@ func descUNASDevice(ns string) *unasDevice { // nolint: funlen 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"} + cache := []string{"source", "name", "pool_id", "mode", "status"} disk := []string{"source", "name", "slot_id", "pool_id", "disk_type", "state", "model", "serial"} share := []string{"source", "name", "share_id", "share_name", "share_type", "status"} @@ -82,6 +90,16 @@ func descUNASDevice(ns string) *unasDevice { // nolint: funlen "UNAS RAID group expected redundancy", raid, nil), RaidProgress: prometheus.NewDesc(ns+"raid_group_progress_percent", "UNAS RAID group rebuild or expansion progress", raid, nil), + PoolCacheSize: prometheus.NewDesc(ns+"pool_cache_size_bytes", + "UNAS storage pool SSD cache size", cache, nil), + PoolCacheUsed: prometheus.NewDesc(ns+"pool_cache_used_bytes", + "UNAS storage pool SSD cache bytes holding cached data", cache, nil), + PoolCacheDirty: prometheus.NewDesc(ns+"pool_cache_dirty_bytes", + "UNAS storage pool SSD cache bytes not yet written to the pool's disks", cache, nil), + PoolCacheReadHitRate: prometheus.NewDesc(ns+"pool_cache_read_hit_percent", + "UNAS storage pool SSD cache read hit rate as reported by the console", cache, nil), + PoolCacheWriteHitRate: prometheus.NewDesc(ns+"pool_cache_write_hit_percent", + "UNAS storage pool SSD cache write hit rate as reported by the console", cache, nil), DiskSize: prometheus.NewDesc(ns+"disk_size_bytes", "UNAS disk size", disk, nil), DiskTemperature: prometheus.NewDesc(ns+"disk_temperature_celsius", @@ -104,6 +122,8 @@ func descUNASDevice(ns string) *unasDevice { // nolint: funlen "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), + DiskLifeSpan: prometheus.NewDesc(ns+"disk_life_span_percent", + "UNAS SSD rated endurance left", disk, nil), ShareQuota: prometheus.NewDesc(ns+"share_quota_bytes", "UNAS share quota (0 when unlimited)", share, nil), ShareUsage: prometheus.NewDesc(ns+"share_usage_bytes", @@ -184,26 +204,57 @@ func (u *promUnifi) exportUNASStorage(r report, d *unifi.UNASDevice, s *unifi.UN {u.UNASDevice.RaidProgress, gauge, rg.Progress, raid}, }) } + + if c := p.Cache; c != nil { + cache := []string{d.SourceName, name, p.ID, c.Mode, c.Status} + + r.send([]*metric{ + {u.UNASDevice.PoolCacheSize, gauge, c.Size, cache}, + {u.UNASDevice.PoolCacheUsed, gauge, c.Composition.Used, cache}, + {u.UNASDevice.PoolCacheDirty, gauge, c.Composition.Dirty, cache}, + {u.UNASDevice.PoolCacheReadHitRate, gauge, c.Composition.ReadHitRate, cache}, + {u.UNASDevice.PoolCacheWriteHitRate, gauge, c.Composition.WriteHitRate, cache}, + }) + } } for _, disk := range s.Disks { - labels := []string{ - d.SourceName, name, disk.SlotID, disk.PoolID, - disk.Type, disk.State, disk.Model, disk.Serial, + u.exportUNASDisk(r, d, disk) + } + + // Cache slots hold SSDs with the same fields as the pool's disks; the + // disk_type label tells them apart. + for _, disk := range s.CacheSlots { + if disk.State == "empty" { + continue } - 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}, - }) + u.exportUNASDisk(r, d, disk) + r.send([]*metric{{u.UNASDevice.DiskLifeSpan, gauge, disk.LifeSpan, unasDiskLabels(d, disk)}}) } } + +func unasDiskLabels(d *unifi.UNASDevice, disk unifi.UNASDisk) []string { + return []string{ + d.SourceName, d.Name(), disk.SlotID, disk.PoolID, + disk.Type, disk.State, disk.Model, disk.Serial, + } +} + +func (u *promUnifi) exportUNASDisk(r report, d *unifi.UNASDevice, disk unifi.UNASDisk) { + labels := unasDiskLabels(d, disk) + + 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}, + }) +} diff --git a/pkg/promunifi/unas_test.go b/pkg/promunifi/unas_test.go index 575bd429..ac20e925 100644 --- a/pkg/promunifi/unas_test.go +++ b/pkg/promunifi/unas_test.go @@ -101,3 +101,44 @@ func TestExportUNASDeviceTolerantOfNilSections(t *testing.T) { u.exportUNASDevice(empty, nil) assert.Empty(t, empty.sent) } + +// The SSD cache reports on its own label set, and an occupied cache bay reports as a disk +// with disk_type SSD plus its remaining endurance. An empty bay reports nothing. +func TestExportUNASSSDCache(t *testing.T) { + t.Parallel() + + d := testUNASDevice() + d.Storage.Pools[0].Cache = &unifi.UNASPoolCache{ + Status: "fullyOperational", + Mode: "readOnly", + Size: unifi.FlexInt{Val: 1000}, + } + d.Storage.Pools[0].Cache.Composition.ReadHitRate = unifi.FlexInt{Val: 87} + d.Storage.CacheSlots = []unifi.UNASDisk{ + {SlotID: "1", Type: "SSD", State: "healthy", Serial: "SSD-1", LifeSpan: unifi.FlexInt{Val: 97}}, + {SlotID: "2", State: "empty"}, + } + + r := &fakeReport{} + u := &promUnifi{UNASDevice: descUNASDevice("unifi_")} + u.exportUNASDevice(r, d) + + byDesc := map[*prometheus.Desc][]*metric{} + for _, m := range r.sent { + byDesc[m.Desc] = append(byDesc[m.Desc], m) + } + + hit := byDesc[u.UNASDevice.PoolCacheReadHitRate] + require.Len(t, hit, 1) + assert.Equal(t, unifi.FlexInt{Val: 87}, hit[0].Value) + assert.Equal(t, []string{"https://unas.example", "UNAS-Pro", "pool-1", "readOnly", "fullyOperational"}, + hit[0].Labels) + + life := byDesc[u.UNASDevice.DiskLifeSpan] + require.Len(t, life, 1, "only the occupied bay reports endurance") + assert.Equal(t, unifi.FlexInt{Val: 97}, life[0].Value) + assert.Equal(t, "SSD", life[0].Labels[4]) + + // The pool's hard drive and the occupied bay each report temperature; the empty bay does not. + assert.Len(t, byDesc[u.UNASDevice.DiskTemperature], 2) +}