From f1356fec37098730ac1f78b43fe863bd764c8af1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 1 Aug 2026 13:50:58 +0300 Subject: [PATCH] Add backup diagnostics snapshots tasks and homelab map --- README.md | 6 ++ activity.go | 189 +++++++++++++++++++++++++++++++++++++++++++++++ activity_test.go | 9 +++ alerts.go | 21 ++++++ backups.go | 35 ++++++--- backups_test.go | 11 +++ web.go | 52 +++++++++++-- 7 files changed, 306 insertions(+), 17 deletions(-) create mode 100644 activity.go create mode 100644 activity_test.go 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}
Упавшие службы
Ошибки ядра
Нажмите, чтобы посмотреть журнал →
+ + +
Обновление каждые 2 секунды · данные читаются напрямую с хоста
@@ -510,6 +540,10 @@ footer{color:#657581;font-size:12px;margin-top:17px} +
Получение snapshots…
+
Получение задач…
+
Связь сервиса с VM/LXC определяется по локальному IP. Storage берётся из конфигурации гостя.
+
@@ -520,7 +554,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} - +
Порядок карточек
Перетаскивайте строки или используйте стрелки.
@@ -579,6 +613,11 @@ let currentServiceStatuses=[]; function endpointHTML(v){const tls=v.tlsDays==null?'Нет TLS':v.tlsDays<0?'Сертификат истёк':v.tlsDays+' дн. до окончания',cert=v.tlsSubject?'
Сертификат: '+safe(v.tlsSubject)+'
Домены: '+safe((v.tlsDnsNames||[]).join(', ')||'не указаны')+'
Издатель: '+safe(v.tlsIssuer||'—')+'
Для этого адреса: '+(v.tlsHostnameValid?'подходит':'не подходит')+'
':'';return '
'+safe(v.label)+'
'+safe(v.address)+'
'+(v.up?'Доступен':'Недоступен')+'
Отклик'+fixed(v.latencyMs)+' ms
HTTP'+(v.statusCode||'—')+'
TLS'+tls+'
Uptime · сутки'+fixed(v.uptimeDay)+'%
Uptime · неделя'+fixed(v.uptimeWeek)+'%
'+cert+(v.error?'
'+safe(v.error)+'
':'')+'
'} function renderServices(svc){const items=svc.services||[];currentServiceStatuses=items;const avg=items.length?items.reduce((a,v)=>a+v.uptimeDay,0)/items.length:0,live=items.filter(v=>v.up),latency=live.length?live.reduce((a,v)=>a+v.latencyMs,0)/live.length:0;el('serviceUp').textContent=svc.up||0;el('serviceTotal').textContent=' / '+(svc.total||0);el('serviceBar').style.width=(svc.total?svc.up/svc.total*100:0)+'%';el('serviceUptime').textContent=svc.total?fixed(avg)+'%':'—';el('serviceLatency').textContent=svc.total?fixed(latency)+' ms':'—';el('serviceList').innerHTML=items.length?items.map(v=>'
'+safe(v.name)+'
'+(v.endpoints||[]).length+' '+((v.endpoints||[]).length===1?'адрес':'адреса')+' проверяются независимо
'+(v.up?'Всё доступно':'Есть проблема')+'
'+(v.endpoints||[]).map(endpointHTML).join('')+'
').join(''):'Добавьте первую проверку в настройках.'} function renderNetworkGroups(interfaces){const groups=new Map();interfaces.forEach(v=>{const key=v.vmid?'guest-'+v.vmid:'host',group=groups.get(key)||{vmid:v.vmid,name:v.vmid?(v.guestName||('VMID '+v.vmid)):'Интерфейсы хоста',type:v.guestType||'Host',items:[]};group.items.push(v);groups.set(key,group)});el('networkInterfacesList').innerHTML=[...groups.values()].map(g=>{const primary=g.vmid?g.items.filter(v=>v.name.startsWith('tap')||v.name.startsWith('veth')):g.items,rx=primary.reduce((a,v)=>a+v.receivePerSec,0),tx=primary.reduce((a,v)=>a+v.transmitPerSec,0),errors=g.items.reduce((a,v)=>a+v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops,0);return '
'+safe(g.name)+'
'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name).join(', '))+'
'+(errors?'Есть потери':'Без ошибок')+'
Сейчас ↓'+rate(rx)+'
Сейчас ↑'+rate(tx)+'
Получено'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'
Передано'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'
Ошибки / drops'+errors+'
'}).join('')} +const taskLabel=type=>({vzdump:'Backup',qmstart:'Запуск VM',qmstop:'Остановка VM',qmshutdown:'Выключение VM',qmsnapshot:'Snapshot VM',vzstart:'Запуск LXC',vzstop:'Остановка LXC',vzsnapshot:'Snapshot LXC',qmigrate:'Миграция',aptupdate:'Обновление пакетов'})[type]||type; +let currentTasks=[]; +function renderTasks(tasks,filter='all'){currentTasks=tasks;const shown=tasks.filter(v=>filter==='all'||filter==='error'&&v.status!=='OK'||filter==='qm'&&(/^(qm|vz)/.test(v.type))||v.type===filter);el('taskList').innerHTML=shown.length?shown.map(v=>'
'+safe(taskLabel(v.type))+(v.id?' · '+safe(v.id):'')+'
'+new Date(v.starttime*1000).toLocaleString('ru-RU')+' · '+safe(v.user||'—')+'
'+safe(v.status||'Выполняется')+'
Длительность'+uptime(v.duration||Math.max(0,Date.now()/1000-v.starttime))+'
Узел'+safe(v.node)+'
VMID'+safe(v.id||'—')+'
').join(''):'Задач этого типа нет.'} +function renderActivity(activity,guests,services,zfs){const snapshots=activity.snapshots||[],tasks=activity.tasks||[],old=snapshots.filter(v=>v.ageSeconds>30*86400),guestCount=new Set(snapshots.map(v=>v.vmid)).size,failed=tasks.filter(v=>v.status&&v.status!=='OK');el('snapshotCount').textContent=snapshots.length;el('oldSnapshots').textContent=old.length;el('snapshotGuests').textContent=guestCount;el('snapshotBar').style.width=Math.min(old.length*20,100)+'%';el('snapshotList').innerHTML=snapshots.length?snapshots.map(v=>'
'+safe(v.guestName||('VMID '+v.vmid))+' · '+safe(v.name)+'
VMID '+v.vmid+' · '+safe(v.guestType)+(v.createdAt?' · '+new Date(v.createdAt*1000).toLocaleString('ru-RU'):'')+'
'+(v.ageSeconds?uptime(v.ageSeconds):'Без даты')+'
Размер'+(v.sizeBytes?size(v.sizeBytes):'Storage не сообщает')+'
Состояние VM'+(v.running?'Запущена':'Остановлена')+'
Описание'+safe(v.description||'—')+'
').join(''):'Snapshots не найдены.';el('taskCount').textContent=tasks.length;el('failedTasks').textContent=failed.length;el('lastTask').textContent=tasks.length?taskLabel(tasks[0].type):'—';el('taskBar').style.width=Math.min(failed.length*20,100)+'%';renderTasks(tasks);renderHomelabMap(guests,services,zfs)} +function renderHomelabMap(guests,services,zfs){const guestItems=(guests.guests||[]).filter(v=>!v.template),serviceItems=services.services||[],pools=zfs.pools||[],serviceOwners=new Map();serviceItems.forEach(s=>{const address=(s.localAddress||'');const owner=guestItems.find(g=>(g.ipAddresses||[]).some(ip=>address.includes(ip)));serviceOwners.set(s.id,owner?.vmid||0)});const guestHTML=guestItems.map(g=>'
'+safe(g.name||('VMID '+g.vmid))+'VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+'
').join(''),serviceHTML=serviceItems.map(s=>{const status=(s.endpoints||[]).every(v=>v.up);return '
'+safe(s.name)+''+(serviceOwners.get(s.id)?'VMID '+serviceOwners.get(s.id):'Связь не определена')+'
'}).join(''),storage=new Set(guestItems.flatMap(g=>g.storage||[]));pools.forEach(p=>storage.add(p.name));const storageHTML=[...storage].map(name=>{const pool=pools.find(p=>p.name===name);return '
'+safe(name)+''+(pool?'ZFS · '+fixed(pool.capacityPercent)+'%':'Storage Proxmox')+'
'}).join('');el('mapBoard').innerHTML='
Хост
Proxmox'+safe(location.hostname)+'
VM и LXC
'+guestHTML+'
Сервисы
'+serviceHTML+'
Storage
'+storageHTML+'
';el('mapGuests').textContent=guestItems.length;el('mapResources').textContent=serviceItems.length+' / '+storage.size;el('mapNodes').textContent=1+guestItems.length+serviceItems.length+storage.size;el('mapBar').style.width='100%'} function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null; const update=x.update||{},updating=['queued','installing'].includes(update.state);el('updateCurrent').textContent=update.currentVersion||'—';el('updateAvailable').textContent=update.availableVersion||'—';el('updateMode').textContent=update.mode==='auto'?'Автоматически':'Уведомлять';el('updateMessage').textContent=update.message||'Проверка ещё не выполнялась';el('updateChecked').textContent=update.checkedAt?'Последняя проверка: '+new Date(update.checkedAt).toLocaleString('ru-RU'):'Проверка запускается автоматически раз в сутки.';el('installUpdate').disabled=updating;el('installUpdate').textContent=updating?'Обновление выполняется…':'Проверить и установить обновление'; const critical=alerts.filter(a=>a.severity==='critical').length,warnings=alerts.filter(a=>a.severity==='warning').length,infos=alerts.filter(a=>a.severity==='info').length;el('alertCount').textContent=alerts.length;el('alertCritical').textContent=critical;el('alertWarnings').textContent=warnings;el('alertHeadline').textContent=critical?'Требуется срочное внимание':warnings?'Есть предупреждения':infos?'Есть информационные события':'Система в норме';el('alertBar').style.width=(critical?100:warnings?65:infos?30:0)+'%'; @@ -597,11 +636,12 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av el('guestList').innerHTML=guests.length?guests.map(v=>{const live=v.status==='running',type=v.type==='qemu'?'VM':'LXC',memPercent=v.maxmem?v.mem/v.maxmem*100:0,diskPercent=v.maxdisk?v.disk/v.maxdisk*100:0,ips=(v.ipAddresses||[]).join(', ')||'IP не найден';return '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+' · '+safe(v.node)+' · '+safe(ips)+'
'+(live?'Запущен':'Остановлен')+'
CPU'+(live?fixed(v.cpu*100)+'%':'—')+' / '+v.maxcpu+'
Память'+(live?size(v.mem)+' · '+fixed(memPercent)+'%':size(v.maxmem))+'
Диск'+(v.maxdisk?size(v.disk)+' · '+fixed(diskPercent)+'%':'—')+'
Сеть↓ '+size(v.netin)+' · ↑ '+size(v.netout)+'
Guest Agent'+(v.agent?'Доступен':'Нет')+'
Автозапуск'+(v.onboot?'Да':'Нет')+'
Storage'+safe((v.storage||[]).join(', ')||'—')+'
Uptime'+(live?uptime(v.uptime):'—')+'
'}).join(''):''+(g.available?'VM и LXC не найдены':'Команда pvesh недоступна')+''; const pveShort=(mt.proxmoxVersion||'Proxmox VE').split('(')[0].trim();el('maintenanceVersion').textContent=pveShort;el('maintenanceUpdates').textContent=mt.availableUpdates??'—';el('maintenanceBar').style.width=Math.min((mt.availableUpdates||0)*5,100)+'%';el('maintenanceReboot').textContent=(mt.rebootRequired||mt.newKernelAvailable)?'Требуется':'Не требуется';el('maintenanceKernel').textContent=mt.kernelVersion||'—';el('dPveVersion').textContent=pveShort;el('dPveVersion').title=mt.proxmoxVersion||'';el('dKernel').textContent=mt.kernelVersion||'—';el('dLatestKernel').textContent=mt.latestInstalledKernel||'—';el('dUpdates').textContent=(mt.availableUpdates??'—')+' пакетов';el('dReboot').textContent=(mt.rebootRequired||mt.newKernelAvailable)?'Требуется':'Не требуется';el('dLastUpdate').textContent=mt.lastSystemUpdate?new Date(mt.lastSystemUpdate).toLocaleString('ru-RU'):'—';el('dNextBackup').textContent=mt.nextBackup||'Расписание не найдено';el('dNextScrub').textContent=mt.nextScrub||'Таймер не найден';el('dMaintenanceChecked').textContent=mt.checkedAt?new Date(mt.checkedAt).toLocaleString('ru-RU'):'—'; const backupLimitHours=alertSettings.backupMaxAgeHours||48,backupLimit=backupLimitHours*3600,backupMap=new Map((b.items||[]).map(item=>[item.vmid,item])),now=Date.now()/1000,protectedGuests=guests.filter(v=>backupMap.has(v.vmid)),freshGuests=protectedGuests.filter(v=>now-backupMap.get(v.vmid).ctime<=backupLimit),missing=guests.length-protectedGuests.length;el('backupFreshLabel').textContent='Свежие · '+backupLimitHours+' часов';el('backupProtected').textContent=protectedGuests.length;el('backupTotal').textContent=' / '+guests.length;el('backupBar').style.width=guests.length?(protectedGuests.length/guests.length*100)+'%':'0%';el('backupFresh').textContent=b.available?freshGuests.length:'Не найдено';el('backupMissing').textContent=b.available?missing:'—'; - el('backupList').innerHTML=guests.length?guests.map(v=>{const item=backupMap.get(v.vmid),type=v.type==='qemu'?'VM':'LXC';if(!item)return '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+'
Копии нет
';const age=now-item.ctime,fresh=age<=backupLimit,date=new Date(item.ctime*1000).toLocaleString('ru-RU');return '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+' · '+date+'
'+(fresh?'Актуальна':'Старше '+backupLimitHours+' ч')+'
Хранилище'+safe(item.storage)+'
Размер'+size(item.size)+'
Формат'+safe(item.format)+'
Возраст'+uptime(age)+'
Создана'+date+'
VMID'+v.vmid+'
'}).join(''):''+(b.available?'VM и LXC не найдены':'Данные резервных копий недоступны')+''; + el('backupList').innerHTML=guests.length?guests.map(v=>{const item=backupMap.get(v.vmid),type=v.type==='qemu'?'VM':'LXC';if(!item)return '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+'
Копии нет
';const age=now-item.ctime,fresh=age<=backupLimit,date=new Date(item.ctime*1000).toLocaleString('ru-RU'),task=(x.activity?.tasks||[]).find(t=>t.type==='vzdump'&&String(t.id)===String(v.vmid)),change=item.sizeChangePercent==null?'Нет предыдущей':(item.sizeChangePercent>=0?'+':'')+fixed(item.sizeChangePercent)+'%';return '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+' · '+date+'
'+(fresh?'Актуальна':'Старше '+backupLimitHours+' ч')+'
Хранилище'+safe(item.storage)+'
Размер'+size(item.size)+'
К предыдущей'+change+'
Последнее задание'+(task?safe(task.status)+' · '+uptime(task.duration):'Общее задание')+'
Возраст'+uptime(age)+'
Формат'+safe(item.format)+'
'}).join(''):''+(b.available?'VM и LXC не найдены':'Данные резервных копий недоступны')+''; const svc=x.services||{services:[],up:0,total:0};renderServices(svc); const sh=x.systemHealth||{},issueCount=sh.systemErrors||0;el('systemIssues').textContent=issueCount;el('systemBar').style.width=Math.min(issueCount*10,100)+'%';el('failedUnits').textContent=sh.failedUnits??'—';el('kernelErrors').textContent=sh.kernelErrors??'—';el('dSystemErrors').textContent=issueCount;el('dKernelErrors').textContent=sh.kernelErrors||0;el('dFailedUnits').textContent=sh.failedUnits||0;el('issueList').innerHTML=(sh.issues||[]).length?sh.issues.map(v=>'
'+safe(v.unit)+'
'+new Date(v.time*1000).toLocaleString('ru-RU')+'
Ошибка
'+safe(v.message)+'
').join(''):'Критических сообщений за 24 часа нет.';el('failedServiceList').innerHTML=(sh.failedServices||[]).length?sh.failedServices.map(v=>'
'+safe(v.unit)+'
'+safe(v.description||'Описание отсутствует')+'
'+safe(v.state)+'
Загрузка'+safe(v.load)+'
Состояние'+safe(v.active)+'
Подсостояние'+safe(v.state)+'
').join(''):'Упавших systemd-служб нет.'; el('issueList').querySelectorAll('.badge').forEach((badge,index)=>{const count=(sh.issues||[])[index]?.count||1;badge.textContent=count>1?'Повторений: '+count:'1 раз'}); renderNetworkGroups(n.interfaces||[]); + renderActivity(x.activity||{},g,svc,z); el('dot').classList.add('live');el('status').textContent='Данные поступают'; } const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed(v)+' MB/s'; @@ -619,10 +659,12 @@ document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>el(b.dataset.o document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclick=()=>d.close();d.onclick=e=>{if(e.target===d)d.close()}}); document.querySelectorAll('[data-system-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-system-tab]').forEach(v=>v.classList.toggle('active',v===tab));document.querySelectorAll('[data-system-panel]').forEach(v=>v.hidden=v.dataset.systemPanel!==tab.dataset.systemTab)}); el('serviceList').onclick=event=>{const button=event.target.closest('[data-service-open]');if(!button)return;const service=currentServiceStatuses.find(v=>v.id===Number(button.dataset.serviceOpen));if(!service)return;const choices=(service.endpoints||[]).filter(v=>/^https?:\/\//.test(v.address));if(choices.length===1){window.open(choices[0].address,'_blank','noopener');return}if(!choices.length)return;el('openServiceTitle').textContent=service.name;el('openServiceChoices').innerHTML=choices.map(v=>''+safe(v.label)+''+safe(v.address)+'').join('');el('openServiceDialog').showModal()}; +document.querySelectorAll('[data-task-filter]').forEach(button=>button.onclick=()=>{document.querySelectorAll('[data-task-filter]').forEach(v=>v.classList.toggle('active',v===button));renderTasks(currentTasks,button.dataset.taskFilter)}); +el('backupList').onclick=async event=>{const button=event.target.closest('[data-verify-backup]');if(!button)return;const target=el('backupList').querySelector('[data-backup-result="'+CSS.escape(button.dataset.verifyBackup)+'"]');button.disabled=true;target.textContent='Проверяем метаданные и читаемость…';try{const response=await fetch('/api/backups/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({volid:button.dataset.verifyBackup})}),result=await response.json();target.textContent=(result.ok?'✓ ':'⚠ ')+result.message;target.className='backup-check '+(result.ok?'status-up':'status-down')}catch(e){target.textContent='Не удалось выполнить проверку.'}finally{button.disabled=false}}; el('alertList').onclick=async event=>{const button=event.target.closest('[data-ack]');if(!button)return;button.disabled=true;const response=await fetch('/api/alerts/acknowledge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({eventId:Number(button.dataset.ack)})});if(response.ok)button.closest('.alert-item').remove();else button.disabled=false}; async function loadAlertHistory(){const response=await fetch('/api/alerts/history');if(!response.ok)return;const events=await response.json()||[];el('alertHistory').innerHTML=events.length?events.map(a=>{const state=a.acknowledgedAt?'Принято · '+new Date(a.acknowledgedAt*1000).toLocaleString('ru-RU'):a.resolvedAt?'Завершено · '+new Date(a.resolvedAt*1000).toLocaleString('ru-RU'):'Активно';return '
'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'
'+safe(a.title)+'
'+safe(a.message)+'
'+state+'
'}).join(''):'
История пока пустаНовые события появятся здесь автоматически.
'} document.querySelectorAll('[data-alert-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-alert-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-alert-panel]').forEach(p=>p.hidden=p.dataset.alertPanel!==tab.dataset.alertTab);if(tab.dataset.alertTab==='history')loadAlertHistory()}); -const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','guests','maintenance','backups','services','system'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',guests:'VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро'};let hiddenCards=[],cardOrder=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e){}cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))]; +const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','guests','maintenance','backups','services','system','snapshots','tasks','map'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',guests:'VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро',snapshots:'Snapshots',tasks:'Задачи Proxmox',map:'Карта homelab'};let hiddenCards=[],cardOrder=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e){}cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))]; function renderCardOrder(){el('cardOrder').innerHTML=cardOrder.map(name=>'
'+cardLabels[name]+'
').join('')} function saveCardOrder(){localStorage.setItem('proxmox-card-order',JSON.stringify(cardOrder));applyCardSettings();renderCardOrder()} function applyCardSettings(){const container=document.querySelector('.cards');cardOrder.forEach(name=>{const card=document.querySelector('[data-card="'+name+'"]');if(card)container.appendChild(card)});cardNames.forEach(name=>{const visible=!hiddenCards.includes(name),card=document.querySelector('[data-card="'+name+'"]'),toggle=document.querySelector('[data-card-toggle="'+name+'"]');if(card)card.hidden=!visible;if(toggle)toggle.checked=visible})}