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
@@ -1,17 +1,26 @@
package handler
import (
"net/http"
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
"github.com/sirupsen/logrus"
"net/http"
)
func ArchiveDevice(c *gin.Context) {
logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
err := deviceRepo.UpdateDeviceArchived(c, c.Param("wwn"), true)
scrutiny_uuid, err := uuid.FromString(c.Param("scrutiny_uuid"))
if err != nil {
logger.Errorln("Invalid scrutiny uuid", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
err = deviceRepo.UpdateDeviceArchived(c, scrutiny_uuid, true)
if err != nil {
logger.Errorln("An error occurred while archiving device", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -1,17 +1,24 @@
package handler
import (
"net/http"
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
"github.com/sirupsen/logrus"
"net/http"
)
func DeleteDevice(c *gin.Context) {
logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
err := deviceRepo.DeleteDevice(c, c.Param("wwn"))
scrutiny_uuid, err := uuid.FromString(c.Param("scrutiny_uuid"))
if err != nil {
logger.Errorln("Invalid scrutiny uuid", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
err = deviceRepo.DeleteDevice(c, scrutiny_uuid)
if err != nil {
logger.Errorln("An error occurred while deleting device", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -6,14 +6,20 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/analogj/scrutiny/webapp/backend/pkg/thresholds"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
"github.com/sirupsen/logrus"
)
func GetDeviceDetails(c *gin.Context) {
logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
device, err := deviceRepo.GetDeviceDetails(c, c.Param("wwn"))
scrutiny_uuid, err := uuid.FromString(c.Param("scrutiny_uuid"))
if err != nil {
logger.Errorln("Invalid scrutiny uuid", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
device, err := deviceRepo.GetDeviceDetails(c, scrutiny_uuid)
if err != nil {
logger.Errorln("An error occurred while retrieving device details", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -25,7 +31,7 @@ func GetDeviceDetails(c *gin.Context) {
durationKey = "forever"
}
smartResults, err := deviceRepo.GetSmartAttributeHistory(c, c.Param("wwn"), durationKey, 0, 0, nil)
smartResults, err := deviceRepo.GetSmartAttributeHistory(c, scrutiny_uuid, durationKey, 0, 0, nil)
if err != nil {
logger.Errorln("An error occurred while retrieving device smart results", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -1,10 +1,11 @@
package handler
import (
"net/http"
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"net/http"
)
func GetDevicesSummary(c *gin.Context) {
@@ -1,12 +1,13 @@
package handler
import (
"net/http"
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
"net/http"
)
// register devices that are detected by various collectors.
@@ -23,9 +24,9 @@ func RegisterDevices(c *gin.Context) {
return
}
//filter any device with empty wwn (they are invalid)
// Filter any device without a scrutiny UUID. This should never happen...
detectedStorageDevices := lo.Filter[models.Device](collectorDeviceWrapper.Data, func(dev models.Device, _ int) bool {
return len(dev.WWN) > 0
return !dev.ScrutinyUUID.IsNil()
})
errs := []error{}
@@ -1,17 +1,24 @@
package handler
import (
"net/http"
"github.com/analogj/scrutiny/webapp/backend/pkg/database"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
"github.com/sirupsen/logrus"
"net/http"
)
func UnarchiveDevice(c *gin.Context) {
logger := c.MustGet("LOGGER").(*logrus.Entry)
deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
err := deviceRepo.UpdateDeviceArchived(c, c.Param("wwn"), false)
scrutiny_uuid, err := uuid.FromString(c.Param("scrutiny_uuid"))
if err != nil {
logger.Errorln("Invalid scrutiny uuid", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
err = deviceRepo.UpdateDeviceArchived(c, scrutiny_uuid, false)
if err != nil {
logger.Errorln("An error occurred while unarchiving device", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -10,6 +10,7 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/notify"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
"github.com/sirupsen/logrus"
)
@@ -22,12 +23,15 @@ func UploadDeviceMetrics(c *gin.Context) {
//appConfig := c.MustGet("CONFIG").(config.Interface)
if c.Param("wwn") == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false})
scrutiny_uuid, err := uuid.FromString(c.Param("scrutiny_uuid"))
if err != nil {
logger.Errorln("Invalid scrutiny uuid", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
var collectorSmartData collector.SmartInfo
err := c.BindJSON(&collectorSmartData)
err = c.BindJSON(&collectorSmartData)
if err != nil {
logger.Errorln("Cannot parse SMART data", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -35,7 +39,7 @@ func UploadDeviceMetrics(c *gin.Context) {
}
//update the device information if necessary
updatedDevice, err := deviceRepo.UpdateDevice(c, c.Param("wwn"), collectorSmartData)
updatedDevice, err := deviceRepo.UpdateDevice(c, scrutiny_uuid, collectorSmartData)
if err != nil {
logger.Errorln("An error occurred while updating device data from smartctl metrics:", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -43,7 +47,7 @@ func UploadDeviceMetrics(c *gin.Context) {
}
// insert smart info
smartData, err := deviceRepo.SaveSmartAttributes(c, c.Param("wwn"), collectorSmartData)
smartData, err := deviceRepo.SaveSmartAttributes(c, scrutiny_uuid, collectorSmartData)
if err != nil {
logger.Errorln("An error occurred while saving smartctl metrics", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -52,7 +56,7 @@ func UploadDeviceMetrics(c *gin.Context) {
if smartData.Status != pkg.DeviceStatusPassed {
//there is a failure detected by Scrutiny, update the device status on the homepage.
updatedDevice, err = deviceRepo.UpdateDeviceStatus(c, c.Param("wwn"), smartData.Status)
updatedDevice, err = deviceRepo.UpdateDeviceStatus(c, scrutiny_uuid, smartData.Status)
if err != nil {
logger.Errorln("An error occurred while updating device status", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -61,7 +65,7 @@ func UploadDeviceMetrics(c *gin.Context) {
}
// save smart temperature data (ignore failures)
err = deviceRepo.SaveSmartTemperature(c, c.Param("wwn"), updatedDevice.DeviceProtocol, collectorSmartData, appConfig.GetBool(fmt.Sprintf("%s.collector.discard_sct_temp_history", config.DB_USER_SETTINGS_SUBKEY)))
err = deviceRepo.SaveSmartTemperature(c, scrutiny_uuid, updatedDevice.DeviceProtocol, collectorSmartData, appConfig.GetBool(fmt.Sprintf("%s.collector.discard_sct_temp_history", config.DB_USER_SETTINGS_SUBKEY)))
if err != nil {
logger.Errorln("An error occurred while saving smartctl temp data", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
@@ -73,6 +77,7 @@ func UploadDeviceMetrics(c *gin.Context) {
logger,
updatedDevice,
smartData,
scrutiny_uuid,
pkg.MetricsStatusThreshold(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_threshold", config.DB_USER_SETTINGS_SUBKEY))),
pkg.MetricsStatusFilterAttributes(appConfig.GetInt(fmt.Sprintf("%s.metrics.status_filter_attributes", config.DB_USER_SETTINGS_SUBKEY))),
appConfig.GetBool(fmt.Sprintf("%s.metrics.repeat_notifications", config.DB_USER_SETTINGS_SUBKEY)),
+13 -12
View File
@@ -2,6 +2,10 @@ package web
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/analogj/go-util/utils"
"github.com/analogj/scrutiny/webapp/backend/pkg/config"
"github.com/analogj/scrutiny/webapp/backend/pkg/errors"
@@ -9,9 +13,6 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/web/middleware"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"net/http"
"path/filepath"
"strings"
)
type AppEngine struct {
@@ -37,15 +38,15 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
api.GET("/health", handler.HealthCheck)
api.POST("/health/notify", handler.SendTestNotification) //check if notifications are configured correctly
api.POST("/devices/register", handler.RegisterDevices) //used by Collector to register new devices and retrieve filtered list
api.GET("/summary", handler.GetDevicesSummary) //used by Dashboard
api.GET("/summary/temp", handler.GetDevicesSummaryTempHistory) //used by Dashboard (Temperature history dropdown)
api.POST("/device/:wwn/smart", handler.UploadDeviceMetrics) //used by Collector to upload data
api.POST("/device/:wwn/selftest", handler.UploadDeviceSelfTests)
api.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details
api.POST("/device/:wwn/archive", handler.ArchiveDevice) //used by UI to archive device
api.POST("/device/:wwn/unarchive", handler.UnarchiveDevice) //used by UI to unarchive device
api.DELETE("/device/:wwn", handler.DeleteDevice) //used by UI to delete device
api.POST("/devices/register", handler.RegisterDevices) //used by Collector to register new devices and retrieve filtered list
api.GET("/summary", handler.GetDevicesSummary) //used by Dashboard
api.GET("/summary/temp", handler.GetDevicesSummaryTempHistory) //used by Dashboard (Temperature history dropdown)
api.POST("/device/:scrutiny_uuid/smart", handler.UploadDeviceMetrics) //used by Collector to upload data
api.POST("/device/:scrutiny_uuid/selftest", handler.UploadDeviceSelfTests)
api.GET("/device/:scrutiny_uuid/details", handler.GetDeviceDetails) //used by Details
api.POST("/device/:scrutiny_uuid/archive", handler.ArchiveDevice) //used by UI to archive device
api.POST("/device/:scrutiny_uuid/unarchive", handler.UnarchiveDevice) //used by UI to unarchive device
api.DELETE("/device/:scrutiny_uuid", handler.DeleteDevice) //used by UI to delete device
api.GET("/settings", handler.GetSettings) //used to get settings
api.POST("/settings", handler.SaveSettings) //used to save settings
+16 -10
View File
@@ -19,6 +19,7 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/web"
"github.com/gofrs/uuid/v5"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
@@ -35,7 +36,7 @@ docker run --rm -it -p 8086:8086 \
-e DOCKER_INFLUXDB_INIT_ORG=scrutiny \
-e DOCKER_INFLUXDB_INIT_BUCKET=metrics \
-e DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=my-super-secret-auth-token \
influxdb:2.0
influxdb:2.2
*/
//func TestMain(m *testing.M) {
@@ -216,7 +217,7 @@ func (suite *ServerTestSuite) TestUploadDeviceMetricsRoute() {
require.Equal(suite.T(), 200, wr.Code)
mr := httptest.NewRecorder()
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/0x5000cca264eb01d7/smart", metricsfile)
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/9a4d34b5-b2ee-51ef-8506-90eea09be417/smart", metricsfile)
router.ServeHTTP(mr, req)
require.Equal(suite.T(), 200, mr.Code)
@@ -275,28 +276,31 @@ func (suite *ServerTestSuite) TestPopulateMultiple() {
router.ServeHTTP(wr, req)
require.Equal(suite.T(), 200, wr.Code)
// NOTE: The scrutiny_uuid's below must come from devicesfile because those get inserted into the database.
// They don't match the scrutiny_uuid that would be derived from the smart info files because the drives
// in those files don't match those in the registration. Currently, scrutiny does not reconcile the two.
mr := httptest.NewRecorder()
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/0x5000cca264eb01d7/smart", metricsfile)
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/ecfaaf20-d1f6-558b-b33a-3e8db19a6c2c/smart", metricsfile)
router.ServeHTTP(mr, req)
require.Equal(suite.T(), 200, mr.Code)
fr := httptest.NewRecorder()
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/0x5000cca264ec3183/smart", failfile)
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/3ea22b35-682b-49fb-a655-abffed108e48/smart", failfile)
router.ServeHTTP(fr, req)
require.Equal(suite.T(), 200, fr.Code)
nr := httptest.NewRecorder()
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/0x5002538e40a22954/smart", nvmefile)
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/d8796fe7-2422-520c-8991-e970993dad3e/smart", nvmefile)
router.ServeHTTP(nr, req)
require.Equal(suite.T(), 200, nr.Code)
sr := httptest.NewRecorder()
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/0x5000cca252c859cc/smart", scsifile)
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/00328b73-9f8a-53ad-8f20-8d0b1be00f47/smart", scsifile)
router.ServeHTTP(sr, req)
require.Equal(suite.T(), 200, sr.Code)
s2r := httptest.NewRecorder()
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/0x5000cca264ebc248/smart", scsi2file)
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/e5ccc378-24fc-5a9d-b1ce-8732096a9ea5/smart", scsi2file)
router.ServeHTTP(s2r, req)
require.Equal(suite.T(), 200, s2r.Code)
@@ -555,7 +559,7 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
require.Equal(suite.T(), 200, wr.Code)
mr := httptest.NewRecorder()
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/a4c8e8ed-11a0-4c97-9bba-306440f1b944/smart", metricsfile)
req, _ = http.NewRequest("POST", suite.Basepath+"/api/device/bde1d2d2-7e5c-525a-8327-6adbfa382637/smart", metricsfile)
router.ServeHTTP(mr, req)
require.Equal(suite.T(), 200, mr.Code)
@@ -568,6 +572,8 @@ func (suite *ServerTestSuite) TestGetDevicesSummaryRoute_Nvme() {
require.NoError(suite.T(), err)
//assert
require.Equal(suite.T(), "a4c8e8ed-11a0-4c97-9bba-306440f1b944", deviceSummary.Data.Summary["a4c8e8ed-11a0-4c97-9bba-306440f1b944"].Device.WWN)
require.Equal(suite.T(), pkg.DeviceStatusPassed, deviceSummary.Data.Summary["a4c8e8ed-11a0-4c97-9bba-306440f1b944"].Device.DeviceStatus)
deviceUUIDString := "bde1d2d2-7e5c-525a-8327-6adbfa382637"
deviceUUID := uuid.Must(uuid.FromString(deviceUUIDString))
require.Equal(suite.T(), deviceUUID, deviceSummary.Data.Summary[deviceUUIDString].Device.ScrutinyUUID)
require.Equal(suite.T(), pkg.DeviceStatusPassed, deviceSummary.Data.Summary[deviceUUIDString].Device.DeviceStatus)
}
@@ -14,7 +14,8 @@
"form_factor": "",
"smart_support": false,
"device_protocol": "NVMe",
"device_type": "nvme"
"device_type": "nvme",
"scrutiny_uuid": "bde1d2d2-7e5c-525a-8327-6adbfa382637"
}
]
}
+17 -10
View File
@@ -12,27 +12,29 @@
"rotational_speed": 0,
"capacity": 500107862016,
"form_factor": "",
"smart_support": false
"smart_support": false,
"scrutiny_uuid": "ecfaaf20-d1f6-558b-b33a-3e8db19a6c2c"
},
{
"wwn": "0x5000cca264eb01d7",
"device_name": "sdb",
"manufacturer": "ATA",
"model_name": "WDC_WD140EDFZ-11A0VA0",
"model_name": "WDC WD140EDFZ-11A0VA0",
"interface_type": "SCSI",
"interface_speed": "",
"serial_number": "9RK1XXXXX",
"serial_number": "9RK1XXXX",
"firmware": "",
"rotational_speed": 0,
"capacity": 14000519643136,
"form_factor": "",
"smart_support": false
"smart_support": false,
"scrutiny_uuid": "3ea22b35-682b-49fb-a655-abffed108e48"
},
{
"wwn": "0x5000cca264ec3183",
"device_name": "sdc",
"manufacturer": "ATA",
"model_name": "WDC_WD140EDFZ-11A0VA0",
"model_name": "WDC WD140EDFZ-11A0VA0",
"interface_type": "SCSI",
"interface_speed": "",
"serial_number": "9RK4XXXXX",
@@ -40,7 +42,8 @@
"rotational_speed": 0,
"capacity": 14000519643136,
"form_factor": "",
"smart_support": false
"smart_support": false,
"scrutiny_uuid": "42caca8a-9b95-5c75-b059-305771a2a193"
},
{
"wwn": "0x5000cca252c859cc",
@@ -54,7 +57,8 @@
"rotational_speed": 0,
"capacity": 8001563222016,
"form_factor": "",
"smart_support": false
"smart_support": false,
"scrutiny_uuid": "d8796fe7-2422-520c-8991-e970993dad3e"
},
{
"wwn": "0x5000cca264ebc248",
@@ -68,7 +72,8 @@
"rotational_speed": 0,
"capacity": 14000519643136,
"form_factor": "",
"smart_support": false
"smart_support": false,
"scrutiny_uuid": "00328b73-9f8a-53ad-8f20-8d0b1be00f47"
},
{
"wwn": "0x50014ee20b2a72a9",
@@ -82,7 +87,8 @@
"rotational_speed": 0,
"capacity": 6001175126016,
"form_factor": "",
"smart_support": false
"smart_support": false,
"scrutiny_uuid": "e5ccc378-24fc-5a9d-b1ce-8732096a9ea5"
},
{
"wwn": "0x5000c500673e6b5f",
@@ -96,7 +102,8 @@
"rotational_speed": 0,
"capacity": 6001175126016,
"form_factor": "",
"smart_support": false
"smart_support": false,
"scrutiny_uuid": "acfbce7d-0e19-579b-895e-85809dab63fb"
}
]
}
@@ -4,15 +4,16 @@
"wwn": "0x5000cca264eb01d7",
"device_name": "sdb",
"manufacturer": "ATA",
"model_name": "WDC_WD140EDFZ-11A0VA0",
"model_name": "WDC WD140EDFZ-11A0VA0",
"interface_type": "SCSI",
"interface_speed": "",
"serial_number": "9RK1XXXXX",
"serial_number": "9RK1XXXX",
"firmware": "",
"rotational_speed": 0,
"capacity": 14000519643136,
"form_factor": "",
"smart_support": false
"smart_support": false,
"scrutiny_uuid": "9a4d34b5-b2ee-51ef-8506-90eea09be417"
}
]
}