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"` PreviousSizeBytes uint64 `json:"previousSize,omitempty"` SizeChangePercent *float64 `json:"sizeChangePercent,omitempty"` PreviousCreatedAt int64 `json:"previousCtime,omitempty"` } 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 { byGuest := make(map[int][]BackupItem) for _, item := range items { if item.VMID == 0 { continue } byGuest[item.VMID] = append(byGuest[item.VMID], item) } result := make([]BackupItem, 0, len(byGuest)) for _, history := range byGuest { sort.Slice(history, func(i, j int) bool { return history[i].CreatedAt > history[j].CreatedAt }) item := history[0] if len(history) > 1 { item.PreviousSizeBytes = history[1].SizeBytes item.PreviousCreatedAt = history[1].CreatedAt if history[1].SizeBytes > 0 { change := (float64(item.SizeBytes) - float64(history[1].SizeBytes)) / float64(history[1].SizeBytes) * 100 item.SizeChangePercent = &change } } 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" }