Compare commits

...

9 Commits

Author SHA1 Message Date
henrygd
cd9c2bd9ab update logs in smart.go
also change max execution time to 2 sec
2025-10-28 17:34:49 -04:00
henrygd
9f969d843c update changelog 2025-10-28 16:52:55 -04:00
henrygd
b22a6472fc missed staging this earlier :) 2025-10-28 16:44:34 -04:00
henrygd
d231ace28e fix SHARE_ALL_SYSTEMS not working for Containers
#1334
2025-10-28 16:25:29 -04:00
henrygd
473cb7f437 merge SMART_DEVICES with devices returned from smartctl scan 2025-10-28 15:38:47 -04:00
henrygd
783ed9f456 cache smartctl scan results for 10 min w/ force option
also add support for sntrealtek
2025-10-28 14:01:45 -04:00
henrygd
9a9a89ee50 handle when power on smart attribute is a string like 0h+0m+0.000s 2025-10-28 13:44:31 -04:00
henrygd
5122d0341d fix S.M.A.R.T. wrong disk is renderer in the DiskSheet table #1336 2025-10-28 12:55:38 -04:00
zjkal
81731689da A small translation error has been fixed (#1343) 2025-10-28 11:09:10 -04:00
9 changed files with 383 additions and 124 deletions

View File

@@ -168,7 +168,7 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
// return empty map to indicate no data
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
}
if err := hctx.Agent.smartManager.Refresh(); err != nil {
if err := hctx.Agent.smartManager.Refresh(false); err != nil {
slog.Debug("smart refresh failed", "err", err)
}
data := hctx.Agent.smartManager.GetCurrentData()

View File

@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"slices"
@@ -22,6 +23,7 @@ type SmartManager struct {
SmartDataMap map[string]*smart.SmartData
SmartDevices []*DeviceInfo
refreshMutex sync.Mutex
lastScanTime time.Time
}
type scanOutput struct {
@@ -42,12 +44,12 @@ type DeviceInfo struct {
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
// Refresh updates SMART data for all known devices on demand.
func (sm *SmartManager) Refresh() error {
// Refresh updates SMART data for all known devices
func (sm *SmartManager) Refresh(forceScan bool) error {
sm.refreshMutex.Lock()
defer sm.refreshMutex.Unlock()
scanErr := sm.ScanDevices()
scanErr := sm.ScanDevices(false)
if scanErr != nil {
slog.Debug("smartctl scan failed", "err", scanErr)
}
@@ -59,7 +61,7 @@ func (sm *SmartManager) Refresh() error {
continue
}
if err := sm.CollectSmart(deviceInfo); err != nil {
slog.Debug("smartctl collect failed, skipping", "device", deviceInfo.Name, "err", err)
slog.Debug("smartctl collect failed", "device", deviceInfo.Name, "err", err)
collectErr = err
}
}
@@ -129,18 +131,25 @@ func (sm *SmartManager) GetCurrentData() map[string]smart.SmartData {
// Scan devices using `smartctl --scan -j`
// If scan fails, return error
// If scan succeeds, parse the output and update the SmartDevices slice
func (sm *SmartManager) ScanDevices() error {
if configuredDevices, ok := GetEnv("SMART_DEVICES"); ok {
config := strings.TrimSpace(configuredDevices)
func (sm *SmartManager) ScanDevices(force bool) error {
if !force && time.Since(sm.lastScanTime) < 30*time.Minute {
return nil
}
sm.lastScanTime = time.Now()
var configuredDevices []*DeviceInfo
if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
slog.Info("SMART_DEVICES", "value", configuredRaw)
config := strings.TrimSpace(configuredRaw)
if config == "" {
return errNoValidSmartData
}
slog.Info("SMART_DEVICES", "config", config)
if err := sm.parseConfiguredDevices(config); err != nil {
parsedDevices, err := sm.parseConfiguredDevices(config)
if err != nil {
return err
}
return nil
configuredDevices = parsedDevices
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -149,18 +158,36 @@ func (sm *SmartManager) ScanDevices() error {
cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j")
output, err := cmd.Output()
var (
scanErr error
scannedDevices []*DeviceInfo
hasValidScan bool
)
if err != nil {
return err
scanErr = err
} else {
scannedDevices, hasValidScan = sm.parseScan(output)
if !hasValidScan {
scanErr = errNoValidSmartData
}
}
hasValidData := sm.parseScan(output)
if !hasValidData {
finalDevices := mergeDeviceLists(scannedDevices, configuredDevices)
sm.updateSmartDevices(finalDevices)
if len(finalDevices) == 0 {
if scanErr != nil {
slog.Debug("smartctl scan failed", "err", scanErr)
return scanErr
}
return errNoValidSmartData
}
return nil
}
func (sm *SmartManager) parseConfiguredDevices(config string) error {
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
entries := strings.Split(config, ",")
devices := make([]*DeviceInfo, 0, len(entries))
for _, entry := range entries {
@@ -173,7 +200,7 @@ func (sm *SmartManager) parseConfiguredDevices(config string) error {
name := strings.TrimSpace(parts[0])
if name == "" {
return fmt.Errorf("invalid SMART_DEVICES entry %q: device name is required", entry)
return nil, fmt.Errorf("invalid SMART_DEVICES entry %q", entry)
}
devType := ""
@@ -188,16 +215,10 @@ func (sm *SmartManager) parseConfiguredDevices(config string) error {
}
if len(devices) == 0 {
sm.Lock()
sm.SmartDevices = nil
sm.Unlock()
return errNoValidSmartData
return nil, errNoValidSmartData
}
sm.Lock()
sm.SmartDevices = devices
sm.Unlock()
return nil
return devices, nil
}
// detectDeviceType extracts the device type reported in smartctl JSON output.
@@ -232,7 +253,7 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
Parse func([]byte) (bool, int)
Alias []string
}{
{Type: "nvme", Parse: sm.parseSmartForNvme, Alias: []string{"sntasmedia"}},
{Type: "nvme", Parse: sm.parseSmartForNvme, Alias: []string{"sntasmedia", "sntrealtek"}},
{Type: "sat", Parse: sm.parseSmartForSata, Alias: []string{"ata"}},
{Type: "scsi", Parse: sm.parseSmartForScsi},
}
@@ -251,9 +272,12 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
deviceInfo.Type = parser.Type
}
return true
} else {
slog.Debug("parser failed", "device", deviceInfo.Name, "parser", parser.Type)
}
}
slog.Debug("parsing failed", "device", deviceInfo.Name)
return false
}
@@ -265,10 +289,12 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
// for initial data collection when no cached data exists
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
// Check if we have any existing data for this device
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Try with -n standby first if we have existing data
@@ -280,12 +306,10 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
if hasExistingData {
// Device is in standby and we have cached data, keep using cache
slog.Debug("device in standby mode, using cached data", "device", deviceInfo.Name)
return nil
}
// No cached data, need to collect initial data by bypassing standby
slog.Debug("device in standby but no cached data, collecting initial data", "device", deviceInfo.Name)
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2()
args = sm.smartctlArgs(deviceInfo, false)
cmd = exec.CommandContext(ctx2, "smartctl", args...)
@@ -296,10 +320,13 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
if !hasValidData {
if err != nil {
slog.Debug("smartctl failed", "device", deviceInfo.Name, "err", err)
return err
}
slog.Debug("no valid SMART data found", "device", deviceInfo.Name)
return errNoValidSmartData
}
return nil
}
@@ -339,43 +366,110 @@ func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
return false
}
// parseScan parses the output of smartctl --scan -j and updates the SmartDevices slice
func (sm *SmartManager) parseScan(output []byte) bool {
sm.Lock()
defer sm.Unlock()
sm.SmartDevices = make([]*DeviceInfo, 0)
// parseScan parses the output of smartctl --scan -j and returns the discovered devices.
func (sm *SmartManager) parseScan(output []byte) ([]*DeviceInfo, bool) {
scan := &scanOutput{}
if err := json.Unmarshal(output, scan); err != nil {
slog.Debug("Failed to parse smartctl scan JSON", "err", err)
return false
return nil, false
}
if len(scan.Devices) == 0 {
return false
slog.Debug("no devices found in smartctl scan")
return nil, false
}
scannedDeviceNameMap := make(map[string]bool, len(scan.Devices))
devices := make([]*DeviceInfo, 0, len(scan.Devices))
for _, device := range scan.Devices {
deviceInfo := &DeviceInfo{
slog.Debug("smartctl scan", "name", device.Name, "type", device.Type, "protocol", device.Protocol)
devices = append(devices, &DeviceInfo{
Name: device.Name,
Type: device.Type,
InfoName: device.InfoName,
Protocol: device.Protocol,
}
sm.SmartDevices = append(sm.SmartDevices, deviceInfo)
scannedDeviceNameMap[device.Name] = true
}
// remove devices that are not in the scan
for key := range sm.SmartDataMap {
if _, ok := scannedDeviceNameMap[key]; !ok {
delete(sm.SmartDataMap, key)
}
})
}
return true
return devices, true
}
// mergeDeviceLists combines scanned and configured SMART devices, preferring
// configured SMART_DEVICES when both sources reference the same device.
func mergeDeviceLists(scanned, configured []*DeviceInfo) []*DeviceInfo {
if len(scanned) == 0 && len(configured) == 0 {
return nil
}
finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
deviceIndex := make(map[string]*DeviceInfo, len(scanned)+len(configured))
for _, dev := range scanned {
if dev == nil || dev.Name == "" {
continue
}
copyDev := *dev
finalDevices = append(finalDevices, &copyDev)
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
}
for _, dev := range configured {
if dev == nil || dev.Name == "" {
continue
}
if existing, ok := deviceIndex[dev.Name]; ok {
if dev.Type != "" {
existing.Type = dev.Type
}
if dev.InfoName != "" {
existing.InfoName = dev.InfoName
}
if dev.Protocol != "" {
existing.Protocol = dev.Protocol
}
continue
}
copyDev := *dev
finalDevices = append(finalDevices, &copyDev)
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
}
return finalDevices
}
// updateSmartDevices replaces the cached device list and prunes SMART data
// entries whose backing device no longer exists.
func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
sm.Lock()
defer sm.Unlock()
sm.SmartDevices = devices
if len(sm.SmartDataMap) == 0 {
return
}
validNames := make(map[string]struct{}, len(devices))
for _, device := range devices {
if device == nil || device.Name == "" {
continue
}
validNames[device.Name] = struct{}{}
}
for key, data := range sm.SmartDataMap {
if data == nil {
delete(sm.SmartDataMap, key)
continue
}
if _, ok := validNames[data.DiskName]; ok {
continue
}
delete(sm.SmartDataMap, key)
}
}
// isVirtualDevice checks if a device is a virtual disk that should be filtered out
@@ -411,20 +505,19 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
}
if data.SerialNumber == "" {
slog.Debug("device has no serial number, skipping", "device", data.Device.Name)
slog.Debug("no serial number", "device", data.Device.Name)
return false, data.Smartctl.ExitStatus
}
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
if sm.isVirtualDevice(&data) {
slog.Debug("skipping virtual device", "device", data.Device.Name, "model", data.ModelName)
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
return false, data.Smartctl.ExitStatus
}
sm.Lock()
defer sm.Unlock()
// get device name (e.g. /dev/sda)
keyName := data.SerialNumber
// if device does not exist in SmartDataMap, initialize it
@@ -453,7 +546,7 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
Value: attr.Value,
Worst: attr.Worst,
Threshold: attr.Thresh,
RawValue: attr.Raw.Value,
RawValue: uint64(attr.Raw.Value),
RawString: attr.Raw.String,
WhenFailed: attr.WhenFailed,
}
@@ -482,7 +575,7 @@ func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
}
if data.SerialNumber == "" {
slog.Debug("scsi device has no serial number, skipping", "device", data.Device.Name)
slog.Debug("no serial number", "device", data.Device.Name)
return false, data.Smartctl.ExitStatus
}
@@ -549,7 +642,6 @@ func parseScsiGigabytesProcessed(value string) int64 {
normalized := strings.ReplaceAll(value, ",", "")
parsed, err := strconv.ParseInt(normalized, 10, 64)
if err != nil {
slog.Debug("failed to parse SCSI gigabytes processed", "value", value, "err", err)
return -1
}
return parsed
@@ -565,14 +657,13 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
}
if data.SerialNumber == "" {
slog.Debug("device has no serial number, skipping", "device", data.Device.Name)
slog.Debug("no serial number", "device", data.Device.Name)
return false, data.Smartctl.ExitStatus
}
sm.Lock()
defer sm.Unlock()
// get device name (e.g. /dev/nvme0)
keyName := data.SerialNumber
// if device does not exist in SmartDataMap, initialize it
@@ -622,9 +713,11 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
// detectSmartctl checks if smartctl is installed, returns an error if not
func (sm *SmartManager) detectSmartctl() error {
if _, err := exec.LookPath("smartctl"); err == nil {
slog.Debug("smartctl found")
return nil
}
return fmt.Errorf("smartctl not found")
slog.Debug("smartctl not found")
return errors.New("smartctl not found")
}
// NewSmartManager creates and initializes a new SmartManager

View File

@@ -38,27 +38,14 @@ func TestParseSmartForScsi(t *testing.T) {
t.Fatalf("expected smart data entry for serial 9YHSDH9B")
}
if deviceData.ModelName != "YADRO WUH721414AL4204" {
t.Fatalf("unexpected model name: %s", deviceData.ModelName)
}
if deviceData.FirmwareVersion != "C240" {
t.Fatalf("unexpected firmware version: %s", deviceData.FirmwareVersion)
}
if deviceData.DiskName != "/dev/sde" {
t.Fatalf("unexpected disk name: %s", deviceData.DiskName)
}
if deviceData.DiskType != "scsi" {
t.Fatalf("unexpected disk type: %s", deviceData.DiskType)
}
if deviceData.Temperature != 34 {
t.Fatalf("unexpected temperature: %d", deviceData.Temperature)
}
if deviceData.SmartStatus != "PASSED" {
t.Fatalf("unexpected SMART status: %s", deviceData.SmartStatus)
}
if deviceData.Capacity != 14000519643136 {
t.Fatalf("unexpected capacity: %d", deviceData.Capacity)
}
assert.Equal(t, deviceData.ModelName, "YADRO WUH721414AL4204")
assert.Equal(t, deviceData.SerialNumber, "9YHSDH9B")
assert.Equal(t, deviceData.FirmwareVersion, "C240")
assert.Equal(t, deviceData.DiskName, "/dev/sde")
assert.Equal(t, deviceData.DiskType, "scsi")
assert.EqualValues(t, deviceData.Temperature, 34)
assert.Equal(t, deviceData.SmartStatus, "PASSED")
assert.EqualValues(t, deviceData.Capacity, 14000519643136)
if len(deviceData.Attributes) == 0 {
t.Fatalf("expected attributes to be populated")
@@ -172,7 +159,7 @@ func TestScanDevicesWithEnvOverride(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData),
}
err := sm.ScanDevices()
err := sm.ScanDevices(true)
require.NoError(t, err)
require.Len(t, sm.SmartDevices, 2)
@@ -189,7 +176,7 @@ func TestScanDevicesWithEnvOverrideInvalid(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData),
}
err := sm.ScanDevices()
err := sm.ScanDevices(true)
require.Error(t, err)
}
@@ -200,7 +187,7 @@ func TestScanDevicesWithEnvOverrideEmpty(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData),
}
err := sm.ScanDevices()
err := sm.ScanDevices(true)
assert.ErrorIs(t, err, errNoValidSmartData)
assert.Empty(t, sm.SmartDevices)
}
@@ -316,7 +303,8 @@ func TestResolveRefreshError(t *testing.T) {
func TestParseScan(t *testing.T) {
sm := &SmartManager{
SmartDataMap: map[string]*smart.SmartData{
"/dev/sdb": {},
"serial-active": {DiskName: "/dev/sda"},
"serial-stale": {DiskName: "/dev/sdb"},
},
}
@@ -327,17 +315,52 @@ func TestParseScan(t *testing.T) {
]
}`)
hasData := sm.parseScan(scanJSON)
devices, hasData := sm.parseScan(scanJSON)
assert.True(t, hasData)
sm.updateSmartDevices(devices)
require.Len(t, sm.SmartDevices, 2)
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
_, exists := sm.SmartDataMap["/dev/sdb"]
assert.False(t, exists, "stale smart data entry should be removed")
_, activeExists := sm.SmartDataMap["serial-active"]
assert.True(t, activeExists, "active smart data should be preserved when device path remains")
_, staleExists := sm.SmartDataMap["serial-stale"]
assert.False(t, staleExists, "stale smart data entry should be removed when device path disappears")
}
func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
{Name: "/dev/nvme0", Type: "nvme"},
}
configured := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat-override"},
{Name: "/dev/sdb", Type: "sat"},
}
merged := mergeDeviceLists(scanned, configured)
require.Len(t, merged, 3)
byName := make(map[string]*DeviceInfo, len(merged))
for _, dev := range merged {
byName[dev.Name] = dev
}
require.Contains(t, byName, "/dev/sda")
assert.Equal(t, "sat-override", byName["/dev/sda"].Type, "configured type should override scanned type")
assert.Equal(t, "scan-info", byName["/dev/sda"].InfoName, "scan metadata should be preserved when config does not provide it")
require.Contains(t, byName, "/dev/nvme0")
assert.Equal(t, "nvme", byName["/dev/nvme0"].Type)
require.Contains(t, byName, "/dev/sdb")
assert.Equal(t, "sat", byName["/dev/sdb"].Type)
}
func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) {

View File

@@ -1,5 +1,10 @@
package smart
import (
"strconv"
"strings"
)
// Common types
type VersionInfo [2]int
@@ -129,30 +134,97 @@ type AtaSmartAttributes struct {
}
type AtaSmartAttribute struct {
ID uint16 `json:"id"`
Name string `json:"name"`
Value uint16 `json:"value"`
Worst uint16 `json:"worst"`
Thresh uint16 `json:"thresh"`
WhenFailed string `json:"when_failed"`
Flags AttributeFlags `json:"flags"`
Raw RawValue `json:"raw"`
ID uint16 `json:"id"`
Name string `json:"name"`
Value uint16 `json:"value"`
Worst uint16 `json:"worst"`
Thresh uint16 `json:"thresh"`
WhenFailed string `json:"when_failed"`
// Flags AttributeFlags `json:"flags"`
Raw RawValue `json:"raw"`
}
type AttributeFlags struct {
Value int `json:"value"`
String string `json:"string"`
Prefailure bool `json:"prefailure"`
UpdatedOnline bool `json:"updated_online"`
Performance bool `json:"performance"`
ErrorRate bool `json:"error_rate"`
EventCount bool `json:"event_count"`
AutoKeep bool `json:"auto_keep"`
}
// type AttributeFlags struct {
// Value int `json:"value"`
// String string `json:"string"`
// Prefailure bool `json:"prefailure"`
// UpdatedOnline bool `json:"updated_online"`
// Performance bool `json:"performance"`
// ErrorRate bool `json:"error_rate"`
// EventCount bool `json:"event_count"`
// AutoKeep bool `json:"auto_keep"`
// }
type RawValue struct {
Value uint64 `json:"value"`
String string `json:"string"`
Value SmartRawValue `json:"value"`
String string `json:"string"`
}
type SmartRawValue uint64
// handles when drives report strings like "0h+0m+0.000s" or "7344 (253d 8h)" for power on hours
func (v *SmartRawValue) UnmarshalJSON(data []byte) error {
trimmed := strings.TrimSpace(string(data))
if len(trimmed) == 0 || trimmed == "null" {
*v = 0
return nil
}
if trimmed[0] != '"' {
parsed, err := strconv.ParseUint(trimmed, 0, 64)
if err != nil {
return err
}
*v = SmartRawValue(parsed)
return nil
}
valueStr, err := strconv.Unquote(trimmed)
if err != nil {
return err
}
if valueStr == "" {
*v = 0
return nil
}
if parsed, err := strconv.ParseUint(valueStr, 0, 64); err == nil {
*v = SmartRawValue(parsed)
return nil
}
if idx := strings.IndexRune(valueStr, 'h'); idx >= 0 {
hoursPart := strings.TrimSpace(valueStr[:idx])
if hoursPart == "" {
*v = 0
return nil
}
if parsed, err := strconv.ParseFloat(hoursPart, 64); err == nil {
*v = SmartRawValue(uint64(parsed))
return nil
}
}
if digits := leadingDigitPrefix(valueStr); digits != "" {
if parsed, err := strconv.ParseUint(digits, 0, 64); err == nil {
*v = SmartRawValue(parsed)
return nil
}
}
*v = 0
return nil
}
func leadingDigitPrefix(value string) string {
var builder strings.Builder
for _, r := range value {
if r < '0' || r > '9' {
break
}
builder.WriteRune(r)
}
return builder.String()
}
// type PowerOnTimeInfo struct {

View File

@@ -0,0 +1,30 @@
package smart
import (
"encoding/json"
"testing"
)
func TestSmartRawValueUnmarshalDuration(t *testing.T) {
input := []byte(`{"value":"62312h+33m+50.907s","string":"62312h+33m+50.907s"}`)
var raw RawValue
if err := json.Unmarshal(input, &raw); err != nil {
t.Fatalf("unexpected error unmarshalling raw value: %v", err)
}
if uint64(raw.Value) != 62312 {
t.Fatalf("expected hours to be 62312, got %d", raw.Value)
}
}
func TestSmartRawValueUnmarshalNumericString(t *testing.T) {
input := []byte(`{"value":"7344","string":"7344"}`)
var raw RawValue
if err := json.Unmarshal(input, &raw); err != nil {
t.Fatalf("unexpected error unmarshalling numeric string: %v", err)
}
if uint64(raw.Value) != 7344 {
t.Fatalf("expected hours to be 7344, got %d", raw.Value)
}
}

View File

@@ -136,6 +136,7 @@ func setCollectionAuthSettings(app core.App) error {
if err != nil {
return err
}
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
@@ -147,6 +148,7 @@ func setCollectionAuthSettings(app core.App) error {
} else {
usersCollection.CreateRule = nil
}
// enable mfaOtp mfa if MFA_OTP env var is set
mfaOtp, _ := GetEnv("MFA_OTP")
usersCollection.OTP.Length = 6
@@ -161,23 +163,37 @@ func setCollectionAuthSettings(app core.App) error {
if err := app.Save(usersCollection); err != nil {
return err
}
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
systemsCollection, err := app.FindCollectionByNameOrId("systems")
if err != nil {
return err
}
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
systemsReadRule := "@request.auth.id != \"\""
if shareAllSystems != "true" {
// default is to only show systems that the user id is assigned to
systemsReadRule += " && users.id ?= @request.auth.id"
var systemsReadRule string
if shareAllSystems == "true" {
systemsReadRule = "@request.auth.id != \"\""
} else {
systemsReadRule = "@request.auth.id != \"\" && users.id ?= @request.auth.id"
}
updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
systemsCollection.ListRule = &systemsReadRule
systemsCollection.ViewRule = &systemsReadRule
systemsCollection.UpdateRule = &updateDeleteRule
systemsCollection.DeleteRule = &updateDeleteRule
return app.Save(systemsCollection)
if err := app.Save(systemsCollection); err != nil {
return err
}
// allow all users to access all containers if SHARE_ALL_SYSTEMS is set
containersCollection, err := app.FindCollectionByNameOrId("containers")
if err != nil {
return err
}
containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1)
containersCollection.ListRule = &containersListRule
return app.Save(containersCollection)
}
// registerCronJobs sets up scheduled tasks

View File

@@ -89,18 +89,25 @@ function formatCapacity(bytes: number): string {
// Function to convert SmartData to DiskInfo
function convertSmartDataToDiskInfo(smartDataRecord: Record<string, SmartData>): DiskInfo[] {
const unknown = "Unknown"
return Object.entries(smartDataRecord).map(([key, smartData]) => ({
device: smartData.dn || key,
model: smartData.mn || "Unknown",
serialNumber: smartData.sn || "Unknown",
firmwareVersion: smartData.fv || "Unknown",
capacity: smartData.c ? formatCapacity(smartData.c) : "Unknown",
status: smartData.s || "Unknown",
model: smartData.mn || unknown,
serialNumber: smartData.sn || unknown,
firmwareVersion: smartData.fv || unknown,
capacity: smartData.c ? formatCapacity(smartData.c) : unknown,
status: smartData.s || unknown,
temperature: smartData.t || 0,
deviceType: smartData.dt || "Unknown",
deviceType: smartData.dt || unknown,
// These fields need to be extracted from SmartAttribute if available
powerOnHours: smartData.a?.find(attr => attr.n.toLowerCase().includes("poweronhours") || attr.n.toLowerCase().includes("power_on_hours"))?.rv,
powerCycles: smartData.a?.find(attr => attr.n.toLowerCase().includes("power") && attr.n.toLowerCase().includes("cycle"))?.rv,
powerOnHours: smartData.a?.find(attr => {
const name = attr.n.toLowerCase();
return name.includes("poweronhours") || name.includes("power_on_hours");
})?.rv,
powerCycles: smartData.a?.find(attr => {
const name = attr.n.toLowerCase();
return (name.includes("power") && name.includes("cycle")) || name.includes("startstopcycles");
})?.rv,
}))
}
@@ -365,7 +372,7 @@ export default function DisksTable({ systemId }: { systemId: string }) {
</Table>
</div>
</Card>
<DiskSheet disk={activeDisk} smartData={activeDisk && smartData ? Object.values(smartData).find(sd => sd.dn === activeDisk.device || sd.mn === activeDisk.model) : undefined} open={sheetOpen} onOpenChange={setSheetOpen} />
<DiskSheet disk={activeDisk} smartData={smartData?.[activeDisk?.serialNumber ?? ""]} open={sheetOpen} onOpenChange={setSheetOpen} />
</div>
)
}

View File

@@ -95,7 +95,7 @@ msgstr "启用的警报"
#: src/components/add-system.tsx
msgid "Add <0>System</0>"
msgstr "添加客户端</0>"
msgstr "<0>添加客户端</0>"
#: src/components/add-system.tsx
msgid "Add New System"

View File

@@ -1,3 +1,21 @@
## 0.15.1
- Add `SMART_DEVICES` environment variable to specify devices and types. (#373, #1335)
- Add support for `scsi`, `sntasmedia`, and `sntrealtek` S.M.A.R.T. types. (#373, #1335)
- Handle power-on time attributes that are formatted as strings (e.g., "0h+0m+0.000s").
- Skip virtual disks in S.M.A.R.T. monitoring. (#1332)
- Add sorting to the S.M.A.R.T. table. (#1333)
- Fix incorrect disk rendering in S.M.A.R.T. device details. (#1336)
- Fix `SHARE_ALL_SYSTEMS` setting not working for containers. (#1334)
- Fix text contrast issue when container details are disabled. (#1324)
## 0.15.0
- Add initial S.M.A.R.T. support for disk health monitoring. (#962)