Initial Proxmox dashboard with host and guest metrics
This commit is contained in:
313
disks.go
Normal file
313
disks.go
Normal file
@@ -0,0 +1,313 @@
|
||||
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, "В журнале самотестирования есть ошибки")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user