Fix NVMe self-tests and mobile layout

This commit is contained in:
Maxim
2026-08-21 19:17:30 +03:00
parent 21b4f528e0
commit 714c29c449
5 changed files with 103 additions and 6 deletions

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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,

7
web.go
View File

@@ -540,7 +540,7 @@ const dashboardHTML = `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Proxmox Host Monitor</title>
<script>document.documentElement.dataset.theme=localStorage.getItem('proxmox-theme')||'dark'</script>
<style>
@@ -574,6 +574,11 @@ footer{color:#657581;font-size:12px;margin-top:17px}
.agents .fill{background:linear-gradient(90deg,#55d58a,#6ba8ff)}.agent-command{display:block;width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--text);font:12px/1.5 ui-monospace,monospace;overflow-wrap:anywhere;white-space:pre-wrap}.agent-containers{display:flex;flex-wrap:wrap;gap:7px;margin-top:13px}
.agent-detail-panel{padding:16px}.agent-detail-panel[hidden]{display:none}.agent-detail-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.agent-detail-summary>div{padding:14px;border:1px solid var(--line);border-radius:12px}.agent-detail-summary b{display:block;margin-top:5px;font:600 16px ui-monospace,monospace}.agent-filter{display:flex;gap:6px;margin-bottom:12px}.agent-filter button{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer}.agent-filter button.active{border-color:var(--green);color:var(--green)}@media(max-width:700px){.agent-detail-summary{grid-template-columns:1fr 1fr}}
.diagnostics .fill{background:linear-gradient(90deg,#43c6d9,#9d8cff)}#diagnosticsDialog{width:min(1180px,calc(100% - 28px))}.diagnostic-chain{display:grid;grid-template-columns:minmax(175px,1fr) 24px minmax(210px,1.35fr) 24px minmax(120px,.7fr) 24px minmax(160px,.9fr);align-items:stretch;gap:8px;padding:8px 0}.diagnostic-node{min-width:0;padding:14px;border:1px solid var(--line);border-radius:11px;background:var(--panel);overflow:hidden}.diagnostic-node .label,.diagnostic-node strong{display:block}.diagnostic-node strong{margin-top:6px;font-size:16px;line-height:1.25;overflow-wrap:anywhere}.diagnostic-node.bad{border-color:#71362f}.diagnostic-arrow{align-self:center;text-align:center;color:var(--muted);font-size:20px}.service-log{margin-top:10px;padding:9px 11px;border-left:2px solid var(--line);background:var(--bg);font:11px/1.5 ui-monospace,monospace;overflow-wrap:anywhere}.process-table{display:grid;gap:7px}.process-row{display:grid;grid-template-columns:minmax(140px,2fr) repeat(4,minmax(70px,1fr));gap:8px;padding:10px;border:1px solid var(--line);border-radius:10px;align-items:center}.process-row small{display:block;color:var(--muted);overflow-wrap:anywhere}.agent-problem-summary{display:flex;flex-wrap:wrap;gap:7px;margin-top:12px}.disk-list[hidden]{display:none!important}#agentHistoryDetails{position:relative}@media(max-width:850px){.diagnostic-chain{display:flex;overflow-x:auto}.diagnostic-node{min-width:190px}.diagnostic-arrow{min-width:24px}}@media(max-width:700px){.process-row{grid-template-columns:1fr 1fr}.agent-filter{overflow-x:auto}}
@media (max-width:700px), (pointer:coarse) and (max-width:1100px){
body{overflow-x:hidden;font-size:14px}main{width:100%;padding:24px 14px max(24px,env(safe-area-inset-bottom))}header{margin-bottom:18px}.cards{grid-template-columns:minmax(0,1fr);gap:12px}.card,.alert-center{grid-column:auto;min-width:0;border-radius:17px}.card:hover{transform:none;box-shadow:none}.card-head{min-height:92px;padding:19px 18px 15px;gap:12px}.name{max-width:none;white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.percent{font-size:30px}.percent small{font-size:13px}.bar{margin:0 18px}.quick{margin-top:16px}.quick div{min-width:0;padding:14px 18px}.quick strong{font-size:17px;overflow-wrap:anywhere}.hint{margin:0 18px 16px;padding-top:13px}.header-actions{width:100%;justify-content:space-between}.status{min-width:0}.status span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}h1{font-size:36px}
dialog,#diagnosticsDialog{width:100%;max-width:none;height:100dvh;max-height:100dvh;margin:0;border:0;border-radius:0}.modal-head{padding:19px 17px}.modal-head>div{min-width:0}.modal-head h2{font-size:22px;overflow-wrap:anywhere}.close{flex:0 0 auto;width:42px;height:42px}.settings-tabs,.detail-tabs,.system-tabs,.task-filter{padding-left:14px;padding-right:14px;scrollbar-width:none}.settings-tabs::-webkit-scrollbar,.detail-tabs::-webkit-scrollbar,.system-tabs::-webkit-scrollbar,.task-filter::-webkit-scrollbar{display:none}.settings-tab,.detail-tab{flex:0 0 auto}.settings-body,.wide,.chart-section{padding:18px 16px}.detail-grid{grid-template-columns:1fr 1fr}.detail{min-width:0;padding:17px 16px}.detail strong{font-size:18px;overflow-wrap:anywhere}.disk-list,.agent-detail-panel{padding:12px}.disk-item{min-width:0;padding:15px}.disk-top,.endpoint-head,.topology-group-head{align-items:flex-start;gap:10px}.disk-model{overflow-wrap:anywhere}.badge[data-reason]:hover::after,.badge[data-reason]:focus::after{position:fixed;left:14px;right:14px;bottom:18px;width:auto}.disk-stats{grid-template-columns:1fr 1fr;gap:16px 10px}.setting-list,.threshold-grid,.theme-options,.maintenance-targets,.service-form{grid-template-columns:1fr}.threshold-item{align-items:flex-start;flex-direction:column}.threshold-item input,[data-settings-panel="email"] .threshold-item input{width:100%}.chart-head{align-items:stretch}.range-switch{max-width:100%;overflow-x:auto}.range-button{flex:1 0 auto}.metric-chart{height:180px}.infra-status,.agent-detail-summary{grid-template-columns:1fr 1fr}.map-board,.topology-board{min-width:0;padding:14px}.map-board{grid-template-columns:1fr}.topology-grid{grid-template-columns:1fr}.diagnostic-chain{display:grid;grid-template-columns:1fr;overflow:visible}.diagnostic-node{min-width:0}.diagnostic-arrow{transform:rotate(90deg)}.process-row{grid-template-columns:1fr 1fr}.maintenance-banner{align-items:flex-start;flex-direction:column}.service-actions>*{flex:1 1 auto;text-align:center}
}
@media(max-width:420px){main{padding-left:10px;padding-right:10px}h1{font-size:32px}.eyebrow{font-size:10px}.card-head{padding-left:15px;padding-right:15px}.percent{font-size:27px}.quick div{padding-left:15px;padding-right:15px}.quick strong{font-size:15px}.detail-grid,.infra-status,.agent-detail-summary{grid-template-columns:1fr}.detail{border-right:0!important}.disk-top{flex-direction:column}.disk-stats{grid-template-columns:1fr 1fr}.modal-head h2{font-size:20px}.process-row{grid-template-columns:1fr}.alert-list{padding:10px}.alert-item{padding:14px 12px}}
</style>
</head>
<body><main>