Identify drives by a Scrutiny UUID instead of wwn (#960)

* Generate a UUIDv5 from a random namespace  based on WWN, model name, and serial number
* Migrate sqlite and influxdb data accordingly
* Update frontend API routes and components
* Fixes #923
This commit is contained in:
Aram Akhavan
2026-03-25 20:16:17 -07:00
committed by GitHub
parent e4c40f7e80
commit c3b2eb2b4f
69 changed files with 815 additions and 402 deletions
+8 -4
View File
@@ -207,10 +207,10 @@ type SmartInfo struct {
ID int `json:"id"`
SubsystemID int `json:"subsystem_id"`
} `json:"nvme_pci_vendor"`
NvmeIeeeOuiIdentifier int `json:"nvme_ieee_oui_identifier"`
NvmeTotalCapacity int64 `json:"nvme_total_capacity"`
NvmeControllerID int `json:"nvme_controller_id"`
NvmeNumberOfNamespaces int `json:"nvme_number_of_namespaces"`
NvmeIeeeOuiIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
NvmeTotalCapacity int64 `json:"nvme_total_capacity"`
NvmeControllerID int `json:"nvme_controller_id"`
NvmeNumberOfNamespaces int `json:"nvme_number_of_namespaces"`
NvmeNamespaces []struct {
ID int `json:"id"`
Size struct {
@@ -226,6 +226,10 @@ type SmartInfo struct {
Bytes int64 `json:"bytes"`
} `json:"utilization"`
FormattedLbaSize int `json:"formatted_lba_size"`
Eui64 struct {
Oui uint32 `json:"oui"`
ExtId uint64 `json:"ext_id"`
} `json:"eui64"`
} `json:"nvme_namespaces"`
NvmeSmartHealthInformationLog NvmeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
+5 -2
View File
@@ -1,9 +1,11 @@
package models
import (
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"time"
"github.com/gofrs/uuid/v5"
)
type DeviceWrapper struct {
@@ -19,7 +21,7 @@ type Device struct {
UpdatedAt time.Time
DeletedAt *time.Time
WWN string `json:"wwn" gorm:"primary_key"`
WWN string `json:"wwn"`
DeviceName string `json:"device_name"`
DeviceUUID string `json:"device_uuid"`
@@ -45,6 +47,7 @@ type Device struct {
// Data set by Scrutiny
DeviceStatus pkg.DeviceStatus `json:"device_status"`
ScrutinyUUID uuid.UUID `json:"scrutiny_uuid" gorm:"primaryKey;uniqueIndex"`
}
func (dv *Device) IsAta() bool {
+3 -1
View File
@@ -1,10 +1,12 @@
package models
import (
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"time"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
)
// This is used in server_test.go
type DeviceSummaryWrapper struct {
Success bool `json:"success"`
Errors []error `json:"errors"`
@@ -10,11 +10,13 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"github.com/gofrs/uuid/v5"
)
type Smart struct {
Date time.Time `json:"date"`
DeviceWWN string `json:"device_wwn"` //(tag)
DeviceWWN string `json:"device_wwn` // deprecated
ScrutinyUUID uuid.UUID `json:"scrutiny_uuid"` //(tag)
DeviceProtocol string `json:"device_protocol"`
//Metrics (fields)
@@ -31,7 +33,7 @@ type Smart struct {
func (sm *Smart) Flatten() (tags map[string]string, fields map[string]interface{}) {
tags = map[string]string{
"device_wwn": sm.DeviceWWN,
"scrutiny_uuid": sm.ScrutinyUUID.String(),
"device_protocol": sm.DeviceProtocol,
}
@@ -53,10 +55,15 @@ func (sm *Smart) Flatten() (tags map[string]string, fields map[string]interface{
func NewSmartFromInfluxDB(attrs map[string]interface{}) (*Smart, error) {
//go though the massive map returned from influxdb. If a key is associated with the Smart struct, assign it. If it starts with "attr.*" group it by attributeId, and pass to attribute inflate.
scrutiny_uuid, err := uuid.FromString(attrs["scrutiny_uuid"].(string))
if err != nil {
return nil, err
}
sm := Smart{
//required fields
Date: attrs["_time"].(time.Time),
DeviceWWN: attrs["device_wwn"].(string),
ScrutinyUUID: scrutiny_uuid,
DeviceProtocol: attrs["device_protocol"].(string),
Attributes: map[string]SmartAttribute{},
@@ -112,14 +119,14 @@ func NewSmartFromInfluxDB(attrs map[string]interface{}) (*Smart, error) {
}
log.Printf("Found Smart Device (%s) Attributes (%v)", sm.DeviceWWN, len(sm.Attributes))
log.Printf("Found Smart Device (%s) Attributes (%v)", sm.ScrutinyUUID, len(sm.Attributes))
return &sm, nil
}
// Parse Collector SMART data results and create Smart object (and associated SmartAtaAttribute entries)
func (sm *Smart) FromCollectorSmartInfo(wwn string, info collector.SmartInfo) error {
sm.DeviceWWN = wwn
func (sm *Smart) FromCollectorSmartInfo(scrutiny_uuid uuid.UUID, info collector.SmartInfo) error {
sm.ScrutinyUUID = scrutiny_uuid
sm.Date = time.Unix(info.LocalTime.TimeT, 0)
//smart metrics
@@ -133,11 +140,12 @@ func (sm *Smart) FromCollectorSmartInfo(wwn string, info collector.SmartInfo) er
sm.DeviceProtocol = info.Device.Protocol
// process ATA/NVME/SCSI protocol data
sm.Attributes = map[string]SmartAttribute{}
if sm.DeviceProtocol == pkg.DeviceProtocolAta {
switch sm.DeviceProtocol {
case pkg.DeviceProtocolAta:
sm.ProcessAtaSmartInfo(info.AtaSmartAttributes.Table)
} else if sm.DeviceProtocol == pkg.DeviceProtocolNvme {
case pkg.DeviceProtocolNvme:
sm.ProcessNvmeSmartInfo(info.NvmeSmartHealthInformationLog)
} else if sm.DeviceProtocol == pkg.DeviceProtocolScsi {
case pkg.DeviceProtocolScsi:
sm.ProcessScsiSmartInfo(info.ScsiGrownDefectList, info.ScsiErrorCounterLog)
}
@@ -67,7 +67,7 @@ func (sa *SmartNvmeAttribute) Inflate(key string, val interface{}) {
}
}
//populate attribute status, using SMART Thresholds & Observed Metadata
// populate attribute status, using SMART Thresholds & Observed Metadata
// Chainable
func (sa *SmartNvmeAttribute) PopulateAttributeStatus() *SmartNvmeAttribute {
@@ -67,9 +67,8 @@ func (sa *SmartScsiAttribute) Inflate(key string, val interface{}) {
}
}
//
//populate attribute status, using SMART Thresholds & Observed Metadata
//Chainable
// populate attribute status, using SMART Thresholds & Observed Metadata
// Chainable
func (sa *SmartScsiAttribute) PopulateAttributeStatus() *SmartScsiAttribute {
//-1 is a special number meaning no threshold.
@@ -10,15 +10,17 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
"github.com/gofrs/uuid/v5"
"github.com/stretchr/testify/require"
)
func TestSmart_Flatten(t *testing.T) {
//setup
timeNow := time.Now()
smartUUID := uuid.Must(uuid.NewV4())
smart := measurements.Smart{
Date: timeNow,
DeviceWWN: "test-wwn",
ScrutinyUUID: smartUUID,
DeviceProtocol: pkg.DeviceProtocolAta,
Temp: 50,
PowerOnHours: 10,
@@ -31,16 +33,17 @@ func TestSmart_Flatten(t *testing.T) {
tags, fields := smart.Flatten()
//assert
require.Equal(t, map[string]string{"device_protocol": "ATA", "device_wwn": "test-wwn"}, tags)
require.Equal(t, map[string]string{"device_protocol": "ATA", "scrutiny_uuid": smartUUID.String()}, tags)
require.Equal(t, map[string]interface{}{"power_cycle_count": int64(10), "power_on_hours": int64(10), "temp": int64(50)}, fields)
}
func TestSmart_Flatten_ATA(t *testing.T) {
//setup
timeNow := time.Now()
smartUUID := uuid.Must(uuid.NewV4())
smart := measurements.Smart{
Date: timeNow,
DeviceWWN: "test-wwn",
ScrutinyUUID: smartUUID,
DeviceProtocol: pkg.DeviceProtocolAta,
Temp: 50,
PowerOnHours: 10,
@@ -72,7 +75,7 @@ func TestSmart_Flatten_ATA(t *testing.T) {
tags, fields := smart.Flatten()
//assert
require.Equal(t, map[string]string{"device_protocol": "ATA", "device_wwn": "test-wwn"}, tags)
require.Equal(t, map[string]string{"device_protocol": "ATA", "scrutiny_uuid": smartUUID.String()}, tags)
require.Equal(t, map[string]interface{}{
"attr.1.attribute_id": "1",
"attr.1.failure_rate": float64(0),
@@ -107,9 +110,10 @@ func TestSmart_Flatten_ATA(t *testing.T) {
func TestSmart_Flatten_SCSI(t *testing.T) {
//setup
timeNow := time.Now()
smartUUID := uuid.Must(uuid.NewV4())
smart := measurements.Smart{
Date: timeNow,
DeviceWWN: "test-wwn",
ScrutinyUUID: smartUUID,
DeviceProtocol: pkg.DeviceProtocolScsi,
Temp: 50,
PowerOnHours: 10,
@@ -127,7 +131,7 @@ func TestSmart_Flatten_SCSI(t *testing.T) {
tags, fields := smart.Flatten()
//assert
require.Equal(t, map[string]string{"device_protocol": "SCSI", "device_wwn": "test-wwn"}, tags)
require.Equal(t, map[string]string{"device_protocol": "SCSI", "scrutiny_uuid": smartUUID.String()}, tags)
require.Equal(t, map[string]interface{}{
"attr.read_errors_corrected_by_eccfast.attribute_id": "read_errors_corrected_by_eccfast",
"attr.read_errors_corrected_by_eccfast.failure_rate": float64(0),
@@ -145,9 +149,10 @@ func TestSmart_Flatten_SCSI(t *testing.T) {
func TestSmart_Flatten_NVMe(t *testing.T) {
//setup
timeNow := time.Now()
smartUUID := uuid.Must(uuid.NewV4())
smart := measurements.Smart{
Date: timeNow,
DeviceWWN: "test-wwn",
ScrutinyUUID: smartUUID,
DeviceProtocol: pkg.DeviceProtocolNvme,
Temp: 50,
PowerOnHours: 10,
@@ -165,7 +170,7 @@ func TestSmart_Flatten_NVMe(t *testing.T) {
tags, fields := smart.Flatten()
//assert
require.Equal(t, map[string]string{"device_protocol": "NVMe", "device_wwn": "test-wwn"}, tags)
require.Equal(t, map[string]string{"device_protocol": "NVMe", "scrutiny_uuid": smartUUID.String()}, tags)
require.Equal(t, map[string]interface{}{
"attr.available_spare.attribute_id": "available_spare",
"attr.available_spare.failure_rate": float64(0),
@@ -182,9 +187,10 @@ func TestSmart_Flatten_NVMe(t *testing.T) {
func TestNewSmartFromInfluxDB_ATA(t *testing.T) {
//setup
timeNow := time.Now()
smartUUID := uuid.Must(uuid.NewV4())
attrs := map[string]interface{}{
"_time": timeNow,
"device_wwn": "test-wwn",
"scrutiny_uuid": smartUUID.String(),
"device_protocol": pkg.DeviceProtocolAta,
"attr.1.attribute_id": "1",
"attr.1.failure_rate": float64(0),
@@ -209,7 +215,7 @@ func TestNewSmartFromInfluxDB_ATA(t *testing.T) {
require.NoError(t, err)
require.Equal(t, &measurements.Smart{
Date: timeNow,
DeviceWWN: "test-wwn",
ScrutinyUUID: smartUUID,
DeviceProtocol: "ATA",
Temp: 50,
PowerOnHours: 10,
@@ -230,9 +236,10 @@ func TestNewSmartFromInfluxDB_ATA(t *testing.T) {
func TestNewSmartFromInfluxDB_NVMe(t *testing.T) {
//setup
timeNow := time.Now()
smartUUID := uuid.Must(uuid.NewV4())
attrs := map[string]interface{}{
"_time": timeNow,
"device_wwn": "test-wwn",
"scrutiny_uuid": smartUUID.String(),
"device_protocol": pkg.DeviceProtocolNvme,
"attr.available_spare.attribute_id": "available_spare",
"attr.available_spare.failure_rate": float64(0),
@@ -253,7 +260,7 @@ func TestNewSmartFromInfluxDB_NVMe(t *testing.T) {
require.NoError(t, err)
require.Equal(t, &measurements.Smart{
Date: timeNow,
DeviceWWN: "test-wwn",
ScrutinyUUID: smartUUID,
DeviceProtocol: "NVMe",
Temp: 50,
PowerOnHours: 10,
@@ -269,9 +276,10 @@ func TestNewSmartFromInfluxDB_NVMe(t *testing.T) {
func TestNewSmartFromInfluxDB_SCSI(t *testing.T) {
//setup
timeNow := time.Now()
smartUUID := uuid.Must(uuid.NewV4())
attrs := map[string]interface{}{
"_time": timeNow,
"device_wwn": "test-wwn",
"scrutiny_uuid": smartUUID.String(),
"device_protocol": pkg.DeviceProtocolScsi,
"attr.read_errors_corrected_by_eccfast.attribute_id": "read_errors_corrected_by_eccfast",
"attr.read_errors_corrected_by_eccfast.failure_rate": float64(0),
@@ -292,7 +300,7 @@ func TestNewSmartFromInfluxDB_SCSI(t *testing.T) {
require.NoError(t, err)
require.Equal(t, &measurements.Smart{
Date: timeNow,
DeviceWWN: "test-wwn",
ScrutinyUUID: smartUUID,
DeviceProtocol: "SCSI",
Temp: 50,
PowerOnHours: 10,
@@ -320,11 +328,12 @@ func TestFromCollectorSmartInfo(t *testing.T) {
//test
smartMdl := measurements.Smart{}
err = smartMdl.FromCollectorSmartInfo("WWN-test", smartJson)
smartUUID := uuid.Must(uuid.NewV4())
err = smartMdl.FromCollectorSmartInfo(smartUUID, smartJson)
//assert
require.NoError(t, err)
require.Equal(t, "WWN-test", smartMdl.DeviceWWN)
require.Equal(t, smartUUID, smartMdl.ScrutinyUUID)
require.Equal(t, pkg.DeviceStatusPassed, smartMdl.Status)
require.Equal(t, 18, len(smartMdl.Attributes))
@@ -352,11 +361,12 @@ func TestFromCollectorSmartInfo_Fail_Smart(t *testing.T) {
//test
smartMdl := measurements.Smart{}
err = smartMdl.FromCollectorSmartInfo("WWN-test", smartJson)
smartUUID := uuid.Must(uuid.NewV4())
err = smartMdl.FromCollectorSmartInfo(smartUUID, smartJson)
//assert
require.NoError(t, err)
require.Equal(t, "WWN-test", smartMdl.DeviceWWN)
require.Equal(t, smartUUID, smartMdl.ScrutinyUUID)
require.Equal(t, pkg.DeviceStatusFailedSmart, smartMdl.Status)
require.Equal(t, 0, len(smartMdl.Attributes))
}
@@ -376,11 +386,12 @@ func TestFromCollectorSmartInfo_Fail_ScrutinySmart(t *testing.T) {
//test
smartMdl := measurements.Smart{}
err = smartMdl.FromCollectorSmartInfo("WWN-test", smartJson)
smartUUID := uuid.Must(uuid.NewV4())
err = smartMdl.FromCollectorSmartInfo(smartUUID, smartJson)
//assert
require.NoError(t, err)
require.Equal(t, "WWN-test", smartMdl.DeviceWWN)
require.Equal(t, smartUUID, smartMdl.ScrutinyUUID)
require.Equal(t, pkg.DeviceStatusFailedScrutiny|pkg.DeviceStatusFailedSmart, smartMdl.Status)
require.Equal(t, 17, len(smartMdl.Attributes))
}
@@ -400,11 +411,12 @@ func TestFromCollectorSmartInfo_Fail_ScrutinyNonCriticalFailed(t *testing.T) {
//test
smartMdl := measurements.Smart{}
err = smartMdl.FromCollectorSmartInfo("WWN-test", smartJson)
smartUUID := uuid.Must(uuid.NewV4())
err = smartMdl.FromCollectorSmartInfo(smartUUID, smartJson)
//assert
require.NoError(t, err)
require.Equal(t, "WWN-test", smartMdl.DeviceWWN)
require.Equal(t, smartUUID, smartMdl.ScrutinyUUID)
require.Equal(t, pkg.DeviceStatusFailedScrutiny, smartMdl.Status)
require.Equal(t, pkg.AttributeStatusFailedScrutiny, smartMdl.Attributes["199"].GetStatus(),
"scrutiny should detect that %d failed (status: %d, %s)",
@@ -433,11 +445,12 @@ func TestFromCollectorSmartInfo_NVMe_Fail_Scrutiny(t *testing.T) {
//test
smartMdl := measurements.Smart{}
err = smartMdl.FromCollectorSmartInfo("WWN-test", smartJson)
smartUUID := uuid.Must(uuid.NewV4())
err = smartMdl.FromCollectorSmartInfo(smartUUID, smartJson)
//assert
require.NoError(t, err)
require.Equal(t, "WWN-test", smartMdl.DeviceWWN)
require.Equal(t, smartUUID, smartMdl.ScrutinyUUID)
require.Equal(t, pkg.DeviceStatusFailedScrutiny, smartMdl.Status)
require.Equal(t, pkg.AttributeStatusFailedScrutiny, smartMdl.Attributes["media_errors"].GetStatus(),
"scrutiny should detect that %s failed (status: %d, %s)",
@@ -464,11 +477,12 @@ func TestFromCollectorSmartInfo_Nvme(t *testing.T) {
//test
smartMdl := measurements.Smart{}
err = smartMdl.FromCollectorSmartInfo("WWN-test", smartJson)
smartUUID := uuid.Must(uuid.NewV4())
err = smartMdl.FromCollectorSmartInfo(smartUUID, smartJson)
//assert
require.NoError(t, err)
require.Equal(t, "WWN-test", smartMdl.DeviceWWN)
require.Equal(t, smartUUID, smartMdl.ScrutinyUUID)
require.Equal(t, pkg.DeviceStatusPassed, smartMdl.Status)
require.Equal(t, 16, len(smartMdl.Attributes))
@@ -491,11 +505,12 @@ func TestFromCollectorSmartInfo_Scsi(t *testing.T) {
//test
smartMdl := measurements.Smart{}
err = smartMdl.FromCollectorSmartInfo("WWN-test", smartJson)
smartUUID := uuid.Must(uuid.NewV4())
err = smartMdl.FromCollectorSmartInfo(smartUUID, smartJson)
//assert
require.NoError(t, err)
require.Equal(t, "WWN-test", smartMdl.DeviceWWN)
require.Equal(t, smartUUID, smartMdl.ScrutinyUUID)
require.Equal(t, pkg.DeviceStatusPassed, smartMdl.Status)
require.Equal(t, 13, len(smartMdl.Attributes))