commit 584ff7e8eed11f3888c5ddd47c84e594cb570174 Author: maxim Date: Fri Jul 31 19:51:22 2026 +0300 Initial Proxmox dashboard with host and guest metrics diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23fd66e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist/ +.DS_Store +*.log +AGENTS.md +sources/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..46e0723 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Proxmox CPU Dashboard + +Локальная веб-панель CPU для Proxmox. Данные читаются напрямую из `/proc` и +`/sys`, без Proxmox API. Интерфейс обновляется каждые две секунды. + +Панель показывает: + +- модель и текущую загрузку CPU; +- количество сокетов, физических ядер и логических потоков; +- среднюю текущую частоту; +- load average за 1, 5 и 15 минут; +- uptime; +- доступные аппаратные температуры. +- использование RAM, доступную память, кэш, буферы и swap. +- заполнение системного диска `/`; +- список физических дисков, модель, серийный номер, SMART, температуру, наработку + и доступные данные об износе; +- текущую скорость и суммарные счётчики сетевого трафика. +- состояние, заполнение, фрагментацию и scrub/resilver ZFS-пулов; +- состояние UPS через NUT или apcupsd, заряд, автономность, нагрузку и напряжение. +- состояние VM и LXC, CPU, память, выделенные ядра и uptime. + +На главном экране находятся компактные карточки CPU, памяти, диска и сети. +Нажатие на карточку открывает подробные показатели. + +## Быстрый запуск + +Скопируйте `dist/proxmox-cpu-dashboard-linux-amd64` на Proxmox, затем: + +```bash +chmod +x proxmox-cpu-dashboard-linux-amd64 +./proxmox-cpu-dashboard-linux-amd64 +``` + +Откройте: + +```text +http://192.168.0.10:9105 +``` + +## Установка как службы + +```bash +install -m 0755 proxmox-cpu-dashboard-linux-amd64 /usr/local/bin/proxmox-cpu-dashboard +install -m 0644 proxmox-cpu-dashboard.service /etc/systemd/system/proxmox-cpu-dashboard.service +systemctl daemon-reload +systemctl enable --now proxmox-cpu-dashboard +systemctl status proxmox-cpu-dashboard +``` + +По умолчанию панель слушает порт `9105` на всех интерфейсах. Другой адрес можно +задать переменной `DASHBOARD_ADDR`, например `127.0.0.1:9105`. + +На веб-сервере пока нет авторизации. Не открывайте порт `9105` в интернет. + +Для SMART-метрик на Proxmox должен быть установлен пакет `smartmontools`: + +```bash +apt update +apt install -y smartmontools +``` + +Без него список дисков и их паспортные данные продолжат отображаться, а SMART +будет отмечен как недоступный. + +Проверить исходные SMART-данные вручную: + +```bash +smartctl -x /dev/sda +smartctl -x -j /dev/sda +smartctl -x /dev/nvme0n1 +``` + +У HDD обычно нет показателя износа в процентах. Для NVMe используется +`Percentage Used`, а для SATA SSD — поддерживаемые накопителем атрибуты ресурса. +Если производитель не публикует такой атрибут, панель корректно показывает `—`. + +## UPS + +Панель автоматически пробует получить данные через NUT (`upsc`), затем через +`apcaccess`. Для проверки NUT: + +```bash +upsc -l +upsc ИМЯ_UPS +``` + +Для проверки apcupsd: + +```bash +apcaccess status +``` + +## API + +- `GET /api/cpu` — один JSON-снимок. +- `GET /api/system` — единый JSON-снимок CPU и памяти. +- `GET /api/events` — поток метрик SSE с интервалом две секунды. + +## Сборка + +```bash +go test ./... +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o proxmox-cpu-dashboard . +``` diff --git a/cpu.go b/cpu.go new file mode 100644 index 0000000..da334da --- /dev/null +++ b/cpu.go @@ -0,0 +1,286 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +const ( + cpuInfoPath = "/proc/cpuinfo" + procStatPath = "/proc/stat" +) + +type cpuCollector struct { + mu sync.Mutex + cpuInfoPath string + procStatPath string + static CPUStatic + previous cpuTimes +} + +type CPUStatic struct { + Model string `json:"model"` + Sockets int `json:"sockets"` + PhysicalCores int `json:"physicalCores"` + LogicalCPUs int `json:"logicalCpus"` +} + +type CPUMetrics struct { + CPUStatic + UsagePercent float64 `json:"usagePercent"` + FrequencyMHz float64 `json:"frequencyMhz"` + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` + Uptime uint64 `json:"uptimeSeconds"` + Temperatures []Temperature `json:"temperatures"` + CollectedAt time.Time `json:"collectedAt"` +} + +type Temperature struct { + Label string `json:"label"` + Celsius float64 `json:"celsius"` +} + +type cpuTimes struct { + total uint64 + idle uint64 +} + +func newCPUCollector(infoPath, statPath string) (*cpuCollector, error) { + static, err := readCPUStatic(infoPath) + if err != nil { + return nil, err + } + previous, err := readCPUTimes(statPath) + if err != nil { + return nil, err + } + return &cpuCollector{ + cpuInfoPath: infoPath, procStatPath: statPath, + static: static, previous: previous, + }, nil +} + +func (c *cpuCollector) collect() (CPUMetrics, error) { + c.mu.Lock() + defer c.mu.Unlock() + + current, err := readCPUTimes(c.procStatPath) + if err != nil { + return CPUMetrics{}, err + } + + var usage float64 + totalDelta := current.total - c.previous.total + idleDelta := current.idle - c.previous.idle + if totalDelta > 0 { + usage = float64(totalDelta-idleDelta) / float64(totalDelta) * 100 + } + c.previous = current + + frequency, _ := averageFrequencyMHz(c.cpuInfoPath) + load1, load5, load15, _ := readLoadAverage() + uptime, _ := readUptime() + + return CPUMetrics{ + CPUStatic: c.static, UsagePercent: usage, FrequencyMHz: frequency, + Load1: load1, Load5: load5, Load15: load15, Uptime: uptime, + Temperatures: readTemperatures(), CollectedAt: time.Now(), + }, nil +} + +func readCPUStatic(path string) (CPUStatic, error) { + file, err := os.Open(path) + if err != nil { + return CPUStatic{}, err + } + defer file.Close() + + var model string + logical := 0 + physicalIDs := map[string]struct{}{} + cores := map[string]struct{}{} + physicalID := "0" + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + physicalID = "0" + continue + } + key, value, found := strings.Cut(line, ":") + if !found { + continue + } + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + switch key { + case "processor": + logical++ + case "model name", "Hardware": + if model == "" { + model = value + } + case "physical id": + physicalID = value + physicalIDs[value] = struct{}{} + case "core id": + cores[physicalID+":"+value] = struct{}{} + } + } + if err := scanner.Err(); err != nil { + return CPUStatic{}, err + } + if model == "" { + return CPUStatic{}, fmt.Errorf("модель CPU не найдена в %s", path) + } + if len(physicalIDs) == 0 { + physicalIDs["0"] = struct{}{} + } + physicalCores := len(cores) + if physicalCores == 0 { + physicalCores = logical + } + return CPUStatic{ + Model: model, Sockets: len(physicalIDs), + PhysicalCores: physicalCores, LogicalCPUs: logical, + }, nil +} + +func cpuModel(path string) (string, error) { + info, err := readCPUStatic(path) + return info.Model, err +} + +func readCPUTimes(path string) (cpuTimes, error) { + file, err := os.Open(path) + if err != nil { + return cpuTimes{}, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + if !scanner.Scan() { + return cpuTimes{}, fmt.Errorf("пустой файл %s", path) + } + fields := strings.Fields(scanner.Text()) + if len(fields) < 5 || fields[0] != "cpu" { + return cpuTimes{}, fmt.Errorf("неожиданный формат %s", path) + } + var values []uint64 + for _, field := range fields[1:] { + value, err := strconv.ParseUint(field, 10, 64) + if err != nil { + return cpuTimes{}, err + } + values = append(values, value) + } + var total uint64 + for _, value := range values { + total += value + } + idle := values[3] + if len(values) > 4 { + idle += values[4] + } + return cpuTimes{total: total, idle: idle}, nil +} + +func averageFrequencyMHz(path string) (float64, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + var sum float64 + var count int + scanner := bufio.NewScanner(file) + for scanner.Scan() { + key, value, found := strings.Cut(scanner.Text(), ":") + if found && strings.TrimSpace(key) == "cpu MHz" { + mhz, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err == nil { + sum += mhz + count++ + } + } + } + if count == 0 { + return 0, fmt.Errorf("частота CPU недоступна") + } + return sum / float64(count), scanner.Err() +} + +func readLoadAverage() (float64, float64, float64, error) { + data, err := os.ReadFile("/proc/loadavg") + if err != nil { + return 0, 0, 0, err + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return 0, 0, 0, fmt.Errorf("неожиданный формат /proc/loadavg") + } + one, err1 := strconv.ParseFloat(fields[0], 64) + five, err2 := strconv.ParseFloat(fields[1], 64) + fifteen, err3 := strconv.ParseFloat(fields[2], 64) + if err1 != nil || err2 != nil || err3 != nil { + return 0, 0, 0, fmt.Errorf("не удалось прочитать load average") + } + return one, five, fifteen, nil +} + +func readUptime() (uint64, error) { + data, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0, err + } + seconds, err := strconv.ParseFloat(strings.Fields(string(data))[0], 64) + return uint64(seconds), err +} + +func readTemperatures() []Temperature { + var result []Temperature + paths, _ := filepath.Glob("/sys/class/hwmon/hwmon*/temp*_input") + for _, path := range paths { + hwmonDir := filepath.Dir(path) + chipData, _ := os.ReadFile(filepath.Join(hwmonDir, "name")) + chip := strings.TrimSpace(string(chipData)) + if !isCPUTemperatureChip(chip) { + continue + } + data, err := os.ReadFile(path) + if err != nil { + continue + } + raw, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64) + if err != nil || raw <= 0 { + continue + } + label := filepath.Base(path) + labelPath := strings.TrimSuffix(path, "_input") + "_label" + if data, err := os.ReadFile(labelPath); err == nil { + label = strings.TrimSpace(string(data)) + } + if chip != "" { + label = chip + " · " + label + } + result = append(result, Temperature{Label: label, Celsius: raw / 1000}) + } + return result +} + +func isCPUTemperatureChip(name string) bool { + switch name { + case "coretemp", "k10temp", "zenpower", "cpu_thermal", "soc_thermal": + return true + default: + return false + } +} diff --git a/disks.go b/disks.go new file mode 100644 index 0000000..915211a --- /dev/null +++ b/disks.go @@ -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, "В журнале самотестирования есть ошибки") + } +} diff --git a/disks_test.go b/disks_test.go new file mode 100644 index 0000000..7f4977e --- /dev/null +++ b/disks_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestIsPhysicalDiskName(t *testing.T) { + tests := map[string]bool{ + "sda": true, "sdb": true, "nvme0n1": true, + "loop0": false, "dm-0": false, "zram0": false, "md0": false, + } + for name, expected := range tests { + if actual := isPhysicalDiskName(name); actual != expected { + t.Fatalf("isPhysicalDiskName(%q) = %v, ожидалось %v", name, actual, expected) + } + } +} + +func TestSumFilesystemUsage(t *testing.T) { + device := lsblkDevice{Children: []lsblkDevice{ + {FSUsed: json.RawMessage(`100`), FSAvail: json.RawMessage(`300`)}, + {FSUsed: json.RawMessage(`200`), FSAvail: json.RawMessage(`400`)}, + }} + used, total, ok := sumFilesystemUsage(device) + if !ok || used != 300 || total != 1000 { + t.Fatalf("неожиданное использование диска: used=%d total=%d ok=%v", used, total, ok) + } +} + +func TestApplyATAWearAndReason(t *testing.T) { + var smart smartctlJSON + attribute := 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"` + }{Name: "SSD_Life_Left", Value: 92, WhenFailed: "FAILING_NOW"} + smart.ATAAttributes.Table = append(smart.ATAAttributes.Table, attribute) + disk := DiskMetrics{} + applyATAAttributes(&disk, smart) + if disk.WearUsedPercent == nil || *disk.WearUsedPercent != 8 { + t.Fatalf("неожиданный износ: %v", disk.WearUsedPercent) + } + if len(disk.AttentionReasons) != 1 { + t.Fatalf("причина предупреждения не добавлена: %+v", disk) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8a9cfdb --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module proxmox-host-info + +go 1.22 diff --git a/guests.go b/guests.go new file mode 100644 index 0000000..50b8e4c --- /dev/null +++ b/guests.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "encoding/json" + "os/exec" + "sort" + "sync" + "time" +) + +type GuestMetrics struct { + VMID int `json:"vmid"` + Name string `json:"name"` + Type string `json:"type"` + Node string `json:"node"` + Status string `json:"status"` + CPU float64 `json:"cpu"` + MaxCPU float64 `json:"maxcpu"` + MemoryBytes uint64 `json:"mem"` + MaxMemory uint64 `json:"maxmem"` + Uptime uint64 `json:"uptime"` + Template int `json:"template"` +} + +type GuestsMetrics struct { + Available bool `json:"available"` + Guests []GuestMetrics `json:"guests"` +} + +type guestCollector struct { + mu sync.Mutex + cached GuestsMetrics + updatedAt time.Time +} + +func (c *guestCollector) collect() GuestsMetrics { + c.mu.Lock() + defer c.mu.Unlock() + if time.Since(c.updatedAt) < 3*time.Second { + return c.cached + } + c.cached = readGuests() + c.updatedAt = time.Now() + return c.cached +} + +func readGuests() GuestsMetrics { + if _, err := exec.LookPath("pvesh"); err != nil { + return GuestsMetrics{} + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "pvesh", "get", "/cluster/resources", "--type", "vm", "--output-format", "json").Output() + result := GuestsMetrics{Available: true} + if err != nil { + return result + } + if json.Unmarshal(output, &result.Guests) != nil { + return result + } + sort.Slice(result.Guests, func(i, j int) bool { + return result.Guests[i].VMID < result.Guests[j].VMID + }) + return result +} diff --git a/guests_test.go b/guests_test.go new file mode 100644 index 0000000..e1540f3 --- /dev/null +++ b/guests_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestGuestJSON(t *testing.T) { + data := []byte(`{"vmid":101,"name":"test","type":"qemu","status":"running","cpu":0.25,"maxcpu":4,"mem":1024,"maxmem":4096,"uptime":60}`) + var guest GuestMetrics + if err := json.Unmarshal(data, &guest); err != nil { + t.Fatal(err) + } + if guest.VMID != 101 || guest.CPU != 0.25 || guest.MaxCPU != 4 { + t.Fatalf("неожиданные данные гостя: %+v", guest) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..af7e80d --- /dev/null +++ b/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +const defaultAddress = ":9105" + +func main() { + address := os.Getenv("DASHBOARD_ADDR") + if address == "" { + address = defaultAddress + } + + collector, err := newCPUCollector(cpuInfoPath, procStatPath) + if err != nil { + log.Fatalf("Не удалось запустить сборщик CPU: %v", err) + } + + server := &http.Server{ + Addr: address, + Handler: routes(collector), + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + log.Printf("Proxmox CPU Dashboard запущен на http://0.0.0.0%s", address) + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("Ошибка веб-сервера: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка остановки сервера: %v\n", err) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..4774f44 --- /dev/null +++ b/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCPUModel(t *testing.T) { + path := filepath.Join(t.TempDir(), "cpuinfo") + content := `processor : 0 +model name : Test CPU 1234 @ 2.40GHz +physical id : 0 +core id : 0 + +processor : 1 +model name : Test CPU 1234 @ 2.40GHz +physical id : 0 +core id : 0 +` + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + model, err := cpuModel(path) + if err != nil { + t.Fatal(err) + } + + if model != "Test CPU 1234 @ 2.40GHz" { + t.Fatalf("получена неожиданная модель CPU: %q", model) + } +} + +func TestCPUStatic(t *testing.T) { + path := filepath.Join(t.TempDir(), "cpuinfo") + content := `processor : 0 +model name : Test CPU +physical id : 0 +core id : 0 + +processor : 1 +model name : Test CPU +physical id : 0 +core id : 1 +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + info, err := readCPUStatic(path) + if err != nil { + t.Fatal(err) + } + if info.Sockets != 1 || info.PhysicalCores != 2 || info.LogicalCPUs != 2 { + t.Fatalf("неожиданная топология CPU: %+v", info) + } +} diff --git a/memory.go b/memory.go new file mode 100644 index 0000000..04cecab --- /dev/null +++ b/memory.go @@ -0,0 +1,73 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +type MemoryMetrics struct { + TotalBytes uint64 `json:"totalBytes"` + UsedBytes uint64 `json:"usedBytes"` + AvailableBytes uint64 `json:"availableBytes"` + CachedBytes uint64 `json:"cachedBytes"` + BuffersBytes uint64 `json:"buffersBytes"` + SwapTotalBytes uint64 `json:"swapTotalBytes"` + SwapUsedBytes uint64 `json:"swapUsedBytes"` + UsagePercent float64 `json:"usagePercent"` + SwapPercent float64 `json:"swapPercent"` +} + +func readMemoryMetrics(path string) (MemoryMetrics, error) { + file, err := os.Open(path) + if err != nil { + return MemoryMetrics{}, err + } + defer file.Close() + + values := make(map[string]uint64) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + key := strings.TrimSuffix(fields[0], ":") + value, err := strconv.ParseUint(fields[1], 10, 64) + if err == nil { + values[key] = value * 1024 + } + } + if err := scanner.Err(); err != nil { + return MemoryMetrics{}, err + } + + total := values["MemTotal"] + available := values["MemAvailable"] + if total == 0 { + return MemoryMetrics{}, fmt.Errorf("MemTotal не найден в %s", path) + } + if available > total { + available = total + } + used := total - available + swapTotal := values["SwapTotal"] + swapFree := values["SwapFree"] + if swapFree > swapTotal { + swapFree = swapTotal + } + swapUsed := swapTotal - swapFree + + result := MemoryMetrics{ + TotalBytes: total, UsedBytes: used, AvailableBytes: available, + CachedBytes: values["Cached"] + values["SReclaimable"], + BuffersBytes: values["Buffers"], SwapTotalBytes: swapTotal, + SwapUsedBytes: swapUsed, UsagePercent: float64(used) / float64(total) * 100, + } + if swapTotal > 0 { + result.SwapPercent = float64(swapUsed) / float64(swapTotal) * 100 + } + return result, nil +} diff --git a/memory_test.go b/memory_test.go new file mode 100644 index 0000000..3088ec0 --- /dev/null +++ b/memory_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadMemoryMetrics(t *testing.T) { + path := filepath.Join(t.TempDir(), "meminfo") + content := `MemTotal: 1000000 kB +MemAvailable: 400000 kB +Buffers: 10000 kB +Cached: 200000 kB +SReclaimable: 10000 kB +SwapTotal: 500000 kB +SwapFree: 300000 kB +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + memory, err := readMemoryMetrics(path) + if err != nil { + t.Fatal(err) + } + if memory.UsagePercent != 60 || memory.SwapPercent != 40 { + t.Fatalf("неожиданные проценты памяти: %+v", memory) + } + if memory.CachedBytes != 210000*1024 { + t.Fatalf("неожиданный размер кэша: %d", memory.CachedBytes) + } +} diff --git a/network.go b/network.go new file mode 100644 index 0000000..f6c618d --- /dev/null +++ b/network.go @@ -0,0 +1,90 @@ +package main + +import ( + "bufio" + "os" + "strconv" + "strings" + "sync" + "time" +) + +type NetworkMetrics struct { + Interface string `json:"interface"` + ReceiveBytes uint64 `json:"receiveBytes"` + TransmitBytes uint64 `json:"transmitBytes"` + ReceivePerSec float64 `json:"receivePerSec"` + TransmitPerSec float64 `json:"transmitPerSec"` +} + +type networkSnapshot struct { + at time.Time + received uint64 + sent uint64 +} + +type networkCollector struct { + mu sync.Mutex + path string + previous networkSnapshot +} + +func newNetworkCollector(path string) (*networkCollector, error) { + snapshot, _, err := readNetworkSnapshot(path) + if err != nil { + return nil, err + } + return &networkCollector{path: path, previous: snapshot}, nil +} + +func (c *networkCollector) collect() (NetworkMetrics, error) { + c.mu.Lock() + defer c.mu.Unlock() + + current, interfaces, err := readNetworkSnapshot(c.path) + if err != nil { + return NetworkMetrics{}, err + } + elapsed := current.at.Sub(c.previous.at).Seconds() + var receivedRate, sentRate float64 + if elapsed > 0 && current.received >= c.previous.received && current.sent >= c.previous.sent { + receivedRate = float64(current.received-c.previous.received) / elapsed + sentRate = float64(current.sent-c.previous.sent) / elapsed + } + c.previous = current + return NetworkMetrics{ + Interface: strings.Join(interfaces, ", "), ReceiveBytes: current.received, + TransmitBytes: current.sent, ReceivePerSec: receivedRate, TransmitPerSec: sentRate, + }, nil +} + +func readNetworkSnapshot(path string) (networkSnapshot, []string, error) { + file, err := os.Open(path) + if err != nil { + return networkSnapshot{}, nil, err + } + defer file.Close() + + var received, sent uint64 + var interfaces []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + name, values, found := strings.Cut(line, ":") + if !found || strings.TrimSpace(name) == "lo" { + continue + } + fields := strings.Fields(values) + if len(fields) < 9 { + continue + } + rx, errRX := strconv.ParseUint(fields[0], 10, 64) + tx, errTX := strconv.ParseUint(fields[8], 10, 64) + if errRX == nil && errTX == nil { + received += rx + sent += tx + interfaces = append(interfaces, strings.TrimSpace(name)) + } + } + return networkSnapshot{at: time.Now(), received: received, sent: sent}, interfaces, scanner.Err() +} diff --git a/network_test.go b/network_test.go new file mode 100644 index 0000000..fa3d211 --- /dev/null +++ b/network_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadNetworkSnapshot(t *testing.T) { + path := filepath.Join(t.TempDir(), "netdev") + content := `Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 100 1 0 0 0 0 0 0 100 1 0 0 0 0 0 0 + eth0: 1200 2 0 0 0 0 0 0 3400 3 0 0 0 0 0 0 + vmbr0: 5600 4 0 0 0 0 0 0 7800 5 0 0 0 0 0 0 +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + snapshot, interfaces, err := readNetworkSnapshot(path) + if err != nil { + t.Fatal(err) + } + if snapshot.received != 6800 || snapshot.sent != 11200 { + t.Fatalf("неожиданные сетевые счётчики: %+v", snapshot) + } + if len(interfaces) != 2 { + t.Fatalf("неожиданный список интерфейсов: %v", interfaces) + } +} diff --git a/proxmox-cpu-dashboard.service b/proxmox-cpu-dashboard.service new file mode 100644 index 0000000..ca2dfb0 --- /dev/null +++ b/proxmox-cpu-dashboard.service @@ -0,0 +1,17 @@ +[Unit] +Description=Proxmox CPU Dashboard +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/proxmox-cpu-dashboard +Environment=DASHBOARD_ADDR=:9105 +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/storage.go b/storage.go new file mode 100644 index 0000000..d7ddc7c --- /dev/null +++ b/storage.go @@ -0,0 +1,29 @@ +package main + +import "syscall" + +type StorageMetrics struct { + Mountpoint string `json:"mountpoint"` + TotalBytes uint64 `json:"totalBytes"` + UsedBytes uint64 `json:"usedBytes"` + FreeBytes uint64 `json:"freeBytes"` + UsagePercent float64 `json:"usagePercent"` +} + +func readStorageMetrics(path string) (StorageMetrics, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return StorageMetrics{}, err + } + total := stat.Blocks * uint64(stat.Bsize) + free := stat.Bavail * uint64(stat.Bsize) + used := total - free + var percent float64 + if total > 0 { + percent = float64(used) / float64(total) * 100 + } + return StorageMetrics{ + Mountpoint: path, TotalBytes: total, UsedBytes: used, + FreeBytes: free, UsagePercent: percent, + }, nil +} diff --git a/ups.go b/ups.go new file mode 100644 index 0000000..04a07a5 --- /dev/null +++ b/ups.go @@ -0,0 +1,158 @@ +package main + +import ( + "context" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +type UPSMetrics struct { + Available bool `json:"available"` + Source string `json:"source"` + Name string `json:"name"` + Model string `json:"model"` + Status string `json:"status"` + StatusLabel string `json:"statusLabel"` + ChargePercent *float64 `json:"chargePercent"` + RuntimeSeconds *float64 `json:"runtimeSeconds"` + LoadPercent *float64 `json:"loadPercent"` + InputVoltage *float64 `json:"inputVoltage"` + OutputVoltage *float64 `json:"outputVoltage"` + InputFrequency *float64 `json:"inputFrequency"` + BatteryVoltage *float64 `json:"batteryVoltage"` + Temperature *float64 `json:"temperatureCelsius"` +} + +type upsCollector struct { + mu sync.Mutex + cached UPSMetrics + updatedAt time.Time +} + +func (c *upsCollector) collect() UPSMetrics { + c.mu.Lock() + defer c.mu.Unlock() + if time.Since(c.updatedAt) < 5*time.Second { + return c.cached + } + if metrics, ok := readNUT(); ok { + c.cached = metrics + } else if metrics, ok := readAPCUPSD(); ok { + c.cached = metrics + } else { + c.cached = UPSMetrics{} + } + c.updatedAt = time.Now() + return c.cached +} + +func readNUT() (UPSMetrics, bool) { + if _, err := exec.LookPath("upsc"); err != nil { + return UPSMetrics{}, false + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + list, err := exec.CommandContext(ctx, "upsc", "-l").Output() + if err != nil || strings.TrimSpace(string(list)) == "" { + return UPSMetrics{}, false + } + name := strings.Fields(string(list))[0] + output, err := exec.CommandContext(ctx, "upsc", name).Output() + if err != nil { + return UPSMetrics{}, false + } + values := parseColonValues(string(output)) + return upsFromValues("NUT", name, values), true +} + +func readAPCUPSD() (UPSMetrics, bool) { + if _, err := exec.LookPath("apcaccess"); err != nil { + return UPSMetrics{}, false + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "apcaccess", "status").Output() + if err != nil { + return UPSMetrics{}, false + } + raw := parseColonValues(string(output)) + values := map[string]string{ + "device.model": raw["MODEL"], "ups.status": raw["STATUS"], + "battery.charge": raw["BCHARGE"], "battery.runtime": minutesToSeconds(raw["TIMELEFT"]), + "ups.load": raw["LOADPCT"], "input.voltage": raw["LINEV"], + "output.voltage": raw["OUTPUTV"], "input.frequency": raw["LINEFREQ"], + "battery.voltage": raw["BATTV"], "ups.temperature": raw["ITEMP"], + } + return upsFromValues("apcupsd", raw["UPSNAME"], values), true +} + +func parseColonValues(output string) map[string]string { + values := make(map[string]string) + for _, line := range strings.Split(output, "\n") { + key, value, ok := strings.Cut(line, ":") + if ok { + values[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + return values +} + +func upsFromValues(source, name string, values map[string]string) UPSMetrics { + status := values["ups.status"] + return UPSMetrics{ + Available: true, Source: source, Name: name, Model: values["device.model"], + Status: status, StatusLabel: upsStatusLabel(status), + ChargePercent: numberPointer(values["battery.charge"]), RuntimeSeconds: numberPointer(values["battery.runtime"]), + LoadPercent: numberPointer(values["ups.load"]), InputVoltage: numberPointer(values["input.voltage"]), + OutputVoltage: numberPointer(values["output.voltage"]), InputFrequency: numberPointer(values["input.frequency"]), + BatteryVoltage: numberPointer(values["battery.voltage"]), Temperature: numberPointer(firstNonEmpty(values["ups.temperature"], values["battery.temperature"])), + } +} + +func numberPointer(value string) *float64 { + field := strings.Fields(value) + if len(field) == 0 { + return nil + } + number, err := strconv.ParseFloat(strings.TrimSuffix(field[0], "%"), 64) + if err != nil { + return nil + } + return &number +} + +func minutesToSeconds(value string) string { + if minutes := numberPointer(value); minutes != nil { + return strconv.FormatFloat(*minutes*60, 'f', -1, 64) + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func upsStatusLabel(status string) string { + switch { + case strings.Contains(status, "LB"): + return "Низкий заряд" + case strings.Contains(status, "OB"): + return "Работа от батареи" + case strings.Contains(status, "OL"): + return "Питание от сети" + case status == "ONLINE": + return "Питание от сети" + case status == "ONBATT": + return "Работа от батареи" + default: + return firstNonEmpty(status, "Неизвестно") + } +} diff --git a/ups_test.go b/ups_test.go new file mode 100644 index 0000000..8605a12 --- /dev/null +++ b/ups_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestUPSParsing(t *testing.T) { + values := parseColonValues("battery.charge: 98.5\nups.status: OL\nbattery.runtime: 1200\n") + metrics := upsFromValues("NUT", "ups", values) + if metrics.ChargePercent == nil || *metrics.ChargePercent != 98.5 { + t.Fatalf("неожиданный заряд: %v", metrics.ChargePercent) + } + if metrics.StatusLabel != "Питание от сети" { + t.Fatalf("неожиданный статус: %s", metrics.StatusLabel) + } +} diff --git a/web.go b/web.go new file mode 100644 index 0000000..9cbcd24 --- /dev/null +++ b/web.go @@ -0,0 +1,268 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +type dashboardMetrics struct { + CPU CPUMetrics `json:"cpu"` + Memory MemoryMetrics `json:"memory"` + Storage StorageMetrics `json:"storage"` + Disks []DiskMetrics `json:"disks"` + Network NetworkMetrics `json:"network"` + ZFS ZFSMetrics `json:"zfs"` + UPS UPSMetrics `json:"ups"` + Guests GuestsMetrics `json:"guests"` + Timestamp time.Time `json:"timestamp"` +} + +func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector) (dashboardMetrics, error) { + cpu, err := cpuCollector.collect() + if err != nil { + return dashboardMetrics{}, err + } + memory, err := readMemoryMetrics("/proc/meminfo") + if err != nil { + return dashboardMetrics{}, err + } + storage, err := readStorageMetrics("/") + if err != nil { + return dashboardMetrics{}, err + } + network, err := networkCollector.collect() + if err != nil { + return dashboardMetrics{}, err + } + return dashboardMetrics{ + CPU: cpu, Memory: memory, Storage: storage, + Disks: diskCollector.collect(), Network: network, + ZFS: zfsCollector.collect(), UPS: upsCollector.collect(), + Guests: guestCollector.collect(), Timestamp: time.Now(), + }, nil +} + +func routes(collector *cpuCollector) http.Handler { + mux := http.NewServeMux() + network, networkErr := newNetworkCollector("/proc/net/dev") + disks := &diskCollector{} + zfs := &zfsCollector{} + ups := &upsCollector{} + guests := &guestCollector{} + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(dashboardHTML)) + }) + mux.HandleFunc("/api/cpu", func(w http.ResponseWriter, _ *http.Request) { + metrics, err := collector.collect() + writeJSON(w, metrics, err) + }) + mux.HandleFunc("/api/system", func(w http.ResponseWriter, _ *http.Request) { + if networkErr != nil { + writeJSON(w, nil, networkErr) + return + } + metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests) + writeJSON(w, metrics, err) + }) + mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Потоковые обновления недоступны", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + if networkErr != nil { + return + } + metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests) + if err == nil { + data, _ := json.Marshal(metrics) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + select { + case <-r.Context().Done(): + return + case <-ticker.C: + } + } + }) + return mux +} + +func writeJSON(w http.ResponseWriter, value any, err error) { + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(value) +} + +const dashboardHTML = ` + + + + +Proxmox Host Monitor + + +
+
Proxmox host telemetry

Host overview

Подключение…
+
+ + + + + + + +
+
Обновление каждые 2 секунды · данные читаются напрямую с хоста
+
+ + + +
+
Загрузка
Сокеты
Физические ядра
+
Логические потоки
Средняя частота
Uptime
+
Load · 1 мин
Load · 5 мин
Load · 15 мин
+
+
Температурные датчики
Поиск датчиков…
+
+ + + +
+
Используется
Доступно
Всего
+
Кэш
Буферы
Загрузка RAM
+
Swap используется
Swap всего
Загрузка swap
+
+
+ + + +
+
Используется
Свободно
Общий объём
+
Заполнение
Точка монтирования/
+
+
Поиск накопителей…
+
+ + + +
+
Получение сейчас
Передача сейчас
Суммарная скорость
+
Получено с запуска
Передано с запуска
Интерфейсы
+
+
+ + + +
Поиск ZFS-пулов…
+
+ + + +
+
Состояние
Заряд
Автономность
+
Нагрузка
Входное напряжение
Выходное напряжение
+
Частота
Батарея
Температура
+
+
Источник данных
Не настроен
+
+ + + +
Получение списка гостей…
+
+ +` diff --git a/zfs.go b/zfs.go new file mode 100644 index 0000000..30a29bd --- /dev/null +++ b/zfs.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +type ZFSPoolMetrics struct { + Name string `json:"name"` + Health string `json:"health"` + SizeBytes uint64 `json:"sizeBytes"` + AllocatedBytes uint64 `json:"allocatedBytes"` + FreeBytes uint64 `json:"freeBytes"` + CapacityPercent float64 `json:"capacityPercent"` + Fragmentation float64 `json:"fragmentationPercent"` + DedupRatio string `json:"dedupRatio"` + Scan string `json:"scan"` + Errors string `json:"errors"` + AttentionReasons []string `json:"attentionReasons"` +} + +type ZFSMetrics struct { + Available bool `json:"available"` + Pools []ZFSPoolMetrics `json:"pools"` +} + +type zfsCollector struct { + mu sync.Mutex + cached ZFSMetrics + updatedAt time.Time +} + +func (c *zfsCollector) collect() ZFSMetrics { + c.mu.Lock() + defer c.mu.Unlock() + if time.Since(c.updatedAt) < 30*time.Second { + return c.cached + } + c.cached = readZFSPools() + c.updatedAt = time.Now() + return c.cached +} + +func readZFSPools() ZFSMetrics { + if _, err := exec.LookPath("zpool"); err != nil { + return ZFSMetrics{} + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "zpool", "list", "-H", "-p", "-o", "name,size,allocated,free,fragmentation,capacity,dedupratio,health").Output() + result := ZFSMetrics{Available: true} + if err != nil { + return result + } + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + fields := strings.Fields(line) + if len(fields) < 8 { + continue + } + pool := ZFSPoolMetrics{Name: fields[0], DedupRatio: fields[6], Health: fields[7]} + pool.SizeBytes, _ = strconv.ParseUint(fields[1], 10, 64) + pool.AllocatedBytes, _ = strconv.ParseUint(fields[2], 10, 64) + pool.FreeBytes, _ = strconv.ParseUint(fields[3], 10, 64) + pool.Fragmentation = parsePercent(fields[4]) + pool.CapacityPercent = parsePercent(fields[5]) + pool.Scan, pool.Errors = readZFSStatus(pool.Name) + if pool.Health != "ONLINE" { + pool.AttentionReasons = append(pool.AttentionReasons, "Состояние пула: "+pool.Health) + } + if pool.Errors != "" && !strings.Contains(pool.Errors, "No known data errors") { + pool.AttentionReasons = append(pool.AttentionReasons, pool.Errors) + } + result.Pools = append(result.Pools, pool) + } + return result +} + +func parsePercent(value string) float64 { + parsed, _ := strconv.ParseFloat(strings.TrimSuffix(value, "%"), 64) + return parsed +} + +func readZFSStatus(pool string) (string, string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + output, _ := exec.CommandContext(ctx, "zpool", "status", pool).Output() + var scan, errors string + for _, line := range strings.Split(string(output), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "scan:") { + scan = strings.TrimSpace(strings.TrimPrefix(trimmed, "scan:")) + } + if strings.HasPrefix(trimmed, "errors:") { + errors = strings.TrimSpace(strings.TrimPrefix(trimmed, "errors:")) + } + } + return scan, errors +} diff --git a/zfs_test.go b/zfs_test.go new file mode 100644 index 0000000..8a7613e --- /dev/null +++ b/zfs_test.go @@ -0,0 +1,9 @@ +package main + +import "testing" + +func TestParsePercent(t *testing.T) { + if value := parsePercent("37%"); value != 37 { + t.Fatalf("неожиданный процент: %v", value) + } +}