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(`
`, 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}