diff --git a/README.md b/README.md index 46e0723..d36bf3f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ - состояние, заполнение, фрагментацию и scrub/resilver ZFS-пулов; - состояние UPS через NUT или apcupsd, заряд, автономность, нагрузку и напряжение. - состояние VM и LXC, CPU, память, выделенные ядра и uptime. +- последнюю найденную резервную копию каждой VM/LXC, storage, размер и возраст. + +Через кнопку настроек можно скрывать неиспользуемые карточки и переключать +светлую или тёмную тему. Выбор сохраняется локально в браузере. На главном экране находятся компактные карточки CPU, памяти, диска и сети. Нажатие на карточку открывает подробные показатели. diff --git a/backups.go b/backups.go new file mode 100644 index 0000000..298f430 --- /dev/null +++ b/backups.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "sort" + "strings" + "sync" + "time" +) + +type BackupItem struct { + VMID int `json:"vmid"` + Storage string `json:"storage"` + VolumeID string `json:"volid"` + Format string `json:"format"` + SizeBytes uint64 `json:"size"` + CreatedAt int64 `json:"ctime"` +} + +type BackupMetrics struct { + Available bool `json:"available"` + Items []BackupItem `json:"items"` +} + +type backupCollector struct { + mu sync.Mutex + cached BackupMetrics + updatedAt time.Time +} + +func (c *backupCollector) collect() BackupMetrics { + c.mu.Lock() + defer c.mu.Unlock() + if time.Since(c.updatedAt) < time.Minute { + return c.cached + } + c.cached = readBackups() + c.updatedAt = time.Now() + return c.cached +} + +func readBackups() BackupMetrics { + if _, err := exec.LookPath("pvesh"); err != nil { + return BackupMetrics{} + } + node, err := os.Hostname() + if err != nil { + return BackupMetrics{Available: true} + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "pvesh", "get", "/nodes/"+node+"/storage", "--content", "backup", "--enabled", "1", "--output-format", "json").Output() + result := BackupMetrics{Available: true} + if err != nil { + return result + } + var storages []struct { + Storage string `json:"storage"` + Active int `json:"active"` + } + if json.Unmarshal(output, &storages) != nil { + return result + } + var items []BackupItem + for _, storage := range storages { + if storage.Storage == "" || storage.Active == 0 { + continue + } + content, err := exec.CommandContext(ctx, "pvesh", "get", "/nodes/"+node+"/storage/"+storage.Storage+"/content", "--content", "backup", "--output-format", "json").Output() + if err != nil { + continue + } + var storageItems []BackupItem + if json.Unmarshal(content, &storageItems) == nil { + for i := range storageItems { + storageItems[i].Storage = storage.Storage + if storageItems[i].Format == "" { + storageItems[i].Format = backupFormat(storageItems[i].VolumeID) + } + } + items = append(items, storageItems...) + } + } + result.Items = latestBackups(items) + return result +} + +func latestBackups(items []BackupItem) []BackupItem { + latest := make(map[int]BackupItem) + for _, item := range items { + if item.VMID == 0 { + continue + } + if previous, ok := latest[item.VMID]; !ok || item.CreatedAt > previous.CreatedAt { + latest[item.VMID] = item + } + } + result := make([]BackupItem, 0, len(latest)) + for _, item := range latest { + result = append(result, item) + } + sort.Slice(result, func(i, j int) bool { return result[i].VMID < result[j].VMID }) + return result +} + +func backupFormat(volumeID string) string { + for _, suffix := range []string{".vma.zst", ".vma.lzo", ".vma.gz", ".tar.zst", ".tar.lzo", ".tar.gz"} { + if strings.HasSuffix(volumeID, suffix) { + return strings.TrimPrefix(suffix, ".") + } + } + return "backup" +} diff --git a/backups_test.go b/backups_test.go new file mode 100644 index 0000000..b9c66e9 --- /dev/null +++ b/backups_test.go @@ -0,0 +1,15 @@ +package main + +import "testing" + +func TestLatestBackups(t *testing.T) { + items := []BackupItem{ + {VMID: 100, CreatedAt: 10, Storage: "old"}, + {VMID: 100, CreatedAt: 20, Storage: "new"}, + {VMID: 101, CreatedAt: 15, Storage: "only"}, + } + latest := latestBackups(items) + if len(latest) != 2 || latest[0].Storage != "new" { + t.Fatalf("неожиданный список последних копий: %+v", latest) + } +} diff --git a/web.go b/web.go index 9cbcd24..eb82fd1 100644 --- a/web.go +++ b/web.go @@ -16,10 +16,11 @@ type dashboardMetrics struct { ZFS ZFSMetrics `json:"zfs"` UPS UPSMetrics `json:"ups"` Guests GuestsMetrics `json:"guests"` + Backups BackupMetrics `json:"backups"` Timestamp time.Time `json:"timestamp"` } -func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector) (dashboardMetrics, error) { +func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector, backupCollector *backupCollector) (dashboardMetrics, error) { cpu, err := cpuCollector.collect() if err != nil { return dashboardMetrics{}, err @@ -40,7 +41,7 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle CPU: cpu, Memory: memory, Storage: storage, Disks: diskCollector.collect(), Network: network, ZFS: zfsCollector.collect(), UPS: upsCollector.collect(), - Guests: guestCollector.collect(), Timestamp: time.Now(), + Guests: guestCollector.collect(), Backups: backupCollector.collect(), Timestamp: time.Now(), }, nil } @@ -51,6 +52,7 @@ func routes(collector *cpuCollector) http.Handler { zfs := &zfsCollector{} ups := &upsCollector{} guests := &guestCollector{} + backups := &backupCollector{} mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -68,7 +70,7 @@ func routes(collector *cpuCollector) http.Handler { writeJSON(w, nil, networkErr) return } - metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests) + metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests, backups) writeJSON(w, metrics, err) }) mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { @@ -87,7 +89,7 @@ func routes(collector *cpuCollector) http.Handler { if networkErr != nil { return } - metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests) + metrics, err := collectDashboard(collector, network, disks, zfs, ups, guests, backups) if err == nil { data, _ := json.Marshal(metrics) fmt.Fprintf(w, "data: %s\n\n", data) @@ -118,69 +120,79 @@ const dashboardHTML = `