mirror of
https://github.com/henrygd/beszel.git
synced 2025-10-30 01:57:04 +00:00
Compare commits
3 Commits
f11564a7ac
...
b3e9857448
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3e9857448 | ||
|
|
2eda9eb0e3 | ||
|
|
82a5df5048 |
234
agent/smart.go
234
agent/smart.go
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -128,6 +130,19 @@ func (sm *SmartManager) GetCurrentData() map[string]smart.SmartData {
|
||||
// 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)
|
||||
if config == "" {
|
||||
return errNoValidSmartData
|
||||
}
|
||||
slog.Info("SMART_DEVICES", "config", config)
|
||||
|
||||
if err := sm.parseConfiguredDevices(config); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -145,8 +160,105 @@ func (sm *SmartManager) ScanDevices() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SmartManager) parseConfiguredDevices(config string) error {
|
||||
entries := strings.Split(config, ",")
|
||||
devices := make([]*DeviceInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(entry, ":", 2)
|
||||
|
||||
name := strings.TrimSpace(parts[0])
|
||||
if name == "" {
|
||||
return fmt.Errorf("invalid SMART_DEVICES entry %q: device name is required", entry)
|
||||
}
|
||||
|
||||
devType := ""
|
||||
if len(parts) == 2 {
|
||||
devType = strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
}
|
||||
|
||||
devices = append(devices, &DeviceInfo{
|
||||
Name: name,
|
||||
Type: devType,
|
||||
})
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
sm.Lock()
|
||||
sm.SmartDevices = nil
|
||||
sm.Unlock()
|
||||
return errNoValidSmartData
|
||||
}
|
||||
|
||||
sm.Lock()
|
||||
sm.SmartDevices = devices
|
||||
sm.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectDeviceType extracts the device type reported in smartctl JSON output.
|
||||
func detectDeviceType(output []byte) string {
|
||||
var payload struct {
|
||||
Device struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"device"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.ToLower(payload.Device.Type)
|
||||
}
|
||||
|
||||
// parseSmartOutput attempts each SMART parser, optionally detecting the type when
|
||||
// it is not provided, and updates the device info when a parser succeeds.
|
||||
func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool {
|
||||
deviceType := strings.ToLower(deviceInfo.Type)
|
||||
|
||||
if deviceType == "" {
|
||||
if detected := detectDeviceType(output); detected != "" {
|
||||
deviceType = detected
|
||||
deviceInfo.Type = detected
|
||||
}
|
||||
}
|
||||
|
||||
parsers := []struct {
|
||||
Type string
|
||||
Parse func([]byte) (bool, int)
|
||||
Alias []string
|
||||
}{
|
||||
{Type: "nvme", Parse: sm.parseSmartForNvme, Alias: []string{"sntasmedia"}},
|
||||
{Type: "sat", Parse: sm.parseSmartForSata, Alias: []string{"ata"}},
|
||||
{Type: "scsi", Parse: sm.parseSmartForScsi},
|
||||
}
|
||||
|
||||
for _, parser := range parsers {
|
||||
if deviceType != "" && deviceType != parser.Type {
|
||||
aliasMatched := slices.Contains(parser.Alias, deviceType)
|
||||
if !aliasMatched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
hasData, _ := parser.Parse(output)
|
||||
if hasData {
|
||||
if deviceInfo.Type == "" {
|
||||
deviceInfo.Type = parser.Type
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CollectSmart collects SMART data for a device
|
||||
// Collect data using `smartctl --all -j /dev/sdX` or `smartctl --all -j /dev/nvmeX`
|
||||
// Collect data using `smartctl -d <type> -aj /dev/<device>` when device type is known
|
||||
// Always attempts to parse output even if command fails, as some data may still be available
|
||||
// If collect fails, return error
|
||||
// If collect succeeds, parse the output and update the SmartDataMap
|
||||
@@ -160,7 +272,8 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||
defer cancel()
|
||||
|
||||
// Try with -n standby first if we have existing data
|
||||
cmd := exec.CommandContext(ctx, "smartctl", "-aj", "-n", "standby", deviceInfo.Name)
|
||||
args := sm.smartctlArgs(deviceInfo, true)
|
||||
cmd := exec.CommandContext(ctx, "smartctl", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Check if device is in standby (exit status 2)
|
||||
@@ -174,20 +287,12 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||
slog.Debug("device in standby but no cached data, collecting initial data", "device", deviceInfo.Name)
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel2()
|
||||
cmd = exec.CommandContext(ctx2, "smartctl", "-aj", deviceInfo.Name)
|
||||
args = sm.smartctlArgs(deviceInfo, false)
|
||||
cmd = exec.CommandContext(ctx2, "smartctl", args...)
|
||||
output, err = cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
hasValidData := false
|
||||
|
||||
switch deviceInfo.Type {
|
||||
case "scsi", "sat", "ata":
|
||||
// parse SATA/SCSI/ATA devices
|
||||
hasValidData, _ = sm.parseSmartForSata(output)
|
||||
case "nvme":
|
||||
// parse nvme devices
|
||||
hasValidData, _ = sm.parseSmartForNvme(output)
|
||||
}
|
||||
hasValidData := sm.parseSmartOutput(deviceInfo, output)
|
||||
|
||||
if !hasValidData {
|
||||
if err != nil {
|
||||
@@ -198,6 +303,28 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// smartctlArgs returns the arguments for the smartctl command
|
||||
// based on the device type and whether to include standby mode
|
||||
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
|
||||
args := make([]string, 0, 7)
|
||||
|
||||
if deviceInfo != nil && deviceInfo.Type != "" {
|
||||
args = append(args, "-d", deviceInfo.Type)
|
||||
}
|
||||
|
||||
args = append(args, "-aj")
|
||||
|
||||
if includeStandby {
|
||||
args = append(args, "-n", "standby")
|
||||
}
|
||||
|
||||
if deviceInfo != nil {
|
||||
args = append(args, deviceInfo.Name)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// hasDataForDevice checks if we have cached SMART data for a specific device
|
||||
func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
|
||||
sm.Lock()
|
||||
@@ -347,6 +474,87 @@ func getSmartStatus(temperature uint8, passed bool) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
||||
var data smart.SmartInfoForScsi
|
||||
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
if data.SerialNumber == "" {
|
||||
slog.Debug("scsi device has no serial number, skipping", "device", data.Device.Name)
|
||||
return false, data.Smartctl.ExitStatus
|
||||
}
|
||||
|
||||
sm.Lock()
|
||||
defer sm.Unlock()
|
||||
|
||||
keyName := data.SerialNumber
|
||||
if _, ok := sm.SmartDataMap[keyName]; !ok {
|
||||
sm.SmartDataMap[keyName] = &smart.SmartData{}
|
||||
}
|
||||
|
||||
smartData := sm.SmartDataMap[keyName]
|
||||
smartData.ModelName = data.ScsiModelName
|
||||
smartData.SerialNumber = data.SerialNumber
|
||||
smartData.FirmwareVersion = data.ScsiRevision
|
||||
smartData.Capacity = data.UserCapacity.Bytes
|
||||
smartData.Temperature = data.Temperature.Current
|
||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||
smartData.DiskName = data.Device.Name
|
||||
smartData.DiskType = data.Device.Type
|
||||
|
||||
attributes := make([]*smart.SmartAttribute, 0, 10)
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnMinutes", RawValue: data.PowerOnTime.Minutes})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "GrownDefectList", RawValue: data.ScsiGrownDefectList})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedStartStopCycles})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedLoadUnloadCycles})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedCycleCountOverDeviceLifetime})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedLoadUnloadCountOverDeviceLifetime})
|
||||
|
||||
readStats := data.ScsiErrorCounterLog.Read
|
||||
writeStats := data.ScsiErrorCounterLog.Write
|
||||
verifyStats := data.ScsiErrorCounterLog.Verify
|
||||
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalErrorsCorrected", RawValue: readStats.TotalErrorsCorrected})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalUncorrectedErrors", RawValue: readStats.TotalUncorrectedErrors})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadCorrectionAlgorithmInvocations", RawValue: readStats.CorrectionAlgorithmInvocations})
|
||||
if val := parseScsiGigabytesProcessed(readStats.GigabytesProcessed); val >= 0 {
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadGigabytesProcessed", RawValue: uint64(val)})
|
||||
}
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalErrorsCorrected", RawValue: writeStats.TotalErrorsCorrected})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalUncorrectedErrors", RawValue: writeStats.TotalUncorrectedErrors})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteCorrectionAlgorithmInvocations", RawValue: writeStats.CorrectionAlgorithmInvocations})
|
||||
if val := parseScsiGigabytesProcessed(writeStats.GigabytesProcessed); val >= 0 {
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteGigabytesProcessed", RawValue: uint64(val)})
|
||||
}
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalErrorsCorrected", RawValue: verifyStats.TotalErrorsCorrected})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalUncorrectedErrors", RawValue: verifyStats.TotalUncorrectedErrors})
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyCorrectionAlgorithmInvocations", RawValue: verifyStats.CorrectionAlgorithmInvocations})
|
||||
if val := parseScsiGigabytesProcessed(verifyStats.GigabytesProcessed); val >= 0 {
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyGigabytesProcessed", RawValue: uint64(val)})
|
||||
}
|
||||
|
||||
smartData.Attributes = attributes
|
||||
sm.SmartDataMap[keyName] = smartData
|
||||
|
||||
return true, data.Smartctl.ExitStatus
|
||||
}
|
||||
|
||||
func parseScsiGigabytesProcessed(value string) int64 {
|
||||
if value == "" {
|
||||
return -1
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||
// Returns hasValidData and exitStatus
|
||||
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||
|
||||
361
agent/smart_test.go
Normal file
361
agent/smart_test.go
Normal file
@@ -0,0 +1,361 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseSmartForScsi(t *testing.T) {
|
||||
fixturePath := filepath.Join("test-data", "smart", "scsi.json")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading fixture: %v", err)
|
||||
}
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForScsi(data)
|
||||
if !hasData {
|
||||
t.Fatalf("expected SCSI data to parse successfully")
|
||||
}
|
||||
if exitStatus != 0 {
|
||||
t.Fatalf("expected exit status 0, got %d", exitStatus)
|
||||
}
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["9YHSDH9B"]
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
|
||||
if len(deviceData.Attributes) == 0 {
|
||||
t.Fatalf("expected attributes to be populated")
|
||||
}
|
||||
|
||||
assertAttrValue(t, deviceData.Attributes, "PowerOnHours", 458)
|
||||
assertAttrValue(t, deviceData.Attributes, "PowerOnMinutes", 25)
|
||||
assertAttrValue(t, deviceData.Attributes, "GrownDefectList", 0)
|
||||
assertAttrValue(t, deviceData.Attributes, "StartStopCycles", 2)
|
||||
assertAttrValue(t, deviceData.Attributes, "LoadUnloadCycles", 418)
|
||||
assertAttrValue(t, deviceData.Attributes, "ReadGigabytesProcessed", 3641)
|
||||
assertAttrValue(t, deviceData.Attributes, "WriteGigabytesProcessed", 2124590)
|
||||
assertAttrValue(t, deviceData.Attributes, "VerifyGigabytesProcessed", 0)
|
||||
}
|
||||
|
||||
func TestParseSmartForSata(t *testing.T) {
|
||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForSata(data)
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 64, exitStatus)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["9C40918040082"]
|
||||
require.True(t, ok, "expected smart data entry for serial 9C40918040082")
|
||||
|
||||
assert.Equal(t, "P3-2TB", deviceData.ModelName)
|
||||
assert.Equal(t, "X0104A0", deviceData.FirmwareVersion)
|
||||
assert.Equal(t, "/dev/sda", deviceData.DiskName)
|
||||
assert.Equal(t, "sat", deviceData.DiskType)
|
||||
assert.Equal(t, uint8(31), deviceData.Temperature)
|
||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
||||
assert.Equal(t, uint64(2048408248320), deviceData.Capacity)
|
||||
if assert.NotEmpty(t, deviceData.Attributes) {
|
||||
assertAttrValue(t, deviceData.Attributes, "Temperature_Celsius", 31)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartForNvme(t *testing.T) {
|
||||
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForNvme(data)
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["2024031600129"]
|
||||
require.True(t, ok, "expected smart data entry for serial 2024031600129")
|
||||
|
||||
assert.Equal(t, "PELADN 512GB", deviceData.ModelName)
|
||||
assert.Equal(t, "VC2S038E", deviceData.FirmwareVersion)
|
||||
assert.Equal(t, "/dev/nvme0", deviceData.DiskName)
|
||||
assert.Equal(t, "nvme", deviceData.DiskType)
|
||||
assert.Equal(t, uint8(61), deviceData.Temperature)
|
||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
||||
assert.Equal(t, uint64(512110190592), deviceData.Capacity)
|
||||
if assert.NotEmpty(t, deviceData.Attributes) {
|
||||
assertAttrValue(t, deviceData.Attributes, "PercentageUsed", 0)
|
||||
assertAttrValue(t, deviceData.Attributes, "DataUnitsWritten", 16040567)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDataForDevice(t *testing.T) {
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: map[string]*smart.SmartData{
|
||||
"serial-1": {DiskName: "/dev/sda"},
|
||||
"serial-2": nil,
|
||||
},
|
||||
}
|
||||
|
||||
assert.True(t, sm.hasDataForDevice("/dev/sda"))
|
||||
assert.False(t, sm.hasDataForDevice("/dev/sdb"))
|
||||
}
|
||||
|
||||
func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
||||
originalDevice := &DeviceInfo{Name: "/dev/sda"}
|
||||
sm := &SmartManager{
|
||||
SmartDevices: []*DeviceInfo{
|
||||
originalDevice,
|
||||
{Name: "/dev/sdb"},
|
||||
},
|
||||
}
|
||||
|
||||
snapshot := sm.devicesSnapshot()
|
||||
require.Len(t, snapshot, 2)
|
||||
|
||||
sm.SmartDevices[0] = &DeviceInfo{Name: "/dev/sdz"}
|
||||
assert.Equal(t, "/dev/sda", snapshot[0].Name)
|
||||
|
||||
snapshot[1] = &DeviceInfo{Name: "/dev/nvme0"}
|
||||
assert.Equal(t, "/dev/sdb", sm.SmartDevices[1].Name)
|
||||
|
||||
sm.SmartDevices = append(sm.SmartDevices, &DeviceInfo{Name: "/dev/nvme1"})
|
||||
assert.Len(t, snapshot, 2)
|
||||
}
|
||||
|
||||
func TestScanDevicesWithEnvOverride(t *testing.T) {
|
||||
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
err := sm.ScanDevices()
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func TestScanDevicesWithEnvOverrideInvalid(t *testing.T) {
|
||||
t.Setenv("SMART_DEVICES", ":sat")
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
err := sm.ScanDevices()
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestScanDevicesWithEnvOverrideEmpty(t *testing.T) {
|
||||
t.Setenv("SMART_DEVICES", " ")
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
err := sm.ScanDevices()
|
||||
assert.ErrorIs(t, err, errNoValidSmartData)
|
||||
assert.Empty(t, sm.SmartDevices)
|
||||
}
|
||||
|
||||
func TestSmartctlArgsWithoutType(t *testing.T) {
|
||||
device := &DeviceInfo{Name: "/dev/sda"}
|
||||
|
||||
sm := &SmartManager{}
|
||||
|
||||
args := sm.smartctlArgs(device, true)
|
||||
assert.Equal(t, []string{"-aj", "-n", "standby", "/dev/sda"}, args)
|
||||
}
|
||||
|
||||
func TestSmartctlArgs(t *testing.T) {
|
||||
sm := &SmartManager{}
|
||||
|
||||
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
||||
assert.Equal(t,
|
||||
[]string{"-d", "sat", "-aj", "-n", "standby", "/dev/sda"},
|
||||
sm.smartctlArgs(sataDevice, true),
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"-d", "sat", "-aj", "/dev/sda"},
|
||||
sm.smartctlArgs(sataDevice, false),
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"-aj", "-n", "standby"},
|
||||
sm.smartctlArgs(nil, true),
|
||||
)
|
||||
}
|
||||
|
||||
func TestResolveRefreshError(t *testing.T) {
|
||||
scanErr := errors.New("scan failed")
|
||||
collectErr := errors.New("collect failed")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
devices []*DeviceInfo
|
||||
data map[string]*smart.SmartData
|
||||
scanErr error
|
||||
collectErr error
|
||||
expectedErr error
|
||||
expectNoErr bool
|
||||
}{
|
||||
{
|
||||
name: "no devices returns scan error",
|
||||
devices: nil,
|
||||
data: make(map[string]*smart.SmartData),
|
||||
scanErr: scanErr,
|
||||
expectedErr: scanErr,
|
||||
},
|
||||
{
|
||||
name: "has data ignores errors",
|
||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
||||
data: map[string]*smart.SmartData{"serial": {}},
|
||||
scanErr: scanErr,
|
||||
collectErr: collectErr,
|
||||
expectNoErr: true,
|
||||
},
|
||||
{
|
||||
name: "collect error preferred",
|
||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
||||
data: make(map[string]*smart.SmartData),
|
||||
collectErr: collectErr,
|
||||
expectedErr: collectErr,
|
||||
},
|
||||
{
|
||||
name: "scan error returned when no data",
|
||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
||||
data: make(map[string]*smart.SmartData),
|
||||
scanErr: scanErr,
|
||||
expectedErr: scanErr,
|
||||
},
|
||||
{
|
||||
name: "no errors returns sentinel",
|
||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
||||
data: make(map[string]*smart.SmartData),
|
||||
expectedErr: errNoValidSmartData,
|
||||
},
|
||||
{
|
||||
name: "no devices collect error",
|
||||
devices: nil,
|
||||
data: make(map[string]*smart.SmartData),
|
||||
collectErr: collectErr,
|
||||
expectedErr: collectErr,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sm := &SmartManager{
|
||||
SmartDevices: tt.devices,
|
||||
SmartDataMap: tt.data,
|
||||
}
|
||||
|
||||
err := sm.resolveRefreshError(tt.scanErr, tt.collectErr)
|
||||
if tt.expectNoErr {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.expectedErr == nil {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScan(t *testing.T) {
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: map[string]*smart.SmartData{
|
||||
"/dev/sdb": {},
|
||||
},
|
||||
}
|
||||
|
||||
scanJSON := []byte(`{
|
||||
"devices": [
|
||||
{"name": "/dev/sda", "type": "sat", "info_name": "/dev/sda [SAT]", "protocol": "ATA"},
|
||||
{"name": "/dev/nvme0", "type": "nvme", "info_name": "/dev/nvme0", "protocol": "NVMe"}
|
||||
]
|
||||
}`)
|
||||
|
||||
hasData := sm.parseScan(scanJSON)
|
||||
assert.True(t, hasData)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) {
|
||||
t.Helper()
|
||||
attr := findAttr(attributes, name)
|
||||
if attr == nil {
|
||||
t.Fatalf("expected attribute %s to be present", name)
|
||||
}
|
||||
if attr.RawValue != expected {
|
||||
t.Fatalf("unexpected attribute %s value: got %d, want %d", name, attr.RawValue, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func findAttr(attributes []*smart.SmartAttribute, name string) *smart.SmartAttribute {
|
||||
for _, attr := range attributes {
|
||||
if attr != nil && attr.Name == name {
|
||||
return attr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
272
agent/test-data/smart/nvme0.json
Normal file
272
agent/test-data/smart/nvme0.json
Normal file
@@ -0,0 +1,272 @@
|
||||
{
|
||||
"json_format_version": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"smartctl": {
|
||||
"version": [
|
||||
7,
|
||||
5
|
||||
],
|
||||
"pre_release": false,
|
||||
"svn_revision": "5714",
|
||||
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
|
||||
"build_info": "(local build)",
|
||||
"argv": [
|
||||
"smartctl",
|
||||
"-aj",
|
||||
"/dev/nvme0"
|
||||
],
|
||||
"exit_status": 0
|
||||
},
|
||||
"local_time": {
|
||||
"time_t": 1761507494,
|
||||
"asctime": "Sun Oct 26 15:38:14 2025 EDT"
|
||||
},
|
||||
"device": {
|
||||
"name": "/dev/nvme0",
|
||||
"info_name": "/dev/nvme0",
|
||||
"type": "nvme",
|
||||
"protocol": "NVMe"
|
||||
},
|
||||
"model_name": "PELADN 512GB",
|
||||
"serial_number": "2024031600129",
|
||||
"firmware_version": "VC2S038E",
|
||||
"nvme_pci_vendor": {
|
||||
"id": 4332,
|
||||
"subsystem_id": 4332
|
||||
},
|
||||
"nvme_ieee_oui_identifier": 57420,
|
||||
"nvme_controller_id": 1,
|
||||
"nvme_version": {
|
||||
"string": "1.4",
|
||||
"value": 66560
|
||||
},
|
||||
"nvme_number_of_namespaces": 1,
|
||||
"nvme_namespaces": [
|
||||
{
|
||||
"id": 1,
|
||||
"size": {
|
||||
"blocks": 1000215216,
|
||||
"bytes": 512110190592
|
||||
},
|
||||
"capacity": {
|
||||
"blocks": 1000215216,
|
||||
"bytes": 512110190592
|
||||
},
|
||||
"utilization": {
|
||||
"blocks": 1000215216,
|
||||
"bytes": 512110190592
|
||||
},
|
||||
"formatted_lba_size": 512,
|
||||
"eui64": {
|
||||
"oui": 57420,
|
||||
"ext_id": 112094110470
|
||||
},
|
||||
"features": {
|
||||
"value": 0,
|
||||
"thin_provisioning": false,
|
||||
"na_fields": false,
|
||||
"dealloc_or_unwritten_block_error": false,
|
||||
"uid_reuse": false,
|
||||
"np_fields": false,
|
||||
"other": 0
|
||||
},
|
||||
"lba_formats": [
|
||||
{
|
||||
"formatted": true,
|
||||
"data_bytes": 512,
|
||||
"metadata_bytes": 0,
|
||||
"relative_performance": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"user_capacity": {
|
||||
"blocks": 1000215216,
|
||||
"bytes": 512110190592
|
||||
},
|
||||
"logical_block_size": 512,
|
||||
"smart_support": {
|
||||
"available": true,
|
||||
"enabled": true
|
||||
},
|
||||
"nvme_firmware_update_capabilities": {
|
||||
"value": 2,
|
||||
"slots": 1,
|
||||
"first_slot_is_read_only": false,
|
||||
"activiation_without_reset": false,
|
||||
"multiple_update_detection": false,
|
||||
"other": 0
|
||||
},
|
||||
"nvme_optional_admin_commands": {
|
||||
"value": 23,
|
||||
"security_send_receive": true,
|
||||
"format_nvm": true,
|
||||
"firmware_download": true,
|
||||
"namespace_management": false,
|
||||
"self_test": true,
|
||||
"directives": false,
|
||||
"mi_send_receive": false,
|
||||
"virtualization_management": false,
|
||||
"doorbell_buffer_config": false,
|
||||
"get_lba_status": false,
|
||||
"command_and_feature_lockdown": false,
|
||||
"other": 0
|
||||
},
|
||||
"nvme_optional_nvm_commands": {
|
||||
"value": 94,
|
||||
"compare": false,
|
||||
"write_uncorrectable": true,
|
||||
"dataset_management": true,
|
||||
"write_zeroes": true,
|
||||
"save_select_feature_nonzero": true,
|
||||
"reservations": false,
|
||||
"timestamp": true,
|
||||
"verify": false,
|
||||
"copy": false,
|
||||
"other": 0
|
||||
},
|
||||
"nvme_log_page_attributes": {
|
||||
"value": 2,
|
||||
"smart_health_per_namespace": false,
|
||||
"commands_effects_log": true,
|
||||
"extended_get_log_page_cmd": false,
|
||||
"telemetry_log": false,
|
||||
"persistent_event_log": false,
|
||||
"supported_log_pages_log": false,
|
||||
"telemetry_data_area_4": false,
|
||||
"other": 0
|
||||
},
|
||||
"nvme_maximum_data_transfer_pages": 32,
|
||||
"nvme_composite_temperature_threshold": {
|
||||
"warning": 100,
|
||||
"critical": 110
|
||||
},
|
||||
"temperature": {
|
||||
"op_limit_max": 100,
|
||||
"critical_limit_max": 110,
|
||||
"current": 61
|
||||
},
|
||||
"nvme_power_states": [
|
||||
{
|
||||
"non_operational_state": false,
|
||||
"relative_read_latency": 0,
|
||||
"relative_read_throughput": 0,
|
||||
"relative_write_latency": 0,
|
||||
"relative_write_throughput": 0,
|
||||
"entry_latency_us": 230000,
|
||||
"exit_latency_us": 50000,
|
||||
"max_power": {
|
||||
"value": 800,
|
||||
"scale": 2,
|
||||
"units_per_watt": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"non_operational_state": false,
|
||||
"relative_read_latency": 1,
|
||||
"relative_read_throughput": 1,
|
||||
"relative_write_latency": 1,
|
||||
"relative_write_throughput": 1,
|
||||
"entry_latency_us": 4000,
|
||||
"exit_latency_us": 50000,
|
||||
"max_power": {
|
||||
"value": 400,
|
||||
"scale": 2,
|
||||
"units_per_watt": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"non_operational_state": false,
|
||||
"relative_read_latency": 2,
|
||||
"relative_read_throughput": 2,
|
||||
"relative_write_latency": 2,
|
||||
"relative_write_throughput": 2,
|
||||
"entry_latency_us": 4000,
|
||||
"exit_latency_us": 250000,
|
||||
"max_power": {
|
||||
"value": 300,
|
||||
"scale": 2,
|
||||
"units_per_watt": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"non_operational_state": true,
|
||||
"relative_read_latency": 3,
|
||||
"relative_read_throughput": 3,
|
||||
"relative_write_latency": 3,
|
||||
"relative_write_throughput": 3,
|
||||
"entry_latency_us": 5000,
|
||||
"exit_latency_us": 10000,
|
||||
"max_power": {
|
||||
"value": 300,
|
||||
"scale": 1,
|
||||
"units_per_watt": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"non_operational_state": true,
|
||||
"relative_read_latency": 4,
|
||||
"relative_read_throughput": 4,
|
||||
"relative_write_latency": 4,
|
||||
"relative_write_throughput": 4,
|
||||
"entry_latency_us": 54000,
|
||||
"exit_latency_us": 45000,
|
||||
"max_power": {
|
||||
"value": 50,
|
||||
"scale": 1,
|
||||
"units_per_watt": 10000
|
||||
}
|
||||
}
|
||||
],
|
||||
"smart_status": {
|
||||
"passed": true,
|
||||
"nvme": {
|
||||
"value": 0
|
||||
}
|
||||
},
|
||||
"nvme_smart_health_information_log": {
|
||||
"nsid": -1,
|
||||
"critical_warning": 0,
|
||||
"temperature": 61,
|
||||
"available_spare": 100,
|
||||
"available_spare_threshold": 32,
|
||||
"percentage_used": 0,
|
||||
"data_units_read": 6573104,
|
||||
"data_units_written": 16040567,
|
||||
"host_reads": 63241130,
|
||||
"host_writes": 253050006,
|
||||
"controller_busy_time": 0,
|
||||
"power_cycles": 430,
|
||||
"power_on_hours": 4399,
|
||||
"unsafe_shutdowns": 44,
|
||||
"media_errors": 0,
|
||||
"num_err_log_entries": 0,
|
||||
"warning_temp_time": 0,
|
||||
"critical_comp_time": 0
|
||||
},
|
||||
"spare_available": {
|
||||
"current_percent": 100,
|
||||
"threshold_percent": 32
|
||||
},
|
||||
"endurance_used": {
|
||||
"current_percent": 0
|
||||
},
|
||||
"power_cycle_count": 430,
|
||||
"power_on_time": {
|
||||
"hours": 4399
|
||||
},
|
||||
"nvme_error_information_log": {
|
||||
"size": 8,
|
||||
"read": 8,
|
||||
"unread": 0
|
||||
},
|
||||
"nvme_self_test_log": {
|
||||
"nsid": -1,
|
||||
"current_self_test_operation": {
|
||||
"value": 0,
|
||||
"string": "No self-test in progress"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
agent/test-data/smart/scan.json
Normal file
36
agent/test-data/smart/scan.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"json_format_version": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"smartctl": {
|
||||
"version": [
|
||||
7,
|
||||
5
|
||||
],
|
||||
"pre_release": false,
|
||||
"svn_revision": "5714",
|
||||
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
|
||||
"build_info": "(local build)",
|
||||
"argv": [
|
||||
"smartctl",
|
||||
"--scan",
|
||||
"-j"
|
||||
],
|
||||
"exit_status": 0
|
||||
},
|
||||
"devices": [
|
||||
{
|
||||
"name": "/dev/sda",
|
||||
"info_name": "/dev/sda [SAT]",
|
||||
"type": "sat",
|
||||
"protocol": "ATA"
|
||||
},
|
||||
{
|
||||
"name": "/dev/nvme0",
|
||||
"info_name": "/dev/nvme0",
|
||||
"type": "nvme",
|
||||
"protocol": "NVMe"
|
||||
}
|
||||
]
|
||||
}
|
||||
125
agent/test-data/smart/scsi.json
Normal file
125
agent/test-data/smart/scsi.json
Normal file
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"json_format_version": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"smartctl": {
|
||||
"version": [
|
||||
7,
|
||||
3
|
||||
],
|
||||
"svn_revision": "5338",
|
||||
"platform_info": "x86_64-linux-6.12.43+deb12-amd64",
|
||||
"build_info": "(local build)",
|
||||
"argv": [
|
||||
"smartctl",
|
||||
"-aj",
|
||||
"/dev/sde"
|
||||
],
|
||||
"exit_status": 0
|
||||
},
|
||||
"local_time": {
|
||||
"time_t": 1761502142,
|
||||
"asctime": "Sun Oct 21 21:09:02 2025 MSK"
|
||||
},
|
||||
"device": {
|
||||
"name": "/dev/sde",
|
||||
"info_name": "/dev/sde",
|
||||
"type": "scsi",
|
||||
"protocol": "SCSI"
|
||||
},
|
||||
"scsi_vendor": "YADRO",
|
||||
"scsi_product": "WUH721414AL4204",
|
||||
"scsi_model_name": "YADRO WUH721414AL4204",
|
||||
"scsi_revision": "C240",
|
||||
"scsi_version": "SPC-4",
|
||||
"user_capacity": {
|
||||
"blocks": 3418095616,
|
||||
"bytes": 14000519643136
|
||||
},
|
||||
"logical_block_size": 4096,
|
||||
"scsi_lb_provisioning": {
|
||||
"name": "fully provisioned",
|
||||
"value": 0,
|
||||
"management_enabled": {
|
||||
"name": "LBPME",
|
||||
"value": 0
|
||||
},
|
||||
"read_zeros": {
|
||||
"name": "LBPRZ",
|
||||
"value": 0
|
||||
}
|
||||
},
|
||||
"rotation_rate": 7200,
|
||||
"form_factor": {
|
||||
"scsi_value": 2,
|
||||
"name": "3.5 inches"
|
||||
},
|
||||
"logical_unit_id": "0x5000cca29063dc00",
|
||||
"serial_number": "9YHSDH9B",
|
||||
"device_type": {
|
||||
"scsi_terminology": "Peripheral Device Type [PDT]",
|
||||
"scsi_value": 0,
|
||||
"name": "disk"
|
||||
},
|
||||
"scsi_transport_protocol": {
|
||||
"name": "SAS (SPL-4)",
|
||||
"value": 6
|
||||
},
|
||||
"smart_support": {
|
||||
"available": true,
|
||||
"enabled": true
|
||||
},
|
||||
"temperature_warning": {
|
||||
"enabled": true
|
||||
},
|
||||
"smart_status": {
|
||||
"passed": true
|
||||
},
|
||||
"temperature": {
|
||||
"current": 34,
|
||||
"drive_trip": 85
|
||||
},
|
||||
"power_on_time": {
|
||||
"hours": 458,
|
||||
"minutes": 25
|
||||
},
|
||||
"scsi_start_stop_cycle_counter": {
|
||||
"year_of_manufacture": "2022",
|
||||
"week_of_manufacture": "41",
|
||||
"specified_cycle_count_over_device_lifetime": 50000,
|
||||
"accumulated_start_stop_cycles": 2,
|
||||
"specified_load_unload_count_over_device_lifetime": 600000,
|
||||
"accumulated_load_unload_cycles": 418
|
||||
},
|
||||
"scsi_grown_defect_list": 0,
|
||||
"scsi_error_counter_log": {
|
||||
"read": {
|
||||
"errors_corrected_by_eccfast": 0,
|
||||
"errors_corrected_by_eccdelayed": 0,
|
||||
"errors_corrected_by_rereads_rewrites": 0,
|
||||
"total_errors_corrected": 0,
|
||||
"correction_algorithm_invocations": 346,
|
||||
"gigabytes_processed": "3,641",
|
||||
"total_uncorrected_errors": 0
|
||||
},
|
||||
"write": {
|
||||
"errors_corrected_by_eccfast": 0,
|
||||
"errors_corrected_by_eccdelayed": 0,
|
||||
"errors_corrected_by_rereads_rewrites": 0,
|
||||
"total_errors_corrected": 0,
|
||||
"correction_algorithm_invocations": 4052,
|
||||
"gigabytes_processed": "2124,590",
|
||||
"total_uncorrected_errors": 0
|
||||
},
|
||||
"verify": {
|
||||
"errors_corrected_by_eccfast": 0,
|
||||
"errors_corrected_by_eccdelayed": 0,
|
||||
"errors_corrected_by_rereads_rewrites": 0,
|
||||
"total_errors_corrected": 0,
|
||||
"correction_algorithm_invocations": 223,
|
||||
"gigabytes_processed": "0,000",
|
||||
"total_uncorrected_errors": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
1013
agent/test-data/smart/sda.json
Normal file
1013
agent/test-data/smart/sda.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -163,6 +163,11 @@ type TemperatureInfo struct {
|
||||
Current uint8 `json:"current"`
|
||||
}
|
||||
|
||||
type TemperatureInfoScsi struct {
|
||||
Current uint8 `json:"current"`
|
||||
DriveTrip uint8 `json:"drive_trip"`
|
||||
}
|
||||
|
||||
// type SelectiveSelfTestTable struct {
|
||||
// LbaMin int `json:"lba_min"`
|
||||
// LbaMax int `json:"lba_max"`
|
||||
@@ -235,6 +240,54 @@ type SmartInfoForSata struct {
|
||||
// AtaSmartSelectiveSelfTestLog AtaSmartSelectiveSelfTestLog `json:"ata_smart_selective_self_test_log"`
|
||||
}
|
||||
|
||||
type ScsiErrorCounter struct {
|
||||
ErrorsCorrectedByECCFast uint64 `json:"errors_corrected_by_eccfast"`
|
||||
ErrorsCorrectedByECCDelayed uint64 `json:"errors_corrected_by_eccdelayed"`
|
||||
ErrorsCorrectedByRereadsRewrites uint64 `json:"errors_corrected_by_rereads_rewrites"`
|
||||
TotalErrorsCorrected uint64 `json:"total_errors_corrected"`
|
||||
CorrectionAlgorithmInvocations uint64 `json:"correction_algorithm_invocations"`
|
||||
GigabytesProcessed string `json:"gigabytes_processed"`
|
||||
TotalUncorrectedErrors uint64 `json:"total_uncorrected_errors"`
|
||||
}
|
||||
|
||||
type ScsiErrorCounterLog struct {
|
||||
Read ScsiErrorCounter `json:"read"`
|
||||
Write ScsiErrorCounter `json:"write"`
|
||||
Verify ScsiErrorCounter `json:"verify"`
|
||||
}
|
||||
|
||||
type ScsiStartStopCycleCounter struct {
|
||||
YearOfManufacture string `json:"year_of_manufacture"`
|
||||
WeekOfManufacture string `json:"week_of_manufacture"`
|
||||
SpecifiedCycleCountOverDeviceLifetime uint64 `json:"specified_cycle_count_over_device_lifetime"`
|
||||
AccumulatedStartStopCycles uint64 `json:"accumulated_start_stop_cycles"`
|
||||
SpecifiedLoadUnloadCountOverDeviceLifetime uint64 `json:"specified_load_unload_count_over_device_lifetime"`
|
||||
AccumulatedLoadUnloadCycles uint64 `json:"accumulated_load_unload_cycles"`
|
||||
}
|
||||
|
||||
type PowerOnTimeScsi struct {
|
||||
Hours uint64 `json:"hours"`
|
||||
Minutes uint64 `json:"minutes"`
|
||||
}
|
||||
|
||||
type SmartInfoForScsi struct {
|
||||
Smartctl SmartctlInfoLegacy `json:"smartctl"`
|
||||
Device DeviceInfo `json:"device"`
|
||||
ScsiVendor string `json:"scsi_vendor"`
|
||||
ScsiProduct string `json:"scsi_product"`
|
||||
ScsiModelName string `json:"scsi_model_name"`
|
||||
ScsiRevision string `json:"scsi_revision"`
|
||||
ScsiVersion string `json:"scsi_version"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
UserCapacity UserCapacity `json:"user_capacity"`
|
||||
Temperature TemperatureInfoScsi `json:"temperature"`
|
||||
SmartStatus SmartStatusInfo `json:"smart_status"`
|
||||
PowerOnTime PowerOnTimeScsi `json:"power_on_time"`
|
||||
ScsiStartStopCycleCounter ScsiStartStopCycleCounter `json:"scsi_start_stop_cycle_counter"`
|
||||
ScsiGrownDefectList uint64 `json:"scsi_grown_defect_list"`
|
||||
ScsiErrorCounterLog ScsiErrorCounterLog `json:"scsi_error_counter_log"`
|
||||
}
|
||||
|
||||
// type AtaSmartErrorLog struct {
|
||||
// Summary SummaryInfo `json:"summary"`
|
||||
// }
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
debounce,
|
||||
decimalString,
|
||||
formatBytes,
|
||||
secondsToString,
|
||||
getHostDisplayValue,
|
||||
listen,
|
||||
parseSemVer,
|
||||
@@ -358,21 +359,13 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
value: system.info.k,
|
||||
},
|
||||
}
|
||||
let uptime: React.ReactNode
|
||||
let uptime: string
|
||||
if (system.info.u < 3600) {
|
||||
uptime = (
|
||||
<Plural
|
||||
value={Math.trunc(system.info.u / 60)}
|
||||
one="# minute"
|
||||
few="# minutes"
|
||||
many="# minutes"
|
||||
other="# minutes"
|
||||
/>
|
||||
)
|
||||
} else if (system.info.u < 172800) {
|
||||
uptime = <Plural value={Math.trunc(system.info.u / 3600)} one="# hour" other="# hours" />
|
||||
uptime = secondsToString(system.info.u, "minute")
|
||||
} else if (system.info.u * 360000) {
|
||||
uptime = secondsToString(system.info.u, "hour")
|
||||
} else {
|
||||
uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
|
||||
uptime = secondsToString(system.info.u, "day")
|
||||
}
|
||||
return [
|
||||
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Activity, Box, Clock, HardDrive, HashIcon, CpuIcon, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon, ArrowUpDownIcon } from "lucide-react"
|
||||
import { Activity, Box, Clock, HardDrive, HashIcon, CpuIcon, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon } from "lucide-react"
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -27,7 +27,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { pb } from "@/lib/api"
|
||||
import { SmartData, SmartAttribute } from "@/types"
|
||||
import { formatBytes, toFixedFloat, formatTemperature, cn } from "@/lib/utils"
|
||||
import { formatBytes, toFixedFloat, formatTemperature, cn, secondsToString } from "@/lib/utils"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { ThermometerIcon } from "@/components/ui/icons"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
@@ -172,8 +172,8 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
||||
accessorKey: "powerOnHours",
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />,
|
||||
cell: ({ row }) => {
|
||||
const hours = row.getValue("powerOnHours") as number | undefined
|
||||
cell: ({ getValue }) => {
|
||||
const hours = (getValue() ?? 0) as number
|
||||
if (!hours && hours !== 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground ms-1.5">
|
||||
@@ -181,11 +181,11 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const days = Math.floor(hours / 24)
|
||||
const seconds = hours * 3600
|
||||
return (
|
||||
<div className="text-sm ms-1.5">
|
||||
<div>{hours.toLocaleString()} hours</div>
|
||||
<div className="text-muted-foreground text-xs">{days} days</div>
|
||||
<div>{secondsToString(seconds, "hour")}</div>
|
||||
<div className="text-muted-foreground text-xs">{secondsToString(seconds, "day")}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -234,7 +234,6 @@ function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name:
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
<ArrowUpDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -342,7 +341,7 @@ export default function DisksTable({ systemId }: { systemId: string }) {
|
||||
onClick={() => openSheet(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<TableCell key={cell.id} className="md:ps-5">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { plural, t } from "@lingui/core/macro"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { listenKeys } from "nanostores"
|
||||
import { timeDay, timeHour, timeMinute } from "d3-time"
|
||||
@@ -111,18 +111,17 @@ export const updateFavicon = (() => {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
|
||||
${
|
||||
downCount > 0 &&
|
||||
`
|
||||
${downCount > 0 &&
|
||||
`
|
||||
<circle cx="40" cy="50" r="22" fill="#f00"/>
|
||||
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
|
||||
`
|
||||
}
|
||||
}
|
||||
</svg>
|
||||
`
|
||||
const blob = new Blob([svg], { type: "image/svg+xml" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
|
||||
; (document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -429,3 +428,17 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
|
||||
return state.result
|
||||
}) as T
|
||||
}
|
||||
|
||||
/** Format seconds to hours, minutes, or seconds */
|
||||
export function secondsToString(seconds: number, unit: "hour" | "minute" | "day"): string {
|
||||
const count = Math.floor(seconds / (unit === "hour" ? 3600 : unit === "minute" ? 60 : 86400))
|
||||
const countString = count.toLocaleString()
|
||||
switch (unit) {
|
||||
case "minute":
|
||||
return plural(count, { one: `${countString} minute`, few: `${countString} minutes`, many: `${countString} minutes`, other: `${countString} minutes` })
|
||||
case "hour":
|
||||
return plural(count, { one: `${countString} hour`, other: `${countString} hours` })
|
||||
case "day":
|
||||
return plural(count, { one: `${countString} day`, other: `${countString} days` })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user