diff --git a/README.md b/README.md index b4d6523..9e1c92d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,13 @@ доступна отправка тестового письма. Поддерживаются STARTTLS (обычно порт 587) и SMTP поверх TLS (порт 465). +Тема письма кодируется по MIME и корректно отображает кириллицу. Сам дайджест +оформлен как HTML-письмо и содержит только новые неподтверждённые события — уже +отправленные или принятые события повторно не рассылаются. В центре уведомлений +есть вкладки активных событий и 30-дневной истории. Кнопка `Принято` скрывает +событие из активных, не удаляя его из журнала; повторное возникновение проблемы +создаёт новое событие. + Пороги уведомлений сохраняются в SQLite и настраиваются через вкладку `Настройки → Уведомления`. Панель раз в минуту сохраняет историю CPU, температуры, памяти, swap, сети, storage, ZFS, UPS и гостей. Данные старше семи diff --git a/alert_events.go b/alert_events.go new file mode 100644 index 0000000..852b454 --- /dev/null +++ b/alert_events.go @@ -0,0 +1,150 @@ +package main + +import ( + "database/sql" + "time" +) + +type AlertEvent struct { + EventID int64 `json:"eventId"` + ID string `json:"id"` + Severity string `json:"severity"` + Source string `json:"source"` + Title string `json:"title"` + Message string `json:"message"` + FirstSeen int64 `json:"firstSeen"` + LastSeen int64 `json:"lastSeen"` + ResolvedAt *int64 `json:"resolvedAt,omitempty"` + AcknowledgedAt *int64 `json:"acknowledgedAt,omitempty"` +} + +func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) { + tx, err := s.db.Begin() + if err != nil { + return alerts, err + } + defer tx.Rollback() + now := time.Now().Unix() + rows, err := tx.Query(`SELECT id,alert_key FROM alert_events WHERE resolved_at IS NULL`) + if err != nil { + return alerts, err + } + active := map[string]int64{} + for rows.Next() { + var id int64 + var key string + if err := rows.Scan(&id, &key); err != nil { + rows.Close() + return alerts, err + } + active[key] = id + } + rows.Close() + seen := map[string]bool{} + for _, alert := range alerts { + seen[alert.ID] = true + if id, ok := active[alert.ID]; ok { + _, err = tx.Exec(`UPDATE alert_events SET severity=?,source=?,title=?,message=?,last_seen=? WHERE id=?`, alert.Severity, alert.Source, alert.Title, alert.Message, now, id) + } else { + result, insertErr := tx.Exec(`INSERT INTO alert_events(alert_key,severity,source,title,message,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)`, alert.ID, alert.Severity, alert.Source, alert.Title, alert.Message, now, now) + if insertErr != nil { + return alerts, insertErr + } + active[alert.ID], _ = result.LastInsertId() + } + if err != nil { + return alerts, err + } + } + for key, id := range active { + if !seen[key] { + if _, err = tx.Exec(`UPDATE alert_events SET resolved_at=? WHERE id=?`, now, id); err != nil { + return alerts, err + } + } + } + if _, err = tx.Exec(`DELETE FROM alert_events WHERE (resolved_at IS NOT NULL OR acknowledged_at IS NOT NULL) AND last_seen < ?`, now-int64(30*24*time.Hour/time.Second)); err != nil { + return alerts, err + } + if err = tx.Commit(); err != nil { + return alerts, err + } + rows, err = s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NULL AND acknowledged_at IS NULL ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,first_seen DESC`) + if err != nil { + return alerts, err + } + defer rows.Close() + var visible []Alert + for rows.Next() { + var a Alert + if err = rows.Scan(&a.EventID, &a.ID, &a.Severity, &a.Source, &a.Title, &a.Message); err != nil { + return alerts, err + } + visible = append(visible, a) + } + return visible, rows.Err() +} + +func (s *Store) AcknowledgeAlert(eventID int64) error { + _, err := s.db.Exec(`UPDATE alert_events SET acknowledged_at=? WHERE id=? AND acknowledged_at IS NULL`, time.Now().Unix(), eventID) + return err +} + +func (s *Store) AlertHistory(limit int) ([]AlertEvent, error) { + if limit < 1 || limit > 500 { + limit = 200 + } + rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message,first_seen,last_seen,resolved_at,acknowledged_at FROM alert_events ORDER BY first_seen DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var result []AlertEvent + for rows.Next() { + var e AlertEvent + var resolved, acked sql.NullInt64 + if err = rows.Scan(&e.EventID, &e.ID, &e.Severity, &e.Source, &e.Title, &e.Message, &e.FirstSeen, &e.LastSeen, &resolved, &acked); err != nil { + return nil, err + } + if resolved.Valid { + e.ResolvedAt = &resolved.Int64 + } + if acked.Valid { + e.AcknowledgedAt = &acked.Int64 + } + result = append(result, e) + } + return result, rows.Err() +} + +func (s *Store) PendingEmailAlerts() ([]Alert, error) { + rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NULL AND acknowledged_at IS NULL AND email_sent_at IS NULL ORDER BY first_seen`) + if err != nil { + return nil, err + } + defer rows.Close() + var result []Alert + for rows.Next() { + var a Alert + if err = rows.Scan(&a.EventID, &a.ID, &a.Severity, &a.Source, &a.Title, &a.Message); err != nil { + return nil, err + } + result = append(result, a) + } + return result, rows.Err() +} + +func (s *Store) MarkAlertsEmailed(alerts []Alert) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + now := time.Now().Unix() + for _, a := range alerts { + if _, err = tx.Exec(`UPDATE alert_events SET email_sent_at=? WHERE id=?`, now, a.EventID); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/alert_events_test.go b/alert_events_test.go new file mode 100644 index 0000000..8837454 --- /dev/null +++ b/alert_events_test.go @@ -0,0 +1,58 @@ +package main + +import "testing" + +func TestAlertLifecycle(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + alert := Alert{ID: "disk", Severity: "warning", Source: "Диски", Title: "SMART", Message: "Проверьте диск"} + visible, err := store.SyncAlerts([]Alert{alert}) + if err != nil || len(visible) != 1 || visible[0].EventID == 0 { + t.Fatalf("unexpected new alert: %+v %v", visible, err) + } + firstID := visible[0].EventID + if err = store.AcknowledgeAlert(firstID); err != nil { + t.Fatal(err) + } + visible, err = store.SyncAlerts([]Alert{alert}) + if err != nil || len(visible) != 0 { + t.Fatalf("acknowledged alert became visible: %+v %v", visible, err) + } + if _, err = store.SyncAlerts(nil); err != nil { + t.Fatal(err) + } + visible, err = store.SyncAlerts([]Alert{alert}) + if err != nil || len(visible) != 1 || visible[0].EventID == firstID { + t.Fatalf("recurring alert was not new: %+v %v", visible, err) + } + history, err := store.AlertHistory(20) + if err != nil || len(history) != 2 { + t.Fatalf("unexpected history: %+v %v", history, err) + } +} + +func TestPendingEmailAlerts(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + visible, err := store.SyncAlerts([]Alert{{ID: "cpu", Severity: "critical", Source: "CPU", Title: "Load"}}) + if err != nil { + t.Fatal(err) + } + pending, err := store.PendingEmailAlerts() + if err != nil || len(pending) != 1 { + t.Fatalf("unexpected pending: %+v %v", pending, err) + } + if err = store.MarkAlertsEmailed(visible); err != nil { + t.Fatal(err) + } + pending, err = store.PendingEmailAlerts() + if err != nil || len(pending) != 0 { + t.Fatalf("emailed alert repeated: %+v %v", pending, err) + } +} diff --git a/alerts.go b/alerts.go index a0b1830..6109f1b 100644 --- a/alerts.go +++ b/alerts.go @@ -8,6 +8,7 @@ import ( ) type Alert struct { + EventID int64 `json:"eventId,omitempty"` ID string `json:"id"` Severity string `json:"severity"` Source string `json:"source"` diff --git a/email.go b/email.go index 76dc4cd..ea1d5e8 100644 --- a/email.go +++ b/email.go @@ -3,7 +3,9 @@ package main import ( "crypto/tls" "fmt" + "html" "log" + "mime" "net" "net/smtp" "sort" @@ -30,33 +32,36 @@ func filterEmailAlerts(alerts []Alert, settings EmailSettings) []Alert { func sendAlertEmail(settings EmailSettings, alerts []Alert, test bool) error { subject := "Proxmox Dashboard: сводка уведомлений" - body := "Система не обнаружила выбранных активных событий." + body := `
PROXMOX DASHBOARD

Сводка событий

` if test { subject = "Proxmox Dashboard: тест почты" - body = "Почтовые уведомления настроены правильно." + body += `
Почта настроена

Тестовое сообщение успешно отправлено вашим сервером.

` } if len(alerts) > 0 { groups := map[string][]Alert{} for _, alert := range alerts { groups[alert.Severity] = append(groups[alert.Severity], alert) } - var lines []string + var sections []string for _, severity := range []string{"critical", "warning", "info"} { items := groups[severity] if len(items) == 0 { continue } - title := map[string]string{"critical": "КРИТИЧЕСКИЕ", "warning": "ПРЕДУПРЕЖДЕНИЯ", "info": "ИНФОРМАЦИЯ"}[severity] - lines = append(lines, title) + title := map[string]string{"critical": "Критические", "warning": "Предупреждения", "info": "Информация"}[severity] + color := map[string]string{"critical": "#ff665c", "warning": "#ffb35c", "info": "#6ba8ff"}[severity] sort.Slice(items, func(i, j int) bool { return items[i].Title < items[j].Title }) + cards := "" for _, item := range items { - lines = append(lines, fmt.Sprintf("• [%s] %s\n %s", item.Source, item.Title, item.Message)) + cards += fmt.Sprintf(`
%s
%s
%s
`, color, html.EscapeString(item.Source), html.EscapeString(item.Title), html.EscapeString(item.Message)) } + sections = append(sections, fmt.Sprintf(`

%s · %d

%s`, color, title, len(items), cards)) } - body = strings.Join(lines, "\n\n") + body += strings.Join(sections, "") subject = fmt.Sprintf("Proxmox Dashboard: событий — %d", len(alerts)) } - message := []byte("From: " + settings.From + "\r\nTo: " + settings.To + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n" + body + "\r\n") + body += `
События собраны напрямую с Proxmox-хоста. Подтверждённые события повторно не отправляются.
` + message := []byte("From: " + settings.From + "\r\nTo: " + settings.To + "\r\nSubject: " + mime.QEncoding.Encode("UTF-8", subject) + "\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n" + body + "\r\n") address := net.JoinHostPort(settings.Host, fmt.Sprint(settings.Port)) tlsConfig := &tls.Config{ServerName: settings.Host, MinVersion: tls.VersionTLS12, InsecureSkipVerify: settings.SkipTLSVerify} //nolint:gosec -- explicit private-network option var client *smtp.Client @@ -114,11 +119,15 @@ func runEmailNotifier(collect func() (dashboardMetrics, error), store *Store) { if !settings.Enabled || time.Since(store.LastEmailAt()) < time.Duration(settings.IntervalMinutes)*time.Minute { continue } - metrics, err := collect() + _, err := collect() if err != nil { continue } - alerts := filterEmailAlerts(metrics.Alerts, settings) + pending, err := store.PendingEmailAlerts() + if err != nil { + continue + } + alerts := filterEmailAlerts(pending, settings) if len(alerts) == 0 { continue } @@ -126,6 +135,10 @@ func runEmailNotifier(collect func() (dashboardMetrics, error), store *Store) { log.Printf("Не удалось отправить почтовую сводку: %v", err) continue } + if err := store.MarkAlertsEmailed(alerts); err != nil { + log.Printf("Не удалось отметить отправленные события: %v", err) + continue + } store.SetLastEmailAt(time.Now()) } } diff --git a/store.go b/store.go index 825ea12..63a977b 100644 --- a/store.go +++ b/store.go @@ -97,6 +97,13 @@ func openStore(path string) (*Store, error) { zfs_usage REAL NOT NULL, guests_cpu REAL NOT NULL, guests_memory REAL NOT NULL )`, `CREATE INDEX IF NOT EXISTS history_ts_idx ON history(ts)`, + `CREATE TABLE IF NOT EXISTS alert_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, alert_key TEXT NOT NULL, severity TEXT NOT NULL, + source TEXT NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, + first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, resolved_at INTEGER, + acknowledged_at INTEGER, email_sent_at INTEGER + )`, + `CREATE INDEX IF NOT EXISTS alert_events_active_idx ON alert_events(alert_key,resolved_at)`, } for _, statement := range statements { if _, err := db.Exec(statement); err != nil { diff --git a/web.go b/web.go index 185e98d..055e056 100644 --- a/web.go +++ b/web.go @@ -48,6 +48,10 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle Guests: guestCollector.collect(), Backups: backupCollector.collect(), Update: readUpdateStatus(), Timestamp: time.Now(), } metrics.Alerts = evaluateAlerts(metrics, store.Thresholds()) + metrics.Alerts, err = store.SyncAlerts(metrics.Alerts) + if err != nil { + return dashboardMetrics{}, err + } return metrics, nil } @@ -113,6 +117,28 @@ func routes(collector *cpuCollector, store *Store) http.Handler { w.WriteHeader(http.StatusMethodNotAllowed) } }) + mux.HandleFunc("/api/alerts/history", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + events, err := store.AlertHistory(200) + writeJSON(w, events, err) + }) + mux.HandleFunc("/api/alerts/acknowledge", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var request struct { + EventID int64 `json:"eventId"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&request); err != nil || request.EventID < 1 { + http.Error(w, "Некорректное событие", http.StatusBadRequest) + return + } + writeJSON(w, map[string]bool{"acknowledged": true}, store.AcknowledgeAlert(request.EventID)) + }) mux.HandleFunc("/api/settings/email", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -214,7 +240,7 @@ dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding: html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9da 0,transparent 34%),var(--bg)}html[data-theme="light"] .card{background:linear-gradient(145deg,#fff,#f7f9fa)}html[data-theme="light"] dialog{background:#fff;box-shadow:0 30px 80px #4e64752b}html[data-theme="light"] .modal-head{background:rgba(255,255,255,.94)}html[data-theme="light"] .close{background:#f1f4f6}html[data-theme="light"] .disk-item,html[data-theme="light"] .alert-item{background:#f7f9fa}html[data-theme="light"] .bar{background:#e2e8ec}html[data-theme="light"] dialog::backdrop{background:rgba(30,45,55,.35)}html[data-theme="light"] .badge[data-reason]:hover::after,html[data-theme="light"] .badge[data-reason]:focus::after,html[data-theme="light"] .chart-tooltip{background:#fff;box-shadow:0 12px 32px #4e647533} footer{color:#657581;font-size:12px;margin-top:17px} @media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats,.setting-list,.threshold-grid{grid-template-columns:repeat(2,1fr)}.chart-head{align-items:flex-start;flex-direction:column}.detail-tabs{overflow-x:auto}} -.save-settings:disabled{cursor:wait;opacity:.55}[data-settings-panel="email"] .threshold-item input{width:min(210px,52vw)} +.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}
@@ -280,7 +306,9 @@ footer{color:#657581;font-size:12px;margin-top:17px} -
Проверка состояния системы…
+
+
Проверка состояния системы…
+
@@ -409,7 +437,7 @@ let alertSettings={backupMaxAgeHours:48}; 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:[]},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)+'%'; - el('alertList').innerHTML=alerts.length?alerts.map(a=>'
'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+'
'+safe(a.title)+'
'+safe(a.message)+'
').join(''):'
Всё в порядкеАктивных предупреждений сейчас нет.
'; + el('alertList').innerHTML=alerts.length?alerts.map(a=>'
'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+'
'+safe(a.title)+'
'+safe(a.message)+'
').join(''):'
Всё в порядкеНовых неподтверждённых событий нет.
'; el('cpuName').textContent=el('cpuDetailName').textContent=c.model;el('cpuUsage').textContent=fixed(c.usagePercent);el('cpuBar').style.width=Math.min(c.usagePercent,100)+'%';el('cpuTemp').textContent=avg===null?'Нет датчика':fixed(avg)+' °C';el('cpuTopology').textContent=c.physicalCores+' / '+c.logicalCpus; el('dCpuUsage').textContent=fixed(c.usagePercent)+'%';el('dSockets').textContent=c.sockets;el('dCores').textContent=c.physicalCores;el('dThreads').textContent=c.logicalCpus;el('dFreq').textContent=(c.frequencyMhz/1000).toFixed(2)+' GHz';el('dUptime').textContent=uptime(c.uptimeSeconds);el('dLoad1').textContent=fixed(c.load1);el('dLoad5').textContent=fixed(c.load5);el('dLoad15').textContent=fixed(c.load15);el('dTemps').innerHTML=c.temperatures.length?c.temperatures.map(t=>''+t.label+''+fixed(t.celsius)+'°C').join(''):'Датчики температуры не найдены'; el('memUsage').textContent=fixed(m.usagePercent);el('memBar').style.width=Math.min(m.usagePercent,100)+'%';el('memUsed').textContent=size(m.usedBytes);el('memAvailable').textContent=size(m.availableBytes);el('dMemUsed').textContent=size(m.usedBytes);el('dMemAvailable').textContent=size(m.availableBytes);el('dMemTotal').textContent=size(m.totalBytes);el('dMemCache').textContent=size(m.cachedBytes);el('dMemBuffers').textContent=size(m.buffersBytes);el('dMemPercent').textContent=fixed(m.usagePercent)+'%';el('dSwapUsed').textContent=size(m.swapUsedBytes);el('dSwapTotal').textContent=size(m.swapTotalBytes);el('dSwapPercent').textContent=fixed(m.swapPercent)+'%'; @@ -437,6 +465,9 @@ function setupDetailTabs(){document.querySelectorAll('dialog').forEach(dialog=>{ setupDetailTabs(); document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>el(b.dataset.open).showModal()); document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclick=()=>d.close();d.onclick=e=>{if(e.target===d)d.close()}}); +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','backups'];let hiddenCards=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]')}catch(e){} function applyCardSettings(){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})} document.querySelectorAll('[data-card-toggle]').forEach(toggle=>toggle.onchange=()=>{const name=toggle.dataset.cardToggle;hiddenCards=toggle.checked?hiddenCards.filter(v=>v!==name):[...new Set([...hiddenCards,name])];localStorage.setItem('proxmox-hidden-cards',JSON.stringify(hiddenCards));applyCardSettings()});applyCardSettings();