Chart history for disks named by their Unraid serial

A SMART row that reports no serial, smartctl's standby row among them,
takes the serial the host's Unraid inventory reports for the same disk:
the adapter shows it and the disk's metrics target reads it. The SMART
and disk I/O writers keyed the row on its own WWN or host/device key
instead, so the chart read a key nothing wrote and the disk's
temperature and I/O history never appeared. The I/O writer did the same
for an Unraid disk with no SMART reading.

HostSMARTDiskMetricID now takes the serial through the adapter's own
rule (hostSMARTDiskSerial over matchUnraidDisk), and the I/O writer keys
a device without a SMART reading by its Unraid serial before the
Proxmox and host/device fallbacks. Source IDs keep the row's own
identity, so registry keys and rehydration are unchanged.

That rule matches by device path alone, so it now skips a path several
SMART rows share. Controller members behind one block device would
otherwise all take the device's serial and merge into one disk, and the
I/O writer's guard against filing an aggregate counter under a member
would stop working. That guard now compares every row on the path by
its own identity, standby rows and legacy "sdc [sat]" labels included,
so an aggregate counter never lands under one member. The I/O writer
also refuses a device that several Unraid rows name.

Samples already written under the old keys stay where they are. The
chart did not read them while the Unraid row named the disk, and a
device-keyed series can hold another disk's samples once device letters
move, so they are not folded into the serial's history.
This commit is contained in:
rcourtman
2026-10-07 16:16:23 +01:00
parent 3e4ea03433
commit 58347f0c4f
9 changed files with 390 additions and 21 deletions
@@ -8791,6 +8791,21 @@ Focused proof lives in `internal/monitoring/physical_disk_roundtrip_test.go`
(`TestNestedProxmoxDefaultQEMUSerialsStayPerNode` and
`TestTrueNASPlaceholderDiskSerialsStayPerApplianceAndShareOneHistoryKey`).
### Unraid serials key agent disk history
`internal/monitoring/monitor_agents.go` changed only so the SMART and disk I/O
history writers key a host agent's disk by the serial its resource carries: the
SMART row's own, else the one the host's Unraid inventory reports for that disk
(`HostSMARTDiskMetricID`, `HostUnraidDeviceMetricID`). While the Unraid row
names such a disk, its SMART and I/O history is written under that serial, which
its chart already reads, instead of its WWN or host/device key. 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`
(`TestHostDiskIOMetricResourceIDFallbacks`) and
`internal/monitoring/issue1595_collection_trust_test.go`
(`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports`).
### Windows braced MachineGuid does not abort agent startup
The Windows unified agent resolves host information through gopsutil's combined
@@ -4790,6 +4790,20 @@ disk history model, and mock seeding plus live mock ticks in
`internal/monitoring/mock_metrics_history.go` must append to that same disk
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
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.
Controller members sharing a device path keep distinct keys, so the I/O
writer's aggregate-device guard, which compares every row on the path by its
own identity (standby rows and legacy `sdc [sat]` labels included), files
their counter under no member.
Proof:
`TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports` and
`TestHostDiskIOMetricResourceIDFallbacks`.
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
@@ -5389,6 +5389,31 @@ disk on a single host. Proof:
`TestHostSMARTDiskSourceIDScopesHardwareIdentityToItsHost`,
`TestRehydratedAgentDisksKeepTheirLiveSourceIDs`,
`TestSplitAgentDisksOnTwoMachinesNeverOverwriteEachOther`.
A SMART row that reports no serial, smartctl's standby row among them, takes
the serial the host's Unraid inventory reports for the same disk
(`hostSMARTDiskSerial` over `matchUnraidDisk`). The adapter shows that serial
and the disk's metrics target reads it, so `HostSMARTDiskMetricID` resolves the
same serial, and the disk I/O writer keys a device with no SMART reading by its
Unraid serial (`HostUnraidDeviceMetricID`) before its Proxmox and host/device
fallbacks. The writers used to key such a row on its WWN or host/device key,
which the chart did not read, so the disk's SMART temperature and I/O history
never appeared. The row's source ID stays its own (`HostSMARTDiskSourceID`
reads only the row), so its SMART and Unraid observations still ingest under
two keys onto one disk. Samples already written under the old key stay there:
the chart did not read that key while the Unraid row named the disk (only
Unraid OS reports an Unraid inventory, and it is never a Proxmox node whose
disk row could replace the serial), and a device-keyed series can hold another
disk's samples once device letters move. The key follows the inventory: while the host
reports no Unraid row for the disk, the row's own key is both written and read.
A row matches its Unraid row by device path alone, so a row on a path several
of the host's SMART rows share, controller members behind one block device,
takes no serial from it, and the I/O writer still files that device's counter
under no member. The I/O writer's inventory fallback also skips a device
several Unraid rows name, while a SMART row keeps the adapter's first match.
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`.
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
@@ -453,6 +453,94 @@ func TestSameSerialAgentDisksOnTwoHostsEachKeepTheirMetricsTarget(t *testing.T)
}
}
// An Unraid host reports each array disk twice: as a SMART row and as an
// Unraid inventory row. When the SMART row carries no serial, smartctl's
// standby row among them, the disk takes the serial its Unraid row reports,
// and its metrics target reads that serial. The SMART and disk I/O writers must
// file under it too, as must the I/O writer for a disk with no SMART row,
// or the disk's temperature and I/O history never chart.
func TestAgentDiskHistoryFollowsTheSerialItsUnraidRowReports(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)
collected := func() *diskinventory.CollectionStatus {
return &diskinventory.CollectionStatus{Temperature: diskinventory.Available("smartctl")}
}
host := models.Host{ID: "host-tower", Hostname: "tower", MachineID: "machine-tower", Status: "online", LastSeen: now}
host.Sensors.SMART = []models.HostDiskSMART{
{Device: "sdb", Temperature: 40, Collection: collected()},
{Device: "/dev/sdc", WWN: "0x5000c500aaaa0001", Temperature: 41, Collection: collected()},
{Device: "sdd", Standby: true, Collection: &diskinventory.CollectionStatus{
Temperature: diskinventory.Unavailable("smartctl", "disk is in standby")}},
}
host.Unraid = &models.HostUnraidStorage{ArrayStarted: true, Disks: []models.HostUnraidDisk{
{Device: "sdb", Serial: "UNRAID-SERIAL-B", Name: "disk1", Role: "data", Status: "online"},
{Device: "sdc", Serial: "UNRAID-SERIAL-C", Name: "disk2", Role: "data", Status: "online"},
{Device: "sdd", Serial: "UNRAID-SERIAL-D", Name: "disk3", Role: "data", Status: "online", SpunDown: true},
{Device: "sde", Serial: "UNRAID-SERIAL-E", Name: "disk4", Role: "data", Status: "online"},
{Device: "sdf", Name: "disk5", Role: "data", Status: "online"},
}}
want := map[string][]string{
"sdb": {"smart_temp", "diskread", "diskwrite", "disk"},
"/dev/sdc": {"smart_temp", "diskread", "diskwrite", "disk"},
"sdd": {"diskread", "diskwrite", "disk"},
"sde": {"diskread", "diskwrite", "disk"},
"sdf": {"diskread", "diskwrite", "disk"},
}
for _, device := range []string{"sdb", "sdc", "sdd", "sde", "sdf"} {
host.DiskIO = append(host.DiskIO, models.DiskIO{Device: device, ReadBytes: 1 << 20, WriteBytes: 1 << 20, IOTime: 100})
}
m.writeHostPhysicalDiskIOMetrics(host, now.Add(-30*time.Second))
for i := range host.DiskIO {
host.DiskIO[i].ReadBytes += 1 << 20
host.DiskIO[i].WriteBytes += 1 << 20
host.DiskIO[i].IOTime += 1000
}
m.writeHostPhysicalDiskIOMetrics(host, now)
m.writeHostSMARTMetrics(host, now)
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 {
metricNames, ok := want[disk.PhysicalDisk.DevPath]
if !ok {
t.Fatalf("%s: unexpected disk %s at %q", tc.name, disk.ID, disk.PhysicalDisk.DevPath)
}
target := tc.registry.MetricsTarget(disk.ID)
if target == nil {
t.Fatalf("%s: disk %s has no metrics target", tc.name, disk.PhysicalDisk.DevPath)
}
for _, metric := range metricNames {
points, err := store.Query(target.ResourceType, target.ResourceID, metric, now.Add(-time.Minute), now.Add(time.Minute), 0)
if err != nil {
t.Fatal(err)
}
if len(points) == 0 {
t.Errorf("%s: disk %s metrics target %+v reads no %s history", tc.name, disk.PhysicalDisk.DevPath, *target, metric)
}
}
}
}
}
// 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
+19 -9
View File
@@ -4228,24 +4228,34 @@ func hostDiskIOMetricResourceID(host models.Host, io models.DiskIO, proxmoxDisks
return ""
}
smartDevice := diskinventory.DeviceToken(io.Device)
smartMetricID := ""
smartRowID := ""
for _, disk := range host.Sensors.SMART {
if disk.Standby {
if !strings.EqualFold(diskinventory.DeviceToken(disk.Device), smartDevice) {
continue
}
if strings.EqualFold(normalizeHostDiskDevice(disk.Device), device) {
candidate := unifiedresources.HostSMARTDiskMetricID(host, disk)
if smartMetricID != "" && smartMetricID != candidate {
// Multiple controller members share this kernel block path.
// The counter belongs to the aggregate device, not any member.
return ""
}
smartMetricID = candidate
// Multiple controller members share this kernel block path. The
// counter belongs to the aggregate device, not any member. Judge that
// on each row's own identity, standby rows included, since members
// never take the path's Unraid serial.
rowID := unifiedresources.HostSMARTDiskSourceID(host, disk)
if smartRowID != "" && smartRowID != rowID {
return ""
}
smartRowID = rowID
if !disk.Standby {
smartMetricID = unifiedresources.HostSMARTDiskMetricID(host, disk)
}
}
if smartMetricID != "" {
return smartMetricID
}
// A disk with no SMART reading to key it, spun down or unread, still
// carries the serial its Unraid row reports.
if unraidMetricID := unifiedresources.HostUnraidDeviceMetricID(host, device); unraidMetricID != "" {
return unraidMetricID
}
for _, pd := range proxmoxDisks {
if pd.device == "" || pd.metricID == "" {
@@ -3550,6 +3550,49 @@ func TestHostDiskIOMetricResourceIDFallbacks(t *testing.T) {
if got != "SATA-SERIAL-456" {
t.Fatalf("proxmox fallback: expected SATA-SERIAL-456, got %q", got)
}
// The disk resource carries the serial the host's Unraid inventory reports
// when no SMART reading names the disk, so its I/O is filed under it.
// Controller members behind one block device keep their own identity, so
// the device's counter belongs to no member, even while one is in standby.
// Two inventory rows on one device name no single disk. A legacy SMART
// device label still names its block device.
host.Sensors.SMART = append(host.Sensors.SMART,
models.HostDiskSMART{Device: "sdb"},
models.HostDiskSMART{Device: "sdc", Serial: "STANDBY-SERIAL", Standby: true},
models.HostDiskSMART{Device: "sde", Controller: "ctrl0", Target: "megaraid,0"},
models.HostDiskSMART{Device: "sde", Controller: "ctrl0", Target: "megaraid,1"},
models.HostDiskSMART{Device: "sdg", WWN: "5000c500a1b2c3e0", Controller: "ctrl1", Target: "megaraid,0"},
models.HostDiskSMART{Device: "/dev/sdg", WWN: "5000c500a1b2c3e1", Controller: "ctrl1", Target: "megaraid,1", Standby: true},
models.HostDiskSMART{Device: "sdh [sat]", Serial: "LEGACY-LABEL-SERIAL"},
)
host.Unraid = &models.HostUnraidStorage{Disks: []models.HostUnraidDisk{
{Device: "sda", Serial: "UNRAID-SERIAL-A"},
{Device: "sdb", Serial: "UNRAID-SERIAL-B"},
{Device: "sdc", Serial: "UNRAID-SERIAL-C"},
{Device: "sdd"},
{Device: "sde", Serial: "UNRAID-SERIAL-E"},
{Device: "sdf", Serial: "UNRAID-SERIAL-F1"},
{Device: "sdf", Serial: "UNRAID-SERIAL-F2"},
}}
for _, tc := range []struct {
device string
want string
}{
{"sda", "UNRAID-SERIAL-A"},
{"sdb", "UNRAID-SERIAL-B"},
{"sdc", "UNRAID-SERIAL-C"},
{"sdd", "myhost:sdd"},
{"sde", ""},
{"sdf", "myhost:sdf"},
{"sdg", ""},
{"sdh", "LEGACY-LABEL-SERIAL"},
{"nvme0n1", "NVME-SERIAL-123"},
} {
if got := hostDiskIOMetricResourceID(host, models.DiskIO{Device: tc.device}, proxmoxDisks); got != tc.want {
t.Fatalf("device %s: expected %q, got %q", tc.device, tc.want, got)
}
}
}
func TestApplyHostReportSkipsMetricsAndSMARTWritesInMockMode(t *testing.T) {
+1 -4
View File
@@ -905,7 +905,7 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res
}
unraidDisk := matchUnraidDisk(host.Unraid, disk)
model := strings.TrimSpace(disk.Model)
serial := strings.TrimSpace(disk.Serial)
serial := hostSMARTDiskSerial(host, disk, unraidDisk)
diskType := strings.TrimSpace(disk.Type)
temperature := disk.Temperature
collection := diskinventory.CloneStatus(disk.Collection)
@@ -914,9 +914,6 @@ func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Res
if model == "" {
model = strings.TrimSpace(unraidDisk.Model)
}
if serial == "" {
serial = strings.TrimSpace(unraidDisk.Serial)
}
if diskType == "" {
diskType = unraidDiskDiskType(*unraidDisk)
}
@@ -565,6 +565,121 @@ func TestHostSMARTDiskSourceIDScopesHardwareIdentityToItsHost(t *testing.T) {
}
}
// A SMART row without a serial takes the one the host's Unraid inventory
// reports for the disk, and its metrics key follows, because the disk resource
// carries that serial and its metrics target reads it. The source ID stays the
// row's own. A row's own serial, even a placeholder, is kept, as the adapter
// keeps it, and the Unraid row then stays a disk of its own. So does a row on a
// device path several rows share: controller members behind one block device
// keep their own identity. A disk with no SMART row is keyed by its Unraid
// serial alone, unless several Unraid rows name its device.
func TestHostSMARTDiskMetricIDTakesTheSerialItsUnraidRowReports(t *testing.T) {
host := models.Host{ID: "host-tower", Hostname: "tower", MachineID: "machine-tower", Status: "online", LastSeen: time.Now().UTC()}
host.Unraid = &models.HostUnraidStorage{ArrayStarted: true, Disks: []models.HostUnraidDisk{
{Name: "disk1", Device: "sdb", Role: "data", Serial: "UNRAID-B"},
{Name: "disk2", Device: "sdc", Role: "data", Serial: "UNRAID-C"},
{Name: "disk3", Device: "sdd", Role: "data", Serial: "UNRAID-D"},
{Name: "disk4", Device: "sde", Role: "data", Serial: "UNRAID-E"},
{Name: "disk5", Device: "sdf", Role: "data", Serial: "UNRAID-F"},
{Name: "disk6", Device: "sdg", Role: "data"},
{Name: "disk7", Device: "sdh", Role: "data", Serial: "UNRAID-H"},
{Name: "disk8", Device: "sdi", Role: "data", Serial: "UNRAID-I1"},
{Name: "disk9", Device: "sdi", Role: "data", Serial: "UNRAID-I2"},
}}
tests := []struct {
name string
disk models.HostDiskSMART
wantSource string
wantMetric string
}{
{
name: "no hardware identity",
disk: models.HostDiskSMART{Device: "/dev/sdb"},
wantSource: "host-tower:sdb",
wantMetric: "UNRAID-B",
},
{
name: "standby row",
disk: models.HostDiskSMART{Device: "sdc", Standby: true},
wantSource: "host-tower:sdc",
wantMetric: "UNRAID-C",
},
{
name: "wwn only",
disk: models.HostDiskSMART{Device: "sdd", WWN: "5000c500a1b2c3d4"},
wantSource: "host-tower/physical-disk:5000c500a1b2c3d4",
wantMetric: "UNRAID-D",
},
{
name: "own serial",
disk: models.HostDiskSMART{Device: "sde", Serial: "SMART-E"},
wantSource: "host-tower/physical-disk:SMART-E",
wantMetric: "SMART-E",
},
{
name: "own placeholder serial",
disk: models.HostDiskSMART{Device: "sdf", Serial: "0000000000"},
wantSource: "host-tower:sdf",
wantMetric: "host-tower:sdf",
},
{
name: "unraid row without serial",
disk: models.HostDiskSMART{Device: "sdg"},
wantSource: "host-tower:sdg",
wantMetric: "host-tower:sdg",
},
{
name: "controller member sharing a path",
disk: models.HostDiskSMART{Device: "sdh", WWN: "5000c500a1b2c3e0", Controller: "ctrl0", Target: "megaraid,0"},
wantSource: "host-tower/physical-disk:5000c500a1b2c3e0",
wantMetric: "5000c500a1b2c3e0",
},
{
name: "second controller member on that path",
disk: models.HostDiskSMART{Device: "/dev/sdh", WWN: "5000c500a1b2c3e1", Controller: "ctrl0", Target: "megaraid,1"},
wantSource: "host-tower/physical-disk:5000c500a1b2c3e1",
wantMetric: "5000c500a1b2c3e1",
},
}
host.Sensors.SMART = make([]models.HostDiskSMART, 0, len(tests))
for _, tt := range tests {
host.Sensors.SMART = append(host.Sensors.SMART, tt.disk)
}
rr := NewRegistry(nil)
rr.IngestSnapshot(models.StateSnapshot{Hosts: []models.Host{host}})
targetBySourceID := make(map[string]string)
for _, disk := range rr.ListByType(ResourceTypePhysicalDisk) {
target := rr.MetricsTarget(disk.ID)
if target == nil {
continue
}
for _, sourceTarget := range rr.SourceTargets(disk.ID) {
if sourceTarget.Source == SourceAgent {
targetBySourceID[sourceTarget.SourceID] = target.ResourceID
}
}
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HostSMARTDiskSourceID(host, tt.disk); got != tt.wantSource {
t.Fatalf("HostSMARTDiskSourceID = %q, want %q", got, tt.wantSource)
}
if got := HostSMARTDiskMetricID(host, tt.disk); got != tt.wantMetric {
t.Fatalf("HostSMARTDiskMetricID = %q, want %q", got, tt.wantMetric)
}
if got := targetBySourceID[tt.wantSource]; got != tt.wantMetric {
t.Fatalf("disk metrics target = %q, want the writer's %q", got, tt.wantMetric)
}
})
}
for device, want := range map[string]string{"/dev/sde": "UNRAID-E", "sdg": "", "sdi": "", "sdz": ""} {
if got := HostUnraidDeviceMetricID(host, device); got != want {
t.Fatalf("HostUnraidDeviceMetricID(%q) = %q, want %q", device, got, want)
}
}
}
// A registry seeded from unified resources rebuilds each agent disk's source
// mapping from the resource alone, after a JSON round trip drops per-source
// parents. It must reproduce the key the agent's observation ingests under,
+70 -8
View File
@@ -26,31 +26,93 @@ func PreferredPhysicalDiskMetricID(serial, wwn, fallback string) string {
// machine: a dual-ported SAS shelf, cloned VMs with an explicit serial and
// fixed-serial USB bridges report one identifier on several hosts, and the
// registry keeps one resource per source ID. The key therefore carries the
// reporting host. A disk without hardware identity keeps its historical
// host/device/topology key, which is also its metrics key.
// reporting host. A row without hardware identity keeps its historical
// host/device/topology key, which is also its metrics key unless the host's
// Unraid inventory reports a serial for the disk.
func HostSMARTDiskSourceID(host models.Host, disk models.HostDiskSMART) string {
if hardwareID := PreferredPhysicalDiskMetricID(disk.Serial, disk.WWN, ""); hardwareID != "" {
return hostPhysicalDiskSourceID(host.ID, hardwareID)
}
return HostSMARTDiskMetricID(host, disk)
return hostSMARTDiskKey(host, disk, "", "")
}
// HostSMARTDiskMetricID returns the history key a host agent's SMART and disk
// I/O metrics are written under. Like every physical-disk source, it prefers
// the drive's serial or WWN, so a disk keeps one history across sources and
// hosts; see PreferredPhysicalDiskMetricID.
// hosts; see PreferredPhysicalDiskMetricID. The serial is the one the disk
// resource carries (hostSMARTDiskSerial), which its metrics target reads.
func HostSMARTDiskMetricID(host models.Host, disk models.HostDiskSMART) string {
device := normalizePhysicalDiskDeviceToken(disk.Device)
serial := hostSMARTDiskSerial(host, disk, matchUnraidDisk(host.Unraid, disk))
return hostSMARTDiskKey(host, disk, serial, disk.WWN)
}
func hostSMARTDiskKey(host models.Host, disk models.HostDiskSMART, serial, wwn string) string {
return diskinventory.PreferredID(
disk.Serial,
disk.WWN,
serial,
wwn,
strings.TrimSpace(host.ID),
device,
normalizePhysicalDiskDeviceToken(disk.Device),
disk.Controller,
disk.Target,
)
}
// hostSMARTDiskSerial returns the serial of the disk a SMART row describes: the
// 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.
func hostSMARTDiskSerial(host models.Host, disk models.HostDiskSMART, unraidDisk *models.HostUnraidDisk) string {
serial := strings.TrimSpace(disk.Serial)
if serial != "" || unraidDisk == nil || hostSMARTDevicePathShared(host, disk) {
return serial
}
return strings.TrimSpace(unraidDisk.Serial)
}
// 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 {
device := strings.ToLower(normalizePhysicalDiskDeviceToken(disk.Device))
if device == "" {
return false
}
rows := 0
for _, other := range host.Sensors.SMART {
if strings.ToLower(normalizePhysicalDiskDeviceToken(other.Device)) == device {
rows++
}
}
return rows > 1
}
// HostUnraidDeviceMetricID returns the history key of the disk a host's Unraid
// inventory reports at device: its usable serial, which the disk resource
// carries and its metrics target reads. It returns "" when the inventory has
// no row for the device, more than one, or a row without a usable serial.
func HostUnraidDeviceMetricID(host models.Host, device string) string {
device = strings.ToLower(normalizePhysicalDiskDeviceToken(device))
if host.Unraid == nil || device == "" {
return ""
}
var matched *models.HostUnraidDisk
for i := range host.Unraid.Disks {
if strings.ToLower(normalizePhysicalDiskDeviceToken(host.Unraid.Disks[i].Device)) != device {
continue
}
if matched != nil {
return ""
}
matched = &host.Unraid.Disks[i]
}
if matched == nil {
return ""
}
return PreferredPhysicalDiskMetricID(matched.Serial, "", "")
}
// HostUnraidDiskSourceID returns the registry source ID of a disk in a host's
// Unraid inventory. It shares HostSMARTDiskSourceID's host-scoped serial key so
// the SMART and Unraid observations of one disk keep one source mapping.