diff --git a/README.md b/README.md index 9f5e0da..a22fffe 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,15 @@ apcaccess status - `GET /api/events` — поток метрик SSE с интервалом две секунды. - `GET/POST/PUT/DELETE /api/services` — управление проверками сервисов. - `GET /api/services/history` — семидневная история доступности и отклика. +- `GET/PUT/DELETE /api/settings/maintenance` — состояние, запуск и досрочное завершение плановых работ. + +## Maintenance-режим + +Во вкладке `Настройки → Maintenance` можно на заданное время отключить все +алерты либо выбрать конкретные сервисы и VM/LXC. Подавленные события не +отправляются по почте и не отображаются как активные, но сохраняются в истории +с причиной плановых работ. После окончания окна обычный мониторинг включается +автоматически. ## Сборка diff --git a/alert_events.go b/alert_events.go index f4204a3..66441e8 100644 --- a/alert_events.go +++ b/alert_events.go @@ -6,16 +6,18 @@ import ( ) 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"` + 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"` + Planned bool `json:"planned"` + MaintenanceReason string `json:"maintenanceReason,omitempty"` } type RecoveryEvent struct { @@ -24,13 +26,17 @@ type RecoveryEvent struct { } func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) { + return s.SyncAlertsWithMaintenance(alerts, nil, "") +} + +func (s *Store) SyncAlertsWithMaintenance(alerts, plannedAlerts []Alert, maintenanceReason string) ([]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`) + rows, err := tx.Query(`SELECT id,alert_key,planned FROM alert_events WHERE resolved_at IS NULL`) if err != nil { return alerts, err } @@ -38,28 +44,43 @@ func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) { for rows.Next() { var id int64 var key string - if err := rows.Scan(&id, &key); err != nil { + var planned bool + if err := rows.Scan(&id, &key, &planned); err != nil { rows.Close() return alerts, err } - active[key] = id + active[alertEventKey(key, planned)] = 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 + syncGroup := func(group []Alert, planned bool) error { + for _, alert := range group { + key := alertEventKey(alert.ID, planned) + seen[key] = true + reason := "" + if planned { + reason = maintenanceReason + } + if id, ok := active[key]; ok { + _, err = tx.Exec(`UPDATE alert_events SET severity=?,source=?,title=?,message=?,last_seen=?,maintenance_reason=? WHERE id=?`, alert.Severity, alert.Source, alert.Title, alert.Message, now, reason, id) + } else { + result, insertErr := tx.Exec(`INSERT INTO alert_events(alert_key,severity,source,title,message,first_seen,last_seen,planned,maintenance_reason) VALUES(?,?,?,?,?,?,?,?,?)`, alert.ID, alert.Severity, alert.Source, alert.Title, alert.Message, now, now, planned, reason) + if insertErr != nil { + return insertErr + } + active[key], _ = result.LastInsertId() + } + if err != nil { + return err } - active[alert.ID], _ = result.LastInsertId() - } - if err != nil { - return alerts, err } + return nil + } + if err = syncGroup(alerts, false); err != nil { + return alerts, err + } + if err = syncGroup(plannedAlerts, true); err != nil { + return alerts, err } for key, id := range active { if !seen[key] { @@ -74,7 +95,7 @@ func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) { if err = tx.Commit(); err != nil { return alerts, err } - rows, err = s.db.Query(`SELECT id,alert_key,severity,source,title,message,first_seen,last_seen 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`) + rows, err = s.db.Query(`SELECT id,alert_key,severity,source,title,message,first_seen,last_seen FROM alert_events WHERE resolved_at IS NULL AND acknowledged_at IS NULL AND planned=0 ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,first_seen DESC`) if err != nil { return alerts, err } @@ -90,6 +111,13 @@ func (s *Store) SyncAlerts(alerts []Alert) ([]Alert, error) { return visible, rows.Err() } +func alertEventKey(id string, planned bool) string { + if planned { + return id + "\x00planned" + } + return id + "\x00active" +} + 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 @@ -99,7 +127,7 @@ 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) + rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message,first_seen,last_seen,resolved_at,acknowledged_at,planned,maintenance_reason FROM alert_events ORDER BY first_seen DESC LIMIT ?`, limit) if err != nil { return nil, err } @@ -108,7 +136,7 @@ func (s *Store) AlertHistory(limit int) ([]AlertEvent, error) { 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 { + if err = rows.Scan(&e.EventID, &e.ID, &e.Severity, &e.Source, &e.Title, &e.Message, &e.FirstSeen, &e.LastSeen, &resolved, &acked, &e.Planned, &e.MaintenanceReason); err != nil { return nil, err } if resolved.Valid { @@ -123,7 +151,7 @@ func (s *Store) AlertHistory(limit int) ([]AlertEvent, error) { } 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`) + 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 AND planned=0 ORDER BY first_seen`) if err != nil { return nil, err } @@ -155,7 +183,7 @@ func (s *Store) MarkAlertsEmailed(alerts []Alert) error { } func (s *Store) PendingImmediateAlerts() ([]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 AND (severity='critical' OR alert_key='ups-battery') ORDER BY first_seen`) + 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 AND planned=0 AND (severity='critical' OR alert_key='ups-battery') ORDER BY first_seen`) if err != nil { return nil, err } @@ -171,7 +199,7 @@ func (s *Store) PendingImmediateAlerts() ([]Alert, error) { return out, rows.Err() } func (s *Store) PendingRecoveries() ([]RecoveryEvent, error) { - rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NOT NULL AND email_sent_at IS NOT NULL AND recovery_sent_at IS NULL ORDER BY resolved_at`) + rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message FROM alert_events WHERE resolved_at IS NOT NULL AND email_sent_at IS NOT NULL AND recovery_sent_at IS NULL AND planned=0 ORDER BY resolved_at`) if err != nil { return nil, err } diff --git a/alerts.go b/alerts.go index e772d2b..28657ea 100644 --- a/alerts.go +++ b/alerts.go @@ -250,9 +250,6 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler if !metrics.Monitor.LastSuccessfulCollection.IsZero() && time.Since(metrics.Monitor.LastSuccessfulCollection) > 30*time.Second { add("monitor-stale", "critical", "Dashboard", "Данные мониторинга устарели", "Последний успешный сбор был "+time.Since(metrics.Monitor.LastSuccessfulCollection).Round(time.Second).String()+" назад.") } - if metrics.Monitor.SlowCollector { - add("monitor-slow", "warning", "Dashboard", "Сбор метрик выполняется слишком долго", fmt.Sprintf("Последний цикл занял %d ms.", metrics.Monitor.CollectionDurationMs)) - } if metrics.Monitor.HistoryWriteError != "" { add("monitor-history", "critical", "Dashboard", "Ошибка записи истории", metrics.Monitor.HistoryWriteError) } diff --git a/alerts_test.go b/alerts_test.go index b205b4b..54f47d9 100644 --- a/alerts_test.go +++ b/alerts_test.go @@ -16,3 +16,12 @@ func TestEvaluateAlerts(t *testing.T) { t.Fatalf("неверная сортировка уведомлений: %+v", alerts) } } + +func TestSlowCollectionDoesNotCreateAlert(t *testing.T) { + metrics := dashboardMetrics{Monitor: MonitorHealth{SlowCollector: true, CollectionDurationMs: 12308}} + for _, alert := range evaluateAlerts(metrics, defaultThresholds()) { + if alert.ID == "monitor-slow" { + t.Fatalf("медленный цикл не должен создавать уведомление: %+v", alert) + } + } +} diff --git a/maintenance_window.go b/maintenance_window.go new file mode 100644 index 0000000..635ea84 --- /dev/null +++ b/maintenance_window.go @@ -0,0 +1,125 @@ +package main + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +type MaintenanceWindow struct { + Enabled bool `json:"enabled"` + Active bool `json:"active"` + StartedAt int64 `json:"startedAt"` + EndsAt int64 `json:"endsAt"` + Reason string `json:"reason"` + AllAlerts bool `json:"allAlerts"` + ServiceIDs []int64 `json:"serviceIds"` + VMIDs []int `json:"vmids"` +} + +func (s *Store) MaintenanceWindow() MaintenanceWindow { + var value string + var window MaintenanceWindow + if s.db.QueryRow(`SELECT value FROM settings WHERE key='maintenance_window'`).Scan(&value) == nil { + _ = json.Unmarshal([]byte(value), &window) + } + now := time.Now().Unix() + window.Active = window.Enabled && window.StartedAt <= now && window.EndsAt > now + if window.Enabled && window.EndsAt <= now { + window.Enabled = false + data, _ := json.Marshal(window) + _, _ = s.db.Exec(`INSERT INTO settings(key,value) VALUES('maintenance_window',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data)) + } + return window +} + +func (s *Store) SaveMaintenanceWindow(window MaintenanceWindow) (MaintenanceWindow, error) { + now := time.Now() + window.Reason = strings.TrimSpace(window.Reason) + if window.Reason == "" { + return window, fmt.Errorf("укажите причину плановых работ") + } + if window.EndsAt <= now.Unix() || window.EndsAt > now.Add(30*24*time.Hour).Unix() { + return window, fmt.Errorf("окончание должно быть в будущем, максимум через 30 дней") + } + if !window.AllAlerts && len(window.ServiceIDs) == 0 && len(window.VMIDs) == 0 { + return window, fmt.Errorf("выберите сервис, VM/LXC или все алерты") + } + window.Enabled = true + window.Active = true + window.StartedAt = now.Unix() + window.ServiceIDs = uniqueInt64(window.ServiceIDs) + window.VMIDs = uniqueInt(window.VMIDs) + data, _ := json.Marshal(window) + _, err := s.db.Exec(`INSERT INTO settings(key,value) VALUES('maintenance_window',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data)) + return window, err +} + +func (s *Store) StopMaintenanceWindow() (MaintenanceWindow, error) { + window := s.MaintenanceWindow() + window.Enabled = false + window.Active = false + window.EndsAt = time.Now().Unix() + data, _ := json.Marshal(window) + _, err := s.db.Exec(`INSERT INTO settings(key,value) VALUES('maintenance_window',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data)) + return window, err +} + +func splitMaintenanceAlerts(alerts []Alert, window MaintenanceWindow) (active, planned []Alert) { + if !window.Active { + return alerts, nil + } + for _, alert := range alerts { + if maintenanceMatchesAlert(window, alert) { + planned = append(planned, alert) + } else { + active = append(active, alert) + } + } + return active, planned +} + +func maintenanceMatchesAlert(window MaintenanceWindow, alert Alert) bool { + if window.AllAlerts { + return true + } + for _, id := range window.ServiceIDs { + value := strconv.FormatInt(id, 10) + if strings.HasPrefix(alert.ID, "service-"+value+"-") || strings.HasPrefix(alert.ID, "service-tls-"+value+"-") { + return true + } + } + for _, vmid := range window.VMIDs { + value := strconv.Itoa(vmid) + if strings.HasSuffix(alert.ID, "-"+value) || strings.Contains(alert.Message, "VMID "+value) { + return true + } + } + return false +} + +func uniqueInt64(values []int64) []int64 { + seen := map[int64]bool{} + result := make([]int64, 0, len(values)) + for _, value := range values { + if value > 0 && !seen[value] { + seen[value] = true + result = append(result, value) + } + } + return result +} + +func uniqueInt(values []int) []int { + seen := map[int]bool{} + result := make([]int, 0, len(values)) + for _, value := range values { + if value > 0 && !seen[value] { + seen[value] = true + result = append(result, value) + } + } + return result +} diff --git a/maintenance_window_test.go b/maintenance_window_test.go new file mode 100644 index 0000000..dc46747 --- /dev/null +++ b/maintenance_window_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "testing" + "time" +) + +func TestMaintenanceFiltersSelectedTargets(t *testing.T) { + window := MaintenanceWindow{Active: true, ServiceIDs: []int64{7}, VMIDs: []int{105}} + alerts := []Alert{ + {ID: "service-7-local"}, + {ID: "service-tls-8-public"}, + {ID: "backup-old-105"}, + {ID: "task-failed-x", Message: "VMID 105, status error"}, + {ID: "cpu-usage"}, + } + active, planned := splitMaintenanceAlerts(alerts, window) + if len(active) != 2 || len(planned) != 3 { + t.Fatalf("неверное разделение: active=%+v planned=%+v", active, planned) + } +} + +func TestMaintenanceExpiresAutomatically(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + window, err := store.SaveMaintenanceWindow(MaintenanceWindow{EndsAt: time.Now().Add(time.Hour).Unix(), Reason: "Обновление", AllAlerts: true}) + if err != nil || !window.Active { + t.Fatalf("режим не включён: %+v %v", window, err) + } + if _, err = store.db.Exec(`UPDATE settings SET value=? WHERE key='maintenance_window'`, `{"enabled":true,"startedAt":1,"endsAt":2,"reason":"test","allAlerts":true}`); err != nil { + t.Fatal(err) + } + if expired := store.MaintenanceWindow(); expired.Active || expired.Enabled { + t.Fatalf("просроченный режим остался активным: %+v", expired) + } +} + +func TestPlannedAlertOnlyAppearsInHistory(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + alert := Alert{ID: "service-7-local", Severity: "critical", Source: "Сервисы", Title: "Недоступен"} + visible, err := store.SyncAlertsWithMaintenance(nil, []Alert{alert}, "Обновление") + if err != nil || len(visible) != 0 { + t.Fatalf("плановый алерт стал активным: %+v %v", visible, err) + } + history, err := store.AlertHistory(10) + if err != nil || len(history) != 1 || !history[0].Planned || history[0].MaintenanceReason != "Обновление" { + t.Fatalf("плановое событие не сохранено: %+v %v", history, err) + } + pending, err := store.PendingEmailAlerts() + if err != nil || len(pending) != 0 { + t.Fatalf("плановое событие попало в почту: %+v %v", pending, err) + } +} diff --git a/store.go b/store.go index 1b0a378..eec9a70 100644 --- a/store.go +++ b/store.go @@ -147,6 +147,9 @@ func openStore(path string) (*Store, error) { } // Older databases get this column without requiring a separate migration tool. _, _ = db.Exec(`ALTER TABLE alert_events ADD COLUMN recovery_sent_at INTEGER`) + _, _ = db.Exec(`ALTER TABLE alert_events ADD COLUMN planned INTEGER NOT NULL DEFAULT 0`) + _, _ = db.Exec(`ALTER TABLE alert_events ADD COLUMN maintenance_reason TEXT NOT NULL DEFAULT ''`) + _, _ = db.Exec(`DELETE FROM alert_events WHERE alert_key='monitor-slow'`) _, _ = db.Exec(`ALTER TABLE service_checks ADD COLUMN endpoint TEXT NOT NULL DEFAULT 'public'`) _, _ = db.Exec(`PRAGMA foreign_keys=ON`) return &Store{db: db, path: path}, nil diff --git a/web.go b/web.go index 065309e..48dda2c 100644 --- a/web.go +++ b/web.go @@ -11,26 +11,27 @@ import ( ) type dashboardMetrics struct { - CPU CPUMetrics `json:"cpu"` - Memory MemoryMetrics `json:"memory"` - Storage StorageMetrics `json:"storage"` - Disks []DiskMetrics `json:"disks"` - MissingDisks []string `json:"missingDisks"` - Network NetworkMetrics `json:"network"` - ZFS ZFSMetrics `json:"zfs"` - UPS UPSMetrics `json:"ups"` - Guests GuestsMetrics `json:"guests"` - Backups BackupMetrics `json:"backups"` - Alerts []Alert `json:"alerts"` - Update UpdateStatus `json:"update"` - Maintenance MaintenanceMetrics `json:"maintenance"` - Services ServicesMetrics `json:"services"` - SystemHealth SystemHealthMetrics `json:"systemHealth"` - Activity ProxmoxActivity `json:"activity"` - Monitor MonitorHealth `json:"monitor"` - UPSShutdown UPSShutdownSettings `json:"upsShutdown"` - Trends []TrendAnomaly `json:"trends"` - Timestamp time.Time `json:"timestamp"` + CPU CPUMetrics `json:"cpu"` + Memory MemoryMetrics `json:"memory"` + Storage StorageMetrics `json:"storage"` + Disks []DiskMetrics `json:"disks"` + MissingDisks []string `json:"missingDisks"` + Network NetworkMetrics `json:"network"` + ZFS ZFSMetrics `json:"zfs"` + UPS UPSMetrics `json:"ups"` + Guests GuestsMetrics `json:"guests"` + Backups BackupMetrics `json:"backups"` + Alerts []Alert `json:"alerts"` + Update UpdateStatus `json:"update"` + Maintenance MaintenanceMetrics `json:"maintenance"` + MaintenanceWindow MaintenanceWindow `json:"maintenanceWindow"` + Services ServicesMetrics `json:"services"` + SystemHealth SystemHealthMetrics `json:"systemHealth"` + Activity ProxmoxActivity `json:"activity"` + Monitor MonitorHealth `json:"monitor"` + UPSShutdown UPSShutdownSettings `json:"upsShutdown"` + Trends []TrendAnomaly `json:"trends"` + 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, activity *activityCollector, ioAttribution *ioAttributionCollector, trends *trendCollector, store *Store, monitor *monitorTracker) (dashboardMetrics, error) { @@ -64,8 +65,10 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle attributeDiskIO(&metrics, ioAttribution.collect(metrics.Guests)) metrics.Trends = trends.collect(metrics, store) decorateNetworkInterfaces(&metrics.Network, metrics.Guests) - metrics.Alerts = correlateAlerts(metrics, evaluateAlerts(metrics, thresholds)) - metrics.Alerts, err = store.SyncAlerts(metrics.Alerts) + metrics.MaintenanceWindow = store.MaintenanceWindow() + allAlerts := correlateAlerts(metrics, evaluateAlerts(metrics, thresholds)) + activeAlerts, plannedAlerts := splitMaintenanceAlerts(allAlerts, metrics.MaintenanceWindow) + metrics.Alerts, err = store.SyncAlertsWithMaintenance(activeAlerts, plannedAlerts, metrics.MaintenanceWindow.Reason) if err != nil { return dashboardMetrics{}, err } @@ -188,6 +191,29 @@ func routes(collector *cpuCollector, store *Store) http.Handler { w.WriteHeader(http.StatusMethodNotAllowed) } }) + mux.HandleFunc("/api/settings/maintenance", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, store.MaintenanceWindow(), nil) + case http.MethodPut: + var window MaintenanceWindow + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&window); err != nil { + http.Error(w, "Некорректные настройки", http.StatusBadRequest) + return + } + value, err := store.SaveMaintenanceWindow(window) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, value, nil) + case http.MethodDelete: + value, err := store.StopMaintenanceWindow() + writeJSON(w, value, err) + default: + 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) @@ -385,6 +411,7 @@ dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding: .alert-list{display:grid;gap:10px;padding:16px}.alert-item{display:grid;grid-template-columns:auto 1fr;gap:13px;padding:17px 18px;border:1px solid var(--line);border-radius:14px;background:#0c141a}.alert-icon{width:10px;height:10px;border-radius:50%;margin-top:6px;background:#6ba8ff;box-shadow:0 0 14px currentColor}.alert-item.warning .alert-icon{background:#ffb35c}.alert-item.critical .alert-icon{background:#ff665c}.alert-title{font-weight:650}.alert-source{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.alert-message{color:var(--muted);font-size:13px;margin-top:4px}.all-good{padding:45px 24px;text-align:center}.all-good strong{display:block;font-size:22px;margin-bottom:7px}.all-good span{color:var(--muted)} 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} +.maintenance-banner{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:-7px 0 18px;padding:12px 16px;border:1px solid #6d4c2c;border-radius:13px;background:rgba(255,179,92,.08);color:#ffb35c}.maintenance-banner[hidden]{display:none}.maintenance-banner strong{color:var(--text)}.maintenance-banner small{display:block;color:var(--muted);margin-top:2px}.maintenance-targets{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px;max-height:240px;overflow:auto}.maintenance-targets .setting-item{background:var(--panel)} @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)}.service-form,.maintenance-grid{grid-template-columns:1fr}.maintenance-grid .detail{border-right:0}.chart-head{align-items:flex-start;flex-direction:column}.detail-tabs,.settings-tabs,.system-tabs{overflow-x:auto}} .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} @@ -396,6 +423,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Proxmox host telemetry

Host overview

Подключение…
+
-
+
Тема оформления
Видимые карточки
@@ -630,6 +658,13 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Настройки хранятся на сервере и применяются для всех браузеров.
+