Files
ProxmoxDash/disks.go

314 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
type DiskMetrics struct {
Name string `json:"name"`
Path string `json:"path"`
Model string `json:"model"`
Serial string `json:"serial"`
Type string `json:"type"`
Protocol string `json:"protocol"`
SizeBytes uint64 `json:"sizeBytes"`
UsedBytes *uint64 `json:"usedBytes"`
UsagePercent *float64 `json:"usagePercent"`
SMARTStatus string `json:"smartStatus"`
SMARTAvailable bool `json:"smartAvailable"`
AttentionReasons []string `json:"attentionReasons"`
Temperature *float64 `json:"temperatureCelsius"`
PowerOnHours *uint64 `json:"powerOnHours"`
WearUsedPercent *float64 `json:"wearUsedPercent"`
}
type diskCollector struct {
mu sync.Mutex
cached []DiskMetrics
updatedAt time.Time
}
func (c *diskCollector) collect() []DiskMetrics {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.updatedAt) < time.Minute && c.cached != nil {
return c.cached
}
c.cached = readPhysicalDisks()
c.updatedAt = time.Now()
return c.cached
}
func readPhysicalDisks() []DiskMetrics {
entries, _ := os.ReadDir("/sys/block")
usage := readDiskUsage()
var disks []DiskMetrics
for _, entry := range entries {
name := entry.Name()
if !isPhysicalDiskName(name) {
continue
}
base := filepath.Join("/sys/block", name)
sectors, _ := strconv.ParseUint(readTrimmed(filepath.Join(base, "size")), 10, 64)
rotational := readTrimmed(filepath.Join(base, "queue/rotational"))
disk := DiskMetrics{
Name: name, Path: "/dev/" + name,
Model: readTrimmed(filepath.Join(base, "device/model")),
Serial: readTrimmed(filepath.Join(base, "device/serial")),
SizeBytes: sectors * 512, SMARTStatus: "Недоступен",
}
if diskUsage, ok := usage[name]; ok && diskUsage.total > 0 {
disk.UsedBytes = &diskUsage.used
percent := float64(diskUsage.used) / float64(diskUsage.total) * 100
disk.UsagePercent = &percent
}
if strings.HasPrefix(name, "nvme") {
disk.Type, disk.Protocol = "NVMe SSD", "NVMe"
} else if rotational == "1" {
disk.Type = "HDD"
} else {
disk.Type = "SSD"
}
applySMART(&disk)
if disk.Model == "" {
disk.Model = disk.Name
}
if disk.Serial == "" {
disk.Serial = "Не указан"
}
disks = append(disks, disk)
}
return disks
}
type diskUsage struct {
used uint64
total uint64
}
type lsblkDevice struct {
Name string `json:"name"`
FSUsed json.RawMessage `json:"fsused"`
FSAvail json.RawMessage `json:"fsavail"`
Children []lsblkDevice `json:"children"`
}
func readDiskUsage() map[string]diskUsage {
output, err := exec.Command("lsblk", "--json", "--bytes", "--output", "NAME,FSUSED,FSAVAIL").Output()
if err != nil {
return nil
}
var data struct {
Devices []lsblkDevice `json:"blockdevices"`
}
if json.Unmarshal(output, &data) != nil {
return nil
}
result := make(map[string]diskUsage)
for _, device := range data.Devices {
used, total, ok := sumFilesystemUsage(device)
if ok {
result[device.Name] = diskUsage{used: used, total: total}
}
}
return result
}
func sumFilesystemUsage(device lsblkDevice) (uint64, uint64, bool) {
used, usedOK := parseJSONUint(device.FSUsed)
available, availableOK := parseJSONUint(device.FSAvail)
if usedOK || availableOK {
return used, used + available, true
}
var totalUsed, totalSize uint64
found := false
for _, child := range device.Children {
childUsed, childTotal, childOK := sumFilesystemUsage(child)
if childOK {
totalUsed += childUsed
totalSize += childTotal
found = true
}
}
return totalUsed, totalSize, found
}
func parseJSONUint(raw json.RawMessage) (uint64, bool) {
value := strings.Trim(strings.TrimSpace(string(raw)), `"`)
if value == "" || value == "null" {
return 0, false
}
parsed, err := strconv.ParseUint(value, 10, 64)
return parsed, err == nil
}
func isPhysicalDiskName(name string) bool {
return strings.HasPrefix(name, "sd") ||
(strings.HasPrefix(name, "nvme") && strings.Contains(name, "n"))
}
func readTrimmed(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
type smartctlJSON struct {
Smartctl struct {
ExitStatus int `json:"exit_status"`
Messages []struct {
String string `json:"string"`
Severity string `json:"severity"`
} `json:"messages"`
} `json:"smartctl"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
Device struct {
Protocol string `json:"protocol"`
} `json:"device"`
SmartStatus *struct {
Passed bool `json:"passed"`
} `json:"smart_status"`
Temperature struct {
Current float64 `json:"current"`
} `json:"temperature"`
PowerOnTime struct {
Hours uint64 `json:"hours"`
} `json:"power_on_time"`
ATAAttributes struct {
Table []struct {
Name string `json:"name"`
Value int `json:"value"`
Threshold int `json:"thresh"`
WhenFailed string `json:"when_failed"`
Raw struct {
String string `json:"string"`
Value int64 `json:"value"`
} `json:"raw"`
} `json:"table"`
} `json:"ata_smart_attributes"`
ATAErrorLog struct {
Summary struct {
Count int `json:"count"`
} `json:"summary"`
} `json:"ata_smart_error_log"`
NVMeHealth *struct {
Temperature float64 `json:"temperature"`
PercentageUsed float64 `json:"percentage_used"`
PowerOnHours uint64 `json:"power_on_hours"`
CriticalWarning int `json:"critical_warning"`
MediaErrors uint64 `json:"media_errors"`
} `json:"nvme_smart_health_information_log"`
}
func applySMART(disk *DiskMetrics) {
if _, err := exec.LookPath("smartctl"); err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
output, _ := exec.CommandContext(ctx, "smartctl", "-a", "-j", disk.Path).Output()
if len(output) == 0 {
return
}
var smart smartctlJSON
if json.Unmarshal(output, &smart) != nil {
return
}
if smart.SmartStatus != nil {
disk.SMARTAvailable = true
disk.SMARTStatus = "Исправен"
if !smart.SmartStatus.Passed {
disk.SMARTStatus = "Ошибка"
disk.AttentionReasons = append(disk.AttentionReasons, "Общая проверка SMART завершилась ошибкой")
}
}
if smart.ModelName != "" {
disk.Model = smart.ModelName
}
if smart.SerialNumber != "" {
disk.Serial = smart.SerialNumber
}
if smart.Device.Protocol != "" {
disk.Protocol = smart.Device.Protocol
}
if smart.Temperature.Current > 0 {
disk.Temperature = &smart.Temperature.Current
} else if smart.NVMeHealth != nil && smart.NVMeHealth.Temperature > 0 {
disk.Temperature = &smart.NVMeHealth.Temperature
}
if smart.PowerOnTime.Hours > 0 {
disk.PowerOnHours = &smart.PowerOnTime.Hours
} else if smart.NVMeHealth != nil && smart.NVMeHealth.PowerOnHours > 0 {
disk.PowerOnHours = &smart.NVMeHealth.PowerOnHours
}
if smart.NVMeHealth != nil {
disk.WearUsedPercent = &smart.NVMeHealth.PercentageUsed
if smart.NVMeHealth.CriticalWarning != 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "NVMe сообщает критическое предупреждение")
}
if smart.NVMeHealth.MediaErrors > 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "Ошибки целостности носителя: "+strconv.FormatUint(smart.NVMeHealth.MediaErrors, 10))
}
}
applyATAAttributes(disk, smart)
applySMARTExitStatus(disk, smart.Smartctl.ExitStatus)
if len(disk.AttentionReasons) > 0 && disk.SMARTStatus == "Исправен" {
disk.SMARTStatus = "Требует внимания"
}
}
func applyATAAttributes(disk *DiskMetrics, smart smartctlJSON) {
wearNames := map[string]bool{
"Percent_Lifetime_Remain": true, "SSD_Life_Left": true,
"Media_Wearout_Indicator": true, "Remaining_Lifetime_Perc": true,
"Wear_Leveling_Count": true,
}
for _, attribute := range smart.ATAAttributes.Table {
if wearNames[attribute.Name] && attribute.Value >= 0 && attribute.Value <= 100 {
wear := float64(100 - attribute.Value)
disk.WearUsedPercent = &wear
}
if attribute.WhenFailed != "" && attribute.WhenFailed != "-" {
reason := attribute.Name + " вышел за порог"
if attribute.Raw.String != "" {
reason += " (" + attribute.Raw.String + ")"
}
disk.AttentionReasons = append(disk.AttentionReasons, reason)
}
}
if smart.ATAErrorLog.Summary.Count > 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "Ошибок в SMART-журнале: "+strconv.Itoa(smart.ATAErrorLog.Summary.Count))
}
}
func applySMARTExitStatus(disk *DiskMetrics, status int) {
if status&8 != 0 {
disk.SMARTStatus = "Ошибка"
disk.AttentionReasons = append(disk.AttentionReasons, "SMART сообщает возможный скорый отказ диска")
}
if status&16 != 0 {
disk.SMARTStatus = "Ошибка"
disk.AttentionReasons = append(disk.AttentionReasons, "Критический SMART-атрибут достиг порога")
}
if status&32 != 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "SMART-атрибут ранее находился ниже порога")
}
if status&64 != 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "В журнале SMART обнаружены ошибки")
}
if status&128 != 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "В журнале самотестирования есть ошибки")
}
}