Add configurable email notification digests
This commit is contained in:
@@ -28,6 +28,12 @@
|
||||
информационные сообщения по CPU, памяти, storage, SMART, ZFS, UPS, VM/LXC и
|
||||
резервным копиям. Критические события всегда отображаются первыми.
|
||||
|
||||
Во вкладке `Настройки → Почта` настраиваются SMTP-сервер, получатель и интервал
|
||||
агрегированной сводки. Можно отдельно выбрать уровни важности и источники
|
||||
событий — например, полностью исключить уведомления о резервных копиях. Там же
|
||||
доступна отправка тестового письма. Поддерживаются STARTTLS (обычно порт 587) и
|
||||
SMTP поверх TLS (порт 465).
|
||||
|
||||
Пороги уведомлений сохраняются в SQLite и настраиваются через вкладку
|
||||
`Настройки → Уведомления`. Панель раз в минуту сохраняет историю CPU,
|
||||
температуры, памяти, swap, сети, storage, ZFS, UPS и гостей. Данные старше семи
|
||||
|
||||
130
email.go
Normal file
130
email.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func filterEmailAlerts(alerts []Alert, settings EmailSettings) []Alert {
|
||||
allowedSeverity, allowedSource := map[string]bool{}, map[string]bool{}
|
||||
for _, value := range settings.Severities {
|
||||
allowedSeverity[value] = true
|
||||
}
|
||||
for _, value := range settings.Sources {
|
||||
allowedSource[value] = true
|
||||
}
|
||||
var result []Alert
|
||||
for _, alert := range alerts {
|
||||
if allowedSeverity[alert.Severity] && allowedSource[alert.Source] {
|
||||
result = append(result, alert)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sendAlertEmail(settings EmailSettings, alerts []Alert, test bool) error {
|
||||
subject := "Proxmox Dashboard: сводка уведомлений"
|
||||
body := "Система не обнаружила выбранных активных событий."
|
||||
if test {
|
||||
subject = "Proxmox Dashboard: тест почты"
|
||||
body = "Почтовые уведомления настроены правильно."
|
||||
}
|
||||
if len(alerts) > 0 {
|
||||
groups := map[string][]Alert{}
|
||||
for _, alert := range alerts {
|
||||
groups[alert.Severity] = append(groups[alert.Severity], alert)
|
||||
}
|
||||
var lines []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)
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Title < items[j].Title })
|
||||
for _, item := range items {
|
||||
lines = append(lines, fmt.Sprintf("• [%s] %s\n %s", item.Source, item.Title, item.Message))
|
||||
}
|
||||
}
|
||||
body = strings.Join(lines, "\n\n")
|
||||
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")
|
||||
address := net.JoinHostPort(settings.Host, fmt.Sprint(settings.Port))
|
||||
var client *smtp.Client
|
||||
if settings.Port == 465 {
|
||||
connection, err := tls.Dial("tcp", address, &tls.Config{ServerName: settings.Host, MinVersion: tls.VersionTLS12})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err = smtp.NewClient(connection, settings.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
client, err = smtp.Dial(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: settings.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
defer client.Close()
|
||||
if settings.Username != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", settings.Username, settings.Password, settings.Host)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(settings.From); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(settings.To); err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = writer.Write(message); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func runEmailNotifier(collect func() (dashboardMetrics, error), store *Store) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
settings := store.EmailSettings(true)
|
||||
if !settings.Enabled || time.Since(store.LastEmailAt()) < time.Duration(settings.IntervalMinutes)*time.Minute {
|
||||
continue
|
||||
}
|
||||
metrics, err := collect()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
alerts := filterEmailAlerts(metrics.Alerts, settings)
|
||||
if len(alerts) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := sendAlertEmail(settings, alerts, false); err != nil {
|
||||
log.Printf("Не удалось отправить почтовую сводку: %v", err)
|
||||
continue
|
||||
}
|
||||
store.SetLastEmailAt(time.Now())
|
||||
}
|
||||
}
|
||||
40
email_test.go
Normal file
40
email_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterEmailAlerts(t *testing.T) {
|
||||
alerts := []Alert{
|
||||
{ID: "cpu", Severity: "critical", Source: "CPU"},
|
||||
{ID: "backup", Severity: "critical", Source: "Backups"},
|
||||
{ID: "info", Severity: "info", Source: "CPU"},
|
||||
}
|
||||
settings := EmailSettings{Severities: []string{"critical"}, Sources: []string{"CPU"}}
|
||||
filtered := filterEmailAlerts(alerts, settings)
|
||||
if len(filtered) != 1 || filtered[0].ID != "cpu" {
|
||||
t.Fatalf("unexpected filtered alerts: %+v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailPasswordIsPreserved(t *testing.T) {
|
||||
store, err := openStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
settings := defaultEmailSettings()
|
||||
settings.Host, settings.From, settings.To, settings.Password = "smtp.example.com", "dash@example.com", "admin@example.com", "secret"
|
||||
if err := store.SaveEmailSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Password = ""
|
||||
settings.IntervalMinutes = 120
|
||||
if err := store.SaveEmailSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := store.EmailSettings(true); got.Password != "secret" || got.IntervalMinutes != 120 {
|
||||
t.Fatalf("unexpected settings: %+v", got)
|
||||
}
|
||||
if got := store.EmailSettings(false); got.Password != "" || !got.HasPassword {
|
||||
t.Fatalf("password leaked or marker lost: %+v", got)
|
||||
}
|
||||
}
|
||||
64
store.go
64
store.go
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -59,6 +60,20 @@ type HistoryPoint struct {
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
type EmailSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
IntervalMinutes int `json:"intervalMinutes"`
|
||||
Severities []string `json:"severities"`
|
||||
Sources []string `json:"sources"`
|
||||
}
|
||||
|
||||
func openStore(path string) (*Store, error) {
|
||||
if path != ":memory:" {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
@@ -114,6 +129,55 @@ func (s *Store) SaveThresholds(value AlertThresholds) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func defaultEmailSettings() EmailSettings {
|
||||
return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Dashboard"}}
|
||||
}
|
||||
|
||||
func (s *Store) EmailSettings(includePassword bool) EmailSettings {
|
||||
settings := defaultEmailSettings()
|
||||
var value string
|
||||
if s.db.QueryRow(`SELECT value FROM settings WHERE key='email_notifications'`).Scan(&value) == nil {
|
||||
_ = json.Unmarshal([]byte(value), &settings)
|
||||
}
|
||||
settings.HasPassword = settings.Password != ""
|
||||
if !includePassword {
|
||||
settings.Password = ""
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func (s *Store) SaveEmailSettings(settings EmailSettings) error {
|
||||
if settings.Port < 1 || settings.Port > 65535 || settings.IntervalMinutes < 5 || settings.IntervalMinutes > 10080 {
|
||||
return fmt.Errorf("проверьте порт и интервал (от 5 минут до 7 дней)")
|
||||
}
|
||||
if settings.Enabled && (settings.Host == "" || settings.From == "" || settings.To == "") {
|
||||
return fmt.Errorf("для отправки нужны SMTP-сервер, отправитель и получатель")
|
||||
}
|
||||
if strings.ContainsAny(settings.Host+settings.From+settings.To+settings.Username, "\r\n") {
|
||||
return fmt.Errorf("поля почты содержат недопустимые символы")
|
||||
}
|
||||
if settings.Password == "" {
|
||||
settings.Password = s.EmailSettings(true).Password
|
||||
}
|
||||
settings.HasPassword = settings.Password != ""
|
||||
data, _ := json.Marshal(settings)
|
||||
_, err := s.db.Exec(`INSERT INTO settings(key,value) VALUES('email_notifications',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) LastEmailAt() time.Time {
|
||||
var value string
|
||||
if s.db.QueryRow(`SELECT value FROM settings WHERE key='email_last_sent'`).Scan(&value) != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
result, _ := time.Parse(time.RFC3339, value)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Store) SetLastEmailAt(value time.Time) {
|
||||
_, _ = s.db.Exec(`INSERT INTO settings(key,value) VALUES('email_last_sent',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, value.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func validateThresholds(t AlertThresholds) error {
|
||||
percentages := []float64{t.CPUWarning, t.CPUCritical, t.MemoryWarning, t.MemoryCritical, t.SwapWarning, t.StorageWarning, t.StorageCritical, t.ZFSWarning, t.ZFSCritical, t.UPSChargeCritical}
|
||||
for _, value := range percentages {
|
||||
|
||||
50
web.go
50
web.go
@@ -63,6 +63,7 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
|
||||
return collectDashboard(collector, network, disks, zfs, ups, guests, backups, store)
|
||||
}
|
||||
go runHistoryCollector(collect, store)
|
||||
go runEmailNotifier(collect, store)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
@@ -112,6 +113,33 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/settings/email", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, store.EmailSettings(false), nil)
|
||||
case http.MethodPut:
|
||||
var settings EmailSettings
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&settings); err != nil {
|
||||
http.Error(w, "Некорректные настройки", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.SaveEmailSettings(settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, store.EmailSettings(false), nil)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/settings/email/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
settings := store.EmailSettings(true)
|
||||
writeJSON(w, map[string]bool{"sent": true}, sendAlertEmail(settings, nil, true))
|
||||
})
|
||||
mux.HandleFunc("/api/history", func(w http.ResponseWriter, r *http.Request) {
|
||||
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
|
||||
points, err := store.History(hours)
|
||||
@@ -186,7 +214,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}
|
||||
.save-settings:disabled{cursor:wait;opacity:.55}[data-settings-panel="email"] .threshold-item input{width:min(210px,52vw)}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
@@ -325,7 +353,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
|
||||
<dialog id="settingsDialog">
|
||||
<div class="modal-head"><div><div class="label">Dashboard</div><h2>Настройки</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||||
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Уведомления</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
|
||||
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="appearance">
|
||||
<div class="settings-group"><div class="label">Тема оформления</div><div class="theme-options"><button class="theme-option" data-theme-choice="dark">Тёмная</button><button class="theme-option" data-theme-choice="light">Светлая</button></div></div>
|
||||
<div class="settings-group"><div class="label">Видимые карточки</div><div class="setting-list">
|
||||
@@ -349,6 +377,20 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
</div>
|
||||
<button class="save-settings" id="saveThresholds">Сохранить пороги</button><div class="settings-note" id="thresholdStatus">Настройки хранятся на сервере и применяются для всех браузеров.</div>
|
||||
</div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="email" hidden>
|
||||
<div class="settings-group"><label class="setting-item"><input type="checkbox" id="emailEnabled"> Включить почтовые дайджесты</label></div>
|
||||
<div class="settings-group"><div class="label">SMTP и получатель</div><div class="threshold-grid">
|
||||
<label class="threshold-item"><span>SMTP-сервер</span><input id="emailHost" type="text"></label><label class="threshold-item"><span>Порт</span><input id="emailPort" type="number"></label>
|
||||
<label class="threshold-item"><span>Логин</span><input id="emailUsername" type="text"></label><label class="threshold-item"><span>Пароль</span><input id="emailPassword" type="password" placeholder="Не изменять"></label>
|
||||
<label class="threshold-item"><span>Отправитель</span><input id="emailFrom" type="email"></label><label class="threshold-item"><span>Получатель</span><input id="emailTo" type="email"></label>
|
||||
<label class="threshold-item"><span>Интервал, минут</span><input id="emailInterval" type="number" min="5"></label>
|
||||
</div></div>
|
||||
<div class="settings-group"><div class="label">Важность</div><div class="setting-list"><label class="setting-item"><input type="checkbox" data-email-severity="critical"> Критические</label><label class="setting-item"><input type="checkbox" data-email-severity="warning"> Предупреждения</label><label class="setting-item"><input type="checkbox" data-email-severity="info"> Информация</label></div></div>
|
||||
<div class="settings-group"><div class="label">Какие разделы отправлять</div><div class="setting-list">
|
||||
<label class="setting-item"><input type="checkbox" data-email-source="CPU"> CPU</label><label class="setting-item"><input type="checkbox" data-email-source="Память"> Память</label><label class="setting-item"><input type="checkbox" data-email-source="Storage"> Storage</label><label class="setting-item"><input type="checkbox" data-email-source="Диски"> Диски</label><label class="setting-item"><input type="checkbox" data-email-source="ZFS"> ZFS</label><label class="setting-item"><input type="checkbox" data-email-source="UPS"> UPS</label><label class="setting-item"><input type="checkbox" data-email-source="VM/LXC"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-email-source="Backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-email-source="Dashboard"> Dashboard</label>
|
||||
</div></div>
|
||||
<button class="save-settings" id="saveEmail">Сохранить настройки почты</button><button class="theme-option" id="testEmail" style="width:100%;margin-top:9px">Отправить тестовое письмо</button><div class="settings-note" id="emailStatus">Пароль хранится в локальной базе сервера и не возвращается в браузер.</div>
|
||||
</div>
|
||||
<div class="settings-body settings-panel" data-settings-panel="updates" hidden>
|
||||
<div class="detail-grid">
|
||||
<div class="detail"><span class="label">Установлена</span><strong id="updateCurrent">—</strong></div><div class="detail"><span class="label">Доступна</span><strong id="updateAvailable">—</strong></div><div class="detail"><span class="label">Режим</span><strong id="updateMode">—</strong></div>
|
||||
@@ -402,6 +444,10 @@ function setTheme(theme){document.documentElement.dataset.theme=theme;localStora
|
||||
document.querySelectorAll('[data-settings-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-settings-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-settings-panel]').forEach(p=>p.hidden=p.dataset.settingsPanel!==tab.dataset.settingsTab)});
|
||||
async function loadThresholds(){const response=await fetch('/api/settings/alerts');if(!response.ok)return;alertSettings=await response.json();document.querySelectorAll('[data-threshold]').forEach(input=>input.value=alertSettings[input.dataset.threshold]??'')}
|
||||
el('saveThresholds').onclick=async()=>{const values={};document.querySelectorAll('[data-threshold]').forEach(input=>values[input.dataset.threshold]=Number(input.value));const status=el('thresholdStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/alerts',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(values)});if(response.ok){alertSettings=await response.json();status.textContent='Пороги сохранены и уже применяются.'}else{status.textContent=await response.text()||'Не удалось сохранить настройки.'}};loadThresholds().catch(()=>{});
|
||||
function emailForm(){return {enabled:el('emailEnabled').checked,host:el('emailHost').value.trim(),port:Number(el('emailPort').value),username:el('emailUsername').value.trim(),password:el('emailPassword').value,from:el('emailFrom').value.trim(),to:el('emailTo').value.trim(),intervalMinutes:Number(el('emailInterval').value),severities:[...document.querySelectorAll('[data-email-severity]:checked')].map(x=>x.dataset.emailSeverity),sources:[...document.querySelectorAll('[data-email-source]:checked')].map(x=>x.dataset.emailSource)}}
|
||||
async function loadEmail(){const response=await fetch('/api/settings/email');if(!response.ok)return;const s=await response.json();el('emailEnabled').checked=s.enabled;el('emailHost').value=s.host||'';el('emailPort').value=s.port||587;el('emailUsername').value=s.username||'';el('emailPassword').value='';el('emailPassword').placeholder=s.hasPassword?'Сохранён · не изменять':'Пароль';el('emailFrom').value=s.from||'';el('emailTo').value=s.to||'';el('emailInterval').value=s.intervalMinutes||60;document.querySelectorAll('[data-email-severity]').forEach(x=>x.checked=(s.severities||[]).includes(x.dataset.emailSeverity));document.querySelectorAll('[data-email-source]').forEach(x=>x.checked=(s.sources||[]).includes(x.dataset.emailSource))}
|
||||
el('saveEmail').onclick=async()=>{const status=el('emailStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/email',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(emailForm())});status.textContent=response.ok?'Настройки сохранены.':await response.text();if(response.ok)loadEmail()};
|
||||
el('testEmail').onclick=async()=>{const status=el('emailStatus');status.textContent='Отправляем тестовое письмо…';const saved=await fetch('/api/settings/email',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(emailForm())});if(!saved.ok){status.textContent=await saved.text();return}const response=await fetch('/api/settings/email/test',{method:'POST'});status.textContent=response.ok?'Тестовое письмо отправлено.':await response.text()};loadEmail().catch(()=>{});
|
||||
el('installUpdate').onclick=async()=>{const button=el('installUpdate');button.disabled=true;button.textContent='Ставим в очередь…';el('updateMessage').textContent='Создаём запрос на обновление…';try{const response=await fetch('/api/update/request',{method:'POST'});if(!response.ok)throw new Error(await response.text());const status=await response.json();el('updateMessage').textContent=status.message;button.textContent='Обновление выполняется…'}catch(e){button.disabled=false;button.textContent='Попробовать ещё раз';el('updateMessage').textContent='Не удалось запустить обновление.'}};
|
||||
const events=new EventSource('/api/events');events.onmessage=e=>render(JSON.parse(e.data));events.onerror=()=>{el('dot').classList.remove('live');el('status').textContent='Переподключение…'};
|
||||
</script></body></html>`
|
||||
|
||||
Reference in New Issue
Block a user