Chart the Unraid temperature an agent disk shows

A host agent's disk can show a temperature only its Unraid inventory
reports. The adapter takes the Unraid row's reading when a SMART row has
none, which happens when the agent's smartctl probe returns no
temperature for a disk Unraid reads with its own per-disk SMART
settings, and a disk the agent sends no SMART row for, such as a
--disk-exclude match, is built from its Unraid row alone. The only
agent disk temperature writer took the SMART row's own reading, and the
unified sync skips agent disks as natively written, so these
temperatures were shown live but never charted.

writeHostSMARTMetrics now writes the reading the disk shows
(HostSMARTDiskTemperature, the adapter's own rule), and a new pass
writes each Unraid row's reading under HostUnraidDiskMetricID, the key
its disk's metrics target reads, unless that key is one of the host's
SMART rows' keys. An Unraid reading therefore never overwrites a SMART
row's own at the same timestamp. Unraid rows sharing a key are one disk
in the registry, which shows the latest of their readings, so the last
row with a reading decides the sample. TemperatureCollected still keeps
spun-down and expired-host readings out of history.

Charting the fallback makes its row matching matter, so the fallback
and the serial a row without one takes now need an Unraid row that
describes the disk: one carrying the row's usable serial, or one naming
the row's device path when the row is that whole block device, not a
controller member (even the only one reported) or one of several rows
on the path. matchUnraidDisk now tries usable serials across the whole
inventory before device paths, and a placeholder serial matches
nothing, so a row no longer takes another disk's Unraid row through a
shared placeholder serial or an earlier path match. A lone member
without a serial or WWN now charts under its own topology key, which
its metrics target reads as written.
This commit is contained in:
Richard Courtman
2026-10-07 17:25:26 +01:00
parent 409c5482ee
commit 7b884bc98a
9 changed files with 474 additions and 34 deletions
@@ -8806,6 +8806,25 @@ in `internal/monitoring/monitor_host_agents_test.go`
`internal/monitoring/issue1595_collection_trust_test.go`
(`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports`).
### Unraid temperatures chart agent disk history
`internal/monitoring/monitor_agents.go` changed only so the SMART history
writer charts the temperature a host agent's disk shows. A SMART row without
its own reading shows the one the host's Unraid inventory reports for the disk,
when that inventory row describes the disk (`HostSMARTDiskTemperature`), and a
disk only the Unraid inventory reports,
such as a member the agent's SMART collection skips under `--disk-exclude`,
shows that row's reading (`HostUnraidDiskTemperature`). Both are now written
as `smart_temp` under the key the disk's metrics target reads
(`HostSMARTDiskMetricID`, `HostUnraidDiskMetricID`); a reading the disk does
not show as collected, a spun-down disk's or an expired host's, is not. Agent
registration, enrolment, install, update, removal, report identity and disk
source IDs are unchanged. Focused proof lives in
`internal/monitoring/monitor_host_agents_test.go`
(`TestApplyHostReportChartsUnraidTemperatureOfDiskWithoutSMART`) and
`internal/monitoring/issue1595_collection_trust_test.go`
(`TestAgentDiskChartsTheUnraidTemperatureItShows`).
### Windows braced MachineGuid does not abort agent startup
The Windows unified agent resolves host information through gopsutil's combined
@@ -4800,8 +4800,10 @@ timeline instead of creating a second drawer-only or mock-only disk history
path.
The SMART-resolved id is the serial the disk resource carries: when a SMART row
reports none, `HostSMARTDiskMetricID` takes the one the host's Unraid inventory
reports for that disk, as the unified-resources adapter does, unless several
SMART rows share the disk's device path, and `hostDiskIOMetricResourceID` keys
reports for that disk, as the unified-resources adapter does, unless the Unraid
row does not describe the disk (`unraidDiskDescribesSMARTRow`: a controller
member, or several SMART rows on the disk's device path), and
`hostDiskIOMetricResourceID` keys
a device with no non-standby SMART row by its Unraid serial
(`HostUnraidDeviceMetricID`, which refuses a device several Unraid rows name)
before the linked Proxmox node's disks and the `<host>:<device>` fallback.
@@ -4812,6 +4814,21 @@ their counter under no member.
Proof:
`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports` and
`TestHostDiskIOMetricResourceIDFallbacks`.
The agent's `smart_temp` series holds the reading the disk resource shows,
not only the SMART row's own. `writeHostSMARTMetrics` writes
`HostSMARTDiskTemperature`, which falls back to the host's Unraid inventory
reading when the row has none, and `writeHostUnraidDiskTemperatures` writes
`HostUnraidDiskTemperature` under `HostUnraidDiskMetricID` for each Unraid row
whose key is not one of the host's SMART rows' keys, such as a `--disk-exclude`
member, so an Unraid reading never overwrites a SMART row's own at the same
timestamp.
Unraid rows sharing a key are one disk in the registry, which shows the latest
of their readings, so the last row with a reading decides that key's single
sample. `diskinventory.TemperatureCollected` keeps a spun-down disk's or an
expired host's leftover reading out of history.
Proof:
`TestAgentDiskChartsTheUnraidTemperatureItShows` and
`TestApplyHostReportChartsUnraidTemperatureOfDiskWithoutSMART`.
That same monitoring-owned disk-health boundary also includes shared storage
risk assessment in `internal/storagehealth/`. When providers or host agents
emit structured storage topology such as Unraid per-disk state, the shared
@@ -5432,6 +5432,26 @@ A row's own serial is kept even when it is a placeholder, as the
adapter keeps it, and the Unraid row then stays a disk of its own. Proof:
`TestHostSMARTDiskMetricIDTakesTheSerialItsUnraidRowReports`,
`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports`.
The adapters' disk temperature rule is shared with the agent disk history
writer. `HostSMARTDiskTemperature` returns the temperature and collection state
a SMART row's disk shows: the row's own reading, or, when it has none, its
Unraid row's under `unraidDiskTemperatureStatus`. `matchUnraidDisk` finds that
Unraid row by the SMART row's usable serial anywhere in the inventory before
any device path, and a placeholder serial matches nothing. The fallback, like
the serial a row without one takes (`hostSMARTDiskSerial`), comes only from an
Unraid row that describes the row's disk (`unraidDiskDescribesSMARTRow`): one
carrying the row's usable serial, or one naming the row's device path when the
row is that whole kernel block device. A controller member, even the only one
reported, and a row sharing its path with other SMART rows are not, because
the Unraid row describes the block device as a whole. `HostUnraidDiskTemperature`
returns what a disk built from an Unraid row alone shows, and
`HostUnraidDiskMetricID` returns the key that disk's metrics target reads: its
usable serial, else its `HostUnraidDiskSourceID`, and nothing for a row without
a device, which is not ingested. The writer used to take only the SMART row's
own reading, so a temperature shown from the Unraid inventory, for a disk whose
SMART probe returned none or one excluded from SMART collection, was never
charted. Proof: `TestAgentDiskChartsTheUnraidTemperatureItShows` and
`TestHostUnraidDiskMetricIDMatchesItsDiskMetricsTarget`.
That same canonical physical-disk view must also expose source-independent host
context. When a disk is API-backed rather than node-backed, typed views should
fall back to canonical host identity such as `identity.hostnames` instead of
@@ -681,6 +681,167 @@ func TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports(t *testing.T) {
}
}
// An Unraid host's disk can show a temperature only its Unraid inventory
// reports: a member the agent's SMART collection skips (--disk-exclude), or
// one Unraid reads with its own per-disk SMART settings while the agent's
// probe returns no temperature. The chart must hold the reading the disk
// shows, under the key its metrics target reads, and nothing for a reading the
// disk does not show as collected (a spun-down disk, a host past its lease) or
// does not show at all (a controller member behind the device Unraid reads).
func TestAgentDiskChartsTheUnraidTemperatureItShows(t *testing.T) {
storeConfig := metrics.DefaultConfig(t.TempDir())
storeConfig.FlushInterval = time.Hour
store, err := metrics.NewStore(storeConfig)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
m := &Monitor{metricsStore: store, rateTracker: NewRateTracker()}
now := time.Now().UTC().Truncate(time.Second)
host := models.Host{ID: "host-tower", Hostname: "tower", MachineID: "machine-tower", Status: "online", LastSeen: now}
host.Sensors.SMART = []models.HostDiskSMART{
// The agent's identity-only row for a disk its probe got nothing from.
{Device: "sdb", Serial: "SER-B", Health: "UNKNOWN", Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data")}},
// A row with its own reading keeps it; the Unraid one is not written too.
{Device: "sdc", WWN: "0x5000c500aaaa0003", Health: "PASSED", Temperature: 41, Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Available("smartctl")}},
// The agent's native standby row for a disk Unraid reports spun down.
{Device: "sdd", Serial: "SER-D", Health: "UNKNOWN", Standby: true, Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("unraid", "disk is reported spun down")}},
// Controller members behind one kernel block device, which the Unraid
// row describes as a whole: neither member takes its temperature.
{Device: "sdh", Controller: "sdh", Target: "megaraid,0", WWN: "0x5000c500bbbb0001", Health: "PASSED", Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data")}},
{Device: "sdh", Controller: "sdh", Target: "megaraid,1", WWN: "0x5000c500bbbb0002", Health: "PASSED", Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data")}},
// A member whose own serial is the Unraid row's is the disk that row
// describes, though its path is shared.
{Device: "sdi", Controller: "sdi", Target: "megaraid,0", Serial: "SER-I", Health: "UNKNOWN", Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data")}},
{Device: "sdi", Controller: "sdi", Target: "megaraid,1", Serial: "SER-I2", Health: "UNKNOWN", Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data")}},
// A placeholder serial is no evidence of which member the row is.
{Device: "sdn", Controller: "sdn", Target: "megaraid,0", Serial: "UNKNOWN", WWN: "0x5000c500cccc0001", Health: "UNKNOWN", Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data")}},
{Device: "sdn", Controller: "sdn", Target: "megaraid,1", Serial: "UNKNOWN", WWN: "0x5000c500cccc0002", Health: "UNKNOWN", Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data")}},
// A placeholder serial matches no Unraid row; the row's path finds its own.
{Device: "sdo", Serial: "0123456789", WWN: "0x5000c500eeee0001", Health: "UNKNOWN", Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data")}},
// The only reported member behind a controller path is still a member.
{Device: "sdq", Controller: "sdq", Target: "megaraid,0", WWN: "0x5000c500ffff0001", Health: "UNKNOWN", Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data")}},
// Without a serial or WWN, a member charts its own reading under its
// topology key.
{Device: "sdr", Controller: "sdr", Target: "megaraid,0", Health: "PASSED", Temperature: 35, Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Available("smartctl")}},
// A SMART row's key is its own: the merged disk shows the row's reading,
// here not collected, and the Unraid one is not charted over it.
{Device: "sdm", WWN: "0x5000c500dddd0001", Health: "PASSED", Temperature: 40, Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}},
}
host.Unraid = &models.HostUnraidStorage{ArrayStarted: true, Disks: []models.HostUnraidDisk{
{Device: "sdb", Serial: "SER-B", Name: "disk1", Role: "data", Status: "online", Temperature: 38},
{Device: "sdc", Serial: "SER-C", Name: "disk2", Role: "data", Status: "online", Temperature: 39},
{Device: "sdd", Serial: "SER-D", Name: "disk3", Role: "data", Status: "online", Temperature: 35, SpunDown: true},
// Members with no SMART row at all.
{Device: "sde", Serial: "SER-E", Name: "disk4", Role: "data", Status: "online", Temperature: 36},
{Device: "sdf", Serial: "SER-F", Name: "disk5", Role: "data", Status: "online", Temperature: 33, SpunDown: true},
{Device: "sdg", Name: "disk6", Role: "data", Status: "online", Temperature: 37},
{Device: "sdh", Serial: "SER-H", Name: "disk7", Role: "data", Status: "online", Temperature: 42},
{Device: "sdi", Serial: "SER-I", Name: "disk8", Role: "data", Status: "online", Temperature: 44},
// Rows sharing one history key are one disk, showing the latest reading.
{Device: "sdj", Name: "disk9", Role: "data", Status: "online"},
{Device: "sdj", Name: "disk10", Role: "data", Status: "online", Temperature: 43},
{Device: "sdk", Name: "disk11", Role: "data", Status: "online", Temperature: 38},
{Device: "sdk", Name: "disk12", Role: "data", Status: "online", Temperature: 39},
{Device: "sdl", Name: "disk13", Role: "data", Status: "online", Temperature: 40},
{Device: "sdl", Name: "disk14", Role: "data", Status: "online", Temperature: 32, SpunDown: true},
{Device: "sdn", Serial: "UNKNOWN", Name: "disk15", Role: "data", Status: "online", Temperature: 45},
{Device: "sdm", Serial: "SER-M", Name: "disk16", Role: "data", Status: "online", Temperature: 37},
{Device: "sdp", Serial: "0123456789", Name: "disk17", Role: "data", Status: "online", Temperature: 30},
{Device: "sdo", Serial: "0123456789", Name: "disk18", Role: "data", Status: "online", Temperature: 46},
{Device: "sdq", Serial: "SER-Q", Name: "disk19", Role: "data", Status: "online", Temperature: 47},
{Device: "sdr", Serial: "SER-R", Name: "disk20", Role: "data", Status: "online", Temperature: 36},
}}
// Disks by usable serial, else WWN, else device path, and the reading each
// shows as collected.
want := map[string]float64{
"SER-B": 38, "SER-C": 41, "SER-D": 0, "SER-E": 36, "SER-F": 0, "sdg": 37,
"SER-H": 42, "0x5000c500bbbb0001": 0, "0x5000c500bbbb0002": 0,
"SER-I": 44, "SER-I2": 0, "sdj": 43, "sdk": 39, "sdl": 0,
"sdn": 45, "0x5000c500cccc0001": 0, "0x5000c500cccc0002": 0, "SER-M": 0,
"0x5000c500eeee0001": 46, "sdo": 46, "sdp": 30, "0x5000c500ffff0001": 0, "SER-Q": 47,
"sdr": 35, "SER-R": 36,
}
m.writeHostSMARTMetrics(host, now)
// Once the host's lease expires its last report is kept, offline, as
// context; nothing it carries is a current reading.
reported := host
reported.Sensors.SMART = append([]models.HostDiskSMART(nil), host.Sensors.SMART...)
state := &models.State{Hosts: []models.Host{reported}}
offline, expired := state.ExpireHostTelemetry(host.ID, host.LastSeen)
if !expired {
t.Fatal("host telemetry did not expire")
}
m.writeHostSMARTMetrics(offline, now.Add(30*time.Second))
store.Flush()
live := unifiedresources.NewRegistry(nil)
live.IngestSnapshot(models.StateSnapshot{Hosts: []models.Host{host}})
rehydrated := unifiedresources.NewRegistry(nil)
rehydrated.IngestResources(live.List())
for _, tc := range []struct {
name string
registry *unifiedresources.ResourceRegistry
}{{"live", live}, {"rehydrated", rehydrated}} {
disks := tc.registry.ListByType(unifiedresources.ResourceTypePhysicalDisk)
if len(disks) != len(want) {
t.Fatalf("%s: disks = %d, want %d", tc.name, len(disks), len(want))
}
for _, disk := range disks {
device := disk.PhysicalDisk.Serial
if !diskinventory.IsUsableHardwareID(device) {
device = ""
}
if device == "" {
device = disk.PhysicalDisk.WWN
}
if device == "" {
device = disk.PhysicalDisk.DevPath
}
wantTemp, ok := want[device]
if !ok {
t.Fatalf("%s: unexpected disk %s at %q", tc.name, disk.ID, device)
}
shown := 0.0
if diskinventory.TemperatureCollected(disk.PhysicalDisk.Temperature, disk.PhysicalDisk.Collection) {
shown = float64(disk.PhysicalDisk.Temperature)
}
if shown != wantTemp {
t.Fatalf("%s: disk %s shows %v°C as collected, want %v°C", tc.name, device, shown, wantTemp)
}
target := tc.registry.MetricsTarget(disk.ID)
if target == nil {
t.Fatalf("%s: disk %s has no metrics target", tc.name, device)
}
points, err := store.Query(target.ResourceType, target.ResourceID, "smart_temp", now.Add(-time.Minute), now.Add(time.Minute), 0)
if err != nil {
t.Fatal(err)
}
switch {
case wantTemp == 0 && len(points) != 0:
t.Errorf("%s: disk %s shows no collected temperature but target %+v charts %+v", tc.name, device, *target, points)
case wantTemp > 0 && (len(points) != 1 || points[0].Value != wantTemp || !points[0].Timestamp.Equal(now)):
t.Errorf("%s: disk %s shows %v°C but target %+v charts %+v, want one sample at the online report", tc.name, device, wantTemp, *target, points)
}
}
}
}
// A SMART row found only by device path may describe the slot's previous
// occupant. It is refused when its identity contradicts the Proxmox disk's,
// judged only on what both producers report like for like: WWNs for every
+50 -2
View File
@@ -4081,14 +4081,18 @@ func (m *Monitor) writeHostSMARTMetrics(host models.Host, now time.Time) {
return
}
smartMetricIDs := make(map[string]struct{}, len(host.Sensors.SMART))
for _, disk := range host.Sensors.SMART {
resourceID := unifiedresources.HostSMARTDiskMetricID(host, disk)
if resourceID == "" {
continue
}
smartMetricIDs[resourceID] = struct{}{}
if diskinventory.TemperatureCollected(disk.Temperature, disk.Collection) {
m.metricsStore.Write("disk", resourceID, "smart_temp", float64(disk.Temperature), now)
// Chart the reading the disk shows: a row without its own carries the
// host's Unraid reading for the disk.
if temperature, collection := unifiedresources.HostSMARTDiskTemperature(host, disk); diskinventory.TemperatureCollected(temperature, collection) {
m.metricsStore.Write("disk", resourceID, "smart_temp", float64(temperature), now)
}
attrs := disk.Attributes
@@ -4127,6 +4131,50 @@ func (m *Monitor) writeHostSMARTMetrics(host models.Host, now time.Time) {
m.metricsStore.Write("disk", resourceID, "smart_unsafe_shutdowns", float64(*attrs.UnsafeShutdowns), now)
}
}
m.writeHostUnraidDiskTemperatures(host, smartMetricIDs, now)
}
// writeHostUnraidDiskTemperatures charts the temperature of each disk only the
// host's Unraid inventory reports, such as a member the agent's SMART
// collection skips under --disk-exclude. smartMetricIDs holds the history keys
// of the host's SMART rows. A disk under one of those keys is that row's to
// chart, and the row already carries the Unraid reading when it has none of
// its own. Unraid rows sharing a key are one disk in the registry, which keeps
// the latest of their readings, so the last row with a reading decides that
// key's sample.
func (m *Monitor) writeHostUnraidDiskTemperatures(host models.Host, smartMetricIDs map[string]struct{}, now time.Time) {
if host.Unraid == nil {
return
}
type reading struct {
temperature int
collection *diskinventory.CollectionStatus
}
var keys []string
readings := make(map[string]reading, len(host.Unraid.Disks))
for _, disk := range host.Unraid.Disks {
resourceID := unifiedresources.HostUnraidDiskMetricID(host, disk)
if resourceID == "" {
continue
}
if _, ok := smartMetricIDs[resourceID]; ok {
continue
}
temperature, collection := unifiedresources.HostUnraidDiskTemperature(host, disk)
if temperature <= 0 {
continue
}
if _, ok := readings[resourceID]; !ok {
keys = append(keys, resourceID)
}
readings[resourceID] = reading{temperature: temperature, collection: collection}
}
for _, resourceID := range keys {
if r := readings[resourceID]; diskinventory.TemperatureCollected(r.temperature, r.collection) {
m.metricsStore.Write("disk", resourceID, "smart_temp", float64(r.temperature), now)
}
}
}
func normalizeHostDiskDevice(device string) string {
@@ -3017,6 +3017,71 @@ func TestApplyHostReportPersistsSMARTMetricsForAgentDisks(t *testing.T) {
}
}
// An Unraid member the agent sends no SMART row for (a --disk-exclude match)
// still shows the temperature its Unraid inventory row reports, so the report
// charts it under the disk's serial. A disk with its own SMART reading keeps
// it, and a spun-down member's leftover reading is not charted.
func TestApplyHostReportChartsUnraidTemperatureOfDiskWithoutSMART(t *testing.T) {
storeCfg := metrics.DefaultConfig(t.TempDir())
storeCfg.WriteBufferSize = 1
store, err := metrics.NewStore(storeCfg)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
monitor := &Monitor{
state: models.NewState(),
alertManager: alerts.NewManager(),
hostTokenBindings: make(map[string]string),
config: &config.Config{},
rateTracker: NewRateTracker(),
metricsStore: store,
}
t.Cleanup(func() { monitor.alertManager.Stop() })
report := agentshost.Report{
Agent: agentshost.AgentInfo{ID: "agent-tower", Version: "1.0.0", IntervalSeconds: 30},
Host: agentshost.HostInfo{ID: "machine-tower", Hostname: "tower", MachineID: "machine-tower"},
Metrics: agentshost.Metrics{
Memory: agentshost.MemoryMetric{TotalBytes: 1024, UsedBytes: 512, FreeBytes: 512, Usage: 50},
},
Sensors: agentshost.Sensors{
SMART: []agentshost.DiskSMART{
{Device: "/dev/sda", Model: "IronWolf", Serial: "SERIAL-TOWER-1", Temperature: 41},
},
},
Unraid: &agentshost.UnraidStorage{ArrayStarted: true, Disks: []agentshost.UnraidDisk{
{Name: "disk1", Device: "sda", Role: "data", Status: "online", Serial: "SERIAL-TOWER-1", Temperature: 40},
{Name: "disk2", Device: "sdb", Role: "data", Status: "online", Serial: "SERIAL-TOWER-2", Temperature: 36},
{Name: "disk3", Device: "sdc", Role: "data", Status: "online", Serial: "SERIAL-TOWER-3", Temperature: 34, SpunDown: true},
}},
Timestamp: time.Now().UTC(),
}
if _, err := monitor.ApplyHostReport(report, nil); err != nil {
t.Fatalf("ApplyHostReport: %v", err)
}
store.Flush()
points := waitForStoredDiskMetric(t, store, "SERIAL-TOWER-2", "smart_temp")
if len(points) != 1 || points[0].Value != 36 {
t.Fatalf("SERIAL-TOWER-2 smart_temp = %+v, want the Unraid reading 36", points)
}
points = waitForStoredDiskMetric(t, store, "SERIAL-TOWER-1", "smart_temp")
if len(points) != 1 || points[0].Value != 41 {
t.Fatalf("SERIAL-TOWER-1 smart_temp = %+v, want its own SMART reading 41", points)
}
now := time.Now().UTC()
points, err = store.Query("disk", "SERIAL-TOWER-3", "smart_temp", now.Add(-time.Hour), now.Add(time.Hour), 60)
if err != nil {
t.Fatalf("Query smart_temp: %v", err)
}
if len(points) != 0 {
t.Fatalf("spun-down SERIAL-TOWER-3 smart_temp = %+v, want none", points)
}
}
func TestApplyHostReportPersistsAgentTemperatureMetric(t *testing.T) {
t.Helper()
+61 -26
View File
@@ -806,10 +806,7 @@ func resourceFromHostUnraidPhysicalDisk(host models.Host, disk models.HostUnraid
health := unraidPhysicalDiskHealth(disk)
assessment := assessUnraidPhysicalDisk(disk)
sizeBytes := unraidDiskSizeBytes(host, disk)
var collection *diskinventory.CollectionStatus
if status := unraidDiskTemperatureStatus(host, disk); status.State != "" {
collection = &diskinventory.CollectionStatus{Temperature: status}
}
temperature, collection := HostUnraidDiskTemperature(host, disk)
resource := Resource{
Type: ResourceTypePhysicalDisk,
Name: name,
@@ -824,7 +821,7 @@ func resourceFromHostUnraidPhysicalDisk(host models.Host, disk models.HostUnraid
SizeBytes: sizeBytes,
Health: health,
Wearout: -1,
Temperature: disk.Temperature,
Temperature: temperature,
Used: unraidDiskMountPath(disk),
StorageRole: unraidDiskRole(&disk),
StorageGroup: unraidDiskGroup(&disk),
@@ -869,6 +866,46 @@ func unraidDiskTemperatureStatus(host models.Host, disk models.HostUnraidDisk) d
}
}
// HostUnraidDiskTemperature returns the temperature the disk resource built
// from a host's Unraid inventory row shows, with its collection state. The
// agent disk history writer charts this reading.
func HostUnraidDiskTemperature(host models.Host, disk models.HostUnraidDisk) (int, *diskinventory.CollectionStatus) {
status := unraidDiskTemperatureStatus(host, disk)
if status.State == "" {
return disk.Temperature, nil
}
return disk.Temperature, &diskinventory.CollectionStatus{Temperature: status}
}
// HostSMARTDiskTemperature returns the temperature the disk resource built
// from a host agent's SMART row shows, with its collection state. The agent
// disk history writer charts this reading.
func HostSMARTDiskTemperature(host models.Host, disk models.HostDiskSMART) (int, *diskinventory.CollectionStatus) {
return hostSMARTDiskTemperature(host, disk, matchUnraidDisk(host.Unraid, disk))
}
// hostSMARTDiskTemperature returns the temperature the disk resource built
// from a host agent's SMART row shows, with the row's collection state: the
// row's own reading, or, when the row has none, the one the host's Unraid
// inventory reports for the disk. Unraid reads its members' temperatures with
// its own per-disk SMART settings, so it can have one when the agent's probe
// returned none. The reading is taken only from an Unraid row that describes
// the row's disk (unraidDiskDescribesSMARTRow).
func hostSMARTDiskTemperature(host models.Host, disk models.HostDiskSMART, unraidDisk *models.HostUnraidDisk) (int, *diskinventory.CollectionStatus) {
collection := diskinventory.CloneStatus(disk.Collection)
if disk.Temperature > 0 || !unraidDiskDescribesSMARTRow(host, disk, unraidDisk) {
return disk.Temperature, collection
}
// The reading is now the Unraid inventory's, and so is its state.
if status := unraidDiskTemperatureStatus(host, *unraidDisk); status.State != "" {
if collection == nil {
collection = &diskinventory.CollectionStatus{}
}
collection.Temperature = status
}
return unraidDisk.Temperature, collection
}
func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Resource, ResourceIdentity) {
name := strings.TrimSpace(disk.Model)
if name == "" {
@@ -907,8 +944,7 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res
model := strings.TrimSpace(disk.Model)
serial := hostSMARTDiskSerial(host, disk, unraidDisk)
diskType := strings.TrimSpace(disk.Type)
temperature := disk.Temperature
collection := diskinventory.CloneStatus(disk.Collection)
temperature, collection := hostSMARTDiskTemperature(host, disk, unraidDisk)
health := strings.TrimSpace(disk.Health)
if unraidDisk != nil {
if model == "" {
@@ -920,16 +956,6 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res
if sizeBytes <= 0 {
sizeBytes = unraidDiskSizeBytes(host, *unraidDisk)
}
if temperature <= 0 {
temperature = unraidDisk.Temperature
// The reading is now the Unraid inventory's, and so is its state.
if status := unraidDiskTemperatureStatus(host, *unraidDisk); status.State != "" {
if collection == nil {
collection = &diskinventory.CollectionStatus{}
}
collection.Temperature = status
}
}
if health == "" || strings.EqualFold(health, "UNKNOWN") {
health = unraidPhysicalDiskHealth(*unraidDisk)
}
@@ -1219,21 +1245,30 @@ func maxInt64() int64 {
return int64(^uint64(0) >> 1)
}
// matchUnraidDisk returns the host's Unraid row for a SMART row: the one
// carrying the row's usable serial, else the one at its device path. A serial
// names the drive and a path only where the kernel put it, so a serial match
// anywhere in the inventory outranks a path match. A placeholder serial, which
// several bridges report alike, is no match at all.
func matchUnraidDisk(unraid *models.HostUnraidStorage, disk models.HostDiskSMART) *models.HostUnraidDisk {
if unraid == nil || len(unraid.Disks) == 0 {
return nil
}
normalizedDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(disk.Device))
normalizedSerial := strings.TrimSpace(strings.ToLower(disk.Serial))
for i := range unraid.Disks {
candidate := &unraid.Disks[i]
if normalizedSerial != "" && strings.EqualFold(strings.TrimSpace(candidate.Serial), normalizedSerial) {
return candidate
if serial := strings.TrimSpace(disk.Serial); diskinventory.IsUsableHardwareID(serial) {
for i := range unraid.Disks {
if strings.EqualFold(strings.TrimSpace(unraid.Disks[i].Serial), serial) {
return &unraid.Disks[i]
}
}
candidateDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(candidate.Device))
if normalizedDevice != "" && candidateDevice != "" && candidateDevice == normalizedDevice {
return candidate
}
normalizedDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(disk.Device))
if normalizedDevice == "" {
return nil
}
for i := range unraid.Disks {
if strings.ToLower(normalizePhysicalDiskDeviceToken(unraid.Disks[i].Device)) == normalizedDevice {
return &unraid.Disks[i]
}
}
return nil
@@ -503,6 +503,50 @@ func TestHostUnraidDiskSourceIDNormalizesDeviceAndPrefersSerial(t *testing.T) {
}
}
// An Unraid inventory row's history key is the one the metrics target of the
// disk resource built from it reads: its usable serial, unscoped like every
// physical-disk history key, or else its host/device source ID. A row without
// a device is not ingested as a disk and has no key.
func TestHostUnraidDiskMetricIDMatchesItsDiskMetricsTarget(t *testing.T) {
host := models.Host{ID: "host-tower", Hostname: "tower"}
tests := []struct {
name string
disk models.HostUnraidDisk
want string
}{
{"serial", models.HostUnraidDisk{Device: "/dev/sdb", Serial: "SERIAL-DATA"}, "SERIAL-DATA"},
{"placeholder serial", models.HostUnraidDisk{Device: "sdc", Serial: "N/A"}, "host-tower:sdc"},
{"no serial", models.HostUnraidDisk{Device: "sdd [sat]"}, "host-tower:sdd"},
{"no device", models.HostUnraidDisk{Name: "disk9", Serial: "SERIAL-MISSING"}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HostUnraidDiskMetricID(host, tt.disk)
if got != tt.want {
t.Fatalf("HostUnraidDiskMetricID(%+v) = %q, want %q", tt.disk, got, tt.want)
}
if tt.want == "" {
return
}
disk := tt.disk
disk.Name, disk.Role, disk.Status = "disk1", "data", "online"
registry := NewRegistry(nil)
registry.IngestSnapshot(models.StateSnapshot{Hosts: []models.Host{{
ID: host.ID, Hostname: host.Hostname, Status: "online",
Unraid: &models.HostUnraidStorage{ArrayStarted: true, Disks: []models.HostUnraidDisk{disk}},
}}})
disks := registry.ListByType(ResourceTypePhysicalDisk)
if len(disks) != 1 {
t.Fatalf("disks = %d, want 1", len(disks))
}
target := registry.MetricsTarget(disks[0].ID)
if target == nil || target.ResourceType != "disk" || target.ResourceID != got {
t.Fatalf("metrics target = %+v, want disk %q", target, got)
}
})
}
}
// An agent disk's source ID carries its host, because a usable serial or WWN
// names the drive but not the machine; its metrics key is the hardware ID
// alone. Without hardware identity the two keep one host/device/topology key.
+35 -4
View File
@@ -61,17 +61,37 @@ func hostSMARTDiskKey(host models.Host, disk models.HostDiskSMART, serial, wwn s
// row's own, or, when the row reports none, the one the host's Unraid
// inventory reports for the disk. smartctl reports no serial for a disk in
// standby, and the Unraid row still names it. A row without a serial matches
// its Unraid row by device path alone, so it takes no serial from a path
// several of the host's rows share: controller members behind one kernel block
// device, which the Unraid row describes as a whole.
// its Unraid row by device path alone, so it takes the serial only where that
// row describes its disk (unraidDiskDescribesSMARTRow).
func hostSMARTDiskSerial(host models.Host, disk models.HostDiskSMART, unraidDisk *models.HostUnraidDisk) string {
serial := strings.TrimSpace(disk.Serial)
if serial != "" || unraidDisk == nil || hostSMARTDevicePathShared(host, disk) {
if serial != "" || !unraidDiskDescribesSMARTRow(host, disk, unraidDisk) {
return serial
}
return strings.TrimSpace(unraidDisk.Serial)
}
// unraidDiskDescribesSMARTRow reports whether the Unraid row matched to a SMART
// row (matchUnraidDisk) describes that row's disk. An Unraid row describes a
// kernel block device as a whole, so it describes the row when it carries the
// row's usable serial, or when it names the row's device path and the row is
// that whole device: not a controller member behind it, even the only one
// reported, nor one of several rows sharing the path.
func unraidDiskDescribesSMARTRow(host models.Host, disk models.HostDiskSMART, unraidDisk *models.HostUnraidDisk) bool {
if unraidDisk == nil {
return false
}
if serial := strings.TrimSpace(disk.Serial); diskinventory.IsUsableHardwareID(serial) &&
strings.EqualFold(serial, strings.TrimSpace(unraidDisk.Serial)) {
return true
}
device := strings.ToLower(normalizePhysicalDiskDeviceToken(disk.Device))
if device == "" || device != strings.ToLower(normalizePhysicalDiskDeviceToken(unraidDisk.Device)) {
return false
}
return !diskinventory.IsControllerMemberTarget(disk.Target) && !hostSMARTDevicePathShared(host, disk)
}
// hostSMARTDevicePathShared reports whether more than one of the host's SMART
// rows, the row itself among them, names the row's device path.
func hostSMARTDevicePathShared(host models.Host, disk models.HostDiskSMART) bool {
@@ -130,6 +150,17 @@ func HostUnraidDiskSourceID(host models.Host, disk models.HostUnraidDisk) string
return ""
}
// HostUnraidDiskMetricID returns the history key of the disk resource built
// from a host's Unraid inventory row: the key its metrics target reads, the
// row's usable serial or else its source ID. It returns "" for a row the
// registry does not ingest as a disk, one without a device.
func HostUnraidDiskMetricID(host models.Host, disk models.HostUnraidDisk) string {
if strings.TrimSpace(disk.Device) == "" {
return ""
}
return PreferredPhysicalDiskMetricID(disk.Serial, "", HostUnraidDiskSourceID(host, disk))
}
const hostPhysicalDiskSourceIDMarker = "/physical-disk:"
func hostPhysicalDiskSourceID(hostID, hardwareID string) string {