Add infrastructure reliability monitoring

This commit is contained in:
Maxim
2026-08-01 16:58:25 +03:00
parent f6ac7eeb1a
commit a570537e64
12 changed files with 926 additions and 51 deletions

View File

@@ -32,7 +32,7 @@ ssh root@192.168.0.10
```bash
apt-get update
apt-get install -y curl ca-certificates tar smartmontools
apt-get install -y curl ca-certificates tar smartmontools iproute2 iputils-ping
cd /tmp
curl -fL \
@@ -120,6 +120,15 @@ ss -lntp | grep 9105
Панель показывает:
- изменение критичных SMART-счётчиков, результаты self-test и исчезновение дисков;
- автоматический короткий SMART self-test раз в неделю для дисков без свежего теста;
- datasets, vdev-классы, ошибки read/write/checksum, scrub и ARC ZFS;
- доступность шлюза, DNS и интернета, packet loss, latency и внешний IP;
- тип, состояние, скорость, duplex и master сетевых интерфейсов;
- состояние связи с NUT/apcupsd, возраст батареи и расчётную автономность UPS;
- настраиваемый сценарий корректного выключения VM/LXC и Proxmox с безопасным dry-run;
- самоконтроль сборщика, записи истории, свободного места базы и systemd watchdog;
- модель и текущую загрузку CPU;
- количество сокетов, физических ядер и логических потоков;
- среднюю текущую частоту;

View File

@@ -75,6 +75,15 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
add("disk-temp-"+disk.Name, "warning", "Диски", disk.Model+": высокая температура", fmt.Sprintf("Температура %.1f °C.", *disk.Temperature))
}
}
if len(disk.CounterChanges) > 0 {
add("disk-growth-"+disk.Name, "critical", "Диски", disk.Model+": ухудшились SMART-счётчики", strings.Join(disk.CounterChanges, " · "))
}
if disk.SelfTestNeverRun {
add("disk-selftest-"+disk.Name, "warning", "Диски", disk.Model+": нет завершённого SMART self-test", "Короткий тест будет автоматически запущен; проверьте его результат после завершения.")
}
}
for _, missing := range metrics.MissingDisks {
add("disk-missing-"+missing, "critical", "Диски", "Физический диск исчез", missing+" больше не обнаруживается в /sys/block.")
}
for _, pool := range metrics.ZFS.Pools {
@@ -89,6 +98,25 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
if strings.Contains(strings.ToLower(pool.Scan), "in progress") {
add("zfs-scan-"+pool.Name, "info", "ZFS", pool.Name+": выполняется scrub/resilver", pool.Scan)
}
if pool.ReadErrors+pool.WriteErrors+pool.ChecksumErrors > 0 {
add("zfs-vdev-errors-"+pool.Name, "critical", "ZFS", pool.Name+": ошибки чтения/записи/checksum", fmt.Sprintf("read=%d, write=%d, checksum=%d.", pool.ReadErrors, pool.WriteErrors, pool.ChecksumErrors))
}
if len(pool.ErrorChanges) > 0 {
add("zfs-new-errors-"+pool.Name, "critical", "ZFS", pool.Name+": появились новые ошибки", strings.Join(pool.ErrorChanges, " · "))
}
if pool.ScrubTooOld {
add("zfs-scrub-old-"+pool.Name, "warning", "ZFS", pool.Name+": scrub просрочен", "Scrub не выполнялся более 35 дней либо ни разу не запускался.")
}
}
for _, dataset := range metrics.ZFS.Datasets {
if dataset.CapacityPercent >= 90 {
add("zfs-dataset-"+dataset.Name, "critical", "ZFS", dataset.Name+": dataset почти заполнен", fmt.Sprintf("Заполнение %.1f%%, доступно %.1f GB.", dataset.CapacityPercent, float64(dataset.AvailableBytes)/1073741824))
} else if dataset.CapacityPercent >= 80 {
add("zfs-dataset-"+dataset.Name, "warning", "ZFS", dataset.Name+": мало свободного места", fmt.Sprintf("Заполнение %.1f%%.", dataset.CapacityPercent))
}
}
if metrics.ZFS.ARC.Available && metrics.ZFS.ARC.Hits+metrics.ZFS.ARC.Misses > 10000 && metrics.ZFS.ARC.HitRatio < 70 {
add("zfs-arc-hit", "warning", "ZFS", "Низкий ARC hit ratio", fmt.Sprintf("Попадания ARC: %.1f%%.", metrics.ZFS.ARC.HitRatio))
}
if metrics.UPS.Available {
@@ -101,6 +129,14 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
if metrics.UPS.ChargePercent != nil && *metrics.UPS.ChargePercent < thresholds.UPSChargeCritical {
add("ups-charge", "critical", "UPS", "Низкий заряд батареи", fmt.Sprintf("Осталось %.1f%%.", *metrics.UPS.ChargePercent))
}
if metrics.UPS.ReplaceBattery {
add("ups-replace-battery", "critical", "UPS", "UPS требует заменить батарею", "NUT/APC сообщает флаг Replace Battery.")
}
if metrics.UPS.BatteryAgeDays > 3*365 {
add("ups-battery-age", "warning", "UPS", "Батарея UPS старше трёх лет", fmt.Sprintf("Возраст батареи примерно %d дней.", metrics.UPS.BatteryAgeDays))
}
} else if metrics.UPS.Source != "" {
add("ups-communication", "warning", "UPS", "Нет связи с UPS", "NUT и apcupsd не вернули данные. Проверьте USB/сетевое подключение и службу UPS.")
}
if metrics.Backups.Available && metrics.Guests.Available {
@@ -164,6 +200,44 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
if networkErrors > 0 {
add("network-errors", "warning", "Сеть", "Сетевые ошибки и потери пакетов", fmt.Sprintf("Суммарный счётчик интерфейсов: %d.", networkErrors))
}
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.GatewayUp {
add("network-gateway", "critical", "Сеть", "Недоступен локальный шлюз", firstNonEmpty(metrics.Network.Health.Gateway, "Default gateway не определён."))
}
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.DNSUp {
add("network-dns", "critical", "Сеть", "Не работает DNS", "Не удалось разрешить deb.debian.org.")
}
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.InternetUp {
add("network-internet", "warning", "Сеть", "Нет доступа в интернет", "Внешняя проверка IP не выполнена.")
}
if metrics.Network.Health.PacketLoss > 0 {
add("network-loss", "warning", "Сеть", "Потери пакетов до шлюза", fmt.Sprintf("Потери %.1f%%, задержка %.1f ms.", metrics.Network.Health.PacketLoss, metrics.Network.Health.LatencyMs))
}
if metrics.Network.Health.ExternalIPChanged {
add("network-public-ip", "info", "Сеть", "Изменился внешний IP", "Новый адрес: "+metrics.Network.Health.ExternalIP)
}
for _, name := range metrics.Network.Health.DisappearedInterfaces {
add("network-interface-missing-"+name, "warning", "Сеть", "Исчез сетевой интерфейс", name+" больше не присутствует в /proc/net/dev.")
}
for _, iface := range metrics.Network.Interfaces {
if iface.Kind == "Physical" && !iface.Carrier && (iface.Master != "" || iface.ReceiveBytes+iface.TransmitBytes > 0) {
add("network-link-"+iface.Name, "warning", "Сеть", iface.Name+": нет carrier", "Физический линк не поднят.")
}
if (iface.Kind == "Bridge" || iface.Kind == "Bond" || iface.Kind == "VLAN") && iface.State == "down" {
add("network-state-"+iface.Name, "warning", "Сеть", iface.Name+": интерфейс выключен", iface.Kind+" находится в состоянии down.")
}
}
if !metrics.Monitor.LastSuccessfulCollection.IsZero() && time.Since(metrics.Monitor.LastSuccessfulCollection) > 30*time.Second {
add("monitor-stale", "critical", "Dashboard", "Данные мониторинга устарели", "Последний успешный сбор был "+time.Since(metrics.Monitor.LastSuccessfulCollection).Round(time.Second).String()+" назад.")
}
if metrics.Monitor.SlowCollector {
add("monitor-slow", "warning", "Dashboard", "Сбор метрик выполняется слишком долго", fmt.Sprintf("Последний цикл занял %d ms.", metrics.Monitor.CollectionDurationMs))
}
if metrics.Monitor.HistoryWriteError != "" {
add("monitor-history", "critical", "Dashboard", "Ошибка записи истории", metrics.Monitor.HistoryWriteError)
}
if metrics.Monitor.DatabaseFreeBytes > 0 && metrics.Monitor.DatabaseFreeBytes < 1024*1024*1024 {
add("monitor-db-space", "warning", "Dashboard", "Мало места для базы истории", fmt.Sprintf("На разделе базы свободно %.1f GB.", float64(metrics.Monitor.DatabaseFreeBytes)/1073741824))
}
oldSnapshots := 0
for _, snapshot := range metrics.Activity.Snapshots {
if snapshot.AgeSeconds > 30*24*3600 {

109
disks.go
View File

@@ -28,12 +28,25 @@ type DiskMetrics struct {
Temperature *float64 `json:"temperatureCelsius"`
PowerOnHours *uint64 `json:"powerOnHours"`
WearUsedPercent *float64 `json:"wearUsedPercent"`
Reallocated uint64 `json:"reallocated"`
Pending uint64 `json:"pending"`
Uncorrectable uint64 `json:"uncorrectable"`
CRCErrors uint64 `json:"crcErrors"`
MediaErrors uint64 `json:"mediaErrors"`
CounterChanges []string `json:"counterChanges"`
LastSelfTest string `json:"lastSelfTest"`
LastSelfTestAt string `json:"lastSelfTestAt"`
SelfTestNeverRun bool `json:"selfTestNeverRun"`
ShortTestStarted bool `json:"shortTestStarted"`
}
type diskCollector struct {
mu sync.Mutex
cached []DiskMetrics
updatedAt time.Time
previous map[string]DiskMetrics
missing []string
lastTests map[string]time.Time
}
func (c *diskCollector) collect() []DiskMetrics {
@@ -42,11 +55,73 @@ func (c *diskCollector) collect() []DiskMetrics {
if time.Since(c.updatedAt) < time.Minute && c.cached != nil {
return c.cached
}
c.cached = readPhysicalDisks()
current := readPhysicalDisks()
if c.previous == nil {
c.previous = map[string]DiskMetrics{}
c.lastTests = map[string]time.Time{}
}
seen := map[string]bool{}
c.missing = nil
for i := range current {
disk := &current[i]
key := disk.Serial
if key == "" || key == "Не указан" {
key = disk.Path
}
seen[key] = true
if old, ok := c.previous[key]; ok {
disk.CounterChanges = diskCounterChanges(old, *disk)
disk.AttentionReasons = append(disk.AttentionReasons, disk.CounterChanges...)
}
if disk.SMARTAvailable && (disk.SelfTestNeverRun || selfTestOlderThanWeek(disk.LastSelfTestAt)) && time.Since(c.lastTests[key]) >= 7*24*time.Hour {
disk.ShortTestStarted = startShortSMARTTest(disk.Path)
if disk.ShortTestStarted {
c.lastTests[key] = time.Now()
}
}
c.previous[key] = *disk
}
for key, disk := range c.previous {
if !seen[key] {
c.missing = append(c.missing, disk.Model+" ("+disk.Serial+")")
}
}
c.cached = current
c.updatedAt = time.Now()
return c.cached
}
func (c *diskCollector) missingDisks() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.missing...)
}
func diskCounterChanges(old, current DiskMetrics) []string {
checks := []struct {
label string
old, now uint64
}{{"Reallocated вырос", old.Reallocated, current.Reallocated}, {"Pending вырос", old.Pending, current.Pending}, {"Uncorrectable вырос", old.Uncorrectable, current.Uncorrectable}, {"CRC-ошибки выросли", old.CRCErrors, current.CRCErrors}, {"Media errors выросли", old.MediaErrors, current.MediaErrors}}
var changes []string
for _, check := range checks {
if check.now > check.old {
changes = append(changes, check.label+": "+strconv.FormatUint(check.old, 10)+" → "+strconv.FormatUint(check.now, 10))
}
}
return changes
}
func selfTestOlderThanWeek(value string) bool {
when, err := time.Parse(time.RFC3339, value)
return err == nil && time.Since(when) > 7*24*time.Hour
}
func startShortSMARTTest(path string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return exec.CommandContext(ctx, "smartctl", "-t", "short", path).Run() == nil
}
func readPhysicalDisks() []DiskMetrics {
entries, _ := os.ReadDir("/sys/block")
usage := readDiskUsage()
@@ -202,6 +277,16 @@ type smartctlJSON struct {
Count int `json:"count"`
} `json:"summary"`
} `json:"ata_smart_error_log"`
ATASelfTestLog struct {
Standard struct {
Table []struct {
Status struct {
String string `json:"string"`
} `json:"status"`
LifetimeHours uint64 `json:"lifetime_hours"`
} `json:"table"`
} `json:"standard"`
} `json:"ata_smart_self_test_log"`
NVMeHealth *struct {
Temperature float64 `json:"temperature"`
PercentageUsed float64 `json:"percentage_used"`
@@ -254,6 +339,7 @@ func applySMART(disk *DiskMetrics) {
}
if smart.NVMeHealth != nil {
disk.WearUsedPercent = &smart.NVMeHealth.PercentageUsed
disk.MediaErrors = smart.NVMeHealth.MediaErrors
if smart.NVMeHealth.CriticalWarning != 0 {
disk.AttentionReasons = append(disk.AttentionReasons, "NVMe сообщает критическое предупреждение")
}
@@ -261,6 +347,17 @@ func applySMART(disk *DiskMetrics) {
disk.AttentionReasons = append(disk.AttentionReasons, "Ошибки целостности носителя: "+strconv.FormatUint(smart.NVMeHealth.MediaErrors, 10))
}
}
if len(smart.ATASelfTestLog.Standard.Table) > 0 {
last := smart.ATASelfTestLog.Standard.Table[0]
disk.LastSelfTest = firstNonEmpty(last.Status.String, "Результат неизвестен")
if disk.PowerOnHours != nil && *disk.PowerOnHours >= last.LifetimeHours {
disk.LastSelfTestAt = time.Now().Add(-time.Duration(*disk.PowerOnHours-last.LifetimeHours) * time.Hour).Format(time.RFC3339)
}
} else {
disk.SelfTestNeverRun = true
disk.LastSelfTest = "Никогда не запускался"
disk.AttentionReasons = append(disk.AttentionReasons, "SMART self-test ещё ни разу не запускался")
}
applyATAAttributes(disk, smart)
applySMARTExitStatus(disk, smart.Smartctl.ExitStatus)
if len(disk.AttentionReasons) > 0 && disk.SMARTStatus == "Исправен" {
@@ -275,6 +372,16 @@ func applyATAAttributes(disk *DiskMetrics, smart smartctlJSON) {
"Wear_Leveling_Count": true,
}
for _, attribute := range smart.ATAAttributes.Table {
switch attribute.Name {
case "Reallocated_Sector_Ct", "Reallocated_Event_Count":
disk.Reallocated += uint64(max(attribute.Raw.Value, 0))
case "Current_Pending_Sector":
disk.Pending = uint64(max(attribute.Raw.Value, 0))
case "Offline_Uncorrectable", "Reported_Uncorrect":
disk.Uncorrectable += uint64(max(attribute.Raw.Value, 0))
case "UDMA_CRC_Error_Count", "Interface_CRC_Error_Count":
disk.CRCErrors += uint64(max(attribute.Raw.Value, 0))
}
if wearNames[attribute.Name] && attribute.Value >= 0 && attribute.Value <= 100 {
wear := float64(100 - attribute.Value)
disk.WearUsedPercent = &wear

View File

@@ -50,6 +50,7 @@ func main() {
Handler: routes(collector, store),
ReadHeaderTimeout: 5 * time.Second,
}
runSystemdWatchdog()
go func() {
log.Printf("Proxmox Dashboard %s запущен на http://0.0.0.0%s", version, address)

View File

@@ -2,7 +2,13 @@ package main
import (
"bufio"
"context"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -16,6 +22,20 @@ type NetworkMetrics struct {
ReceivePerSec float64 `json:"receivePerSec"`
TransmitPerSec float64 `json:"transmitPerSec"`
Interfaces []NetworkInterface `json:"interfaces"`
Health NetworkHealth `json:"health"`
}
type NetworkHealth struct {
Gateway string `json:"gateway"`
GatewayUp bool `json:"gatewayUp"`
DNSUp bool `json:"dnsUp"`
InternetUp bool `json:"internetUp"`
PacketLoss float64 `json:"packetLoss"`
LatencyMs float64 `json:"latencyMs"`
ExternalIP string `json:"externalIp"`
ExternalIPChanged bool `json:"externalIpChanged"`
DisappearedInterfaces []string `json:"disappearedInterfaces"`
CheckedAt time.Time `json:"checkedAt"`
}
type NetworkInterface struct {
@@ -31,6 +51,12 @@ type NetworkInterface struct {
VMID int `json:"vmid,omitempty"`
GuestName string `json:"guestName,omitempty"`
GuestType string `json:"guestType,omitempty"`
Kind string `json:"kind"`
State string `json:"state"`
Carrier bool `json:"carrier"`
SpeedMbps int `json:"speedMbps,omitempty"`
Duplex string `json:"duplex,omitempty"`
Master string `json:"master,omitempty"`
}
type networkSnapshot struct {
@@ -44,6 +70,10 @@ type networkCollector struct {
path string
previous networkSnapshot
previousInterfaces map[string]NetworkInterface
health NetworkHealth
healthUpdatedAt time.Time
externalIP string
knownInterfaces map[string]bool
}
func newNetworkCollector(path string) (*networkCollector, error) {
@@ -56,7 +86,11 @@ func newNetworkCollector(path string) (*networkCollector, error) {
for _, v := range details {
previous[v.Name] = v
}
return &networkCollector{path: path, previous: snapshot, previousInterfaces: previous}, nil
known := map[string]bool{}
for _, v := range details {
known[v.Name] = true
}
return &networkCollector{path: path, previous: snapshot, previousInterfaces: previous, knownInterfaces: known}, nil
}
func (c *networkCollector) collect() (NetworkMetrics, error) {
@@ -75,6 +109,7 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
}
for i := range details {
v := &details[i]
decorateInterface(v)
if old, ok := c.previousInterfaces[v.Name]; ok && elapsed > 0 && v.ReceiveBytes >= old.ReceiveBytes && v.TransmitBytes >= old.TransmitBytes {
v.ReceivePerSec = float64(v.ReceiveBytes-old.ReceiveBytes) / elapsed
v.TransmitPerSec = float64(v.TransmitBytes-old.TransmitBytes) / elapsed
@@ -85,12 +120,100 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
c.previousInterfaces[v.Name] = v
}
c.previous = current
if time.Since(c.healthUpdatedAt) > 30*time.Second {
c.health = checkNetworkHealth(c.externalIP)
if c.health.ExternalIP != "" {
c.externalIP = c.health.ExternalIP
}
c.healthUpdatedAt = time.Now()
}
currentNames := map[string]bool{}
for _, v := range details {
currentNames[v.Name] = true
}
c.health.DisappearedInterfaces = nil
for name := range c.knownInterfaces {
if !currentNames[name] {
c.health.DisappearedInterfaces = append(c.health.DisappearedInterfaces, name)
}
}
c.knownInterfaces = currentNames
return NetworkMetrics{
Interface: strings.Join(interfaceNames, ", "), Interfaces: details, ReceiveBytes: current.received,
TransmitBytes: current.sent, ReceivePerSec: receivedRate, TransmitPerSec: sentRate,
TransmitBytes: current.sent, ReceivePerSec: receivedRate, TransmitPerSec: sentRate, Health: c.health,
}, nil
}
func decorateInterface(v *NetworkInterface) {
base := filepath.Join("/sys/class/net", v.Name)
v.State = readTrimmed(filepath.Join(base, "operstate"))
v.Carrier = readTrimmed(filepath.Join(base, "carrier")) == "1"
v.SpeedMbps, _ = strconv.Atoi(readTrimmed(filepath.Join(base, "speed")))
v.Duplex = readTrimmed(filepath.Join(base, "duplex"))
if target, err := filepath.EvalSymlinks(filepath.Join(base, "master")); err == nil {
v.Master = filepath.Base(target)
}
switch {
case strings.HasPrefix(v.Name, "vmbr"):
v.Kind = "Bridge"
case strings.HasPrefix(v.Name, "bond"):
v.Kind = "Bond"
case strings.Contains(v.Name, "."):
v.Kind = "VLAN"
case strings.HasPrefix(v.Name, "tap") || strings.HasPrefix(v.Name, "veth"):
v.Kind = "Guest"
default:
v.Kind = "Physical"
}
}
func checkNetworkHealth(previousIP string) NetworkHealth {
result := NetworkHealth{CheckedAt: time.Now()}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
if out, err := exec.CommandContext(ctx, "ip", "route", "show", "default").Output(); err == nil {
f := strings.Fields(string(out))
for i := range f {
if f[i] == "via" && i+1 < len(f) {
result.Gateway = f[i+1]
break
}
}
}
if result.Gateway != "" {
out, _ := exec.CommandContext(ctx, "ping", "-c", "3", "-W", "1", result.Gateway).CombinedOutput()
text := string(out)
result.GatewayUp = strings.Contains(text, "0% packet loss")
if i := strings.Index(text, "packet loss"); i > 0 {
start := strings.LastIndex(text[:i], " ")
if start >= 0 {
result.PacketLoss, _ = strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(text[start:i]), "%"), 64)
}
}
if i := strings.Index(text, "min/avg/max"); i >= 0 {
if eq := strings.Index(text[i:], "="); eq >= 0 {
parts := strings.Split(strings.Fields(text[i+eq+1:])[0], "/")
if len(parts) > 1 {
result.LatencyMs, _ = strconv.ParseFloat(parts[1], 64)
}
}
}
}
resolver := net.Resolver{}
_, err := resolver.LookupHost(ctx, "deb.debian.org")
result.DNSUp = err == nil
client := http.Client{Timeout: 4 * time.Second}
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.ipify.org", nil)
if response, err := client.Do(request); err == nil {
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, 128))
result.ExternalIP = strings.TrimSpace(string(body))
result.InternetUp = response.StatusCode == 200 && net.ParseIP(result.ExternalIP) != nil
}
result.ExternalIPChanged = previousIP != "" && result.ExternalIP != "" && previousIP != result.ExternalIP
return result
}
func readNetworkSnapshot(path string) (networkSnapshot, []string, []NetworkInterface, error) {
file, err := os.Open(path)
if err != nil {

View File

@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Type=notify
ExecStart=/usr/local/bin/proxmox-cpu-dashboard
Environment=DASHBOARD_ADDR=:9105
Environment=DASHBOARD_DB_PATH=/var/lib/proxmox-dashboard/dashboard.db
@@ -12,6 +12,8 @@ StateDirectory=proxmox-dashboard
StateDirectoryMode=0750
Restart=on-failure
RestartSec=3
WatchdogSec=30
TimeoutStartSec=20
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true

226
reliability.go Normal file
View File

@@ -0,0 +1,226 @@
package main
import (
"context"
"fmt"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
)
func notifySystemd(message string) bool {
address := os.Getenv("NOTIFY_SOCKET")
if address == "" {
return false
}
if strings.HasPrefix(address, "@") {
address = "\x00" + address[1:]
}
connection, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: address, Net: "unixgram"})
if err != nil {
return false
}
defer connection.Close()
_, err = connection.Write([]byte(message))
return err == nil
}
func runSystemdWatchdog() {
if !notifySystemd("READY=1\nSTATUS=Dashboard collects Proxmox metrics") {
return
}
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
notifySystemd("WATCHDOG=1")
}
}()
}
type MonitorHealth struct {
LastSuccessfulCollection time.Time `json:"lastSuccessfulCollection"`
LastCollectionError string `json:"lastCollectionError"`
CollectionDurationMs int64 `json:"collectionDurationMs"`
SlowCollector bool `json:"slowCollector"`
LastHistoryWrite time.Time `json:"lastHistoryWrite"`
HistoryWriteError string `json:"historyWriteError"`
DatabaseFreeBytes uint64 `json:"databaseFreeBytes"`
DatabasePath string `json:"databasePath"`
WatchdogEnabled bool `json:"watchdogEnabled"`
}
type monitorTracker struct {
mu sync.RWMutex
lastSuccess time.Time
lastError string
duration time.Duration
}
func (m *monitorTracker) record(start time.Time, err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.duration = time.Since(start)
if err != nil {
m.lastError = err.Error()
return
}
m.lastSuccess = time.Now()
m.lastError = ""
}
func (m *monitorTracker) snapshot(store *Store) MonitorHealth {
m.mu.RLock()
result := MonitorHealth{LastSuccessfulCollection: m.lastSuccess, LastCollectionError: m.lastError, CollectionDurationMs: m.duration.Milliseconds(), SlowCollector: m.duration > 10*time.Second, DatabasePath: store.path, WatchdogEnabled: os.Getenv("NOTIFY_SOCKET") != ""}
m.mu.RUnlock()
result.LastHistoryWrite, result.HistoryWriteError = store.historyHealth()
path := store.path
if path == ":memory:" {
path = "/tmp"
} else {
path = filepath.Dir(path)
}
var stat syscall.Statfs_t
if syscall.Statfs(path, &stat) == nil {
result.DatabaseFreeBytes = stat.Bavail * uint64(stat.Bsize)
}
return result
}
type UPSShutdownSettings struct {
Enabled bool `json:"enabled"`
ChargePercent float64 `json:"chargePercent"`
RuntimeSeconds float64 `json:"runtimeSeconds"`
GraceSeconds int `json:"graceSeconds"`
GuestOrder []int `json:"guestOrder"`
}
func defaultUPSShutdownSettings() UPSShutdownSettings {
return UPSShutdownSettings{ChargePercent: 15, RuntimeSeconds: 600, GraceSeconds: 120}
}
func (s *Store) UPSShutdownSettings() UPSShutdownSettings {
value := defaultUPSShutdownSettings()
s.loadJSONSetting("ups_shutdown", &value)
return value
}
func (s *Store) SaveUPSShutdownSettings(value UPSShutdownSettings) error {
if value.ChargePercent < 1 || value.ChargePercent > 80 || value.RuntimeSeconds < 60 || value.RuntimeSeconds > 7200 || value.GraceSeconds < 30 || value.GraceSeconds > 1800 {
return fmt.Errorf("проверьте пороги UPS и задержку")
}
return s.saveJSONSetting("ups_shutdown", value)
}
type ShutdownStep struct {
Order int `json:"order"`
Target string `json:"target"`
Action string `json:"action"`
Detail string `json:"detail"`
}
func buildShutdownDryRun(settings UPSShutdownSettings, guests GuestsMetrics) []ShutdownStep {
byID := map[int]GuestMetrics{}
for _, g := range guests.Guests {
if g.Template == 0 && g.Status == "running" {
byID[g.VMID] = g
}
}
var ordered []GuestMetrics
for _, id := range settings.GuestOrder {
if g, ok := byID[id]; ok {
ordered = append(ordered, g)
delete(byID, id)
}
}
var rest []GuestMetrics
for _, g := range byID {
rest = append(rest, g)
}
sort.Slice(rest, func(i, j int) bool { return rest[i].VMID < rest[j].VMID })
ordered = append(ordered, rest...)
steps := []ShutdownStep{{Order: 1, Target: "UPS", Action: "Ожидание порога", Detail: fmt.Sprintf("заряд ≤ %.0f%% или автономность ≤ %.0f сек.", settings.ChargePercent, settings.RuntimeSeconds)}}
for _, g := range ordered {
kind := "LXC"
if g.Type == "qemu" {
kind = "VM"
}
steps = append(steps, ShutdownStep{Order: len(steps) + 1, Target: fmt.Sprintf("%s %d · %s", kind, g.VMID, g.Name), Action: "Корректное завершение", Detail: fmt.Sprintf("ожидание до %d сек.", settings.GraceSeconds)})
}
steps = append(steps, ShutdownStep{Order: len(steps) + 1, Target: "Proxmox host", Action: "Завершение работы", Detail: "только после остановки VM/LXC"})
return steps
}
func runUPSShutdownController(store *Store, ups *upsCollector, guests *guestCollector) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
triggered := false
for range ticker.C {
settings := store.UPSShutdownSettings()
metrics := ups.collect()
onBattery := strings.Contains(metrics.Status, "OB") || metrics.Status == "ONBATT"
if !settings.Enabled || !metrics.Available || !onBattery {
if metrics.Available && !onBattery {
triggered = false
}
continue
}
lowCharge := metrics.ChargePercent != nil && *metrics.ChargePercent <= settings.ChargePercent
lowRuntime := metrics.RuntimeSeconds != nil && *metrics.RuntimeSeconds <= settings.RuntimeSeconds
if triggered || (!lowCharge && !lowRuntime) {
continue
}
triggered = true
log.Printf("UPS достиг порога безопасного выключения; запускаем остановку гостей")
go executeUPSShutdown(settings, guests.collect())
}
}
func executeUPSShutdown(settings UPSShutdownSettings, guests GuestsMetrics) {
steps := buildShutdownDryRun(settings, guests)
_ = steps
byID := map[int]GuestMetrics{}
for _, g := range guests.Guests {
if g.Template == 0 && g.Status == "running" {
byID[g.VMID] = g
}
}
var order []int
for _, id := range settings.GuestOrder {
if _, ok := byID[id]; ok {
order = append(order, id)
delete(byID, id)
}
}
var remaining []int
for id := range byID {
remaining = append(remaining, id)
}
sort.Ints(remaining)
order = append(order, remaining...)
for _, id := range order {
g := GuestMetrics{}
for _, candidate := range guests.Guests {
if candidate.VMID == id {
g = candidate
break
}
}
command := "pct"
if g.Type == "qemu" {
command = "qm"
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(settings.GraceSeconds+30)*time.Second)
err := exec.CommandContext(ctx, command, "shutdown", fmt.Sprint(id), "--timeout", fmt.Sprint(settings.GraceSeconds)).Run()
cancel()
if err != nil {
log.Printf("UPS shutdown: не удалось остановить VMID %d: %v", id, err)
}
}
log.Printf("UPS shutdown: гости обработаны, выключаем Proxmox host")
_ = exec.Command("shutdown", "-h", "now").Run()
}

38
reliability_test.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"strings"
"testing"
"time"
)
func TestDiskCounterChanges(t *testing.T) {
old := DiskMetrics{Reallocated: 1, Pending: 2, CRCErrors: 4}
current := DiskMetrics{Reallocated: 2, Pending: 2, CRCErrors: 7}
changes := strings.Join(diskCounterChanges(old, current), " ")
if !strings.Contains(changes, "Reallocated") || !strings.Contains(changes, "CRC") || strings.Contains(changes, "Pending") {
t.Fatalf("unexpected counter changes: %s", changes)
}
}
func TestParseScrub(t *testing.T) {
status, at, duration, old := parseScrub("scrub repaired 0B in 01:12:03 with 0 errors on Fri Jan 2 03:04:05 2026")
if status != "Завершён" || at == "" || duration != "01:12:03" || !old {
t.Fatalf("unexpected scrub: %q %q %q %v", status, at, duration, old)
}
}
func TestShutdownDryRunUsesConfiguredOrder(t *testing.T) {
settings := UPSShutdownSettings{ChargePercent: 15, RuntimeSeconds: 600, GraceSeconds: 120, GuestOrder: []int{105}}
guests := GuestsMetrics{Guests: []GuestMetrics{{VMID: 101, Name: "one", Type: "qemu", Status: "running"}, {VMID: 105, Name: "five", Type: "lxc", Status: "running"}}}
steps := buildShutdownDryRun(settings, guests)
if len(steps) != 4 || !strings.Contains(steps[1].Target, "105") || !strings.Contains(steps[2].Target, "101") || steps[3].Target != "Proxmox host" {
t.Fatalf("unexpected plan: %+v", steps)
}
}
func TestSelfTestOlderThanWeek(t *testing.T) {
if !selfTestOlderThanWeek(time.Now().Add(-8*24*time.Hour).Format(time.RFC3339)) || selfTestOlderThanWeek(time.Now().Format(time.RFC3339)) {
t.Fatal("self-test age evaluation is incorrect")
}
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
@@ -58,7 +59,13 @@ type HistoryPoint struct {
GuestsMemory float64 `json:"guestsMemory"`
}
type Store struct{ db *sql.DB }
type Store struct {
db *sql.DB
path string
healthMu sync.RWMutex
lastHistoryAt time.Time
lastHistoryError string
}
type EmailSettings struct {
Enabled bool `json:"enabled"`
@@ -130,7 +137,7 @@ func openStore(path string) (*Store, error) {
_, _ = db.Exec(`ALTER TABLE alert_events ADD COLUMN recovery_sent_at INTEGER`)
_, _ = db.Exec(`ALTER TABLE service_checks ADD COLUMN endpoint TEXT NOT NULL DEFAULT 'public'`)
_, _ = db.Exec(`PRAGMA foreign_keys=ON`)
return &Store{db: db}, nil
return &Store{db: db, path: path}, nil
}
type EntityHistoryPoint struct {
@@ -285,9 +292,45 @@ func (s *Store) AddHistory(point HistoryPoint) error {
_, err := s.db.Exec(`INSERT OR REPLACE INTO history(ts,cpu_usage,cpu_temp,memory_usage,swap_usage,root_usage,network_rx,network_tx,ups_charge,ups_load,zfs_usage,guests_cpu,guests_memory) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
point.Timestamp, point.CPUUsage, point.CPUTemperature, point.MemoryUsage, point.SwapUsage, point.RootUsage, point.NetworkReceive, point.NetworkTransmit, point.UPSCharge, point.UPSLoad, point.ZFSUsage, point.GuestsCPU, point.GuestsMemory)
if err != nil {
s.setHistoryHealth(time.Time{}, err)
return err
}
_, err = s.db.Exec(`DELETE FROM history WHERE ts < ?`, time.Now().Add(-historyRetention).Unix())
s.setHistoryHealth(time.Now(), err)
return err
}
func (s *Store) setHistoryHealth(at time.Time, err error) {
s.healthMu.Lock()
defer s.healthMu.Unlock()
if !at.IsZero() {
s.lastHistoryAt = at
}
if err != nil {
s.lastHistoryError = err.Error()
} else {
s.lastHistoryError = ""
}
}
func (s *Store) historyHealth() (time.Time, string) {
s.healthMu.RLock()
defer s.healthMu.RUnlock()
return s.lastHistoryAt, s.lastHistoryError
}
func (s *Store) loadJSONSetting(key string, target any) bool {
var value string
if s.db.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&value) != nil {
return false
}
return json.Unmarshal([]byte(value), target) == nil
}
func (s *Store) saveJSONSetting(key string, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
_, err = s.db.Exec(`INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, string(data))
return err
}

57
ups.go
View File

@@ -10,20 +10,26 @@ import (
)
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"`
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"`
CommunicationOK bool `json:"communicationOk"`
BatteryDate string `json:"batteryDate"`
BatteryAgeDays int `json:"batteryAgeDays,omitempty"`
ReplaceBattery bool `json:"replaceBattery"`
LowBatteryCharge *float64 `json:"lowBatteryCharge,omitempty"`
LowBatteryRuntime *float64 `json:"lowBatteryRuntime,omitempty"`
}
type upsCollector struct {
@@ -43,7 +49,13 @@ func (c *upsCollector) collect() UPSMetrics {
} else if metrics, ok := readAPCUPSD(); ok {
c.cached = metrics
} else {
c.cached = UPSMetrics{}
if c.cached.Available || c.cached.Source != "" {
c.cached.Available = false
c.cached.CommunicationOK = false
c.cached.StatusLabel = "Нет связи"
} else {
c.cached = UPSMetrics{}
}
}
c.updatedAt = time.Now()
return c.cached
@@ -85,6 +97,7 @@ func readAPCUPSD() (UPSMetrics, bool) {
"ups.load": raw["LOADPCT"], "input.voltage": raw["LINEV"],
"output.voltage": raw["OUTPUTV"], "input.frequency": raw["LINEFREQ"],
"battery.voltage": raw["BATTV"], "ups.temperature": raw["ITEMP"],
"battery.date": raw["BATTDATE"], "battery.charge.low": raw["MBATTCHG"], "battery.runtime.low": minutesToSeconds(raw["MINTIMEL"]),
}
return upsFromValues("apcupsd", raw["UPSNAME"], values), true
}
@@ -102,14 +115,24 @@ func parseColonValues(output string) map[string]string {
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"],
metrics := UPSMetrics{
Available: true, CommunicationOK: 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"])),
BatteryDate: firstNonEmpty(values["battery.date"], values["battery.mfr.date"]), ReplaceBattery: strings.Contains(status, "RB"), LowBatteryCharge: numberPointer(values["battery.charge.low"]), LowBatteryRuntime: numberPointer(values["battery.runtime.low"]),
}
if metrics.BatteryDate != "" {
for _, layout := range []string{"2006/01/02", "2006-01-02", "01/02/2006", "02/01/2006"} {
if date, err := time.Parse(layout, metrics.BatteryDate); err == nil {
metrics.BatteryAgeDays = int(time.Since(date).Hours() / 24)
break
}
}
}
return metrics
}
func numberPointer(value string) *float64 {

78
web.go
View File

@@ -15,6 +15,7 @@ type dashboardMetrics struct {
Memory MemoryMetrics `json:"memory"`
Storage StorageMetrics `json:"storage"`
Disks []DiskMetrics `json:"disks"`
MissingDisks []string `json:"missingDisks"`
Network NetworkMetrics `json:"network"`
ZFS ZFSMetrics `json:"zfs"`
UPS UPSMetrics `json:"ups"`
@@ -26,10 +27,12 @@ type dashboardMetrics struct {
Services ServicesMetrics `json:"services"`
SystemHealth SystemHealthMetrics `json:"systemHealth"`
Activity ProxmoxActivity `json:"activity"`
Monitor MonitorHealth `json:"monitor"`
UPSShutdown UPSShutdownSettings `json:"upsShutdown"`
Timestamp time.Time `json:"timestamp"`
}
func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector, backupCollector *backupCollector, maintenanceCollector *maintenanceCollector, serviceCollector *serviceCollector, systemHealth *systemHealthCollector, activity *activityCollector, store *Store) (dashboardMetrics, error) {
func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector, backupCollector *backupCollector, maintenanceCollector *maintenanceCollector, serviceCollector *serviceCollector, systemHealth *systemHealthCollector, activity *activityCollector, store *Store, monitor *monitorTracker) (dashboardMetrics, error) {
cpu, err := cpuCollector.collect()
if err != nil {
return dashboardMetrics{}, err
@@ -52,6 +55,9 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
ZFS: zfsCollector.collect(), UPS: upsCollector.collect(),
Guests: guestCollector.collect(), Backups: backupCollector.collect(), Maintenance: maintenanceCollector.collect(), Services: serviceCollector.collect(), SystemHealth: systemHealth.collect(), Update: readUpdateStatus(), Timestamp: time.Now(),
}
metrics.MissingDisks = diskCollector.missingDisks()
metrics.Monitor = monitor.snapshot(store)
metrics.UPSShutdown = store.UPSShutdownSettings()
metrics.Activity = activity.collect(metrics.Guests)
decorateNetworkInterfaces(&metrics.Network, metrics.Guests)
metrics.Alerts = evaluateAlerts(metrics, store.Thresholds())
@@ -115,8 +121,13 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
services := newServiceCollector(store)
systemHealth := newSystemHealthCollector()
activity := newActivityCollector()
monitor := &monitorTracker{}
go runUPSShutdownController(store, ups, guests)
collect := func() (dashboardMetrics, error) {
return collectDashboard(collector, network, disks, zfs, ups, guests, backups, maintenance, services, systemHealth, activity, store)
started := time.Now()
metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests, backups, maintenance, services, systemHealth, activity, store, monitor)
monitor.record(started, err)
return metrics, err
}
go runHistoryCollector(collect, store)
go runEmailNotifier(collect, store)
@@ -219,6 +230,32 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
settings := store.EmailSettings(true)
writeJSON(w, map[string]bool{"sent": true}, sendAlertEmail(settings, nil, true))
})
mux.HandleFunc("/api/settings/ups-shutdown", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, store.UPSShutdownSettings(), nil)
case http.MethodPut:
var settings UPSShutdownSettings
if json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&settings) != nil {
http.Error(w, "Некорректные настройки", http.StatusBadRequest)
return
}
if err := store.SaveUPSShutdownSettings(settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, settings, nil)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/api/ups/shutdown-dry-run", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
writeJSON(w, buildShutdownDryRun(store.UPSShutdownSettings(), guests.collect()), nil)
})
mux.HandleFunc("/api/history", func(w http.ResponseWriter, r *http.Request) {
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
points, err := store.History(hours)
@@ -347,6 +384,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
.services .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.system-health .fill{background:linear-gradient(90deg,#6ba8ff,#9d8cff)}.service-form{display:grid;grid-template-columns:1fr 1fr;gap:12px}.field{display:grid;gap:6px;min-width:0}.field>span{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.07em}.field small{color:var(--muted);font-size:11px}.field input,.field select{width:100%;min-width:0;padding:11px 12px;border:1px solid var(--line);border-radius:9px;background:var(--bg);color:var(--text)}.check-field{display:flex;align-items:center;gap:10px;padding:12px;border:1px solid var(--line);border-radius:9px}.check-field input{width:18px;height:18px;accent-color:var(--green)}.service-actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:10px}.service-actions button,.open-address{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer;text-decoration:none}.status-up{color:var(--green)}.status-down{color:#ff665c}.issue-message{overflow-wrap:anywhere}.full-span{grid-column:1/-1}.endpoint-list{display:grid;gap:10px;margin-top:16px}.endpoint{padding:14px;border:1px solid var(--line);border-radius:12px;background:rgba(100,130,150,.04)}.endpoint-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.endpoint-address{color:var(--muted);font:12px ui-monospace,monospace;overflow-wrap:anywhere}.certificate{margin-top:11px;padding-top:11px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.certificate b{color:var(--text)}.maintenance-grid .detail{min-width:0}.maintenance-grid .detail strong{font-size:clamp(15px,2.2vw,21px);overflow-wrap:anywhere;word-break:break-word}.system-tabs{display:flex;gap:6px;padding:12px 26px;border-bottom:1px solid var(--line)}.system-panel[hidden]{display:none}
.snapshots .fill{background:linear-gradient(90deg,#9d8cff,#ff78b8)}.tasks .fill{background:linear-gradient(90deg,#43c6d9,#6ba8ff)}.homelab-map .fill{background:linear-gradient(90deg,#55d58a,#9d8cff)}.map-board{display:grid;grid-template-columns:170px 1fr 1fr 1fr;gap:14px;padding:18px;min-width:680px}.map-column{display:grid;align-content:start;gap:9px}.map-column-title{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.1em}.map-node{padding:12px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.map-node.good{border-color:#2d6245}.map-node.bad{border-color:#71362f}.map-node.warn{border-color:#6d4c2c}.map-node strong{display:block}.map-node small{display:block;color:var(--muted);margin-top:3px}.map-scroll{overflow-x:auto}.backup-check{margin-top:9px;color:var(--muted);font-size:12px}.task-filter{display:flex;gap:6px;padding:12px 16px;overflow-x:auto}
.topology-board{padding:18px;min-width:620px}.topology-host{margin-bottom:14px}.topology-grid{display:grid;grid-template-columns:repeat(2,minmax(270px,1fr));gap:12px}.topology-group{padding:14px;border:1px solid var(--line);border-radius:15px;background:rgba(100,130,150,.04)}.topology-group-head{display:flex;justify-content:space-between;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--line)}.topology-section{margin-top:11px}.topology-section-title{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}.topology-items{display:flex;flex-wrap:wrap;gap:6px}.topology-chip{padding:6px 9px;border:1px solid var(--line);border-radius:9px;background:var(--panel);font-size:12px}.topology-chip.good{border-color:#2d6245}.topology-chip.bad{border-color:#71362f}.topology-unassigned{margin-top:14px}@media(max-width:700px){.topology-grid{grid-template-columns:1fr}}
.monitor .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.shutdown-plan{display:grid;gap:8px;margin-top:12px}.shutdown-step{padding:10px 12px;border:1px solid var(--line);border-radius:10px}.infra-status{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:14px 0}.infra-status>div{padding:11px;border:1px solid var(--line);border-radius:10px}.infra-status b{display:block;margin-top:4px}@media(max-width:700px){.infra-status{grid-template-columns:1fr 1fr}}
</style>
</head>
<body><main>
@@ -394,6 +432,10 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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 monitor" data-card="monitor" data-open="monitorDialog" aria-label="Открыть самоконтроль дашборда">
<div class="card-head"><div><div class="label">Monitoring health</div><div class="name">Самоконтроль</div></div><div class="percent"><span id="monitorState">—</span></div></div><div class="bar"><div class="fill" id="monitorBar"></div></div>
<div class="quick"><div><span class="label">Последний сбор</span><strong id="monitorLast">—</strong></div><div><span class="label">База</span><strong id="monitorDB">—</strong></div></div><div class="hint">Watchdog, история и свежесть данных →</div>
</button>
<button class="card guests" data-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>
@@ -484,13 +526,14 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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>
<div class="wide"><span class="label">Доступность инфраструктуры</span><div class="infra-status"><div><span>Шлюз</span><b id="dNetGateway">—</b></div><div><span>DNS</span><b id="dNetDNS">—</b></div><div><span>Интернет</span><b id="dNetInternet">—</b></div><div><span>Внешний IP</span><b id="dNetExternalIP">—</b></div></div></div>
<div class="disk-list" id="networkInterfacesList"></div>
<div class="chart-section"><div class="chart-head"><strong>Входящий и исходящий трафик</strong><span>Последние 7 дней</span></div><canvas class="metric-chart" id="networkChart"></canvas></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>
<div class="wide"><span class="label">ARC</span><div class="infra-status"><div><span>Размер</span><b id="zfsArcSize">—</b></div><div><span>Цель</span><b id="zfsArcTarget">—</b></div><div><span>Hit ratio</span><b id="zfsArcHit">—</b></div><div><span>Datasets</span><b id="zfsDatasetCount">—</b></div></div></div><div class="disk-list" id="zfsList"><span class="empty">Поиск ZFS-пулов…</span></div>
<div class="chart-section"><div class="chart-head"><strong>Заполнение каждого ZFS-пула</strong><span>Отдельно по каждому пулу</span></div><canvas class="metric-chart" id="zfsChart"></canvas></div>
</dialog>
@@ -500,11 +543,14 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<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 class="detail"><span class="label">Связь с UPS</span><strong id="dUpsCommunication">—</strong></div><div class="detail"><span class="label">Дата батареи</span><strong id="dUpsBatteryDate">—</strong></div><div class="detail"><span class="label">Возраст батареи</span><strong id="dUpsBatteryAge">—</strong></div>
</div>
<div class="wide"><span class="label">Источник данных</span><div class="temps"><span class="temp" id="dUpsSource">Не настроен</span></div></div>
<div class="chart-section"><div class="chart-head"><strong>Заряд и нагрузка UPS</strong><span>Последние 7 дней</span></div><canvas class="metric-chart" id="upsChart"></canvas></div>
</dialog>
<dialog id="monitorDialog"><div class="modal-head"><div><div class="label">Dashboard health</div><h2>Самоконтроль мониторинга</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="detail-grid"><div class="detail"><span class="label">Последний успешный сбор</span><strong id="dMonitorLast">—</strong></div><div class="detail"><span class="label">Длительность цикла</span><strong id="dMonitorDuration">—</strong></div><div class="detail"><span class="label">Последняя запись истории</span><strong id="dMonitorHistory">—</strong></div><div class="detail"><span class="label">Свободно для базы</span><strong id="dMonitorDB">—</strong></div><div class="detail"><span class="label">Systemd watchdog</span><strong id="dMonitorWatchdog">—</strong></div><div class="detail"><span class="label">Ошибка</span><strong id="dMonitorError">—</strong></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>
@@ -547,18 +593,19 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<dialog id="settingsDialog">
<div class="modal-head"><div><div class="label">Dashboard</div><h2>Настройки</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="services">Сервисы</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="services">Сервисы</button><button class="settings-tab" data-settings-tab="ups">UPS shutdown</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
<div class="settings-body settings-panel" data-settings-panel="appearance">
<div class="settings-group"><div class="label">Тема оформления</div><div class="theme-options"><button class="theme-option" data-theme-choice="dark">Тёмная</button><button class="theme-option" data-theme-choice="light">Светлая</button></div></div>
<div class="settings-group"><div class="label">Видимые карточки</div><div class="setting-list">
<label class="setting-item"><input type="checkbox" data-card-toggle="alerts"> Центр уведомлений</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="cpu"> CPU</label><label class="setting-item"><input type="checkbox" data-card-toggle="memory"> Память</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="storage"> Диски</label><label class="setting-item"><input type="checkbox" data-card-toggle="network"> Сеть</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="zfs"> ZFS</label><label class="setting-item"><input type="checkbox" data-card-toggle="ups"> UPS</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="zfs"> ZFS</label><label class="setting-item"><input type="checkbox" data-card-toggle="ups"> UPS</label><label class="setting-item"><input type="checkbox" data-card-toggle="monitor"> Самоконтроль</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="guests"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="maintenance"> Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-card-toggle="services"> Сервисы</label><label class="setting-item"><input type="checkbox" data-card-toggle="system"> Система</label><label class="setting-item"><input type="checkbox" data-card-toggle="snapshots"> Snapshots</label><label class="setting-item"><input type="checkbox" data-card-toggle="tasks"> Задачи Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="map"> Карта homelab</label>
</div></div>
<div class="settings-group"><div class="label">Порядок карточек</div><div class="settings-note">Перетаскивайте строки или используйте стрелки.</div><div class="card-order" id="cardOrder"></div></div>
</div>
<div class="settings-body settings-panel" data-settings-panel="ups" hidden><div class="settings-group"><div class="label">Безопасное выключение при работе от батареи</div><div class="settings-note">Автоматическое выключение по умолчанию отключено. Dry-run ничего не останавливает и показывает будущий порядок.</div><div class="threshold-grid"><label class="setting-item"><input type="checkbox" id="upsShutdownEnabled"> Разрешить сценарий</label><label class="threshold-item"><span>Заряд, %</span><input type="number" id="upsShutdownCharge"></label><label class="threshold-item"><span>Автономность, сек.</span><input type="number" id="upsShutdownRuntime"></label><label class="threshold-item"><span>Ожидание гостя, сек.</span><input type="number" id="upsShutdownGrace"></label><label class="field full-span"><span>Порядок VMID через запятую</span><input id="upsShutdownOrder" placeholder="101, 105, 220"><small>Неуказанные запущенные гости добавятся после них по VMID.</small></label></div><button class="save-settings" id="saveUPSShutdown">Сохранить сценарий</button><button class="theme-option" id="dryRunUPSShutdown" style="margin-top:10px;width:100%">Показать dry-run</button><div class="shutdown-plan" id="upsShutdownPlan"></div><div class="settings-note" id="upsShutdownStatus"></div></div></div>
<div class="settings-body settings-panel" data-settings-panel="alerts" hidden>
<div class="threshold-grid">
<label class="threshold-item"><span>CPU · предупреждение, %</span><input type="number" data-threshold="cpuWarning"></label><label class="threshold-item"><span>CPU · критично, %</span><input type="number" data-threshold="cpuCritical"></label>
@@ -610,10 +657,11 @@ 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+'м'};
let alertSettings={backupMaxAgeHours:48};
let lastDashboardEvent=0;
let currentServiceStatuses=[];
function endpointHTML(v){const tls=v.tlsDays==null?'Нет TLS':v.tlsDays<0?'Сертификат истёк':v.tlsDays+' дн. до окончания',cert=v.tlsSubject?'<div class="certificate"><div>Сертификат: <b>'+safe(v.tlsSubject)+'</b></div><div>Домены: <b>'+safe((v.tlsDnsNames||[]).join(', ')||'не указаны')+'</b></div><div>Издатель: <b>'+safe(v.tlsIssuer||'—')+'</b></div><div>Для этого адреса: <b class="'+(v.tlsHostnameValid?'status-up':'status-down')+'">'+(v.tlsHostnameValid?'подходит':'не подходит')+'</b></div></div>':'';return '<div class="endpoint"><div class="endpoint-head"><div><b>'+safe(v.label)+'</b><div class="endpoint-address">'+safe(v.address)+'</div></div><span class="badge '+(v.up?'':'bad')+'">'+(v.up?'Доступен':'Недоступен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Отклик</span><b>'+fixed(v.latencyMs)+' ms</b></div><div class="disk-stat"><span>HTTP</span><b>'+(v.statusCode||'—')+'</b></div><div class="disk-stat"><span>TLS</span><b>'+tls+'</b></div><div class="disk-stat"><span>Uptime · сутки</span><b>'+fixed(v.uptimeDay)+'%</b></div><div class="disk-stat"><span>Uptime · неделя</span><b>'+fixed(v.uptimeWeek)+'%</b></div></div>'+cert+(v.error?'<div class="settings-note status-down">'+safe(v.error)+'</div>':'')+'</div>'}
function renderServices(svc){const items=svc.services||[];currentServiceStatuses=items;const avg=items.length?items.reduce((a,v)=>a+v.uptimeDay,0)/items.length:0,live=items.filter(v=>v.up),latency=live.length?live.reduce((a,v)=>a+v.latencyMs,0)/live.length:0;el('serviceUp').textContent=svc.up||0;el('serviceTotal').textContent=' / '+(svc.total||0);el('serviceBar').style.width=(svc.total?svc.up/svc.total*100:0)+'%';el('serviceUptime').textContent=svc.total?fixed(avg)+'%':'—';el('serviceLatency').textContent=svc.total?fixed(latency)+' ms':'—';el('serviceList').innerHTML=items.length?items.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+(v.endpoints||[]).length+' '+((v.endpoints||[]).length===1?'адрес':'адреса')+' проверяются независимо</div></div><span class="badge '+(v.up?'':'bad')+'">'+(v.up?'Всё доступно':'Есть проблема')+'</span></div><div class="endpoint-list">'+(v.endpoints||[]).map(endpointHTML).join('')+'</div><div class="service-actions"><button data-service-open="'+v.id+'">Открыть сервис ↗</button></div></article>').join(''):'<span class="empty">Добавьте первую проверку в настройках.</span>'}
function renderNetworkGroups(interfaces){const groups=new Map();interfaces.forEach(v=>{const key=v.vmid?'guest-'+v.vmid:'host',group=groups.get(key)||{vmid:v.vmid,name:v.vmid?(v.guestName||('VMID '+v.vmid)):'Интерфейсы хоста',type:v.guestType||'Host',items:[]};group.items.push(v);groups.set(key,group)});el('networkInterfacesList').innerHTML=[...groups.values()].map(g=>{const primary=g.vmid?g.items.filter(v=>v.name.startsWith('tap')||v.name.startsWith('veth')):g.items,rx=primary.reduce((a,v)=>a+v.receivePerSec,0),tx=primary.reduce((a,v)=>a+v.transmitPerSec,0),errors=g.items.reduce((a,v)=>a+v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops,0);return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(g.name)+'</div><div class="disk-meta">'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name).join(', '))+'</div></div><span class="badge '+(errors?'warn':'')+'">'+(errors?'Есть потери':'Без ошибок')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Сейчас ↓</span><b>'+rate(rx)+'</b></div><div class="disk-stat"><span>Сейчас ↑</span><b>'+rate(tx)+'</b></div><div class="disk-stat"><span>Получено</span><b>'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'</b></div><div class="disk-stat"><span>Передано</span><b>'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'</b></div><div class="disk-stat"><span>Ошибки / drops</span><b>'+errors+'</b></div></div></article>'}).join('')}
function renderNetworkGroups(interfaces){const groups=new Map();interfaces.forEach(v=>{const key=v.vmid?'guest-'+v.vmid:'host',group=groups.get(key)||{vmid:v.vmid,name:v.vmid?(v.guestName||('VMID '+v.vmid)):'Интерфейсы хоста',type:v.guestType||'Host',items:[]};group.items.push(v);groups.set(key,group)});el('networkInterfacesList').innerHTML=[...groups.values()].map(g=>{const primary=g.vmid?g.items.filter(v=>v.name.startsWith('tap')||v.name.startsWith('veth')):g.items,rx=primary.reduce((a,v)=>a+v.receivePerSec,0),tx=primary.reduce((a,v)=>a+v.transmitPerSec,0),errors=g.items.reduce((a,v)=>a+v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops,0),down=primary.some(v=>v.kind==='Physical'&&!v.carrier&&(v.master||v.receiveBytes+v.transmitBytes>0));return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(g.name)+'</div><div class="disk-meta">'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name+' ['+(v.kind||'Interface')+(v.speedMbps?' · '+v.speedMbps+'Mb/s':'')+(v.duplex?' · '+v.duplex:'')+(v.master?' → '+v.master:'')+']').join(', '))+'</div></div><span class="badge '+(errors||down?'warn':'')+'"'+(errors||down?' tabindex="0" data-reason="'+safe(down?'Нет carrier на используемом физическом интерфейсе':'Счётчики errors/drops больше нуля')+'"':'')+'>'+(down?'Нет линка':errors?'Есть потери':'Без ошибок')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Сейчас ↓</span><b>'+rate(rx)+'</b></div><div class="disk-stat"><span>Сейчас ↑</span><b>'+rate(tx)+'</b></div><div class="disk-stat"><span>Получено</span><b>'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'</b></div><div class="disk-stat"><span>Передано</span><b>'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'</b></div><div class="disk-stat"><span>Ошибки / drops</span><b>'+errors+'</b></div></div></article>'}).join('')}
const taskLabel=type=>({vzdump:'Backup',qmstart:'Запуск VM',qmstop:'Остановка VM',qmshutdown:'Выключение VM',qmsnapshot:'Snapshot VM',vzstart:'Запуск LXC',vzstop:'Остановка LXC',vzsnapshot:'Snapshot LXC',qmigrate:'Миграция',aptupdate:'Обновление пакетов'})[type]||type;
function explainWarningBadges(){el('snapshotList').querySelectorAll('.badge.warn').forEach(v=>{v.tabIndex=0;v.dataset.reason='Snapshot старше 30 дней. Проверьте, нужен ли он: старые снимки могут занимать место в storage.'});el('taskList').querySelectorAll('.badge.bad').forEach(v=>{v.tabIndex=0;v.dataset.reason='Задача Proxmox завершилась не со статусом OK. Откройте её журнал в интерфейсе Proxmox для подробностей.'})}
let currentTasks=[],currentTaskFilter='all';
@@ -622,6 +670,7 @@ function renderActivity(activity,guests,services,zfs){const snapshots=activity.s
function topologyHost(address){if(!address)return '';try{return new URL(address.includes('://')?address:'http://'+address).hostname.replace(/^\[|\]$/g,'')}catch(e){return address.replace(/^[a-z]+:\/\//i,'').split('/')[0].replace(/:\d+$/,'')}}
function renderHomelabMap(guests,services,zfs){const guestItems=(guests.guests||[]).filter(v=>!v.template),serviceItems=services.services||[],pools=zfs.pools||[],serviceOwners=new Map();serviceItems.forEach(s=>{const host=topologyHost(s.localAddress||'');const owner=guestItems.find(g=>(g.ipAddresses||[]).some(ip=>ip===host));serviceOwners.set(s.id,owner?.vmid||0)});const serviceChip=s=>{const healthy=(s.endpoints||[]).every(v=>v.up);return '<span class="topology-chip '+(healthy?'good':'bad')+'"'+(!healthy?' tabindex="0" data-reason="Один или несколько адресов сервиса недоступны"':'')+'>'+safe(s.name)+'</span>'},storageChip=name=>{const pool=pools.find(p=>p.name===name),healthy=!pool||pool.health==='ONLINE';return '<span class="topology-chip '+(healthy?'good':'bad')+'">'+safe(name)+(pool?' · '+fixed(pool.capacityPercent)+'%':'')+'</span>'};const groups=guestItems.map(g=>{const owned=serviceItems.filter(s=>serviceOwners.get(s.id)===g.vmid),storage=[...new Set(g.storage||[])],running=g.status==='running';return '<section class="topology-group"><div class="topology-group-head"><div><strong>'+safe(g.name||('VMID '+g.vmid))+'</strong><div class="disk-meta">VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+' · '+safe((g.ipAddresses||[]).join(', ')||'IP не получен')+'</div></div><span class="badge '+(running?'':'warn')+'"'+(!running?' tabindex="0" data-reason="VM/LXC сейчас остановлена"':'')+'>'+(running?'Работает':'Остановлена')+'</span></div><div class="topology-section"><div class="topology-section-title">Сервисы</div><div class="topology-items">'+(owned.length?owned.map(serviceChip).join(''):'<span class="empty">Не сопоставлены</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Storage</div><div class="topology-items">'+(storage.length?storage.map(storageChip).join(''):'<span class="empty">Не определён</span>')+'</div></div></section>'}).join(''),unassigned=serviceItems.filter(s=>!serviceOwners.get(s.id)),usedStorage=new Set(guestItems.flatMap(g=>g.storage||[])),unassignedPools=pools.filter(p=>!usedStorage.has(p.name));el('mapBoard').innerHTML='<div class="topology-host map-node good"><strong>Proxmox host</strong><small>'+safe(location.hostname)+' · '+guestItems.length+' VM/LXC</small></div><div class="topology-grid">'+groups+'</div>'+((unassigned.length||unassignedPools.length)?'<section class="topology-group topology-unassigned"><div class="topology-group-head"><strong>Без автоматической привязки</strong><span class="badge warn" tabindex="0" data-reason="Для сервиса не найден совпадающий IP гостя либо storage не используется в конфигурации VM/LXC">Проверьте связи</span></div><div class="topology-section"><div class="topology-section-title">Сервисы</div><div class="topology-items">'+unassigned.map(serviceChip).join('')+'</div></div><div class="topology-section"><div class="topology-section-title">Остальные ZFS-пулы</div><div class="topology-items">'+unassignedPools.map(p=>storageChip(p.name)).join('')+'</div></div></section>':'');const storage=new Set([...usedStorage,...pools.map(p=>p.name)]);el('mapGuests').textContent=guestItems.length;el('mapResources').textContent=serviceItems.length+' / '+storage.size;el('mapNodes').textContent=1+guestItems.length+serviceItems.length+storage.size;el('mapBar').style.width='100%'}
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:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
lastDashboardEvent=Date.now();
const update=x.update||{},updating=['queued','installing'].includes(update.state);el('updateCurrent').textContent=update.currentVersion||'—';el('updateAvailable').textContent=update.availableVersion||'—';el('updateMode').textContent=update.mode==='auto'?'Автоматически':'Уведомлять';el('updateMessage').textContent=update.message||'Проверка ещё не выполнялась';el('updateChecked').textContent=update.checkedAt?'Последняя проверка: '+new Date(update.checkedAt).toLocaleString('ru-RU'):'Проверка запускается автоматически раз в сутки.';el('installUpdate').disabled=updating;el('installUpdate').textContent=updating?'Обновление выполняется…':'Проверить и установить обновление';
const critical=alerts.filter(a=>a.severity==='critical').length,warnings=alerts.filter(a=>a.severity==='warning').length,infos=alerts.filter(a=>a.severity==='info').length;el('alertCount').textContent=alerts.length;el('alertCritical').textContent=critical;el('alertWarnings').textContent=warnings;el('alertHeadline').textContent=critical?'Требуется срочное внимание':warnings?'Есть предупреждения':infos?'Есть информационные события':'Система в норме';el('alertBar').style.width=(critical?100:warnings?65:infos?30:0)+'%';
el('alertList').innerHTML=alerts.length?alerts.map(a=>'<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><button class="ack-alert" data-ack="'+a.eventId+'">Принято</button></div></article>').join(''):'<div class="all-good"><strong>Всё в порядке</strong><span>Новых неподтверждённых событий нет.</span></div>';
@@ -630,11 +679,13 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av
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||'—';
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)+' · '+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')+' · '+(d.wearUsedPercent==null?'—':fixed(d.wearUsedPercent)+'%')+'</b></div><div class="disk-stat"><span>Reallocated / Pending</span><b>'+d.reallocated+' / '+d.pending+'</b></div><div class="disk-stat"><span>Uncorrectable / CRC</span><b>'+d.uncorrectable+' / '+d.crcErrors+'</b></div><div class="disk-stat"><span>Последний self-test</span><b>'+safe(d.lastSelfTest||'—')+(d.lastSelfTestAt?' · '+new Date(d.lastSelfTestAt).toLocaleDateString('ru-RU'):'')+(d.shortTestStarted?' · запущен новый':'')+'</b></div></div></article>'}).join(''):'<span class="empty">Физические накопители не найдены</span>';
const totalRate=n.receivePerSec+n.transmitPerSec,nh=n.health||{};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||'—';el('dNetGateway').textContent=(nh.gateway||'Не определён')+' · '+(nh.gatewayUp?'доступен':'нет ответа')+(nh.latencyMs?' · '+fixed(nh.latencyMs)+' ms':'');el('dNetDNS').textContent=nh.dnsUp?'Работает':'Ошибка';el('dNetInternet').textContent=nh.internetUp?'Доступен':'Недоступен';el('dNetExternalIP').textContent=(nh.externalIp||'—')+(nh.externalIpChanged?' · изменился':'');
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?zBad+' требуют внимания':pools.length+' ONLINE';el('zfsUsed').textContent=zTotal?size(zUsed)+' / '+size(zTotal)+' · '+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 datasets=z.datasets||[],arc=z.arc||{};el('zfsArcSize').textContent=arc.available?size(arc.sizeBytes):'—';el('zfsArcTarget').textContent=arc.available?size(arc.targetBytes):'—';el('zfsArcHit').textContent=arc.available?fixed(arc.hitRatio)+'%':'—';el('zfsDatasetCount').textContent=datasets.length;el('zfsList').innerHTML=pools.length?pools.map(p=>{const bad=p.health!=='ONLINE'||p.readErrors+p.writeErrors+p.checksumErrors>0,reason=(p.attentionReasons||[]).join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'',poolDatasets=datasets.filter(d=>d.pool===p.name),vdevs=p.vdevs||[];return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(p.name)+'</div><div class="disk-meta">Scrub: '+safe(p.scrubStatus||p.scan||'нет данных')+(p.lastScrubAt?' · '+new Date(p.lastScrubAt).toLocaleString('ru-RU'):'')+(p.scrubDuration?' · '+safe(p.scrubDuration):'')+'</div></div><span class="badge '+(bad?'bad':p.scrubTooOld?'warn':'')+'"'+tip+'>'+safe(p.health)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Объём / свободно</span><b>'+size(p.sizeBytes)+' / '+size(p.freeBytes)+'</b></div><div class="disk-stat"><span>Заполнение</span><b>'+fixed(p.capacityPercent)+'%</b></div><div class="disk-stat"><span>Read / write / checksum</span><b>'+p.readErrors+' / '+p.writeErrors+' / '+p.checksumErrors+'</b></div></div><div class="topology-section"><div class="topology-section-title">Vdev · data / special / cache / log</div><div class="topology-items">'+(vdevs.length?vdevs.map(v=>'<span class="topology-chip '+(v.state==='ONLINE'?'good':'bad')+'">'+safe(v.class)+' · '+safe(v.name)+' · '+safe(v.state)+' · '+v.read+'/'+v.write+'/'+v.checksum+'</span>').join(''):'<span class="empty">Нет данных</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Datasets</div><div class="topology-items">'+(poolDatasets.length?poolDatasets.map(d=>'<span class="topology-chip '+(d.capacityPercent>=80?'bad':'good')+'">'+safe(d.name)+' · '+fixed(d.capacityPercent)+'% · '+size(d.usedBytes)+'</span>').join(''):'<span class="empty">Нет datasets</span>')+'</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('dUpsCommunication').textContent=u.communicationOk?'Связь установлена':'Нет связи';el('dUpsBatteryDate').textContent=u.batteryDate||'Не сообщает';el('dUpsBatteryAge').textContent=u.batteryAgeDays?u.batteryAgeDays+' дней':'—';el('dUpsSource').textContent=u.available?(u.source+' · '+(u.name||'UPS')):'Установите и настройте NUT или apcupsd';
const mh=x.monitor||{},last=mh.lastSuccessfulCollection?new Date(mh.lastSuccessfulCollection):null,stale=!last||Date.now()-last.getTime()>30000,monitorProblem=stale||mh.lastCollectionError||mh.historyWriteError||mh.slowCollector;el('monitorState').textContent=monitorProblem?'Внимание':'OK';el('monitorBar').style.width=monitorProblem?'100%':'12%';el('monitorLast').textContent=last?Math.max(0,Math.round((Date.now()-last.getTime())/1000))+' сек. назад':'—';el('monitorDB').textContent=size(mh.databaseFreeBytes);el('dMonitorLast').textContent=last?last.toLocaleString('ru-RU'):'Ещё не завершён';el('dMonitorDuration').textContent=(mh.collectionDurationMs||0)+' ms';el('dMonitorHistory').textContent=mh.lastHistoryWrite?new Date(mh.lastHistoryWrite).toLocaleString('ru-RU'):'Ещё не записывалась';el('dMonitorDB').textContent=size(mh.databaseFreeBytes);el('dMonitorWatchdog').textContent=mh.watchdogEnabled?'Включён · 30 сек.':'Не активен';el('dMonitorError').textContent=mh.lastCollectionError||mh.historyWriteError||'Нет';
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,diskPercent=v.maxdisk?v.disk/v.maxdisk*100:0,ips=(v.ipAddresses||[]).join(', ')||'IP не найден';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)+' · '+safe(ips)+'</div></div><span class="badge '+(live?'':'warn')+'">'+(live?'Запущен':'Остановлен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>CPU</span><b>'+(live?fixed(v.cpu*100)+'%':'—')+' / '+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>Диск</span><b>'+(v.maxdisk?size(v.disk)+' · '+fixed(diskPercent)+'%':'—')+'</b></div><div class="disk-stat"><span>Сеть</span><b>↓ '+size(v.netin)+' · ↑ '+size(v.netout)+'</b></div><div class="disk-stat"><span>Guest Agent</span><b>'+(v.agent?'Доступен':'Нет')+'</b></div><div class="disk-stat"><span>Автозапуск</span><b>'+(v.onboot?'Да':'Нет')+'</b></div><div class="disk-stat"><span>Storage</span><b>'+safe((v.storage||[]).join(', ')||'—')+'</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>';
const pveShort=(mt.proxmoxVersion||'Proxmox VE').split('(')[0].trim();el('maintenanceVersion').textContent=pveShort;el('maintenanceUpdates').textContent=mt.availableUpdates??'—';el('maintenanceBar').style.width=Math.min((mt.availableUpdates||0)*5,100)+'%';el('maintenanceReboot').textContent=(mt.rebootRequired||mt.newKernelAvailable)?'Требуется':'Не требуется';el('maintenanceKernel').textContent=mt.kernelVersion||'—';el('dPveVersion').textContent=pveShort;el('dPveVersion').title=mt.proxmoxVersion||'';el('dKernel').textContent=mt.kernelVersion||'—';el('dLatestKernel').textContent=mt.latestInstalledKernel||'—';el('dUpdates').textContent=(mt.availableUpdates??'—')+' пакетов';el('dReboot').textContent=(mt.rebootRequired||mt.newKernelAvailable)?'Требуется':'Не требуется';el('dLastUpdate').textContent=mt.lastSystemUpdate?new Date(mt.lastSystemUpdate).toLocaleString('ru-RU'):'—';el('dNextBackup').textContent=mt.nextBackup||'Расписание не найдено';el('dNextScrub').textContent=mt.nextScrub||'Таймер не найден';el('dMaintenanceChecked').textContent=mt.checkedAt?new Date(mt.checkedAt).toLocaleString('ru-RU'):'—';
@@ -667,7 +718,7 @@ el('backupList').onclick=async event=>{const button=event.target.closest('[data-
el('alertList').onclick=async event=>{const button=event.target.closest('[data-ack]');if(!button)return;button.disabled=true;const response=await fetch('/api/alerts/acknowledge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({eventId:Number(button.dataset.ack)})});if(response.ok)button.closest('.alert-item').remove();else button.disabled=false};
async function loadAlertHistory(){const response=await fetch('/api/alerts/history');if(!response.ok)return;const events=await response.json()||[];el('alertHistory').innerHTML=events.length?events.map(a=>{const state=a.acknowledgedAt?'Принято · '+new Date(a.acknowledgedAt*1000).toLocaleString('ru-RU'):a.resolvedAt?'Завершено · '+new Date(a.resolvedAt*1000).toLocaleString('ru-RU'):'Активно';return '<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><span class="history-state">'+state+'</span></div></article>'}).join(''):'<div class="all-good"><strong>История пока пуста</strong><span>Новые события появятся здесь автоматически.</span></div>'}
document.querySelectorAll('[data-alert-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-alert-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-alert-panel]').forEach(p=>p.hidden=p.dataset.alertPanel!==tab.dataset.alertTab);if(tab.dataset.alertTab==='history')loadAlertHistory()});
const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','guests','maintenance','backups','services','system','snapshots','tasks','map'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',guests:'VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро',snapshots:'Snapshots',tasks:'Задачи Proxmox',map:'Карта homelab'};let hiddenCards=[],cardOrder=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e){}cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))];
const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','monitor','guests','maintenance','backups','services','system','snapshots','tasks','map'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',monitor:'Самоконтроль',guests:'VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро',snapshots:'Snapshots',tasks:'Задачи Proxmox',map:'Карта homelab'};let hiddenCards=[],cardOrder=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e){}cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))];
function renderCardOrder(){el('cardOrder').innerHTML=cardOrder.map(name=>'<div class="order-item" draggable="true" data-order-card="'+name+'"><span class="order-handle">⠿</span><span>'+cardLabels[name]+'</span><button class="order-button" data-move="up" aria-label="Выше">↑</button><button class="order-button" data-move="down" aria-label="Ниже">↓</button></div>').join('')}
function saveCardOrder(){localStorage.setItem('proxmox-card-order',JSON.stringify(cardOrder));applyCardSettings();renderCardOrder()}
function applyCardSettings(){const container=document.querySelector('.cards');cardOrder.forEach(name=>{const card=document.querySelector('[data-card="'+name+'"]');if(card)container.appendChild(card)});cardNames.forEach(name=>{const visible=!hiddenCards.includes(name),card=document.querySelector('[data-card="'+name+'"]'),toggle=document.querySelector('[data-card-toggle="'+name+'"]');if(card)card.hidden=!visible;if(toggle)toggle.checked=visible})}
@@ -682,10 +733,15 @@ async function loadServices(){const response=await fetch('/api/services');if(!re
function resetServiceForm(){el('serviceId').value='';el('serviceName').value='';el('serviceType').value='http';el('serviceURL').value='';el('serviceLocal').value='';el('serviceCode').value=200;el('serviceText').value='';el('serviceTimeout').value=5;el('serviceEnabled').checked=true}
el('saveService').onclick=async()=>{const value={id:Number(el('serviceId').value)||0,name:el('serviceName').value.trim(),checkType:el('serviceType').value,url:el('serviceURL').value.trim(),localAddress:el('serviceLocal').value.trim(),expectedCode:Number(el('serviceCode').value)||200,expectedText:el('serviceText').value,timeoutSeconds:Number(el('serviceTimeout').value)||5,enabled:el('serviceEnabled').checked},status=el('serviceStatus');status.textContent='Сохранение…';const response=await fetch('/api/services',{method:value.id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)});status.textContent=response.ok?'Сервис сохранён. Первая проверка появится в течение 15 секунд.':await response.text();if(response.ok){resetServiceForm();loadServices()}};
el('serviceSettingsList').onclick=async event=>{const edit=event.target.closest('[data-service-edit]'),del=event.target.closest('[data-service-delete]');if(edit){const v=configuredServices.find(x=>x.id===Number(edit.dataset.serviceEdit));if(!v)return;el('serviceId').value=v.id;el('serviceName').value=v.name;el('serviceType').value=v.checkType;el('serviceURL').value=v.url;el('serviceLocal').value=v.localAddress||'';el('serviceCode').value=v.expectedCode;el('serviceText').value=v.expectedText||'';el('serviceTimeout').value=v.timeoutSeconds;el('serviceEnabled').checked=v.enabled;return}if(del&&confirm('Удалить сервис и его историю?')){const response=await fetch('/api/services?id='+del.dataset.serviceDelete,{method:'DELETE'});if(response.ok)loadServices()}};loadServices().catch(()=>{});
function upsShutdownForm(){return {enabled:el('upsShutdownEnabled').checked,chargePercent:Number(el('upsShutdownCharge').value),runtimeSeconds:Number(el('upsShutdownRuntime').value),graceSeconds:Number(el('upsShutdownGrace').value),guestOrder:el('upsShutdownOrder').value.split(',').map(v=>Number(v.trim())).filter(v=>v>0)}}
async function loadUPSShutdown(){const response=await fetch('/api/settings/ups-shutdown');if(!response.ok)return;const s=await response.json();el('upsShutdownEnabled').checked=!!s.enabled;el('upsShutdownCharge').value=s.chargePercent||15;el('upsShutdownRuntime').value=s.runtimeSeconds||600;el('upsShutdownGrace').value=s.graceSeconds||120;el('upsShutdownOrder').value=(s.guestOrder||[]).join(', ')}
el('saveUPSShutdown').onclick=async()=>{const status=el('upsShutdownStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/ups-shutdown',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(upsShutdownForm())});status.textContent=response.ok?(upsShutdownForm().enabled?'Сценарий сохранён и разрешён.':'Сценарий сохранён, автоматическое выполнение отключено.'):await response.text()};
el('dryRunUPSShutdown').onclick=async()=>{const saved=await fetch('/api/settings/ups-shutdown',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(upsShutdownForm())});if(!saved.ok){el('upsShutdownStatus').textContent=await saved.text();return}const response=await fetch('/api/ups/shutdown-dry-run',{method:'POST'}),steps=await response.json();el('upsShutdownPlan').innerHTML=(steps||[]).map(v=>'<div class="shutdown-step"><b>'+v.order+'. '+safe(v.target)+'</b><div class="disk-meta">'+safe(v.action)+' · '+safe(v.detail)+'</div></div>').join('');el('upsShutdownStatus').textContent='Dry-run завершён: ни одна VM и хост не были остановлены.'};loadUPSShutdown().catch(()=>{});
function emailForm(){return {enabled:el('emailEnabled').checked,skipTlsVerify:el('emailSkipTLS').checked,host:el('emailHost').value.trim(),port:Number(el('emailPort').value),username:el('emailUsername').value.trim(),password:el('emailPassword').value,from:el('emailFrom').value.trim(),to:el('emailTo').value.trim(),intervalMinutes:Number(el('emailInterval').value),severities:[...document.querySelectorAll('[data-email-severity]:checked')].map(x=>x.dataset.emailSeverity),sources:[...document.querySelectorAll('[data-email-source]:checked')].map(x=>x.dataset.emailSource)}}
async function loadEmail(){const response=await fetch('/api/settings/email');if(!response.ok)return;const s=await response.json();el('emailEnabled').checked=s.enabled;el('emailSkipTLS').checked=!!s.skipTlsVerify;el('emailHost').value=s.host||'';el('emailPort').value=s.port||587;el('emailUsername').value=s.username||'';el('emailPassword').value='';el('emailPassword').placeholder=s.hasPassword?'Сохранён · не изменять':'Пароль';el('emailFrom').value=s.from||'';el('emailTo').value=s.to||'';el('emailInterval').value=s.intervalMinutes||60;document.querySelectorAll('[data-email-severity]').forEach(x=>x.checked=(s.severities||[]).includes(x.dataset.emailSeverity));document.querySelectorAll('[data-email-source]').forEach(x=>x.checked=(s.sources||[]).includes(x.dataset.emailSource))}
el('saveEmail').onclick=async()=>{const status=el('emailStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/email',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(emailForm())});status.textContent=response.ok?'Настройки сохранены.':await response.text();if(response.ok)loadEmail()};
el('testEmail').onclick=async()=>{const status=el('emailStatus');status.textContent='Отправляем тестовое письмо…';const saved=await fetch('/api/settings/email',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(emailForm())});if(!saved.ok){status.textContent=await saved.text();return}const response=await fetch('/api/settings/email/test',{method:'POST'});status.textContent=response.ok?'Тестовое письмо отправлено.':await response.text()};loadEmail().catch(()=>{});
el('installUpdate').onclick=async()=>{const button=el('installUpdate');button.disabled=true;button.textContent='Ставим в очередь…';el('updateMessage').textContent='Создаём запрос на обновление…';try{const response=await fetch('/api/update/request',{method:'POST'});if(!response.ok)throw new Error(await response.text());const status=await response.json();el('updateMessage').textContent=status.message;button.textContent='Обновление выполняется…'}catch(e){button.disabled=false;button.textContent='Попробовать ещё раз';el('updateMessage').textContent='Не удалось запустить обновление.'}};
const events=new EventSource('/api/events');events.onmessage=e=>render(JSON.parse(e.data));events.onerror=()=>{el('dot').classList.remove('live');el('status').textContent='Переподключение…'};
setInterval(()=>{if(lastDashboardEvent&&Date.now()-lastDashboardEvent>15000){el('dot').classList.remove('live');el('status').textContent='Данные устарели · переподключение…';el('monitorState').textContent='STALE';el('monitorBar').style.width='100%'}},3000);
</script></body></html>`

205
zfs.go
View File

@@ -2,7 +2,9 @@ package main
import (
"context"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
@@ -10,28 +12,66 @@ import (
)
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"`
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"`
ReadErrors uint64 `json:"readErrors"`
WriteErrors uint64 `json:"writeErrors"`
ChecksumErrors uint64 `json:"checksumErrors"`
VDevs []ZFSVDev `json:"vdevs"`
ScrubStatus string `json:"scrubStatus"`
LastScrubAt string `json:"lastScrubAt"`
ScrubDuration string `json:"scrubDuration"`
ScrubTooOld bool `json:"scrubTooOld"`
ErrorChanges []string `json:"errorChanges"`
}
type ZFSVDev struct {
Name string `json:"name"`
Class string `json:"class"`
State string `json:"state"`
Read uint64 `json:"read"`
Write uint64 `json:"write"`
Checksum uint64 `json:"checksum"`
}
type ZFSDataset struct {
Name string `json:"name"`
Pool string `json:"pool"`
UsedBytes uint64 `json:"usedBytes"`
AvailableBytes uint64 `json:"availableBytes"`
ReferencedBytes uint64 `json:"referencedBytes"`
Mountpoint string `json:"mountpoint"`
CapacityPercent float64 `json:"capacityPercent"`
}
type ZFSARC struct {
Available bool `json:"available"`
SizeBytes uint64 `json:"sizeBytes"`
TargetBytes uint64 `json:"targetBytes"`
HitRatio float64 `json:"hitRatio"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
}
type ZFSMetrics struct {
Available bool `json:"available"`
Pools []ZFSPoolMetrics `json:"pools"`
Datasets []ZFSDataset `json:"datasets"`
ARC ZFSARC `json:"arc"`
}
type zfsCollector struct {
mu sync.Mutex
cached ZFSMetrics
updatedAt time.Time
previous map[string]ZFSPoolMetrics
}
func (c *zfsCollector) collect() ZFSMetrics {
@@ -40,7 +80,26 @@ func (c *zfsCollector) collect() ZFSMetrics {
if time.Since(c.updatedAt) < 30*time.Second {
return c.cached
}
c.cached = readZFSPools()
current := readZFSPools()
if c.previous == nil {
c.previous = map[string]ZFSPoolMetrics{}
}
for i := range current.Pools {
pool := &current.Pools[i]
if old, ok := c.previous[pool.Name]; ok {
checks := []struct {
label string
before, now uint64
}{{"read", old.ReadErrors, pool.ReadErrors}, {"write", old.WriteErrors, pool.WriteErrors}, {"checksum", old.ChecksumErrors, pool.ChecksumErrors}}
for _, check := range checks {
if check.now > check.before {
pool.ErrorChanges = append(pool.ErrorChanges, check.label+": "+strconv.FormatUint(check.before, 10)+" → "+strconv.FormatUint(check.now, 10))
}
}
}
c.previous[pool.Name] = *pool
}
c.cached = current
c.updatedAt = time.Now()
return c.cached
}
@@ -67,15 +126,35 @@ func readZFSPools() ZFSMetrics {
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)
pool.Scan, pool.Errors, pool.VDevs = readZFSStatus(pool.Name)
for _, vdev := range pool.VDevs {
pool.ReadErrors += vdev.Read
pool.WriteErrors += vdev.Write
pool.ChecksumErrors += vdev.Checksum
if vdev.State != "ONLINE" {
pool.AttentionReasons = append(pool.AttentionReasons, vdev.Class+" "+vdev.Name+": "+vdev.State)
}
}
pool.ScrubStatus, pool.LastScrubAt, pool.ScrubDuration, pool.ScrubTooOld = parseScrub(pool.Scan)
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)
}
if pool.ReadErrors+pool.WriteErrors+pool.ChecksumErrors > 0 {
pool.AttentionReasons = append(pool.AttentionReasons, "Ошибки vdev: read="+strconv.FormatUint(pool.ReadErrors, 10)+", write="+strconv.FormatUint(pool.WriteErrors, 10)+", checksum="+strconv.FormatUint(pool.ChecksumErrors, 10))
}
if pool.ScrubTooOld {
pool.AttentionReasons = append(pool.AttentionReasons, "Scrub не выполнялся более 35 дней")
}
if pool.CapacityPercent >= 80 {
pool.AttentionReasons = append(pool.AttentionReasons, "Мало свободного места для безопасной CoW-работы ZFS")
}
result.Pools = append(result.Pools, pool)
}
result.Datasets = readZFSDatasets()
result.ARC = readZFSARC()
return result
}
@@ -84,11 +163,13 @@ func parsePercent(value string) float64 {
return parsed
}
func readZFSStatus(pool string) (string, string) {
func readZFSStatus(pool string) (string, string, []ZFSVDev) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
output, _ := exec.CommandContext(ctx, "zpool", "status", pool).Output()
var scan, errors string
var scan, errors, currentClass string
var vdevs []ZFSVDev
inConfig := false
for _, line := range strings.Split(string(output), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "scan:") {
@@ -97,6 +178,98 @@ func readZFSStatus(pool string) (string, string) {
if strings.HasPrefix(trimmed, "errors:") {
errors = strings.TrimSpace(strings.TrimPrefix(trimmed, "errors:"))
}
if strings.HasPrefix(trimmed, "config:") {
inConfig = true
continue
}
if !inConfig || trimmed == "" || strings.HasPrefix(trimmed, "NAME") {
continue
}
fields := strings.Fields(trimmed)
if len(fields) == 1 && (fields[0] == "logs" || fields[0] == "cache" || fields[0] == "spares" || fields[0] == "special") {
currentClass = fields[0]
continue
}
if len(fields) >= 5 && (fields[1] == "ONLINE" || fields[1] == "DEGRADED" || fields[1] == "OFFLINE" || fields[1] == "UNAVAIL" || fields[1] == "FAULTED" || fields[1] == "REMOVED") {
read, _ := strconv.ParseUint(fields[2], 10, 64)
write, _ := strconv.ParseUint(fields[3], 10, 64)
checksum, _ := strconv.ParseUint(fields[4], 10, 64)
class := currentClass
if class == "" {
class = "data"
}
vdevs = append(vdevs, ZFSVDev{Name: fields[0], Class: class, State: fields[1], Read: read, Write: write, Checksum: checksum})
}
}
return scan, errors
return scan, errors, vdevs
}
var scrubDatePattern = regexp.MustCompile(`(?:on|since) (\w{3} \w{3}\s+\d+ \d\d:\d\d:\d\d \d{4})`)
func parseScrub(scan string) (status, lastAt, duration string, tooOld bool) {
lower := strings.ToLower(scan)
status = scan
if strings.Contains(lower, "in progress") {
status = "Выполняется"
} else if strings.Contains(lower, "repaired") {
status = "Завершён"
} else if strings.Contains(lower, "none requested") || scan == "" {
status = "Никогда не запускался"
return status, "", "", true
}
if match := scrubDatePattern.FindStringSubmatch(scan); len(match) > 1 {
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", match[1]); err == nil {
lastAt = t.Format(time.RFC3339)
tooOld = time.Since(t) > 35*24*time.Hour
}
}
if parts := strings.Split(scan, " in "); len(parts) > 1 {
duration = strings.Fields(parts[1])[0]
}
return
}
func readZFSDatasets() []ZFSDataset {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "zfs", "list", "-H", "-p", "-o", "name,used,avail,refer,mountpoint").Output()
if err != nil {
return nil
}
var result []ZFSDataset
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
f := strings.Fields(line)
if len(f) < 5 {
continue
}
used, _ := strconv.ParseUint(f[1], 10, 64)
avail, _ := strconv.ParseUint(f[2], 10, 64)
refer, _ := strconv.ParseUint(f[3], 10, 64)
pct := 0.0
if used+avail > 0 {
pct = float64(used) / float64(used+avail) * 100
}
result = append(result, ZFSDataset{Name: f[0], Pool: strings.Split(f[0], "/")[0], UsedBytes: used, AvailableBytes: avail, ReferencedBytes: refer, Mountpoint: strings.Join(f[4:], " "), CapacityPercent: pct})
}
return result
}
func readZFSARC() ZFSARC {
data, err := os.ReadFile("/proc/spl/kstat/zfs/arcstats")
if err != nil {
return ZFSARC{}
}
values := map[string]uint64{}
for _, line := range strings.Split(string(data), "\n") {
f := strings.Fields(line)
if len(f) >= 3 {
values[f[0]], _ = strconv.ParseUint(f[2], 10, 64)
}
}
hits, misses := values["hits"], values["misses"]
ratio := 0.0
if hits+misses > 0 {
ratio = float64(hits) / float64(hits+misses) * 100
}
return ZFSARC{Available: true, SizeBytes: values["size"], TargetBytes: values["c"], HitRatio: ratio, Hits: hits, Misses: misses}
}