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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
dist/
.DS_Store
*.log
AGENTS.md
sources/

105
README.md Normal file
View File

@@ -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 .
```

286
cpu.go Normal file
View File

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

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

52
disks_test.go Normal file
View File

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

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module proxmox-host-info
go 1.22

66
guests.go Normal file
View File

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

17
guests_test.go Normal file
View File

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

50
main.go Normal file
View File

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

59
main_test.go Normal file
View File

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

73
memory.go Normal file
View File

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

33
memory_test.go Normal file
View File

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

90
network.go Normal file
View File

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

31
network_test.go Normal file
View File

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

View File

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

29
storage.go Normal file
View File

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

158
ups.go Normal file
View File

@@ -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, "Неизвестно")
}
}

14
ups_test.go Normal file
View File

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

268
web.go Normal file
View File

@@ -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 = `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Proxmox Host Monitor</title>
<style>
:root{color-scheme:dark;--bg:#080d12;--panel:#111920;--line:#26323c;--muted:#8d9ba8;--text:#f2f5f7;--green:#55d58a;--violet:#9d8cff}
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 78% -8%,#173226 0,transparent 34%),var(--bg);color:var(--text);font:15px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,sans-serif}
button{font:inherit}main{width:min(1060px,calc(100% - 32px));margin:auto;padding:46px 0}
header{display:flex;align-items:end;justify-content:space-between;margin-bottom:22px}.eyebrow{color:var(--green);font:700 11px/1 monospace;letter-spacing:.16em;text-transform:uppercase}
h1{font-size:clamp(28px,5vw,44px);letter-spacing:-.045em;margin:9px 0 0}.status{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px}.dot{width:8px;height:8px;border-radius:50%;background:#f39a56}.dot.live{background:var(--green);box-shadow:0 0 15px var(--green)}
.cards{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:1fr;gap:16px}.card{appearance:none;display:flex;flex-direction:column;text-align:left;color:inherit;width:100%;padding:0;background:linear-gradient(145deg,rgba(18,28,36,.98),rgba(12,19,25,.98));border:1px solid var(--line);border-radius:20px;overflow:hidden;cursor:pointer;transition:transform .18s,border-color .18s,box-shadow .18s}.card:hover{transform:translateY(-2px);border-color:#3d4e5c;box-shadow:0 20px 55px rgba(0,0,0,.22)}.card:focus-visible{outline:2px solid var(--green);outline-offset:3px}
.card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;min-height:106px;padding:23px 24px 18px}.card-head>div:first-child{min-width:0}.label{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.1em}.name{font-size:17px;font-weight:650;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:350px}.percent{flex:0 0 auto;font:700 35px/.95 ui-monospace,monospace;letter-spacing:-.07em}.percent small{font-size:16px;color:var(--green)}
.bar{height:5px;background:#222e37;margin:0 24px;overflow:hidden;border-radius:20px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--green),#afe36d);transition:width .5s ease}.memory .fill{background:linear-gradient(90deg,var(--violet),#72b9ff)}
.storage .fill{background:linear-gradient(90deg,#ffb35c,#ff755c)}.network .fill{background:linear-gradient(90deg,#43c6d9,#65ddaa)}.zfs .fill{background:linear-gradient(90deg,#46c0ff,#6577ff)}.ups .fill{background:linear-gradient(90deg,#d5e85a,#55d58a)}.guests .fill{background:linear-gradient(90deg,#ff78b8,#9d8cff)}
.quick{display:grid;grid-template-columns:1fr 1fr;margin-top:19px;border-top:1px solid var(--line)}.quick div{padding:16px 24px}.quick div+div{border-left:1px solid var(--line)}.quick strong{display:block;font:650 20px/1.2 ui-monospace,monospace;margin-top:5px}.hint{color:#657581;font-size:11px;margin:auto 24px 18px;padding-top:14px}
dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding:0;color:var(--text);background:#101820;border:1px solid #34434f;border-radius:22px;box-shadow:0 30px 100px #000b;overflow:auto}dialog::backdrop{background:rgba(3,7,10,.75);backdrop-filter:blur(6px)}
.modal-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;padding:26px 28px;background:rgba(16,24,32,.94);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}.modal-head h2{font-size:24px;letter-spacing:-.03em;margin:5px 0 0}.close{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:#17222b;color:var(--text);font-size:22px;cursor:pointer}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr)}.detail{padding:22px 27px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.detail:nth-child(3n){border-right:0}.detail strong{display:block;font:650 24px/1.2 ui-monospace,monospace;margin-top:7px}.wide{padding:24px 28px}.temps{display:flex;flex-wrap:wrap;gap:9px;margin-top:12px}.temp{padding:7px 11px;border:1px solid var(--line);border-radius:99px}.temp b{color:var(--green);margin-left:7px}.empty{color:var(--muted);font-size:13px}
.disk-list{display:grid;gap:12px;padding:16px}.disk-item{padding:19px;border:1px solid var(--line);border-radius:15px;background:#0c141a}.disk-top{display:flex;justify-content:space-between;gap:20px}.disk-model{font-size:17px;font-weight:650}.disk-meta{color:var(--muted);font:12px/1.5 ui-monospace,monospace;margin-top:4px;overflow-wrap:anywhere}.badge{position:relative;height:max-content;padding:5px 9px;border:1px solid #2d6245;border-radius:99px;color:var(--green);font-size:11px;white-space:nowrap}.badge.warn{color:#ffb35c;border-color:#6d4c2c}.badge.bad{color:#ff755c;border-color:#71362f}.badge[data-reason]:hover::after,.badge[data-reason]:focus::after{content:attr(data-reason);position:absolute;z-index:5;right:0;bottom:calc(100% + 10px);width:280px;padding:11px 13px;border:1px solid #4c5963;border-radius:10px;background:#1a242c;color:var(--text);font:12px/1.45 Inter,system-ui,sans-serif;white-space:normal;box-shadow:0 14px 35px #0008}.disk-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-top:18px}.disk-stat span{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.disk-stat b{display:block;margin-top:5px;font:600 14px ui-monospace,monospace}
footer{color:#657581;font-size:12px;margin-top:17px}
@media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats{grid-template-columns:repeat(2,1fr)}}
</style>
</head>
<body><main>
<header><div><div class="eyebrow">Proxmox host telemetry</div><h1>Host overview</h1></div><div class="status"><i class="dot" id="dot"></i><span id="status">Подключение…</span></div></header>
<section class="cards">
<button class="card" data-open="cpuDialog" aria-label="Открыть подробности процессора">
<div class="card-head"><div><div class="label">CPU</div><div class="name" id="cpuName">Загрузка данных…</div></div><div class="percent"><span id="cpuUsage">—</span><small>%</small></div></div>
<div class="bar"><div class="fill" id="cpuBar"></div></div>
<div class="quick"><div><span class="label">Средняя температура</span><strong id="cpuTemp">—</strong></div><div><span class="label">Ядра / потоки</span><strong id="cpuTopology">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card memory" data-open="memoryDialog" aria-label="Открыть подробности оперативной памяти">
<div class="card-head"><div><div class="label">Memory</div><div class="name">Оперативная память</div></div><div class="percent"><span id="memUsage">—</span><small>%</small></div></div>
<div class="bar"><div class="fill" id="memBar"></div></div>
<div class="quick"><div><span class="label">Используется</span><strong id="memUsed">—</strong></div><div><span class="label">Доступно</span><strong id="memAvailable">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card storage" data-open="storageDialog" aria-label="Открыть подробности системного диска">
<div class="card-head"><div><div class="label">Storage</div><div class="name">Физические диски</div></div><div class="percent"><span id="diskCount">—</span><small id="diskCountLabel"> дисков</small></div></div>
<div class="bar"><div class="fill" id="diskBar"></div></div>
<div class="quick"><div><span class="label">SMART</span><strong id="diskHealth">—</strong></div><div><span class="label">Системный раздел</span><strong id="diskUsage">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card network" data-open="networkDialog" aria-label="Открыть подробности сети">
<div class="card-head"><div><div class="label">Network</div><div class="name" id="netInterfaces">Сетевые интерфейсы</div></div><div class="percent"><span id="netTotal">—</span><small> MB/s</small></div></div>
<div class="bar"><div class="fill" id="netBar"></div></div>
<div class="quick"><div><span class="label">Получение ↓</span><strong id="netDown">—</strong></div><div><span class="label">Передача ↑</span><strong id="netUp">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card zfs" data-open="zfsDialog" aria-label="Открыть подробности ZFS">
<div class="card-head"><div><div class="label">ZFS</div><div class="name">Пулы хранения</div></div><div class="percent"><span id="zfsCount">—</span><small> пулов</small></div></div>
<div class="bar"><div class="fill" id="zfsBar"></div></div>
<div class="quick"><div><span class="label">Состояние</span><strong id="zfsHealth">—</strong></div><div><span class="label">Занято</span><strong id="zfsUsed">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card ups" data-open="upsDialog" aria-label="Открыть подробности ИБП">
<div class="card-head"><div><div class="label">UPS</div><div class="name" id="upsName">Источник бесперебойного питания</div></div><div class="percent"><span id="upsCharge">—</span><small>%</small></div></div>
<div class="bar"><div class="fill" id="upsBar"></div></div>
<div class="quick"><div><span class="label">Состояние</span><strong id="upsStatus">—</strong></div><div><span class="label">Автономность</span><strong id="upsRuntime">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card guests" data-open="guestsDialog" aria-label="Открыть подробности VM и LXC">
<div class="card-head"><div><div class="label">Virtualization</div><div class="name">VM и LXC</div></div><div class="percent"><span id="guestRunning">—</span><small id="guestTotal"> / —</small></div></div>
<div class="bar"><div class="fill" id="guestBar"></div></div>
<div class="quick"><div><span class="label">CPU гостей</span><strong id="guestCPU">—</strong></div><div><span class="label">Память гостей</span><strong id="guestMemory">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
</section>
<footer>Обновление каждые 2 секунды · данные читаются напрямую с хоста</footer>
</main>
<dialog id="cpuDialog">
<div class="modal-head"><div><div class="label">CPU details</div><h2 id="cpuDetailName">Процессор</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="detail-grid">
<div class="detail"><span class="label">Загрузка</span><strong id="dCpuUsage">—</strong></div><div class="detail"><span class="label">Сокеты</span><strong id="dSockets">—</strong></div><div class="detail"><span class="label">Физические ядра</span><strong id="dCores">—</strong></div>
<div class="detail"><span class="label">Логические потоки</span><strong id="dThreads">—</strong></div><div class="detail"><span class="label">Средняя частота</span><strong id="dFreq">—</strong></div><div class="detail"><span class="label">Uptime</span><strong id="dUptime">—</strong></div>
<div class="detail"><span class="label">Load · 1 мин</span><strong id="dLoad1">—</strong></div><div class="detail"><span class="label">Load · 5 мин</span><strong id="dLoad5">—</strong></div><div class="detail"><span class="label">Load · 15 мин</span><strong id="dLoad15">—</strong></div>
</div>
<div class="wide"><span class="label">Температурные датчики</span><div class="temps" id="dTemps"><span class="empty">Поиск датчиков…</span></div></div>
</dialog>
<dialog id="memoryDialog">
<div class="modal-head"><div><div class="label">Memory details</div><h2>Оперативная память</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="detail-grid">
<div class="detail"><span class="label">Используется</span><strong id="dMemUsed">—</strong></div><div class="detail"><span class="label">Доступно</span><strong id="dMemAvailable">—</strong></div><div class="detail"><span class="label">Всего</span><strong id="dMemTotal">—</strong></div>
<div class="detail"><span class="label">Кэш</span><strong id="dMemCache">—</strong></div><div class="detail"><span class="label">Буферы</span><strong id="dMemBuffers">—</strong></div><div class="detail"><span class="label">Загрузка RAM</span><strong id="dMemPercent">—</strong></div>
<div class="detail"><span class="label">Swap используется</span><strong id="dSwapUsed">—</strong></div><div class="detail"><span class="label">Swap всего</span><strong id="dSwapTotal">—</strong></div><div class="detail"><span class="label">Загрузка swap</span><strong id="dSwapPercent">—</strong></div>
</div>
</dialog>
<dialog id="storageDialog">
<div class="modal-head"><div><div class="label">Storage details</div><h2>Физические диски</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="detail-grid">
<div class="detail"><span class="label">Используется</span><strong id="dDiskUsed">—</strong></div><div class="detail"><span class="label">Свободно</span><strong id="dDiskFree">—</strong></div><div class="detail"><span class="label">Общий объём</span><strong id="dDiskTotal">—</strong></div>
<div class="detail"><span class="label">Заполнение</span><strong id="dDiskPercent">—</strong></div><div class="detail"><span class="label">Точка монтирования</span><strong>/</strong></div>
</div>
<div class="disk-list" id="diskList"><span class="empty">Поиск накопителей…</span></div>
</dialog>
<dialog id="networkDialog">
<div class="modal-head"><div><div class="label">Network details</div><h2>Сетевой трафик</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="detail-grid">
<div class="detail"><span class="label">Получение сейчас</span><strong id="dNetDown">—</strong></div><div class="detail"><span class="label">Передача сейчас</span><strong id="dNetUp">—</strong></div><div class="detail"><span class="label">Суммарная скорость</span><strong id="dNetTotal">—</strong></div>
<div class="detail"><span class="label">Получено с запуска</span><strong id="dNetReceived">—</strong></div><div class="detail"><span class="label">Передано с запуска</span><strong id="dNetSent">—</strong></div><div class="detail"><span class="label">Интерфейсы</span><strong id="dNetInterfaces">—</strong></div>
</div>
</dialog>
<dialog id="zfsDialog">
<div class="modal-head"><div><div class="label">ZFS details</div><h2>Пулы хранения</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="disk-list" id="zfsList"><span class="empty">Поиск ZFS-пулов…</span></div>
</dialog>
<dialog id="upsDialog">
<div class="modal-head"><div><div class="label">UPS details</div><h2 id="dUpsName">Источник бесперебойного питания</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="detail-grid">
<div class="detail"><span class="label">Состояние</span><strong id="dUpsStatus">—</strong></div><div class="detail"><span class="label">Заряд</span><strong id="dUpsCharge">—</strong></div><div class="detail"><span class="label">Автономность</span><strong id="dUpsRuntime">—</strong></div>
<div class="detail"><span class="label">Нагрузка</span><strong id="dUpsLoad">—</strong></div><div class="detail"><span class="label">Входное напряжение</span><strong id="dUpsInput">—</strong></div><div class="detail"><span class="label">Выходное напряжение</span><strong id="dUpsOutput">—</strong></div>
<div class="detail"><span class="label">Частота</span><strong id="dUpsFrequency">—</strong></div><div class="detail"><span class="label">Батарея</span><strong id="dUpsBattery">—</strong></div><div class="detail"><span class="label">Температура</span><strong id="dUpsTemp">—</strong></div>
</div>
<div class="wide"><span class="label">Источник данных</span><div class="temps"><span class="temp" id="dUpsSource">Не настроен</span></div></div>
</dialog>
<dialog id="guestsDialog">
<div class="modal-head"><div><div class="label">Virtualization details</div><h2>VM и LXC</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="disk-list" id="guestList"><span class="empty">Получение списка гостей…</span></div>
</dialog>
<script>
const el=id=>document.getElementById(id), fixed=n=>Number(n).toFixed(1);
const size=n=>{if(!n)return '0 B';const u=['B','KB','MB','GB','TB'],i=Math.min(Math.floor(Math.log(n)/Math.log(1024)),4);return (n/1024**i).toFixed(i>2?1:0)+' '+u[i]};
const rate=n=>size(n)+'/s';
const safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const uptime=s=>{const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?d+'д '+h+'ч':h?h+'ч '+m+'м':m+'м'};
function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
el('cpuName').textContent=el('cpuDetailName').textContent=c.model;el('cpuUsage').textContent=fixed(c.usagePercent);el('cpuBar').style.width=Math.min(c.usagePercent,100)+'%';el('cpuTemp').textContent=avg===null?'Нет датчика':fixed(avg)+' °C';el('cpuTopology').textContent=c.physicalCores+' / '+c.logicalCpus;
el('dCpuUsage').textContent=fixed(c.usagePercent)+'%';el('dSockets').textContent=c.sockets;el('dCores').textContent=c.physicalCores;el('dThreads').textContent=c.logicalCpus;el('dFreq').textContent=(c.frequencyMhz/1000).toFixed(2)+' GHz';el('dUptime').textContent=uptime(c.uptimeSeconds);el('dLoad1').textContent=fixed(c.load1);el('dLoad5').textContent=fixed(c.load5);el('dLoad15').textContent=fixed(c.load15);el('dTemps').innerHTML=c.temperatures.length?c.temperatures.map(t=>'<span class="temp">'+t.label+'<b>'+fixed(t.celsius)+'°C</b></span>').join(''):'<span class="empty">Датчики температуры не найдены</span>';
el('memUsage').textContent=fixed(m.usagePercent);el('memBar').style.width=Math.min(m.usagePercent,100)+'%';el('memUsed').textContent=size(m.usedBytes);el('memAvailable').textContent=size(m.availableBytes);el('dMemUsed').textContent=size(m.usedBytes);el('dMemAvailable').textContent=size(m.availableBytes);el('dMemTotal').textContent=size(m.totalBytes);el('dMemCache').textContent=size(m.cachedBytes);el('dMemBuffers').textContent=size(m.buffersBytes);el('dMemPercent').textContent=fixed(m.usagePercent)+'%';el('dSwapUsed').textContent=size(m.swapUsedBytes);el('dSwapTotal').textContent=size(m.swapTotalBytes);el('dSwapPercent').textContent=fixed(m.swapPercent)+'%';
const failed=disks.filter(d=>d.smartStatus==='Ошибка').length,attention=disks.filter(d=>d.smartStatus==='Требует внимания').length,available=disks.filter(d=>d.smartAvailable).length;let health=failed?'Ошибка':attention?'Внимание':available===disks.length&&disks.length?'Исправны':available?'Частично':'Нет данных';
el('diskCount').textContent=disks.length;el('diskCountLabel').textContent=disks.length===1?' диск':' дисков';el('diskHealth').textContent=health;el('diskUsage').textContent=fixed(s.usagePercent)+'%';el('diskBar').style.width=Math.min(s.usagePercent,100)+'%';el('dDiskUsed').textContent=size(s.usedBytes);el('dDiskFree').textContent=size(s.freeBytes);el('dDiskTotal').textContent=size(s.totalBytes);el('dDiskPercent').textContent=fixed(s.usagePercent)+'%';
el('diskList').innerHTML=disks.length?disks.map(d=>{const badge=d.smartStatus==='Ошибка'?'bad':d.smartStatus==='Исправен'?'':'warn',reason=(d.attentionReasons||[]).join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'';return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(d.model)+'</div><div class="disk-meta">'+safe(d.path)+' · SN: '+safe(d.serial)+'</div></div><span class="badge '+badge+'"'+tip+'>'+safe(d.smartStatus)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Тип</span><b>'+safe(d.type)+'</b></div><div class="disk-stat"><span>Объём</span><b>'+size(d.sizeBytes)+'</b></div><div class="disk-stat"><span>Занято</span><b>'+(d.usedBytes==null?'—':size(d.usedBytes)+' · '+fixed(d.usagePercent)+'%')+'</b></div><div class="disk-stat"><span>Температура</span><b>'+(d.temperatureCelsius==null?'—':fixed(d.temperatureCelsius)+' °C')+'</b></div><div class="disk-stat"><span>Наработка</span><b>'+(d.powerOnHours==null?'—':d.powerOnHours+' ч')+'</b></div><div class="disk-stat"><span>Износ</span><b>'+(d.wearUsedPercent==null?'—':fixed(d.wearUsedPercent)+'%')+'</b></div></div></article>'}).join(''):'<span class="empty">Физические накопители не найдены</span>';
const totalRate=n.receivePerSec+n.transmitPerSec;el('netInterfaces').textContent=n.interface||'Сетевые интерфейсы';el('netTotal').textContent=(totalRate/1048576).toFixed(2);el('netBar').style.width=Math.min(totalRate/125000000*100,100)+'%';el('netDown').textContent=rate(n.receivePerSec);el('netUp').textContent=rate(n.transmitPerSec);el('dNetDown').textContent=rate(n.receivePerSec);el('dNetUp').textContent=rate(n.transmitPerSec);el('dNetTotal').textContent=rate(totalRate);el('dNetReceived').textContent=size(n.receiveBytes);el('dNetSent').textContent=size(n.transmitBytes);el('dNetInterfaces').textContent=n.interface||'—';
const pools=z.pools||[],zTotal=pools.reduce((a,p)=>a+p.sizeBytes,0),zUsed=pools.reduce((a,p)=>a+p.allocatedBytes,0),zPercent=zTotal?zUsed/zTotal*100:0,zBad=pools.filter(p=>p.health!=='ONLINE').length;el('zfsCount').textContent=pools.length;el('zfsHealth').textContent=!z.available?'Не найден':zBad?'Внимание':pools.length?'Исправны':'Нет пулов';el('zfsUsed').textContent=zTotal?size(zUsed)+' · '+fixed(zPercent)+'%':'—';el('zfsBar').style.width=Math.min(zPercent,100)+'%';
el('zfsList').innerHTML=pools.length?pools.map(p=>{const bad=p.health!=='ONLINE',reason=(p.attentionReasons||[]).join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'';return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(p.name)+'</div><div class="disk-meta">'+safe(p.scan||'Данные scrub отсутствуют')+'</div></div><span class="badge '+(bad?'bad':'')+'"'+tip+'>'+safe(p.health)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Объём</span><b>'+size(p.sizeBytes)+'</b></div><div class="disk-stat"><span>Занято</span><b>'+size(p.allocatedBytes)+'</b></div><div class="disk-stat"><span>Свободно</span><b>'+size(p.freeBytes)+'</b></div><div class="disk-stat"><span>Заполнение</span><b>'+fixed(p.capacityPercent)+'%</b></div><div class="disk-stat"><span>Фрагментация</span><b>'+fixed(p.fragmentationPercent)+'%</b></div><div class="disk-stat"><span>Dedup</span><b>'+safe(p.dedupRatio)+'</b></div></div></article>'}).join(''):'<span class="empty">'+(z.available?'ZFS-пулы не найдены':'Команда zpool недоступна')+'</span>';
const val=(v,suffix='')=>v==null?'—':fixed(v)+suffix;el('upsName').textContent=u.available?(u.model||u.name||'Источник бесперебойного питания'):'UPS не настроен';el('upsCharge').textContent=u.chargePercent==null?'—':fixed(u.chargePercent);el('upsBar').style.width=u.chargePercent==null?'0%':Math.min(u.chargePercent,100)+'%';el('upsStatus').textContent=u.available?u.statusLabel:'Не найден';el('upsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsName').textContent=u.model||u.name||'Источник бесперебойного питания';el('dUpsStatus').textContent=u.available?u.statusLabel:'Не настроен';el('dUpsCharge').textContent=val(u.chargePercent,'%');el('dUpsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsLoad').textContent=val(u.loadPercent,'%');el('dUpsInput').textContent=val(u.inputVoltage,' V');el('dUpsOutput').textContent=val(u.outputVoltage,' V');el('dUpsFrequency').textContent=val(u.inputFrequency,' Hz');el('dUpsBattery').textContent=val(u.batteryVoltage,' V');el('dUpsTemp').textContent=val(u.temperatureCelsius,' °C');el('dUpsSource').textContent=u.available?(u.source+' · '+(u.name||'UPS')):'Установите и настройте NUT или apcupsd';
const guests=(g.guests||[]).filter(v=>!v.template),running=guests.filter(v=>v.status==='running'),guestCores=running.reduce((a,v)=>a+v.maxcpu,0),guestCPU=guestCores?running.reduce((a,v)=>a+v.cpu*v.maxcpu,0)/guestCores*100:0,guestMem=running.reduce((a,v)=>a+v.mem,0),guestMaxMem=running.reduce((a,v)=>a+v.maxmem,0);el('guestRunning').textContent=running.length;el('guestTotal').textContent=' / '+guests.length;el('guestBar').style.width=guests.length?(running.length/guests.length*100)+'%':'0%';el('guestCPU').textContent=g.available?fixed(guestCPU)+'%':'Не найдено';el('guestMemory').textContent=g.available?(size(guestMem)+' / '+size(guestMaxMem)):'—';
el('guestList').innerHTML=guests.length?guests.map(v=>{const live=v.status==='running',type=v.type==='qemu'?'VM':'LXC',memPercent=v.maxmem?v.mem/v.maxmem*100:0;return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+safe(v.node)+'</div></div><span class="badge '+(live?'':'warn')+'">'+(live?'Запущен':'Остановлен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Тип</span><b>'+type+'</b></div><div class="disk-stat"><span>CPU</span><b>'+(live?fixed(v.cpu*100)+'%':'—')+'</b></div><div class="disk-stat"><span>Ядра</span><b>'+v.maxcpu+'</b></div><div class="disk-stat"><span>Память</span><b>'+(live?size(v.mem)+' · '+fixed(memPercent)+'%':size(v.maxmem))+'</b></div><div class="disk-stat"><span>Лимит RAM</span><b>'+size(v.maxmem)+'</b></div><div class="disk-stat"><span>Uptime</span><b>'+(live?uptime(v.uptime):'—')+'</b></div></div></article>'}).join(''):'<span class="empty">'+(g.available?'VM и LXC не найдены':'Команда pvesh недоступна')+'</span>';
el('dot').classList.add('live');el('status').textContent='Данные поступают';
}
document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>el(b.dataset.open).showModal());
document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclick=()=>d.close();d.onclick=e=>{if(e.target===d)d.close()}});
const events=new EventSource('/api/events');events.onmessage=e=>render(JSON.parse(e.data));events.onerror=()=>{el('dot').classList.remove('live');el('status').textContent='Переподключение…'};
</script></body></html>`

102
zfs.go Normal file
View File

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

9
zfs_test.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestParsePercent(t *testing.T) {
if value := parsePercent("37%"); value != 37 {
t.Fatalf("неожиданный процент: %v", value)
}
}