diff --git a/README.md b/README.md index 2e09b7b..d6d0c58 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,20 @@ информационные сообщения по CPU, памяти, storage, SMART, ZFS, UPS, VM/LXC и резервным копиям. Критические события всегда отображаются первыми. +Пороги уведомлений сохраняются в SQLite и настраиваются через вкладку +`Настройки → Уведомления`. Панель раз в минуту сохраняет историю CPU, +температуры, памяти, swap, сети, storage, ZFS, UPS и гостей. Данные старше семи +дней удаляются автоматически. Графики находятся внутри подробных окон карточек. + +База данных по умолчанию находится здесь: + +```text +/var/lib/proxmox-dashboard/dashboard.db +``` + +Готовые версии для Linux публикуются в разделе Releases репозитория. Бинарник +не требует установки Go или системной библиотеки SQLite. + На главном экране находятся компактные карточки CPU, памяти, диска и сети. Нажатие на карточку открывает подробные показатели. diff --git a/alerts.go b/alerts.go index 526d3a8..bfc57cf 100644 --- a/alerts.go +++ b/alerts.go @@ -15,37 +15,37 @@ type Alert struct { Message string `json:"message"` } -func evaluateAlerts(metrics dashboardMetrics) []Alert { +func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Alert { var alerts []Alert add := func(id, severity, source, title, message string) { alerts = append(alerts, Alert{ID: id, Severity: severity, Source: source, Title: title, Message: message}) } - if metrics.CPU.UsagePercent >= 95 { + if metrics.CPU.UsagePercent >= thresholds.CPUCritical { add("cpu-usage", "critical", "CPU", "Очень высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent)) - } else if metrics.CPU.UsagePercent >= 85 { + } else if metrics.CPU.UsagePercent >= thresholds.CPUWarning { add("cpu-usage", "warning", "CPU", "Высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent)) } for _, temperature := range metrics.CPU.Temperatures { - if temperature.Celsius >= 90 { + if temperature.Celsius >= thresholds.CPUTempCritical { add("cpu-temp-"+temperature.Label, "critical", "CPU", "Критическая температура CPU", fmt.Sprintf("%s: %.1f °C.", temperature.Label, temperature.Celsius)) - } else if temperature.Celsius >= 80 { + } else if temperature.Celsius >= thresholds.CPUTempWarning { add("cpu-temp-"+temperature.Label, "warning", "CPU", "Высокая температура CPU", fmt.Sprintf("%s: %.1f °C.", temperature.Label, temperature.Celsius)) } } - if metrics.Memory.UsagePercent >= 95 { + if metrics.Memory.UsagePercent >= thresholds.MemoryCritical { add("memory", "critical", "Память", "Почти закончилась оперативная память", fmt.Sprintf("Используется %.1f%% RAM.", metrics.Memory.UsagePercent)) - } else if metrics.Memory.UsagePercent >= 85 { + } else if metrics.Memory.UsagePercent >= thresholds.MemoryWarning { add("memory", "warning", "Память", "Высокое использование памяти", fmt.Sprintf("Используется %.1f%% RAM.", metrics.Memory.UsagePercent)) } - if metrics.Memory.SwapTotalBytes > 0 && metrics.Memory.SwapPercent >= 50 { + if metrics.Memory.SwapTotalBytes > 0 && metrics.Memory.SwapPercent >= thresholds.SwapWarning { add("swap", "warning", "Память", "Активно используется swap", fmt.Sprintf("Занято %.1f%% swap.", metrics.Memory.SwapPercent)) } - if metrics.Storage.UsagePercent >= 95 { + if metrics.Storage.UsagePercent >= thresholds.StorageCritical { add("root-storage", "critical", "Storage", "Системный раздел почти заполнен", fmt.Sprintf("Раздел / заполнен на %.1f%%.", metrics.Storage.UsagePercent)) - } else if metrics.Storage.UsagePercent >= 85 { + } else if metrics.Storage.UsagePercent >= thresholds.StorageWarning { add("root-storage", "warning", "Storage", "Мало места на системном разделе", fmt.Sprintf("Раздел / заполнен на %.1f%%.", metrics.Storage.UsagePercent)) } for _, disk := range metrics.Disks { @@ -55,9 +55,9 @@ func evaluateAlerts(metrics dashboardMetrics) []Alert { add("disk-smart-"+disk.Name, "warning", "Диски", disk.Model+": SMART требует внимания", joinReasons(disk.AttentionReasons, "Проверьте SMART-показатели диска.")) } if disk.Temperature != nil { - limit := 60.0 + limit := thresholds.DiskTempWarning if strings.Contains(disk.Type, "NVMe") { - limit = 75 + limit = thresholds.NVMeTempWarning } if *disk.Temperature >= limit { add("disk-temp-"+disk.Name, "warning", "Диски", disk.Model+": высокая температура", fmt.Sprintf("Температура %.1f °C.", *disk.Temperature)) @@ -69,9 +69,9 @@ func evaluateAlerts(metrics dashboardMetrics) []Alert { if pool.Health != "ONLINE" { add("zfs-health-"+pool.Name, "critical", "ZFS", pool.Name+": пул не ONLINE", joinReasons(pool.AttentionReasons, "Текущее состояние: "+pool.Health+".")) } - if pool.CapacityPercent >= 90 { + if pool.CapacityPercent >= thresholds.ZFSCritical { add("zfs-capacity-"+pool.Name, "critical", "ZFS", pool.Name+": пул почти заполнен", fmt.Sprintf("Заполнение %.1f%%.", pool.CapacityPercent)) - } else if pool.CapacityPercent >= 80 { + } else if pool.CapacityPercent >= thresholds.ZFSWarning { add("zfs-capacity-"+pool.Name, "warning", "ZFS", pool.Name+": мало свободного места", fmt.Sprintf("Заполнение %.1f%%.", pool.CapacityPercent)) } if strings.Contains(strings.ToLower(pool.Scan), "in progress") { @@ -86,7 +86,7 @@ func evaluateAlerts(metrics dashboardMetrics) []Alert { } else if strings.Contains(status, "OB") || status == "ONBATT" { add("ups-battery", "warning", "UPS", "Сервер работает от UPS", "Основное питание отсутствует, используется батарея.") } - if metrics.UPS.ChargePercent != nil && *metrics.UPS.ChargePercent < 30 { + if metrics.UPS.ChargePercent != nil && *metrics.UPS.ChargePercent < thresholds.UPSChargeCritical { add("ups-charge", "critical", "UPS", "Низкий заряд батареи", fmt.Sprintf("Осталось %.1f%%.", *metrics.UPS.ChargePercent)) } } @@ -111,8 +111,8 @@ func evaluateAlerts(metrics dashboardMetrics) []Alert { continue } age := now.Sub(time.Unix(backup.CreatedAt, 0)) - if age > 48*time.Hour { - add(fmt.Sprintf("backup-old-%d", guest.VMID), "warning", "Backups", name+": копия старше 48 часов", fmt.Sprintf("Последняя копия создана %.0f часов назад на %s.", age.Hours(), backup.Storage)) + if age > time.Duration(thresholds.BackupMaxAgeHours*float64(time.Hour)) { + add(fmt.Sprintf("backup-old-%d", guest.VMID), "warning", "Backups", fmt.Sprintf("%s: копия старше %.0f часов", name, thresholds.BackupMaxAgeHours), fmt.Sprintf("Последняя копия создана %.0f часов назад на %s.", age.Hours(), backup.Storage)) } } } diff --git a/alerts_test.go b/alerts_test.go index 74f6951..b205b4b 100644 --- a/alerts_test.go +++ b/alerts_test.go @@ -8,7 +8,7 @@ func TestEvaluateAlerts(t *testing.T) { Storage: StorageMetrics{UsagePercent: 96}, Backups: BackupMetrics{}, Guests: GuestsMetrics{}, } - alerts := evaluateAlerts(metrics) + alerts := evaluateAlerts(metrics, defaultThresholds()) if len(alerts) != 2 { t.Fatalf("ожидалось два уведомления, получено: %+v", alerts) } diff --git a/go.mod b/go.mod index 8a9cfdb..920def0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module proxmox-host-info -go 1.22 +go 1.25.0 + +require modernc.org/sqlite v1.54.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.46.0 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..106ecbe --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/history.go b/history.go new file mode 100644 index 0000000..de9cab4 --- /dev/null +++ b/history.go @@ -0,0 +1,67 @@ +package main + +import ( + "log" + "time" +) + +func runHistoryCollector(collect func() (dashboardMetrics, error), store *Store) { + record := func() { + metrics, err := collect() + if err != nil { + log.Printf("Не удалось собрать исторические метрики: %v", err) + return + } + if err := store.AddHistory(historyPointFromMetrics(metrics)); err != nil { + log.Printf("Не удалось сохранить исторические метрики: %v", err) + } + } + record() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for range ticker.C { + record() + } +} + +func historyPointFromMetrics(metrics dashboardMetrics) HistoryPoint { + point := HistoryPoint{ + Timestamp: time.Now().Unix(), CPUUsage: metrics.CPU.UsagePercent, + MemoryUsage: metrics.Memory.UsagePercent, SwapUsage: metrics.Memory.SwapPercent, + RootUsage: metrics.Storage.UsagePercent, NetworkReceive: metrics.Network.ReceivePerSec, + NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent, + UPSLoad: metrics.UPS.LoadPercent, + } + if len(metrics.CPU.Temperatures) > 0 { + for _, temperature := range metrics.CPU.Temperatures { + point.CPUTemperature += temperature.Celsius + } + point.CPUTemperature /= float64(len(metrics.CPU.Temperatures)) + } + var zfsSize, zfsUsed uint64 + for _, pool := range metrics.ZFS.Pools { + zfsSize += pool.SizeBytes + zfsUsed += pool.AllocatedBytes + } + if zfsSize > 0 { + point.ZFSUsage = float64(zfsUsed) / float64(zfsSize) * 100 + } + var guestCores, guestUsedCPU float64 + var guestMemory, guestMaxMemory uint64 + for _, guest := range metrics.Guests.Guests { + if guest.Template != 0 || guest.Status != "running" { + continue + } + guestCores += guest.MaxCPU + guestUsedCPU += guest.CPU * guest.MaxCPU + guestMemory += guest.MemoryBytes + guestMaxMemory += guest.MaxMemory + } + if guestCores > 0 { + point.GuestsCPU = guestUsedCPU / guestCores * 100 + } + if guestMaxMemory > 0 { + point.GuestsMemory = float64(guestMemory) / float64(guestMaxMemory) * 100 + } + return point +} diff --git a/history_test.go b/history_test.go new file mode 100644 index 0000000..6c77343 --- /dev/null +++ b/history_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestHistoryPointFromMetrics(t *testing.T) { + metrics := dashboardMetrics{ + CPU: CPUMetrics{Temperatures: []Temperature{{Celsius: 40}, {Celsius: 60}}}, + Guests: GuestsMetrics{Guests: []GuestMetrics{{Status: "running", CPU: .5, MaxCPU: 4, MemoryBytes: 50, MaxMemory: 100}}}, + } + point := historyPointFromMetrics(metrics) + if point.CPUTemperature != 50 || point.GuestsCPU != 50 || point.GuestsMemory != 50 { + t.Fatalf("неожиданная историческая точка: %+v", point) + } +} diff --git a/main.go b/main.go index af7e80d..e8b765c 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,9 @@ import ( ) const defaultAddress = ":9105" +const defaultDatabasePath = "/var/lib/proxmox-dashboard/dashboard.db" + +var version = "dev" func main() { address := os.Getenv("DASHBOARD_ADDR") @@ -24,15 +27,24 @@ func main() { if err != nil { log.Fatalf("Не удалось запустить сборщик CPU: %v", err) } + databasePath := os.Getenv("DASHBOARD_DB_PATH") + if databasePath == "" { + databasePath = defaultDatabasePath + } + store, err := openStore(databasePath) + if err != nil { + log.Fatalf("Не удалось открыть базу данных: %v", err) + } + defer store.Close() server := &http.Server{ Addr: address, - Handler: routes(collector), + Handler: routes(collector, store), ReadHeaderTimeout: 5 * time.Second, } go func() { - log.Printf("Proxmox CPU Dashboard запущен на http://0.0.0.0%s", address) + log.Printf("Proxmox Dashboard %s запущен на http://0.0.0.0%s", version, address) if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Ошибка веб-сервера: %v", err) } diff --git a/proxmox-cpu-dashboard.service b/proxmox-cpu-dashboard.service index ca2dfb0..df72dfa 100644 --- a/proxmox-cpu-dashboard.service +++ b/proxmox-cpu-dashboard.service @@ -7,6 +7,9 @@ Wants=network-online.target Type=simple ExecStart=/usr/local/bin/proxmox-cpu-dashboard Environment=DASHBOARD_ADDR=:9105 +Environment=DASHBOARD_DB_PATH=/var/lib/proxmox-dashboard/dashboard.db +StateDirectory=proxmox-dashboard +StateDirectoryMode=0750 Restart=on-failure RestartSec=3 NoNewPrivileges=true diff --git a/store.go b/store.go new file mode 100644 index 0000000..ad2beb1 --- /dev/null +++ b/store.go @@ -0,0 +1,181 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +const historyRetention = 7 * 24 * time.Hour + +type AlertThresholds struct { + CPUWarning float64 `json:"cpuWarning"` + CPUCritical float64 `json:"cpuCritical"` + CPUTempWarning float64 `json:"cpuTempWarning"` + CPUTempCritical float64 `json:"cpuTempCritical"` + MemoryWarning float64 `json:"memoryWarning"` + MemoryCritical float64 `json:"memoryCritical"` + SwapWarning float64 `json:"swapWarning"` + StorageWarning float64 `json:"storageWarning"` + StorageCritical float64 `json:"storageCritical"` + ZFSWarning float64 `json:"zfsWarning"` + ZFSCritical float64 `json:"zfsCritical"` + DiskTempWarning float64 `json:"diskTempWarning"` + NVMeTempWarning float64 `json:"nvmeTempWarning"` + UPSChargeCritical float64 `json:"upsChargeCritical"` + BackupMaxAgeHours float64 `json:"backupMaxAgeHours"` +} + +func defaultThresholds() AlertThresholds { + return AlertThresholds{ + CPUWarning: 85, CPUCritical: 95, CPUTempWarning: 80, CPUTempCritical: 90, + MemoryWarning: 85, MemoryCritical: 95, SwapWarning: 50, + StorageWarning: 85, StorageCritical: 95, ZFSWarning: 80, ZFSCritical: 90, + DiskTempWarning: 60, NVMeTempWarning: 75, UPSChargeCritical: 30, + BackupMaxAgeHours: 48, + } +} + +type HistoryPoint struct { + Timestamp int64 `json:"timestamp"` + CPUUsage float64 `json:"cpuUsage"` + CPUTemperature float64 `json:"cpuTemperature"` + MemoryUsage float64 `json:"memoryUsage"` + SwapUsage float64 `json:"swapUsage"` + RootUsage float64 `json:"rootUsage"` + NetworkReceive float64 `json:"networkReceive"` + NetworkTransmit float64 `json:"networkTransmit"` + UPSCharge *float64 `json:"upsCharge"` + UPSLoad *float64 `json:"upsLoad"` + ZFSUsage float64 `json:"zfsUsage"` + GuestsCPU float64 `json:"guestsCpu"` + GuestsMemory float64 `json:"guestsMemory"` +} + +type Store struct{ db *sql.DB } + +func openStore(path string) (*Store, error) { + if path != ":memory:" { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, err + } + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + statements := []string{ + `PRAGMA journal_mode=WAL`, + `PRAGMA busy_timeout=5000`, + `CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS history ( + ts INTEGER PRIMARY KEY, cpu_usage REAL NOT NULL, cpu_temp REAL NOT NULL, + memory_usage REAL NOT NULL, swap_usage REAL NOT NULL, root_usage REAL NOT NULL, + network_rx REAL NOT NULL, network_tx REAL NOT NULL, ups_charge REAL, ups_load REAL, + zfs_usage REAL NOT NULL, guests_cpu REAL NOT NULL, guests_memory REAL NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS history_ts_idx ON history(ts)`, + } + for _, statement := range statements { + if _, err := db.Exec(statement); err != nil { + db.Close() + return nil, err + } + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) Thresholds() AlertThresholds { + defaults := defaultThresholds() + var value string + if err := s.db.QueryRow(`SELECT value FROM settings WHERE key='alert_thresholds'`).Scan(&value); err != nil { + return defaults + } + if json.Unmarshal([]byte(value), &defaults) != nil { + return defaultThresholds() + } + return defaults +} + +func (s *Store) SaveThresholds(value AlertThresholds) error { + if err := validateThresholds(value); err != nil { + return err + } + data, _ := json.Marshal(value) + _, err := s.db.Exec(`INSERT INTO settings(key,value) VALUES('alert_thresholds',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data)) + return err +} + +func validateThresholds(t AlertThresholds) error { + percentages := []float64{t.CPUWarning, t.CPUCritical, t.MemoryWarning, t.MemoryCritical, t.SwapWarning, t.StorageWarning, t.StorageCritical, t.ZFSWarning, t.ZFSCritical, t.UPSChargeCritical} + for _, value := range percentages { + if value < 0 || value > 100 { + return fmt.Errorf("процентные пороги должны быть от 0 до 100") + } + } + temperatures := []float64{t.CPUTempWarning, t.CPUTempCritical, t.DiskTempWarning, t.NVMeTempWarning} + for _, value := range temperatures { + if value < 0 || value > 150 { + return fmt.Errorf("температурные пороги должны быть от 0 до 150") + } + } + if t.CPUWarning >= t.CPUCritical || t.CPUTempWarning >= t.CPUTempCritical || t.MemoryWarning >= t.MemoryCritical || t.StorageWarning >= t.StorageCritical || t.ZFSWarning >= t.ZFSCritical { + return fmt.Errorf("порог предупреждения должен быть ниже критического") + } + if t.BackupMaxAgeHours < 1 || t.BackupMaxAgeHours > 24*365 { + return fmt.Errorf("возраст backup должен быть от 1 до 8760 часов") + } + return nil +} + +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 { + return err + } + _, err = s.db.Exec(`DELETE FROM history WHERE ts < ?`, time.Now().Add(-historyRetention).Unix()) + return err +} + +func (s *Store) History(hours int) ([]HistoryPoint, error) { + if hours < 1 { + hours = 24 + } + if hours > 168 { + hours = 168 + } + bucket := int64(60) + if hours > 48 { + bucket = 600 + } + rows, err := s.db.Query(`SELECT (ts/?)*?, AVG(cpu_usage),AVG(cpu_temp),AVG(memory_usage),AVG(swap_usage),AVG(root_usage),AVG(network_rx),AVG(network_tx),AVG(ups_charge),AVG(ups_load),AVG(zfs_usage),AVG(guests_cpu),AVG(guests_memory) FROM history WHERE ts>=? GROUP BY (ts/?) ORDER BY ts`, bucket, bucket, time.Now().Add(-time.Duration(hours)*time.Hour).Unix(), bucket) + if err != nil { + return nil, err + } + defer rows.Close() + var points []HistoryPoint + for rows.Next() { + var point HistoryPoint + var upsCharge, upsLoad sql.NullFloat64 + if err := rows.Scan(&point.Timestamp, &point.CPUUsage, &point.CPUTemperature, &point.MemoryUsage, &point.SwapUsage, &point.RootUsage, &point.NetworkReceive, &point.NetworkTransmit, &upsCharge, &upsLoad, &point.ZFSUsage, &point.GuestsCPU, &point.GuestsMemory); err != nil { + return nil, err + } + if upsCharge.Valid { + point.UPSCharge = &upsCharge.Float64 + } + if upsLoad.Valid { + point.UPSLoad = &upsLoad.Float64 + } + points = append(points, point) + } + return points, rows.Err() +} diff --git a/store_test.go b/store_test.go new file mode 100644 index 0000000..fbe3e93 --- /dev/null +++ b/store_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" + "time" +) + +func TestStoreSettingsAndHistory(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + + thresholds := defaultThresholds() + thresholds.CPUWarning = 70 + if err := store.SaveThresholds(thresholds); err != nil { + t.Fatal(err) + } + if loaded := store.Thresholds(); loaded.CPUWarning != 70 { + t.Fatalf("настройки не сохранились: %+v", loaded) + } + + if err := store.AddHistory(HistoryPoint{Timestamp: time.Now().Unix(), CPUUsage: 42}); err != nil { + t.Fatal(err) + } + points, err := store.History(1) + if err != nil || len(points) != 1 || points[0].CPUUsage != 42 { + t.Fatalf("история не сохранилась: points=%+v err=%v", points, err) + } +} diff --git a/web.go b/web.go index 3be8dc8..ea39df2 100644 --- a/web.go +++ b/web.go @@ -3,7 +3,9 @@ package main import ( "encoding/json" "fmt" + "io" "net/http" + "strconv" "time" ) @@ -21,7 +23,7 @@ type dashboardMetrics struct { Timestamp time.Time `json:"timestamp"` } -func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector, backupCollector *backupCollector) (dashboardMetrics, error) { +func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector, backupCollector *backupCollector, store *Store) (dashboardMetrics, error) { cpu, err := cpuCollector.collect() if err != nil { return dashboardMetrics{}, err @@ -44,11 +46,11 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle ZFS: zfsCollector.collect(), UPS: upsCollector.collect(), Guests: guestCollector.collect(), Backups: backupCollector.collect(), Timestamp: time.Now(), } - metrics.Alerts = evaluateAlerts(metrics) + metrics.Alerts = evaluateAlerts(metrics, store.Thresholds()) return metrics, nil } -func routes(collector *cpuCollector) http.Handler { +func routes(collector *cpuCollector, store *Store) http.Handler { mux := http.NewServeMux() network, networkErr := newNetworkCollector("/proc/net/dev") disks := &diskCollector{} @@ -56,6 +58,10 @@ func routes(collector *cpuCollector) http.Handler { ups := &upsCollector{} guests := &guestCollector{} backups := &backupCollector{} + collect := func() (dashboardMetrics, error) { + return collectDashboard(collector, network, disks, zfs, ups, guests, backups, store) + } + go runHistoryCollector(collect, store) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -68,14 +74,39 @@ func routes(collector *cpuCollector) http.Handler { metrics, err := collector.collect() writeJSON(w, metrics, err) }) + mux.HandleFunc("/api/version", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, map[string]string{"version": version}, nil) }) mux.HandleFunc("/api/system", func(w http.ResponseWriter, _ *http.Request) { if networkErr != nil { writeJSON(w, nil, networkErr) return } - metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests, backups) + metrics, err := collect() writeJSON(w, metrics, err) }) + mux.HandleFunc("/api/settings/alerts", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, store.Thresholds(), nil) + case http.MethodPut: + var thresholds AlertThresholds + if err := json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&thresholds); err != nil { + http.Error(w, "Некорректные настройки", http.StatusBadRequest) + return + } + if err := store.SaveThresholds(thresholds); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, thresholds, nil) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/api/history", func(w http.ResponseWriter, r *http.Request) { + hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) + points, err := store.History(hours) + writeJSON(w, points, err) + }) mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { @@ -92,7 +123,7 @@ func routes(collector *cpuCollector) http.Handler { if networkErr != nil { return } - metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests, backups) + metrics, err := collect() if err == nil { data, _ := json.Marshal(metrics) fmt.Fprintf(w, "data: %s\n\n", data) @@ -139,11 +170,12 @@ h1{font-size:clamp(28px,5vw,44px);letter-spacing:-.045em;margin:9px 0 0}.header- dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding:0;color:var(--text);background:#101820;border:1px solid #34434f;border-radius:22px;box-shadow:0 30px 100px #000b;overflow:auto}dialog::backdrop{background:rgba(3,7,10,.75);backdrop-filter:blur(6px)} .modal-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;padding:26px 28px;background:rgba(16,24,32,.94);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}.modal-head h2{font-size:24px;letter-spacing:-.03em;margin:5px 0 0}.close{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:#17222b;color:var(--text);font-size:22px;cursor:pointer}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr)}.detail{padding:22px 27px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.detail:nth-child(3n){border-right:0}.detail strong{display:block;font:650 24px/1.2 ui-monospace,monospace;margin-top:7px}.wide{padding:24px 28px}.temps{display:flex;flex-wrap:wrap;gap:9px;margin-top:12px}.temp{padding:7px 11px;border:1px solid var(--line);border-radius:99px}.temp b{color:var(--green);margin-left:7px}.empty{color:var(--muted);font-size:13px} .disk-list{display:grid;gap:12px;padding:16px}.disk-item{padding:19px;border:1px solid var(--line);border-radius:15px;background:#0c141a}.disk-top{display:flex;justify-content:space-between;gap:20px}.disk-model{font-size:17px;font-weight:650}.disk-meta{color:var(--muted);font:12px/1.5 ui-monospace,monospace;margin-top:4px;overflow-wrap:anywhere}.badge{position:relative;height:max-content;padding:5px 9px;border:1px solid #2d6245;border-radius:99px;color:var(--green);font-size:11px;white-space:nowrap}.badge.warn{color:#ffb35c;border-color:#6d4c2c}.badge.bad{color:#ff755c;border-color:#71362f}.badge[data-reason]:hover::after,.badge[data-reason]:focus::after{content:attr(data-reason);position:absolute;z-index:5;right:0;bottom:calc(100% + 10px);width:280px;padding:11px 13px;border:1px solid #4c5963;border-radius:10px;background:#1a242c;color:var(--text);font:12px/1.45 Inter,system-ui,sans-serif;white-space:normal;box-shadow:0 14px 35px #0008}.disk-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-top:18px}.disk-stat span{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.disk-stat b{display:block;margin-top:5px;font:600 14px ui-monospace,monospace} -.settings-body{padding:22px 26px 28px}.settings-group+.settings-group{margin-top:25px}.theme-options{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:11px}.theme-option{padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--text);cursor:pointer}.theme-option.active{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green)}.setting-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:11px}.setting-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.setting-item input{accent-color:var(--green);width:17px;height:17px}.card[hidden]{display:none} +.settings-tabs{display:flex;gap:6px;padding:12px 26px 0}.settings-tab{padding:9px 13px;border:1px solid transparent;border-radius:10px;background:transparent;color:var(--muted);cursor:pointer}.settings-tab.active{background:var(--panel);border-color:var(--line);color:var(--text)}.settings-body{padding:22px 26px 28px}.settings-panel[hidden]{display:none}.settings-group+.settings-group{margin-top:25px}.theme-options{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:11px}.theme-option{padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--text);cursor:pointer}.theme-option.active{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green)}.setting-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:11px}.setting-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.setting-item input{accent-color:var(--green);width:17px;height:17px}.threshold-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.threshold-item{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.threshold-item span{font-size:13px}.threshold-item input{width:78px;padding:7px 8px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--text);font:600 14px ui-monospace,monospace}.save-settings{width:100%;margin-top:16px;padding:12px;border:0;border-radius:11px;background:var(--green);color:#07120c;font-weight:700;cursor:pointer}.settings-note{color:var(--muted);font-size:12px;margin-top:10px}.card[hidden]{display:none} +.chart-section{padding:22px 25px;border-top:1px solid var(--line)}.chart-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.chart-head span{color:var(--muted);font-size:12px}.metric-chart{display:block;width:100%;height:180px;border-radius:12px;background:rgba(100,130,150,.05)} .alert-list{display:grid;gap:10px;padding:16px}.alert-item{display:grid;grid-template-columns:auto 1fr;gap:13px;padding:17px 18px;border:1px solid var(--line);border-radius:14px;background:#0c141a}.alert-icon{width:10px;height:10px;border-radius:50%;margin-top:6px;background:#6ba8ff;box-shadow:0 0 14px currentColor}.alert-item.warning .alert-icon{background:#ffb35c}.alert-item.critical .alert-icon{background:#ff665c}.alert-title{font-weight:650}.alert-source{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.alert-message{color:var(--muted);font-size:13px;margin-top:4px}.all-good{padding:45px 24px;text-align:center}.all-good strong{display:block;font-size:22px;margin-bottom:7px}.all-good span{color:var(--muted)} html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9da 0,transparent 34%),var(--bg)}html[data-theme="light"] .card{background:linear-gradient(145deg,#fff,#f7f9fa)}html[data-theme="light"] dialog{background:#fff;box-shadow:0 30px 80px #4e64752b}html[data-theme="light"] .modal-head{background:rgba(255,255,255,.94)}html[data-theme="light"] .close{background:#f1f4f6}html[data-theme="light"] .disk-item,html[data-theme="light"] .alert-item{background:#f7f9fa}html[data-theme="light"] .bar{background:#e2e8ec}html[data-theme="light"] dialog::backdrop{background:rgba(30,45,55,.35)}html[data-theme="light"] .badge[data-reason]:hover::after,html[data-theme="light"] .badge[data-reason]:focus::after{background:#fff} footer{color:#657581;font-size:12px;margin-top:17px} -@media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats,.setting-list{grid-template-columns:repeat(2,1fr)}} +@media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats,.setting-list,.threshold-grid{grid-template-columns:repeat(2,1fr)}}