Initial Proxmox dashboard with host and guest metrics
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
AGENTS.md
|
||||
sources/
|
||||
105
README.md
Normal file
105
README.md
Normal 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
286
cpu.go
Normal 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
313
disks.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DiskMetrics struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
Type string `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
SizeBytes uint64 `json:"sizeBytes"`
|
||||
UsedBytes *uint64 `json:"usedBytes"`
|
||||
UsagePercent *float64 `json:"usagePercent"`
|
||||
SMARTStatus string `json:"smartStatus"`
|
||||
SMARTAvailable bool `json:"smartAvailable"`
|
||||
AttentionReasons []string `json:"attentionReasons"`
|
||||
Temperature *float64 `json:"temperatureCelsius"`
|
||||
PowerOnHours *uint64 `json:"powerOnHours"`
|
||||
WearUsedPercent *float64 `json:"wearUsedPercent"`
|
||||
}
|
||||
|
||||
type diskCollector struct {
|
||||
mu sync.Mutex
|
||||
cached []DiskMetrics
|
||||
updatedAt time.Time
|
||||
}
|
||||
|
||||
func (c *diskCollector) collect() []DiskMetrics {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if time.Since(c.updatedAt) < time.Minute && c.cached != nil {
|
||||
return c.cached
|
||||
}
|
||||
c.cached = readPhysicalDisks()
|
||||
c.updatedAt = time.Now()
|
||||
return c.cached
|
||||
}
|
||||
|
||||
func readPhysicalDisks() []DiskMetrics {
|
||||
entries, _ := os.ReadDir("/sys/block")
|
||||
usage := readDiskUsage()
|
||||
var disks []DiskMetrics
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !isPhysicalDiskName(name) {
|
||||
continue
|
||||
}
|
||||
base := filepath.Join("/sys/block", name)
|
||||
sectors, _ := strconv.ParseUint(readTrimmed(filepath.Join(base, "size")), 10, 64)
|
||||
rotational := readTrimmed(filepath.Join(base, "queue/rotational"))
|
||||
disk := DiskMetrics{
|
||||
Name: name, Path: "/dev/" + name,
|
||||
Model: readTrimmed(filepath.Join(base, "device/model")),
|
||||
Serial: readTrimmed(filepath.Join(base, "device/serial")),
|
||||
SizeBytes: sectors * 512, SMARTStatus: "Недоступен",
|
||||
}
|
||||
if diskUsage, ok := usage[name]; ok && diskUsage.total > 0 {
|
||||
disk.UsedBytes = &diskUsage.used
|
||||
percent := float64(diskUsage.used) / float64(diskUsage.total) * 100
|
||||
disk.UsagePercent = &percent
|
||||
}
|
||||
if strings.HasPrefix(name, "nvme") {
|
||||
disk.Type, disk.Protocol = "NVMe SSD", "NVMe"
|
||||
} else if rotational == "1" {
|
||||
disk.Type = "HDD"
|
||||
} else {
|
||||
disk.Type = "SSD"
|
||||
}
|
||||
applySMART(&disk)
|
||||
if disk.Model == "" {
|
||||
disk.Model = disk.Name
|
||||
}
|
||||
if disk.Serial == "" {
|
||||
disk.Serial = "Не указан"
|
||||
}
|
||||
disks = append(disks, disk)
|
||||
}
|
||||
return disks
|
||||
}
|
||||
|
||||
type diskUsage struct {
|
||||
used uint64
|
||||
total uint64
|
||||
}
|
||||
|
||||
type lsblkDevice struct {
|
||||
Name string `json:"name"`
|
||||
FSUsed json.RawMessage `json:"fsused"`
|
||||
FSAvail json.RawMessage `json:"fsavail"`
|
||||
Children []lsblkDevice `json:"children"`
|
||||
}
|
||||
|
||||
func readDiskUsage() map[string]diskUsage {
|
||||
output, err := exec.Command("lsblk", "--json", "--bytes", "--output", "NAME,FSUSED,FSAVAIL").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var data struct {
|
||||
Devices []lsblkDevice `json:"blockdevices"`
|
||||
}
|
||||
if json.Unmarshal(output, &data) != nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]diskUsage)
|
||||
for _, device := range data.Devices {
|
||||
used, total, ok := sumFilesystemUsage(device)
|
||||
if ok {
|
||||
result[device.Name] = diskUsage{used: used, total: total}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sumFilesystemUsage(device lsblkDevice) (uint64, uint64, bool) {
|
||||
used, usedOK := parseJSONUint(device.FSUsed)
|
||||
available, availableOK := parseJSONUint(device.FSAvail)
|
||||
if usedOK || availableOK {
|
||||
return used, used + available, true
|
||||
}
|
||||
var totalUsed, totalSize uint64
|
||||
found := false
|
||||
for _, child := range device.Children {
|
||||
childUsed, childTotal, childOK := sumFilesystemUsage(child)
|
||||
if childOK {
|
||||
totalUsed += childUsed
|
||||
totalSize += childTotal
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return totalUsed, totalSize, found
|
||||
}
|
||||
|
||||
func parseJSONUint(raw json.RawMessage) (uint64, bool) {
|
||||
value := strings.Trim(strings.TrimSpace(string(raw)), `"`)
|
||||
if value == "" || value == "null" {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func isPhysicalDiskName(name string) bool {
|
||||
return strings.HasPrefix(name, "sd") ||
|
||||
(strings.HasPrefix(name, "nvme") && strings.Contains(name, "n"))
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
type smartctlJSON struct {
|
||||
Smartctl struct {
|
||||
ExitStatus int `json:"exit_status"`
|
||||
Messages []struct {
|
||||
String string `json:"string"`
|
||||
Severity string `json:"severity"`
|
||||
} `json:"messages"`
|
||||
} `json:"smartctl"`
|
||||
ModelName string `json:"model_name"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Device struct {
|
||||
Protocol string `json:"protocol"`
|
||||
} `json:"device"`
|
||||
SmartStatus *struct {
|
||||
Passed bool `json:"passed"`
|
||||
} `json:"smart_status"`
|
||||
Temperature struct {
|
||||
Current float64 `json:"current"`
|
||||
} `json:"temperature"`
|
||||
PowerOnTime struct {
|
||||
Hours uint64 `json:"hours"`
|
||||
} `json:"power_on_time"`
|
||||
ATAAttributes struct {
|
||||
Table []struct {
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
Threshold int `json:"thresh"`
|
||||
WhenFailed string `json:"when_failed"`
|
||||
Raw struct {
|
||||
String string `json:"string"`
|
||||
Value int64 `json:"value"`
|
||||
} `json:"raw"`
|
||||
} `json:"table"`
|
||||
} `json:"ata_smart_attributes"`
|
||||
ATAErrorLog struct {
|
||||
Summary struct {
|
||||
Count int `json:"count"`
|
||||
} `json:"summary"`
|
||||
} `json:"ata_smart_error_log"`
|
||||
NVMeHealth *struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
PercentageUsed float64 `json:"percentage_used"`
|
||||
PowerOnHours uint64 `json:"power_on_hours"`
|
||||
CriticalWarning int `json:"critical_warning"`
|
||||
MediaErrors uint64 `json:"media_errors"`
|
||||
} `json:"nvme_smart_health_information_log"`
|
||||
}
|
||||
|
||||
func applySMART(disk *DiskMetrics) {
|
||||
if _, err := exec.LookPath("smartctl"); err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
output, _ := exec.CommandContext(ctx, "smartctl", "-a", "-j", disk.Path).Output()
|
||||
if len(output) == 0 {
|
||||
return
|
||||
}
|
||||
var smart smartctlJSON
|
||||
if json.Unmarshal(output, &smart) != nil {
|
||||
return
|
||||
}
|
||||
if smart.SmartStatus != nil {
|
||||
disk.SMARTAvailable = true
|
||||
disk.SMARTStatus = "Исправен"
|
||||
if !smart.SmartStatus.Passed {
|
||||
disk.SMARTStatus = "Ошибка"
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, "Общая проверка SMART завершилась ошибкой")
|
||||
}
|
||||
}
|
||||
if smart.ModelName != "" {
|
||||
disk.Model = smart.ModelName
|
||||
}
|
||||
if smart.SerialNumber != "" {
|
||||
disk.Serial = smart.SerialNumber
|
||||
}
|
||||
if smart.Device.Protocol != "" {
|
||||
disk.Protocol = smart.Device.Protocol
|
||||
}
|
||||
if smart.Temperature.Current > 0 {
|
||||
disk.Temperature = &smart.Temperature.Current
|
||||
} else if smart.NVMeHealth != nil && smart.NVMeHealth.Temperature > 0 {
|
||||
disk.Temperature = &smart.NVMeHealth.Temperature
|
||||
}
|
||||
if smart.PowerOnTime.Hours > 0 {
|
||||
disk.PowerOnHours = &smart.PowerOnTime.Hours
|
||||
} else if smart.NVMeHealth != nil && smart.NVMeHealth.PowerOnHours > 0 {
|
||||
disk.PowerOnHours = &smart.NVMeHealth.PowerOnHours
|
||||
}
|
||||
if smart.NVMeHealth != nil {
|
||||
disk.WearUsedPercent = &smart.NVMeHealth.PercentageUsed
|
||||
if smart.NVMeHealth.CriticalWarning != 0 {
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, "NVMe сообщает критическое предупреждение")
|
||||
}
|
||||
if smart.NVMeHealth.MediaErrors > 0 {
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, "Ошибки целостности носителя: "+strconv.FormatUint(smart.NVMeHealth.MediaErrors, 10))
|
||||
}
|
||||
}
|
||||
applyATAAttributes(disk, smart)
|
||||
applySMARTExitStatus(disk, smart.Smartctl.ExitStatus)
|
||||
if len(disk.AttentionReasons) > 0 && disk.SMARTStatus == "Исправен" {
|
||||
disk.SMARTStatus = "Требует внимания"
|
||||
}
|
||||
}
|
||||
|
||||
func applyATAAttributes(disk *DiskMetrics, smart smartctlJSON) {
|
||||
wearNames := map[string]bool{
|
||||
"Percent_Lifetime_Remain": true, "SSD_Life_Left": true,
|
||||
"Media_Wearout_Indicator": true, "Remaining_Lifetime_Perc": true,
|
||||
"Wear_Leveling_Count": true,
|
||||
}
|
||||
for _, attribute := range smart.ATAAttributes.Table {
|
||||
if wearNames[attribute.Name] && attribute.Value >= 0 && attribute.Value <= 100 {
|
||||
wear := float64(100 - attribute.Value)
|
||||
disk.WearUsedPercent = &wear
|
||||
}
|
||||
if attribute.WhenFailed != "" && attribute.WhenFailed != "-" {
|
||||
reason := attribute.Name + " вышел за порог"
|
||||
if attribute.Raw.String != "" {
|
||||
reason += " (" + attribute.Raw.String + ")"
|
||||
}
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, reason)
|
||||
}
|
||||
}
|
||||
if smart.ATAErrorLog.Summary.Count > 0 {
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, "Ошибок в SMART-журнале: "+strconv.Itoa(smart.ATAErrorLog.Summary.Count))
|
||||
}
|
||||
}
|
||||
|
||||
func applySMARTExitStatus(disk *DiskMetrics, status int) {
|
||||
if status&8 != 0 {
|
||||
disk.SMARTStatus = "Ошибка"
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, "SMART сообщает возможный скорый отказ диска")
|
||||
}
|
||||
if status&16 != 0 {
|
||||
disk.SMARTStatus = "Ошибка"
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, "Критический SMART-атрибут достиг порога")
|
||||
}
|
||||
if status&32 != 0 {
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, "SMART-атрибут ранее находился ниже порога")
|
||||
}
|
||||
if status&64 != 0 {
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, "В журнале SMART обнаружены ошибки")
|
||||
}
|
||||
if status&128 != 0 {
|
||||
disk.AttentionReasons = append(disk.AttentionReasons, "В журнале самотестирования есть ошибки")
|
||||
}
|
||||
}
|
||||
52
disks_test.go
Normal file
52
disks_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
66
guests.go
Normal file
66
guests.go
Normal 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
17
guests_test.go
Normal 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
50
main.go
Normal 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
59
main_test.go
Normal 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
73
memory.go
Normal 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
33
memory_test.go
Normal 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
90
network.go
Normal 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
31
network_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
17
proxmox-cpu-dashboard.service
Normal file
17
proxmox-cpu-dashboard.service
Normal 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
29
storage.go
Normal 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
158
ups.go
Normal 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
14
ups_test.go
Normal 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
268
web.go
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
102
zfs.go
Normal 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
9
zfs_test.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePercent(t *testing.T) {
|
||||
if value := parsePercent("37%"); value != 37 {
|
||||
t.Fatalf("неожиданный процент: %v", value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user