Add backup diagnostics snapshots tasks and homelab map

This commit is contained in:
Maxim
2026-08-01 13:50:58 +03:00
parent 92607f62d5
commit f1356fec37
7 changed files with 306 additions and 17 deletions

189
activity.go Normal file
View File

@@ -0,0 +1,189 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type ProxmoxTask struct {
UPID string `json:"upid"`
Node string `json:"node"`
Type string `json:"type"`
ID string `json:"id"`
User string `json:"user"`
Status string `json:"status"`
StartTime int64 `json:"starttime"`
EndTime int64 `json:"endtime"`
Duration int64 `json:"duration"`
}
type GuestSnapshot struct {
VMID int `json:"vmid"`
GuestName string `json:"guestName"`
GuestType string `json:"guestType"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt int64 `json:"createdAt"`
Running bool `json:"running"`
Parent string `json:"parent,omitempty"`
SizeBytes *uint64 `json:"sizeBytes,omitempty"`
AgeSeconds int64 `json:"ageSeconds"`
}
type ProxmoxActivity struct {
Available bool `json:"available"`
Tasks []ProxmoxTask `json:"tasks"`
Snapshots []GuestSnapshot `json:"snapshots"`
CheckedAt int64 `json:"checkedAt"`
}
type activityCollector struct {
mu sync.RWMutex
value ProxmoxActivity
refreshing bool
}
func newActivityCollector() *activityCollector {
c := &activityCollector{refreshing: true}
go c.refresh()
return c
}
func (c *activityCollector) collect(guests GuestsMetrics) ProxmoxActivity {
c.mu.RLock()
value := c.value
c.mu.RUnlock()
c.mu.Lock()
if !c.refreshing && time.Since(time.Unix(value.CheckedAt, 0)) > 10*time.Minute {
c.refreshing = true
go c.refreshWithGuests(guests)
}
c.mu.Unlock()
return value
}
func (c *activityCollector) refresh() { c.refreshWithGuests(readGuests()) }
func (c *activityCollector) refreshWithGuests(guests GuestsMetrics) {
value := readProxmoxActivity(guests)
c.mu.Lock()
c.value = value
c.refreshing = false
c.mu.Unlock()
}
func readProxmoxActivity(guests GuestsMetrics) ProxmoxActivity {
result := ProxmoxActivity{CheckedAt: time.Now().Unix()}
if _, err := exec.LookPath("pvesh"); err != nil {
return result
}
node, err := os.Hostname()
if err != nil {
return result
}
result.Available = true
out := commandOutput(8*time.Second, "pvesh", "get", "/nodes/"+node+"/tasks", "--limit", "100", "--output-format", "json")
if out != "" && json.Unmarshal([]byte(out), &result.Tasks) == nil {
for i := range result.Tasks {
if result.Tasks[i].EndTime > result.Tasks[i].StartTime {
result.Tasks[i].Duration = result.Tasks[i].EndTime - result.Tasks[i].StartTime
}
}
}
for _, guest := range guests.Guests {
if guest.Template != 0 {
continue
}
kind := "qemu"
label := "VM"
if guest.Type != "qemu" {
kind = "lxc"
label = "LXC"
}
out := commandOutput(5*time.Second, "pvesh", "get", fmt.Sprintf("/nodes/%s/%s/%d/snapshot", guest.Node, kind, guest.VMID), "--output-format", "json")
if out == "" {
continue
}
var raw []struct {
Name string `json:"name"`
Description string `json:"description"`
SnapTime int64 `json:"snaptime"`
Running int `json:"running"`
Parent string `json:"parent"`
VMState any `json:"vmstate"`
}
if json.Unmarshal([]byte(out), &raw) != nil {
continue
}
for _, s := range raw {
if s.Name == "current" {
continue
}
item := GuestSnapshot{VMID: guest.VMID, GuestName: guest.Name, GuestType: label, Name: s.Name, Description: s.Description, CreatedAt: s.SnapTime, Running: s.Running != 0, Parent: s.Parent}
if item.CreatedAt > 0 {
item.AgeSeconds = time.Now().Unix() - item.CreatedAt
}
if size := uintValue(s.VMState); size > 1 {
item.SizeBytes = &size
}
result.Snapshots = append(result.Snapshots, item)
}
}
sort.Slice(result.Snapshots, func(i, j int) bool { return result.Snapshots[i].CreatedAt > result.Snapshots[j].CreatedAt })
return result
}
func uintValue(v any) uint64 {
switch n := v.(type) {
case float64:
return uint64(n)
case string:
value, _ := strconv.ParseUint(n, 10, 64)
return value
}
return 0
}
type BackupCheckResult struct {
OK bool `json:"ok"`
Level string `json:"level"`
Message string `json:"message"`
CheckedAt int64 `json:"checkedAt"`
}
func verifyBackup(volid string) BackupCheckResult {
result := BackupCheckResult{CheckedAt: time.Now().Unix(), Level: "metadata"}
if volid == "" || strings.ContainsAny(volid, "\r\n") {
result.Message = "Некорректный идентификатор backup"
return result
}
out, err := exec.Command("pvesm", "path", volid).CombinedOutput()
if err != nil {
result.Message = "Backup не найден в подключённом storage: " + strings.TrimSpace(string(out))
return result
}
path := strings.TrimSpace(string(out))
if strings.HasPrefix(path, "pbs://") {
ctxOutput := commandOutput(20*time.Second, "pvesm", "extractconfig", volid)
if ctxOutput == "" {
result.Message = "Snapshot найден в PBS, но конфигурацию для тестового чтения получить не удалось"
return result
}
result.OK = true
result.Level = "pbs-metadata"
result.Message = "PBS snapshot и его конфигурация успешно прочитаны. Полное восстановление VM не запускалось."
return result
}
info, err := os.Stat(path)
if err != nil {
result.Message = "Метаданные доступны, но локальный файл не открыт: " + err.Error()
return result
}
if info.Size() == 0 {
result.Message = "Файл backup пуст"
return result
}
result.OK = true
result.Level = "readability"
result.Message = fmt.Sprintf("Файл доступен для чтения, размер %d байт. Полное тестовое восстановление не запускалось.", info.Size())
return result
}