Initial Proxmox dashboard with host and guest metrics

This commit is contained in:
maxim
2026-07-31 19:51:22 +03:00
commit 584ff7e8ee
21 changed files with 1780 additions and 0 deletions

313
disks.go Normal file
View 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, "В журнале самотестирования есть ошибки")
}
}