diff --git a/README.md b/README.md
index f1745c3..8f9156f 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,12 @@
ожидаемый код и текст, TLS-сертификат, uptime за сутки и неделю;
- ошибки systemd и ядра за 24 часа, упавшие службы, ошибки и drops каждого
сетевого интерфейса.
+- snapshots VM/LXC с возрастом, описанием и доступным размером;
+- последние задачи Proxmox: backup, start/stop, snapshot, migration, пользователь,
+ длительность и результат;
+- сравнение размера backup с предыдущей копией и ручную безопасную проверку
+ читаемости локального архива или конфигурации PBS snapshot;
+- карту homelab: Proxmox, VM/LXC, сервисы и используемые storage.
Через кнопку настроек можно скрывать неиспользуемые карточки и переключать
светлую или тёмную тему. Карточки можно переставлять перетаскиванием или
diff --git a/activity.go b/activity.go
new file mode 100644
index 0000000..8c5b8aa
--- /dev/null
+++ b/activity.go
@@ -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
+}
diff --git a/activity_test.go b/activity_test.go
new file mode 100644
index 0000000..124b88a
--- /dev/null
+++ b/activity_test.go
@@ -0,0 +1,9 @@
+package main
+
+import "testing"
+
+func TestUintValue(t *testing.T) {
+ if uintValue("42") != 42 || uintValue(float64(7)) != 7 || uintValue(nil) != 0 {
+ t.Fatal("unexpected numeric conversion")
+ }
+}
diff --git a/alerts.go b/alerts.go
index 9648230..8f617b5 100644
--- a/alerts.go
+++ b/alerts.go
@@ -164,6 +164,27 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
if networkErrors > 0 {
add("network-errors", "warning", "Сеть", "Сетевые ошибки и потери пакетов", fmt.Sprintf("Суммарный счётчик интерфейсов: %d.", networkErrors))
}
+ oldSnapshots := 0
+ for _, snapshot := range metrics.Activity.Snapshots {
+ if snapshot.AgeSeconds > 30*24*3600 {
+ oldSnapshots++
+ }
+ }
+ if oldSnapshots > 0 {
+ add("old-snapshots", "warning", "VM/LXC", "Есть старые snapshots", fmt.Sprintf("Снимков старше 30 дней: %d.", oldSnapshots))
+ }
+ for _, task := range metrics.Activity.Tasks {
+ if task.StartTime < time.Now().Add(-24*time.Hour).Unix() {
+ continue
+ }
+ if task.Status != "" && task.Status != "OK" {
+ severity := "warning"
+ if task.Type == "vzdump" {
+ severity = "critical"
+ }
+ add("task-failed-"+task.UPID, severity, "Proxmox", task.Type+": задача завершилась ошибкой", fmt.Sprintf("VMID %s, пользователь %s, статус %s.", task.ID, task.User, task.Status))
+ }
+ }
priority := map[string]int{"critical": 0, "warning": 1, "info": 2}
sort.SliceStable(alerts, func(i, j int) bool {
diff --git a/backups.go b/backups.go
index 298f430..13fd3d9 100644
--- a/backups.go
+++ b/backups.go
@@ -12,12 +12,15 @@ import (
)
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"`
+ 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 {
@@ -89,17 +92,25 @@ func readBackups() BackupMetrics {
}
func latestBackups(items []BackupItem) []BackupItem {
- latest := make(map[int]BackupItem)
+ byGuest := 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
- }
+ byGuest[item.VMID] = append(byGuest[item.VMID], item)
}
- result := make([]BackupItem, 0, len(latest))
- for _, item := range latest {
+ 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 })
diff --git a/backups_test.go b/backups_test.go
index b9c66e9..138d4f2 100644
--- a/backups_test.go
+++ b/backups_test.go
@@ -12,4 +12,15 @@ func TestLatestBackups(t *testing.T) {
if len(latest) != 2 || latest[0].Storage != "new" {
t.Fatalf("неожиданный список последних копий: %+v", latest)
}
+ if latest[0].PreviousSizeBytes != 0 {
+ t.Fatalf("unexpected previous size: %+v", latest[0])
+ }
+}
+
+func TestLatestBackupsComparesSize(t *testing.T) {
+ items := []BackupItem{{VMID: 100, CreatedAt: 20, SizeBytes: 120}, {VMID: 100, CreatedAt: 10, SizeBytes: 100}}
+ latest := latestBackups(items)
+ if len(latest) != 1 || latest[0].SizeChangePercent == nil || *latest[0].SizeChangePercent != 20 {
+ t.Fatalf("unexpected comparison: %+v", latest)
+ }
}
diff --git a/web.go b/web.go
index dc96b5c..ab6862d 100644
--- a/web.go
+++ b/web.go
@@ -25,10 +25,11 @@ type dashboardMetrics struct {
Maintenance MaintenanceMetrics `json:"maintenance"`
Services ServicesMetrics `json:"services"`
SystemHealth SystemHealthMetrics `json:"systemHealth"`
+ Activity ProxmoxActivity `json:"activity"`
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, 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) (dashboardMetrics, error) {
cpu, err := cpuCollector.collect()
if err != nil {
return dashboardMetrics{}, err
@@ -51,6 +52,7 @@ 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.Activity = activity.collect(metrics.Guests)
decorateNetworkInterfaces(&metrics.Network, metrics.Guests)
metrics.Alerts = evaluateAlerts(metrics, store.Thresholds())
metrics.Alerts, err = store.SyncAlerts(metrics.Alerts)
@@ -112,8 +114,9 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
maintenance := &maintenanceCollector{}
services := newServiceCollector(store)
systemHealth := newSystemHealthCollector()
+ activity := newActivityCollector()
collect := func() (dashboardMetrics, error) {
- return collectDashboard(collector, network, disks, zfs, ups, guests, backups, maintenance, services, systemHealth, store)
+ return collectDashboard(collector, network, disks, zfs, ups, guests, backups, maintenance, services, systemHealth, activity, store)
}
go runHistoryCollector(collect, store)
go runEmailNotifier(collect, store)
@@ -256,6 +259,20 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
points, err := store.ServiceHistory(id, hours)
writeJSON(w, points, err)
})
+ mux.HandleFunc("/api/backups/verify", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ var request struct {
+ VolumeID string `json:"volid"`
+ }
+ if json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&request) != nil {
+ http.Error(w, "Некорректный backup", http.StatusBadRequest)
+ return
+ }
+ writeJSON(w, verifyBackup(request.VolumeID), nil)
+ })
mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
@@ -328,6 +345,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
.save-settings:disabled{cursor:wait;opacity:.55}[data-settings-panel="email"] .threshold-item input{width:min(210px,52vw)}.ack-alert{margin-top:11px;padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font-size:11px;cursor:pointer}.history-state{display:inline-block;margin-top:8px;color:var(--muted);font-size:11px}.alert-panel[hidden]{display:none}
.maintenance .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.card-order{display:grid;gap:7px;margin-top:11px}.order-item{display:flex;align-items:center;gap:10px;padding:9px 10px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}.order-item.dragging{opacity:.45}.order-handle{cursor:grab;color:var(--muted);font-size:17px}.order-item span:nth-child(2){flex:1}.order-button{width:30px;height:28px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--text);cursor:pointer}
.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}
@@ -405,6 +423,18 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Упавшие службы—
Ошибки ядра—
Нажмите, чтобы посмотреть журнал →
+
+
+
@@ -510,6 +540,10 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Упавших служб нет.
+
+
+
+