diff --git a/alerts.go b/alerts.go index fddd78a..647429b 100644 --- a/alerts.go +++ b/alerts.go @@ -74,7 +74,10 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler if disk.SMARTStatus == "Ошибка" { add("disk-smart-"+disk.Name, "critical", "Диски", disk.Model+": ошибка SMART", joinReasons(disk.AttentionReasons, "SMART сообщает о неисправности диска.")) } else if disk.SMARTStatus == "Требует внимания" { - add("disk-smart-"+disk.Name, "warning", "Диски", disk.Model+": SMART требует внимания", joinReasons(disk.AttentionReasons, "Проверьте SMART-показатели диска.")) + reasons := withoutReason(disk.AttentionReasons, "SMART self-test ещё ни разу не запускался") + if len(reasons) > 0 { + add("disk-smart-"+disk.Name, "warning", "Диски", disk.Model+": SMART требует внимания", joinReasons(reasons, "Проверьте SMART-показатели диска.")) + } } if disk.Temperature != nil { limit := thresholds.DiskTempWarning @@ -386,3 +389,13 @@ func joinReasons(reasons []string, fallback string) string { } return strings.Join(reasons, " · ") } + +func withoutReason(reasons []string, excluded string) []string { + filtered := make([]string, 0, len(reasons)) + for _, reason := range reasons { + if reason != excluded { + filtered = append(filtered, reason) + } + } + return filtered +} diff --git a/alerts_test.go b/alerts_test.go index 54f47d9..7a9ac0d 100644 --- a/alerts_test.go +++ b/alerts_test.go @@ -25,3 +25,17 @@ func TestSlowCollectionDoesNotCreateAlert(t *testing.T) { } } } + +func TestNeverRunSelfTestCreatesOneAlert(t *testing.T) { + metrics := dashboardMetrics{Disks: []DiskMetrics{{ + Name: "nvme0n1", + Model: "Test NVMe", + SMARTStatus: "Требует внимания", + SelfTestNeverRun: true, + AttentionReasons: []string{"SMART self-test ещё ни разу не запускался"}, + }}} + alerts := evaluateAlerts(metrics, defaultThresholds()) + if len(alerts) != 1 || alerts[0].ID != "disk-selftest-nvme0n1" { + t.Fatalf("ожидалось одно точное уведомление о self-test, получено: %+v", alerts) + } +} diff --git a/disks.go b/disks.go index 6547567..ac12cf6 100644 --- a/disks.go +++ b/disks.go @@ -391,6 +391,17 @@ type smartctlJSON struct { } `json:"table"` } `json:"standard"` } `json:"ata_smart_self_test_log"` + NVMeSelfTestLog struct { + Table []struct { + SelfTestCode struct { + String string `json:"string"` + } `json:"self_test_code"` + SelfTestResult struct { + String string `json:"string"` + } `json:"self_test_result"` + PowerOnHours uint64 `json:"power_on_hours"` + } `json:"table"` + } `json:"nvme_self_test_log"` NVMeHealth *struct { Temperature float64 `json:"temperature"` PercentageUsed float64 `json:"percentage_used"` @@ -410,6 +421,10 @@ func applySMART(disk *DiskMetrics) { if len(output) == 0 { return } + applySMARTJSON(disk, output) +} + +func applySMARTJSON(disk *DiskMetrics, output []byte) { var smart smartctlJSON if json.Unmarshal(output, &smart) != nil { return @@ -451,12 +466,20 @@ func applySMART(disk *DiskMetrics) { disk.AttentionReasons = append(disk.AttentionReasons, "Ошибки целостности носителя: "+strconv.FormatUint(smart.NVMeHealth.MediaErrors, 10)) } } - if len(smart.ATASelfTestLog.Standard.Table) > 0 { + if len(smart.NVMeSelfTestLog.Table) > 0 { + last := smart.NVMeSelfTestLog.Table[0] + description := strings.TrimSpace(last.SelfTestCode.String) + result := firstNonEmpty(strings.TrimSpace(last.SelfTestResult.String), "Результат неизвестен") + if description != "" { + disk.LastSelfTest = description + " · " + result + } else { + disk.LastSelfTest = result + } + setLastSelfTestAt(disk, last.PowerOnHours) + } else if len(smart.ATASelfTestLog.Standard.Table) > 0 { last := smart.ATASelfTestLog.Standard.Table[0] disk.LastSelfTest = firstNonEmpty(last.Status.String, "Результат неизвестен") - if disk.PowerOnHours != nil && *disk.PowerOnHours >= last.LifetimeHours { - disk.LastSelfTestAt = time.Now().Add(-time.Duration(*disk.PowerOnHours-last.LifetimeHours) * time.Hour).Format(time.RFC3339) - } + setLastSelfTestAt(disk, last.LifetimeHours) } else { disk.SelfTestNeverRun = true disk.LastSelfTest = "Никогда не запускался" @@ -469,6 +492,12 @@ func applySMART(disk *DiskMetrics) { } } +func setLastSelfTestAt(disk *DiskMetrics, testPowerOnHours uint64) { + if disk.PowerOnHours != nil && *disk.PowerOnHours >= testPowerOnHours { + disk.LastSelfTestAt = time.Now().Add(-time.Duration(*disk.PowerOnHours-testPowerOnHours) * time.Hour).Format(time.RFC3339) + } +} + func applyATAAttributes(disk *DiskMetrics, smart smartctlJSON) { wearNames := map[string]bool{ "Percent_Lifetime_Remain": true, "SSD_Life_Left": true, diff --git a/disks_test.go b/disks_test.go index c9cb4b5..fa448b6 100644 --- a/disks_test.go +++ b/disks_test.go @@ -6,8 +6,44 @@ import ( "path/filepath" "strings" "testing" + "time" ) +func TestApplySMARTJSONReadsNVMeSelfTestHistory(t *testing.T) { + disk := DiskMetrics{Path: "/dev/nvme0n1"} + output := []byte(`{ + "model_name":"ADATA LEGEND 900", + "smart_status":{"passed":true}, + "power_on_time":{"hours":960}, + "nvme_self_test_log":{ + "current_self_test_operation":{"value":0,"string":"No self-test in progress"}, + "table":[{ + "self_test_code":{"value":1,"string":"Short"}, + "self_test_result":{"value":0,"string":"Completed without error"}, + "power_on_hours":959 + }] + } + }`) + + before := time.Now().Add(-2 * time.Hour) + applySMARTJSON(&disk, output) + after := time.Now() + + if disk.SelfTestNeverRun { + t.Fatal("завершённый NVMe self-test ошибочно отмечен как никогда не запускавшийся") + } + if disk.LastSelfTest != "Short · Completed without error" { + t.Fatalf("неожиданный результат self-test: %q", disk.LastSelfTest) + } + parsed, err := time.Parse(time.RFC3339, disk.LastSelfTestAt) + if err != nil || parsed.Before(before) || parsed.After(after) { + t.Fatalf("неверно вычислено время self-test: %q, err=%v", disk.LastSelfTestAt, err) + } + if len(disk.AttentionReasons) != 0 || disk.SMARTStatus != "Исправен" { + t.Fatalf("исправный NVMe требует внимания: %+v", disk) + } +} + func TestIsPhysicalDiskName(t *testing.T) { tests := map[string]bool{ "sda": true, "sdb": true, "nvme0n1": true, diff --git a/web.go b/web.go index 2ff9929..5ab3231 100644 --- a/web.go +++ b/web.go @@ -540,7 +540,7 @@ const dashboardHTML = ` - + Proxmox Host Monitor