5 Commits

Author SHA1 Message Date
Maxim
d3fbbf2a6b Improve service checks and system health details 2026-08-01 12:21:26 +03:00
Maxim
980f69cf8f Add homelab service and system health monitoring 2026-08-01 12:02:09 +03:00
Maxim
ad8972320d Expand guest metrics and add Proxmox maintenance overview 2026-08-01 11:38:03 +03:00
Maxim
5491be2e0c Add acknowledged alert history and HTML email digests 2026-08-01 11:00:16 +03:00
Maxim
9e907f8455 Allow optional SMTP TLS verification bypass 2026-07-31 23:31:22 +03:00
16 changed files with 1595 additions and 70 deletions

View File

@@ -19,10 +19,21 @@
- состояние, заполнение, фрагментацию и scrub/resilver ZFS-пулов;
- состояние UPS через NUT или apcupsd, заряд, автономность, нагрузку и напряжение.
- состояние VM и LXC, CPU, память, выделенные ядра и uptime.
- IP-адреса VM через QEMU Guest Agent, автозапуск, storage, заполнение диска и
суммарный сетевой трафик гостей;
- последнюю найденную резервную копию каждой VM/LXC, storage, размер и возраст.
- версию Proxmox, запущенное и последнее установленное ядро, доступные
обновления, необходимость перезагрузки, последнее обновление системы,
ближайшие backup и ZFS scrub.
- управляемый мониторинг HTTP/HTTPS, TCP и ping для homelab-сервисов: отклик,
ожидаемый код и текст, TLS-сертификат, uptime за сутки и неделю;
- ошибки systemd и ядра за 24 часа, упавшие службы, ошибки и drops каждого
сетевого интерфейса.
Через кнопку настроек можно скрывать неиспользуемые карточки и переключать
светлую или тёмную тему. Выбор сохраняется локально в браузере.
светлую или тёмную тему. Карточки можно переставлять перетаскиванием или
стрелками в настройках. Видимость, порядок и тема сохраняются локально в
браузере.
Центр уведомлений объединяет критические события, предупреждения и
информационные сообщения по CPU, памяти, storage, SMART, ZFS, UPS, VM/LXC и
@@ -34,6 +45,19 @@
доступна отправка тестового письма. Поддерживаются STARTTLS (обычно порт 587) и
SMTP поверх TLS (порт 465).
Тема письма кодируется по MIME и корректно отображает кириллицу. Сам дайджест
оформлен как HTML-письмо и содержит только новые неподтверждённые события — уже
отправленные или принятые события повторно не рассылаются. В центре уведомлений
есть вкладки активных событий и 30-дневной истории. Кнопка `Принято` скрывает
событие из активных, не удаляя его из журнала; повторное возникновение проблемы
создаёт новое событие.
Критические события отправляются сразу, не ожидая очередной сводки. К ним
относятся недоступность сервиса, отказ SMART/ZFS/systemd, критический перегрев и
низкий заряд UPS. Переход UPS на батарею также отправляется немедленно. После
устранения уже отправленной проблемы приходит отдельное уведомление о
восстановлении.
Пороги уведомлений сохраняются в SQLite и настраиваются через вкладку
`Настройки → Уведомления`. Панель раз в минуту сохраняет историю CPU,
температуры, памяти, swap, сети, storage, ZFS, UPS и гостей. Данные старше семи
@@ -159,6 +183,8 @@ apcaccess status
- `GET /api/cpu` — один JSON-снимок.
- `GET /api/system` — единый JSON-снимок CPU и памяти.
- `GET /api/events` — поток метрик SSE с интервалом две секунды.
- `GET/POST/PUT/DELETE /api/services` — управление проверками сервисов.
- `GET /api/services/history` — семидневная история доступности и отклика.
## Сборка

202
alert_events.go Normal file
View File

@@ -0,0 +1,202 @@
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"`
}
type RecoveryEvent struct {
EventID int64
ID, Severity, Source, Title, Message string
}
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()
}
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`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []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
}
out = append(out, a)
}
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`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RecoveryEvent
for rows.Next() {
var v RecoveryEvent
if err = rows.Scan(&v.EventID, &v.ID, &v.Severity, &v.Source, &v.Title, &v.Message); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (s *Store) MarkRecoveriesEmailed(items []RecoveryEvent) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
now := time.Now().Unix()
for _, v := range items {
if _, err = tx.Exec(`UPDATE alert_events SET recovery_sent_at=? WHERE id=?`, now, v.EventID); err != nil {
return err
}
}
return tx.Commit()
}

58
alert_events_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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"`
@@ -25,6 +26,12 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
} else if metrics.Update.State == "error" {
add("dashboard-update-error", "warning", "Dashboard", "Не удалось проверить обновления", metrics.Update.Message)
}
if metrics.Maintenance.RebootRequired || metrics.Maintenance.NewKernelAvailable {
add("maintenance-reboot", "warning", "Proxmox", "Требуется перезагрузка хоста", "Установлено новое ядро или система запросила перезагрузку.")
}
if metrics.Maintenance.AvailableUpdates > 0 {
add("maintenance-updates", "info", "Proxmox", "Доступны обновления системы", fmt.Sprintf("Пакетов для обновления: %d.", metrics.Maintenance.AvailableUpdates))
}
if metrics.CPU.UsagePercent >= thresholds.CPUCritical {
add("cpu-usage", "critical", "CPU", "Очень высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent))
@@ -133,6 +140,30 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
add("guests-stopped", "info", "VM/LXC", "Есть остановленные гости", fmt.Sprintf("Остановлено VM/LXC: %d. Это уведомление информационное.", stopped))
}
}
for _, service := range metrics.Services.Services {
for _, endpoint := range service.Endpoints {
if !endpoint.Up {
add(fmt.Sprintf("service-%d-%s", service.ID, endpoint.Kind), "critical", "Сервисы", service.Name+": недоступен "+strings.ToLower(endpoint.Label), endpoint.Error)
} else if endpoint.TLSDays != nil && *endpoint.TLSDays < 0 {
add(fmt.Sprintf("service-tls-%d-%s", service.ID, endpoint.Kind), "critical", "Сервисы", service.Name+": сертификат истёк", fmt.Sprintf("%s: истёк %d дн. назад.", endpoint.Label, -*endpoint.TLSDays))
} else if endpoint.TLSDays != nil && *endpoint.TLSDays < 14 {
add(fmt.Sprintf("service-tls-%d-%s", service.ID, endpoint.Kind), "warning", "Сервисы", service.Name+": сертификат скоро истечёт", fmt.Sprintf("%s: осталось %d дн.", endpoint.Label, *endpoint.TLSDays))
}
}
}
if metrics.SystemHealth.FailedUnits > 0 {
add("systemd-failed", "critical", "Система", "Есть упавшие systemd-службы", fmt.Sprintf("Не запущено служб: %d.", metrics.SystemHealth.FailedUnits))
}
if metrics.SystemHealth.KernelErrors > 0 {
add("kernel-errors", "warning", "Система", "Ошибки ядра за 24 часа", fmt.Sprintf("Найдено сообщений: %d.", metrics.SystemHealth.KernelErrors))
}
var networkErrors uint64
for _, iface := range metrics.Network.Interfaces {
networkErrors += iface.ReceiveErrors + iface.TransmitErrors + iface.ReceiveDrops + iface.TransmitDrops
}
if networkErrors > 0 {
add("network-errors", "warning", "Сеть", "Сетевые ошибки и потери пакетов", fmt.Sprintf("Суммарный счётчик интерфейсов: %d.", networkErrors))
}
priority := map[string]int{"critical": 0, "warning": 1, "info": 2}
sort.SliceStable(alerts, func(i, j int) bool {

View File

@@ -3,7 +3,9 @@ package main
import (
"crypto/tls"
"fmt"
"html"
"log"
"mime"
"net"
"net/smtp"
"sort"
@@ -30,37 +32,41 @@ func filterEmailAlerts(alerts []Alert, settings EmailSettings) []Alert {
func sendAlertEmail(settings EmailSettings, alerts []Alert, test bool) error {
subject := "Proxmox Dashboard: сводка уведомлений"
body := "Система не обнаружила выбранных активных событий."
body := `<div style="padding:32px;background:#0b1117;color:#edf3f7;font-family:Arial,sans-serif"><div style="max-width:680px;margin:auto"><div style="color:#55d58a;font-size:12px;letter-spacing:2px">PROXMOX DASHBOARD</div><h1 style="margin:10px 0 24px">Сводка событий</h1>`
if test {
subject = "Proxmox Dashboard: тест почты"
body = "Почтовые уведомления настроены правильно."
body += `<div style="padding:22px;border:1px solid #2b3944;border-radius:14px;background:#111a22"><b style="font-size:18px">Почта настроена</b><p style="color:#9aabb7;margin-bottom:0">Тестовое сообщение успешно отправлено вашим сервером.</p></div>`
}
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(`<div style="margin:10px 0;padding:16px 18px;border:1px solid #2b3944;border-left:4px solid %s;border-radius:10px;background:#111a22"><div style="color:#8fa0ac;font-size:11px;text-transform:uppercase">%s</div><div style="font-weight:bold;font-size:16px;margin:5px 0">%s</div><div style="color:#aab7c0;font-size:14px">%s</div></div>`, color, html.EscapeString(item.Source), html.EscapeString(item.Title), html.EscapeString(item.Message))
}
sections = append(sections, fmt.Sprintf(`<h2 style="font-size:14px;color:%s;margin:24px 0 8px">%s · %d</h2>%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 += `<div style="color:#687985;font-size:12px;margin-top:28px">События собраны напрямую с Proxmox-хоста. Подтверждённые события повторно не отправляются.</div></div></div>`
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
if settings.Port == 465 {
connection, err := tls.Dial("tcp", address, &tls.Config{ServerName: settings.Host, MinVersion: tls.VersionTLS12})
connection, err := tls.Dial("tcp", address, tlsConfig)
if err != nil {
return err
}
@@ -75,7 +81,7 @@ func sendAlertEmail(settings EmailSettings, alerts []Alert, test bool) error {
return err
}
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(&tls.Config{ServerName: settings.Host, MinVersion: tls.VersionTLS12}); err != nil {
if err = client.StartTLS(tlsConfig); err != nil {
return err
}
}
@@ -105,6 +111,55 @@ func sendAlertEmail(settings EmailSettings, alerts []Alert, test bool) error {
return client.Quit()
}
func sendRecoveryEmail(settings EmailSettings, items []RecoveryEvent) error {
alerts := make([]Alert, 0, len(items))
for _, v := range items {
alerts = append(alerts, Alert{ID: v.ID, Severity: "info", Source: v.Source, Title: "Восстановлено: " + v.Title, Message: "Проблема больше не наблюдается."})
}
return sendAlertEmail(settings, alerts, false)
}
func runImmediateNotifier(collect func() (dashboardMetrics, error), store *Store) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
settings := store.EmailSettings(true)
if !settings.Enabled {
continue
}
if _, err := collect(); err != nil {
continue
}
urgent, err := store.PendingImmediateAlerts()
if err == nil && len(urgent) > 0 {
urgent = filterEmailAlerts(urgent, settings)
if len(urgent) > 0 && sendAlertEmail(settings, urgent, false) == nil {
_ = store.MarkAlertsEmailed(urgent)
}
}
recoveries, err := store.PendingRecoveries()
if err == nil && len(recoveries) > 0 {
var filtered []RecoveryEvent
for _, v := range recoveries {
if contains(settings.Sources, v.Source) {
filtered = append(filtered, v)
}
}
if len(filtered) > 0 && sendRecoveryEmail(settings, filtered) == nil {
_ = store.MarkRecoveriesEmailed(filtered)
}
}
}
}
func contains(items []string, want string) bool {
for _, v := range items {
if v == want {
return true
}
}
return false
}
func runEmailNotifier(collect func() (dashboardMetrics, error), store *Store) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
@@ -113,11 +168,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
}
@@ -125,6 +184,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())
}
}

152
guests.go
View File

@@ -3,8 +3,11 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"time"
)
@@ -21,6 +24,14 @@ type GuestMetrics struct {
MaxMemory uint64 `json:"maxmem"`
Uptime uint64 `json:"uptime"`
Template int `json:"template"`
DiskBytes uint64 `json:"disk"`
MaxDisk uint64 `json:"maxdisk"`
NetInBytes uint64 `json:"netin"`
NetOutBytes uint64 `json:"netout"`
OnBoot bool `json:"onboot"`
Agent bool `json:"agent"`
IPAddresses []string `json:"ipAddresses"`
Storage []string `json:"storage"`
}
type GuestsMetrics struct {
@@ -32,6 +43,14 @@ type guestCollector struct {
mu sync.Mutex
cached GuestsMetrics
updatedAt time.Time
details map[int]guestDetails
detailsUpdatedAt time.Time
detailsRefreshing bool
}
type guestDetails struct {
OnBoot, Agent bool
IPAddresses, Storage []string
}
func (c *guestCollector) collect() GuestsMetrics {
@@ -41,10 +60,143 @@ func (c *guestCollector) collect() GuestsMetrics {
return c.cached
}
c.cached = readGuests()
if time.Since(c.detailsUpdatedAt) > time.Minute && !c.detailsRefreshing {
c.detailsRefreshing = true
guests := append([]GuestMetrics(nil), c.cached.Guests...)
go func() {
details := readGuestDetails(guests)
c.mu.Lock()
c.details = details
c.detailsUpdatedAt = time.Now()
c.detailsRefreshing = false
c.mu.Unlock()
}()
}
for i := range c.cached.Guests {
if detail, ok := c.details[c.cached.Guests[i].VMID]; ok {
c.cached.Guests[i].OnBoot = detail.OnBoot
c.cached.Guests[i].Agent = detail.Agent
c.cached.Guests[i].IPAddresses = detail.IPAddresses
c.cached.Guests[i].Storage = detail.Storage
}
}
c.updatedAt = time.Now()
return c.cached
}
func readGuestDetails(guests []GuestMetrics) map[int]guestDetails {
result := map[int]guestDetails{}
for _, guest := range guests {
if guest.Template != 0 {
continue
}
kind := guest.Type
if kind != "qemu" {
kind = "lxc"
}
data := commandOutput(5*time.Second, "pvesh", "get", "/nodes/"+guest.Node+"/"+kind+"/"+strconv.Itoa(guest.VMID)+"/config", "--output-format", "json")
var config map[string]any
if json.Unmarshal([]byte(data), &config) != nil {
continue
}
detail := guestDetails{}
detail.OnBoot = numberValue(config["onboot"]) == 1
detail.Agent = agentEnabled(config["agent"])
storageSet := map[string]bool{}
ipSet := map[string]bool{}
for key, value := range config {
s := fmt.Sprint(value)
if key == "rootfs" || strings.HasPrefix(key, "scsi") || strings.HasPrefix(key, "sata") || strings.HasPrefix(key, "virtio") {
if name := storageName(s); name != "" {
storageSet[name] = true
}
}
if strings.HasPrefix(key, "net") || strings.HasPrefix(key, "ipconfig") {
for _, ip := range ipsFromConfig(s) {
ipSet[ip] = true
}
}
}
if guest.Status == "running" && kind == "qemu" {
agent := commandOutput(5*time.Second, "pvesh", "get", "/nodes/"+guest.Node+"/qemu/"+strconv.Itoa(guest.VMID)+"/agent/network-get-interfaces", "--output-format", "json")
for _, ip := range ipsFromAgent(agent) {
ipSet[ip] = true
}
if agent != "" {
detail.Agent = true
}
}
for value := range storageSet {
detail.Storage = append(detail.Storage, value)
}
for value := range ipSet {
detail.IPAddresses = append(detail.IPAddresses, value)
}
sort.Strings(detail.Storage)
sort.Strings(detail.IPAddresses)
result[guest.VMID] = detail
}
return result
}
func numberValue(value any) float64 {
switch v := value.(type) {
case float64:
return v
case string:
n, _ := strconv.ParseFloat(v, 64)
return n
}
return 0
}
func agentEnabled(value any) bool {
s := strings.ToLower(fmt.Sprint(value))
return s == "1" || strings.Contains(s, "enabled=1")
}
func storageName(value string) string {
first := strings.SplitN(value, ",", 2)[0]
parts := strings.SplitN(first, ":", 2)
if len(parts) == 2 {
return parts[0]
}
return ""
}
func ipsFromConfig(value string) []string {
var result []string
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "ip=") || strings.HasPrefix(part, "ip6=") {
ip := strings.SplitN(part, "=", 2)[1]
if ip != "dhcp" && ip != "auto" {
result = append(result, strings.SplitN(ip, "/", 2)[0])
}
}
}
return result
}
func ipsFromAgent(data string) []string {
var payload struct {
Result []struct {
IPAddresses []struct {
Address string `json:"ip-address"`
Type string `json:"ip-address-type"`
} `json:"ip-addresses"`
} `json:"result"`
}
if json.Unmarshal([]byte(data), &payload) != nil {
return nil
}
var result []string
for _, iface := range payload.Result {
for _, ip := range iface.IPAddresses {
if ip.Address != "127.0.0.1" && ip.Address != "::1" && !strings.HasPrefix(ip.Address, "fe80:") {
result = append(result, ip.Address)
}
}
}
return result
}
func readGuests() GuestsMetrics {
if _, err := exec.LookPath("pvesh"); err != nil {
return GuestsMetrics{}

View File

@@ -15,3 +15,16 @@ func TestGuestJSON(t *testing.T) {
t.Fatalf("неожиданные данные гостя: %+v", guest)
}
}
func TestGuestConfigHelpers(t *testing.T) {
if got := storageName("local-zfs:vm-101-disk-0,size=32G"); got != "local-zfs" {
t.Fatalf("storage: %s", got)
}
ips := ipsFromConfig("name=eth0,bridge=vmbr0,ip=192.168.0.20/24,ip6=auto")
if len(ips) != 1 || ips[0] != "192.168.0.20" {
t.Fatalf("ips: %+v", ips)
}
if !agentEnabled("enabled=1,fstrim_cloned_disks=1") {
t.Fatal("agent should be enabled")
}
}

264
maintenance.go Normal file
View File

@@ -0,0 +1,264 @@
package main
import (
"bufio"
"compress/gzip"
"context"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type MaintenanceMetrics struct {
Available bool `json:"available"`
ProxmoxVersion string `json:"proxmoxVersion"`
KernelVersion string `json:"kernelVersion"`
LatestInstalledKernel string `json:"latestInstalledKernel"`
NewKernelAvailable bool `json:"newKernelAvailable"`
RebootRequired bool `json:"rebootRequired"`
AvailableUpdates int `json:"availableUpdates"`
LastSystemUpdate time.Time `json:"lastSystemUpdate"`
NextBackup string `json:"nextBackup"`
NextScrub string `json:"nextScrub"`
CheckedAt time.Time `json:"checkedAt"`
}
type maintenanceCollector struct {
mu sync.Mutex
cached MaintenanceMetrics
updatedAt time.Time
refreshing bool
}
func (c *maintenanceCollector) collect() MaintenanceMetrics {
c.mu.Lock()
if time.Since(c.updatedAt) >= 15*time.Minute && !c.refreshing {
c.refreshing = true
go func() {
metrics := readMaintenance()
c.mu.Lock()
c.cached = metrics
c.updatedAt = time.Now()
c.refreshing = false
c.mu.Unlock()
}()
}
result := c.cached
c.mu.Unlock()
return result
}
func commandOutput(timeout time.Duration, name string, args ...string) string {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
command := exec.CommandContext(ctx, name, args...)
command.Env = append(os.Environ(), "LC_ALL=C")
output, err := command.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}
func readMaintenance() MaintenanceMetrics {
result := MaintenanceMetrics{Available: true, CheckedAt: time.Now()}
result.ProxmoxVersion = firstLine(commandOutput(5*time.Second, "pveversion"))
result.KernelVersion = commandOutput(3*time.Second, "uname", "-r")
result.RebootRequired = fileExists("/var/run/reboot-required")
apt := commandOutput(45*time.Second, "apt-get", "-s", "-o", "Debug::NoLocking=1", "upgrade")
for _, line := range strings.Split(apt, "\n") {
if strings.HasPrefix(line, "Inst ") {
result.AvailableUpdates++
}
}
result.LastSystemUpdate = lastAPTUpdate("/var/log/apt/history.log")
installed := commandOutput(8*time.Second, "dpkg-query", "-W", "-f=${Package} ${Version}\\n", "pve-kernel-*", "proxmox-kernel-*")
result.LatestInstalledKernel = newestKernel(installed)
result.NewKernelAvailable = result.LatestInstalledKernel != "" && !strings.Contains(result.KernelVersion, result.LatestInstalledKernel)
result.NextBackup = nextBackupSchedule()
result.NextScrub = nextScrubTimer(commandOutput(8*time.Second, "systemctl", "list-timers", "--all", "--no-legend", "--plain"))
if result.NextScrub == "" {
result.NextScrub = nextZFSScrubCron("/etc/cron.d/zfsutils-linux", time.Now())
}
return result
}
func firstLine(value string) string {
if i := strings.IndexByte(value, '\n'); i >= 0 {
return value[:i]
}
return value
}
func fileExists(path string) bool { _, err := os.Stat(path); return err == nil }
func lastAPTUpdate(path string) time.Time {
file, err := os.Open(path)
if err == nil {
defer file.Close()
if result := scanAPTUpdate(file); !result.IsZero() {
return result
}
}
files, _ := filepath.Glob(path + ".*.gz")
sort.Slice(files, func(i, j int) bool {
a, _ := os.Stat(files[i])
b, _ := os.Stat(files[j])
return a.ModTime().After(b.ModTime())
})
for _, name := range files {
compressed, err := os.Open(name)
if err != nil {
continue
}
reader, err := gzip.NewReader(compressed)
if err != nil {
compressed.Close()
continue
}
result := scanAPTUpdate(reader)
reader.Close()
compressed.Close()
if !result.IsZero() {
return result
}
}
return time.Time{}
}
func scanAPTUpdate(reader io.Reader) time.Time {
scanner := bufio.NewScanner(reader)
var result time.Time
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "End-Date:") {
value := strings.TrimSpace(strings.TrimPrefix(line, "End-Date:"))
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local); err == nil {
result = parsed
}
}
}
return result
}
var versionNumbers = regexp.MustCompile(`\d+`)
func newestKernel(output string) string {
var versions []string
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) > 1 && !strings.Contains(fields[0], "helper") {
versions = append(versions, fields[1])
}
}
sort.Slice(versions, func(i, j int) bool { return compareNumericVersion(versions[i], versions[j]) > 0 })
if len(versions) > 0 {
return versions[0]
}
return ""
}
func compareNumericVersion(a, b string) int {
aa, bb := versionNumbers.FindAllString(a, -1), versionNumbers.FindAllString(b, -1)
for i := 0; i < len(aa) || i < len(bb); i++ {
av, bv := "0", "0"
if i < len(aa) {
av = aa[i]
}
if i < len(bb) {
bv = bb[i]
}
if len(av) != len(bv) {
if len(av) > len(bv) {
return 1
}
return -1
}
if av != bv {
if av > bv {
return 1
}
return -1
}
}
return 0
}
func nextBackupSchedule() string {
data := commandOutput(8*time.Second, "pvesh", "get", "/cluster/backup", "--output-format", "json")
if data == "" {
return ""
}
var jobs []struct {
Enabled *int `json:"enabled"`
Schedule string `json:"schedule"`
}
if json.Unmarshal([]byte(data), &jobs) != nil {
return ""
}
var next []string
for _, job := range jobs {
if (job.Enabled != nil && *job.Enabled == 0) || job.Schedule == "" {
continue
}
calendar := commandOutput(5*time.Second, "systemd-analyze", "calendar", "--iterations=1", job.Schedule)
for _, line := range strings.Split(calendar, "\n") {
if strings.Contains(line, "Next elapse:") {
trimmed := strings.TrimSpace(line)
next = append(next, strings.TrimSpace(strings.TrimPrefix(trimmed, "Next elapse:")))
}
}
}
sort.Strings(next)
if len(next) > 0 {
return next[0]
}
return ""
}
func nextScrubTimer(output string) string {
for _, line := range strings.Split(output, "\n") {
if strings.Contains(strings.ToLower(line), "zfs") && strings.Contains(strings.ToLower(line), "scrub") {
fields := strings.Fields(line)
if len(fields) >= 4 {
return strings.Join(fields[:4], " ")
}
}
}
return ""
}
func nextZFSScrubCron(path string, now time.Time) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
if !strings.Contains(line, "/usr/lib/zfs-linux/scrub") {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
return ""
}
minute, _ := strconv.Atoi(fields[0])
hour, _ := strconv.Atoi(fields[1])
rangeParts := strings.SplitN(fields[2], "-", 2)
if len(rangeParts) != 2 {
return ""
}
from, _ := strconv.Atoi(rangeParts[0])
to, _ := strconv.Atoi(rangeParts[1])
for offset := 0; offset < 70; offset++ {
day := time.Date(now.Year(), now.Month(), now.Day()+offset, hour, minute, 0, 0, now.Location())
if day.After(now) && day.Day() >= from && day.Day() <= to && day.Weekday() == time.Sunday {
return day.Format("Mon 2006-01-02 15:04 MST")
}
}
}
return ""
}

33
maintenance_test.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestNewestKernel(t *testing.T) {
output := "proxmox-kernel-6.8 6.8.12-5\nproxmox-kernel-6.14 6.14.8-2"
if got := newestKernel(output); got != "6.14.8-2" {
t.Fatalf("unexpected newest kernel: %s", got)
}
}
func TestNextZFSScrubCron(t *testing.T) {
path := filepath.Join(t.TempDir(), "zfs")
if err := os.WriteFile(path, []byte("24 0 8-14 * * root /usr/lib/zfs-linux/scrub\n"), 0600); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
if got := nextZFSScrubCron(path, now); got != "Sun 2026-08-09 00:24 UTC" {
t.Fatalf("unexpected cron scrub: %s", got)
}
}
func TestNextScrubTimer(t *testing.T) {
line := "Sun 2026-08-02 00:24:00 MSK 1 day left zfs-scrub-weekly@tank.timer"
if got := nextScrubTimer(line); got != "Sun 2026-08-02 00:24:00 MSK" {
t.Fatalf("unexpected scrub: %s", got)
}
}

View File

@@ -15,6 +15,17 @@ type NetworkMetrics struct {
TransmitBytes uint64 `json:"transmitBytes"`
ReceivePerSec float64 `json:"receivePerSec"`
TransmitPerSec float64 `json:"transmitPerSec"`
Interfaces []NetworkInterface `json:"interfaces"`
}
type NetworkInterface struct {
Name string `json:"name"`
ReceiveBytes uint64 `json:"receiveBytes"`
TransmitBytes uint64 `json:"transmitBytes"`
ReceiveErrors uint64 `json:"receiveErrors"`
TransmitErrors uint64 `json:"transmitErrors"`
ReceiveDrops uint64 `json:"receiveDrops"`
TransmitDrops uint64 `json:"transmitDrops"`
}
type networkSnapshot struct {
@@ -30,7 +41,7 @@ type networkCollector struct {
}
func newNetworkCollector(path string) (*networkCollector, error) {
snapshot, _, err := readNetworkSnapshot(path)
snapshot, _, _, err := readNetworkSnapshot(path)
if err != nil {
return nil, err
}
@@ -41,7 +52,7 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
c.mu.Lock()
defer c.mu.Unlock()
current, interfaces, err := readNetworkSnapshot(c.path)
current, interfaceNames, details, err := readNetworkSnapshot(c.path)
if err != nil {
return NetworkMetrics{}, err
}
@@ -53,20 +64,21 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
}
c.previous = current
return NetworkMetrics{
Interface: strings.Join(interfaces, ", "), ReceiveBytes: current.received,
Interface: strings.Join(interfaceNames, ", "), Interfaces: details, ReceiveBytes: current.received,
TransmitBytes: current.sent, ReceivePerSec: receivedRate, TransmitPerSec: sentRate,
}, nil
}
func readNetworkSnapshot(path string) (networkSnapshot, []string, error) {
func readNetworkSnapshot(path string) (networkSnapshot, []string, []NetworkInterface, error) {
file, err := os.Open(path)
if err != nil {
return networkSnapshot{}, nil, err
return networkSnapshot{}, nil, nil, err
}
defer file.Close()
var received, sent uint64
var interfaces []string
var details []NetworkInterface
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
@@ -84,7 +96,12 @@ func readNetworkSnapshot(path string) (networkSnapshot, []string, error) {
received += rx
sent += tx
interfaces = append(interfaces, strings.TrimSpace(name))
rxErr, _ := strconv.ParseUint(fields[2], 10, 64)
rxDrop, _ := strconv.ParseUint(fields[3], 10, 64)
txErr, _ := strconv.ParseUint(fields[10], 10, 64)
txDrop, _ := strconv.ParseUint(fields[11], 10, 64)
details = append(details, NetworkInterface{Name: strings.TrimSpace(name), ReceiveBytes: rx, TransmitBytes: tx, ReceiveErrors: rxErr, ReceiveDrops: rxDrop, TransmitErrors: txErr, TransmitDrops: txDrop})
}
}
return networkSnapshot{at: time.Now(), received: received, sent: sent}, interfaces, scanner.Err()
return networkSnapshot{at: time.Now(), received: received, sent: sent}, interfaces, details, scanner.Err()
}

View File

@@ -18,7 +18,7 @@ func TestReadNetworkSnapshot(t *testing.T) {
t.Fatal(err)
}
snapshot, interfaces, err := readNetworkSnapshot(path)
snapshot, interfaces, details, err := readNetworkSnapshot(path)
if err != nil {
t.Fatal(err)
}
@@ -28,4 +28,7 @@ func TestReadNetworkSnapshot(t *testing.T) {
if len(interfaces) != 2 {
t.Fatalf("неожиданный список интерфейсов: %v", interfaces)
}
if len(details) != 2 {
t.Fatalf("unexpected interface details: %+v", details)
}
}

341
services.go Normal file
View File

@@ -0,0 +1,341 @@
package main
import (
"context"
"crypto/tls"
"database/sql"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
type MonitoredService struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
LocalAddress string `json:"localAddress"`
CheckType string `json:"checkType"`
ExpectedCode int `json:"expectedCode"`
ExpectedText string `json:"expectedText"`
TimeoutSeconds int `json:"timeoutSeconds"`
Enabled bool `json:"enabled"`
}
type ServiceStatus struct {
MonitoredService
Endpoints []ServiceEndpointStatus `json:"endpoints"`
Up bool `json:"up"`
CheckedAt int64 `json:"checkedAt"`
LatencyMS float64 `json:"latencyMs"`
StatusCode int `json:"statusCode"`
Error string `json:"error,omitempty"`
TLSExpiry *int64 `json:"tlsExpiry,omitempty"`
TLSDays *int `json:"tlsDays,omitempty"`
UptimeDay float64 `json:"uptimeDay"`
UptimeWeek float64 `json:"uptimeWeek"`
}
type ServiceEndpointStatus struct {
Kind string `json:"kind"`
Label string `json:"label"`
Address string `json:"address"`
Up bool `json:"up"`
CheckedAt int64 `json:"checkedAt"`
LatencyMS float64 `json:"latencyMs"`
StatusCode int `json:"statusCode"`
Error string `json:"error,omitempty"`
TLSExpiry *int64 `json:"tlsExpiry,omitempty"`
TLSDays *int `json:"tlsDays,omitempty"`
TLSSubject string `json:"tlsSubject,omitempty"`
TLSIssuer string `json:"tlsIssuer,omitempty"`
TLSDNSNames []string `json:"tlsDnsNames,omitempty"`
TLSHostnameValid bool `json:"tlsHostnameValid"`
TLSHostnameError string `json:"tlsHostnameError,omitempty"`
UptimeDay float64 `json:"uptimeDay"`
UptimeWeek float64 `json:"uptimeWeek"`
}
type ServicesMetrics struct {
Available bool `json:"available"`
Up int `json:"up"`
Total int `json:"total"`
Services []ServiceStatus `json:"services"`
}
type ServiceHistoryPoint struct {
Timestamp int64 `json:"timestamp"`
Up bool `json:"up"`
LatencyMS float64 `json:"latencyMs"`
}
func (s *Store) Services() ([]MonitoredService, error) {
rows, err := s.db.Query(`SELECT id,name,url,local_address,check_type,expected_code,expected_text,timeout_seconds,enabled FROM services ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []MonitoredService
for rows.Next() {
var v MonitoredService
if err = rows.Scan(&v.ID, &v.Name, &v.URL, &v.LocalAddress, &v.CheckType, &v.ExpectedCode, &v.ExpectedText, &v.TimeoutSeconds, &v.Enabled); err != nil {
return nil, err
}
result = append(result, v)
}
return result, rows.Err()
}
func validateService(v MonitoredService) error {
v.Name = strings.TrimSpace(v.Name)
v.URL = strings.TrimSpace(v.URL)
v.LocalAddress = strings.TrimSpace(v.LocalAddress)
if v.Name == "" || (v.URL == "" && v.LocalAddress == "") {
return fmt.Errorf("укажите название и хотя бы один адрес")
}
if v.CheckType != "http" && v.CheckType != "tcp" && v.CheckType != "ping" {
return fmt.Errorf("неизвестный тип проверки")
}
if v.TimeoutSeconds < 1 || v.TimeoutSeconds > 30 {
return fmt.Errorf("таймаут должен быть от 1 до 30 секунд")
}
if v.CheckType == "http" {
for _, target := range []string{v.URL, v.LocalAddress} {
if target == "" {
continue
}
u, err := url.Parse(target)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("оба адреса должны начинаться с http:// или https://")
}
}
}
return nil
}
func (s *Store) SaveService(v MonitoredService) (MonitoredService, error) {
if err := validateService(v); err != nil {
return v, err
}
if v.ExpectedCode == 0 {
v.ExpectedCode = 200
}
if v.ID == 0 {
r, err := s.db.Exec(`INSERT INTO services(name,url,local_address,check_type,expected_code,expected_text,timeout_seconds,enabled) VALUES(?,?,?,?,?,?,?,?)`, v.Name, v.URL, v.LocalAddress, v.CheckType, v.ExpectedCode, v.ExpectedText, v.TimeoutSeconds, v.Enabled)
if err != nil {
return v, err
}
v.ID, _ = r.LastInsertId()
} else {
_, err := s.db.Exec(`UPDATE services SET name=?,url=?,local_address=?,check_type=?,expected_code=?,expected_text=?,timeout_seconds=?,enabled=? WHERE id=?`, v.Name, v.URL, v.LocalAddress, v.CheckType, v.ExpectedCode, v.ExpectedText, v.TimeoutSeconds, v.Enabled, v.ID)
if err != nil {
return v, err
}
}
return v, nil
}
func (s *Store) DeleteService(id int64) error {
_, err := s.db.Exec(`DELETE FROM services WHERE id=?`, id)
return err
}
func (s *Store) addServiceCheck(id int64, endpoint string, v ServiceEndpointStatus) {
var expiry any
if v.TLSExpiry != nil {
expiry = *v.TLSExpiry
}
_, _ = s.db.Exec(`INSERT INTO service_checks(service_id,ts,up,latency_ms,status_code,error,tls_expiry,endpoint) VALUES(?,?,?,?,?,?,?,?)`, id, v.CheckedAt, v.Up, v.LatencyMS, v.StatusCode, v.Error, expiry, endpoint)
_, _ = s.db.Exec(`DELETE FROM service_checks WHERE ts<?`, time.Now().Add(-historyRetention).Unix())
}
func (s *Store) serviceUptime(id int64, endpoint string, since time.Time) float64 {
var value sql.NullFloat64
_ = s.db.QueryRow(`SELECT AVG(up)*100 FROM service_checks WHERE service_id=? AND endpoint=? AND ts>=?`, id, endpoint, since.Unix()).Scan(&value)
if value.Valid {
return value.Float64
}
return 0
}
func (s *Store) ServiceHistory(id int64, hours int) ([]ServiceHistoryPoint, error) {
if hours < 1 {
hours = 24
}
if hours > 168 {
hours = 168
}
rows, err := s.db.Query(`SELECT ts,up,latency_ms FROM service_checks WHERE service_id=? AND ts>=? ORDER BY ts`, id, time.Now().Add(-time.Duration(hours)*time.Hour).Unix())
if err != nil {
return nil, err
}
defer rows.Close()
var out []ServiceHistoryPoint
for rows.Next() {
var p ServiceHistoryPoint
if err = rows.Scan(&p.Timestamp, &p.Up, &p.LatencyMS); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
type serviceCollector struct {
mu sync.RWMutex
metrics ServicesMetrics
store *Store
}
func newServiceCollector(store *Store) *serviceCollector {
c := &serviceCollector{store: store}
go c.run()
return c
}
func (c *serviceCollector) collect() ServicesMetrics {
c.mu.RLock()
defer c.mu.RUnlock()
return c.metrics
}
func (c *serviceCollector) run() {
c.checkAll()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
c.checkAll()
}
}
func (c *serviceCollector) checkAll() {
items, err := c.store.Services()
if err != nil {
return
}
m := ServicesMetrics{Available: true}
for _, v := range items {
if !v.Enabled {
continue
}
st := checkService(v)
for i := range st.Endpoints {
ep := &st.Endpoints[i]
ep.UptimeDay = c.store.serviceUptime(v.ID, ep.Kind, time.Now().Add(-24*time.Hour))
ep.UptimeWeek = c.store.serviceUptime(v.ID, ep.Kind, time.Now().Add(-7*24*time.Hour))
c.store.addServiceCheck(v.ID, ep.Kind, *ep)
}
if len(st.Endpoints) > 0 {
st.UptimeDay = st.Endpoints[0].UptimeDay
st.UptimeWeek = st.Endpoints[0].UptimeWeek
}
m.Total++
if st.Up {
m.Up++
}
m.Services = append(m.Services, st)
}
c.mu.Lock()
c.metrics = m
c.mu.Unlock()
}
func checkService(v MonitoredService) ServiceStatus {
st := ServiceStatus{MonitoredService: v, CheckedAt: time.Now().Unix(), Up: true}
if v.URL != "" {
st.Endpoints = append(st.Endpoints, checkServiceEndpoint(v, "public", "Внешний адрес", v.URL))
}
if v.LocalAddress != "" {
st.Endpoints = append(st.Endpoints, checkServiceEndpoint(v, "local", "Локальный адрес", v.LocalAddress))
}
for _, ep := range st.Endpoints {
if !ep.Up {
st.Up = false
}
st.LatencyMS += ep.LatencyMS
if st.StatusCode == 0 {
st.StatusCode = ep.StatusCode
}
if st.Error == "" && ep.Error != "" {
st.Error = ep.Label + ": " + ep.Error
}
}
if len(st.Endpoints) > 0 {
st.LatencyMS /= float64(len(st.Endpoints))
ep := st.Endpoints[0]
st.TLSExpiry = ep.TLSExpiry
st.TLSDays = ep.TLSDays
}
return st
}
func checkServiceEndpoint(v MonitoredService, kind, label, target string) ServiceEndpointStatus {
st := ServiceEndpointStatus{Kind: kind, Label: label, Address: target, CheckedAt: time.Now().Unix()}
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(v.TimeoutSeconds)*time.Second)
defer cancel()
switch v.CheckType {
case "tcp":
d := net.Dialer{}
conn, err := d.DialContext(ctx, "tcp", target)
if err == nil {
st.Up = true
conn.Close()
} else {
st.Error = err.Error()
}
case "ping":
host := strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://")
host = strings.Split(host, "/")[0]
host = strings.Split(host, ":")[0]
arg := "-W"
timeout := strconv.Itoa(v.TimeoutSeconds)
if _, err := exec.LookPath("ping"); err != nil {
st.Error = "ping не установлен"
} else if out, err := exec.CommandContext(ctx, "ping", "-c", "1", arg, timeout, host).CombinedOutput(); err == nil {
st.Up = true
} else {
st.Error = strings.TrimSpace(string(out))
}
default:
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}}
client := &http.Client{Transport: tr, Timeout: time.Duration(v.TimeoutSeconds) * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
st.Error = err.Error()
break
}
resp, err := client.Do(req)
if err != nil {
st.Error = err.Error()
} else {
st.StatusCode = resp.StatusCode
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
st.Up = resp.StatusCode == v.ExpectedCode
if st.Up && v.ExpectedText != "" && !strings.Contains(string(body), v.ExpectedText) {
st.Up = false
st.Error = "ожидаемый текст не найден"
}
if !st.Up && st.Error == "" {
st.Error = fmt.Sprintf("HTTP %d, ожидался %d", resp.StatusCode, v.ExpectedCode)
}
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
cert := resp.TLS.PeerCertificates[0]
expiry := cert.NotAfter.Unix()
days := int(time.Until(cert.NotAfter).Hours() / 24)
st.TLSExpiry = &expiry
st.TLSDays = &days
st.TLSSubject = cert.Subject.CommonName
st.TLSIssuer = cert.Issuer.CommonName
st.TLSDNSNames = cert.DNSNames
host := req.URL.Hostname()
if err := cert.VerifyHostname(host); err == nil {
st.TLSHostnameValid = true
} else {
st.TLSHostnameError = err.Error()
}
}
}
}
st.LatencyMS = float64(time.Since(start).Microseconds()) / 1000
return st
}

40
services_test.go Normal file
View File

@@ -0,0 +1,40 @@
package main
import "testing"
func TestServiceCRUD(t *testing.T) {
store, err := openStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
v, err := store.SaveService(MonitoredService{Name: "Test", URL: "https://example.test", CheckType: "http", ExpectedCode: 204, TimeoutSeconds: 2, Enabled: true})
if err != nil || v.ID == 0 {
t.Fatalf("save: %+v %v", v, err)
}
items, err := store.Services()
if err != nil || len(items) != 1 {
t.Fatalf("list: %+v %v", items, err)
}
if err := store.DeleteService(v.ID); err != nil {
t.Fatal(err)
}
}
func TestValidateServiceAllowsLocalAddress(t *testing.T) {
v := MonitoredService{Name: "Host", LocalAddress: "192.168.0.12", CheckType: "ping", TimeoutSeconds: 5}
if err := validateService(v); err != nil {
t.Fatal(err)
}
}
func TestValidateServiceChecksBothHTTPAddresses(t *testing.T) {
v := MonitoredService{Name: "Gitea", URL: "https://git.example.test", LocalAddress: "192.168.0.6:3000", CheckType: "http", TimeoutSeconds: 5}
if err := validateService(v); err == nil {
t.Fatal("expected invalid local HTTP address")
}
v.LocalAddress = "https://192.168.0.6:3000"
if err := validateService(v); err != nil {
t.Fatal(err)
}
}

View File

@@ -67,6 +67,7 @@ type EmailSettings struct {
Username string `json:"username"`
Password string `json:"password,omitempty"`
HasPassword bool `json:"hasPassword"`
SkipTLSVerify bool `json:"skipTlsVerify"`
From string `json:"from"`
To string `json:"to"`
IntervalMinutes int `json:"intervalMinutes"`
@@ -96,6 +97,26 @@ 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)`,
`CREATE TABLE IF NOT EXISTS services (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL,
local_address TEXT NOT NULL DEFAULT '', check_type TEXT NOT NULL DEFAULT 'http',
expected_code INTEGER NOT NULL DEFAULT 200, expected_text TEXT NOT NULL DEFAULT '',
timeout_seconds INTEGER NOT NULL DEFAULT 5, enabled INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS service_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT, service_id INTEGER NOT NULL, ts INTEGER NOT NULL,
up INTEGER NOT NULL, latency_ms REAL NOT NULL, status_code INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '', tls_expiry INTEGER,
FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS service_checks_service_ts_idx ON service_checks(service_id,ts)`,
}
for _, statement := range statements {
if _, err := db.Exec(statement); err != nil {
@@ -103,6 +124,10 @@ func openStore(path string) (*Store, error) {
return nil, err
}
}
// 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 service_checks ADD COLUMN endpoint TEXT NOT NULL DEFAULT 'public'`)
_, _ = db.Exec(`PRAGMA foreign_keys=ON`)
return &Store{db: db}, nil
}
@@ -130,7 +155,7 @@ func (s *Store) SaveThresholds(value AlertThresholds) error {
}
func defaultEmailSettings() EmailSettings {
return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Dashboard"}}
return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Proxmox", "Dashboard", "Сервисы", "Система", "Сеть"}}
}
func (s *Store) EmailSettings(includePassword bool) EmailSettings {

116
system_health.go Normal file
View File

@@ -0,0 +1,116 @@
package main
import (
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
type SystemIssue struct {
Time int64 `json:"time"`
Unit string `json:"unit"`
Priority string `json:"priority"`
Message string `json:"message"`
Count int `json:"count"`
}
type SystemHealthMetrics struct {
Available bool `json:"available"`
FailedUnits int `json:"failedUnits"`
KernelErrors int `json:"kernelErrors"`
SystemErrors int `json:"systemErrors"`
Issues []SystemIssue `json:"issues"`
FailedServices []FailedService `json:"failedServices"`
CheckedAt int64 `json:"checkedAt"`
}
type FailedService struct {
Unit string `json:"unit"`
Load string `json:"load"`
Active string `json:"active"`
State string `json:"state"`
Description string `json:"description"`
}
type systemHealthCollector struct {
mu sync.RWMutex
value SystemHealthMetrics
}
func newSystemHealthCollector() *systemHealthCollector {
c := &systemHealthCollector{}
go c.run()
return c
}
func (c *systemHealthCollector) collect() SystemHealthMetrics {
c.mu.RLock()
defer c.mu.RUnlock()
return c.value
}
func (c *systemHealthCollector) run() {
c.refresh()
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
c.refresh()
}
}
func (c *systemHealthCollector) refresh() {
m := SystemHealthMetrics{CheckedAt: time.Now().Unix()}
if _, err := exec.LookPath("journalctl"); err != nil {
c.mu.Lock()
c.value = m
c.mu.Unlock()
return
}
m.Available = true
if out, err := exec.Command("systemctl", "--failed", "--no-legend", "--plain").Output(); err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
unit := strings.TrimPrefix(fields[0], "●")
offset := 1
if unit == "" && len(fields) > 4 {
unit = fields[1]
offset = 2
}
description := ""
if len(fields) > offset+3 {
description = strings.Join(fields[offset+3:], " ")
}
m.FailedServices = append(m.FailedServices, FailedService{Unit: unit, Load: fields[offset], Active: fields[offset+1], State: fields[offset+2], Description: description})
m.FailedUnits++
}
}
out, _ := exec.Command("journalctl", "--since", "24 hours ago", "-p", "0..3", "--no-pager", "-o", "short-unix", "-n", "100").Output()
pidPattern := regexp.MustCompile(`\[[0-9]+\]`)
grouped := map[string]int{}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
f := strings.Fields(line)
if len(f) < 2 {
continue
}
raw := strings.TrimSuffix(f[0], ":")
sec, _ := strconv.ParseFloat(raw, 64)
unit := strings.TrimSuffix(f[1], ":")
message := strings.Join(f[2:], " ")
normalized := pidPattern.ReplaceAllString(message, "[*]")
key := unit + "\x00" + normalized
if index, ok := grouped[key]; ok {
m.Issues[index].Count++
m.Issues[index].Time = int64(sec)
} else {
grouped[key] = len(m.Issues)
m.Issues = append(m.Issues, SystemIssue{Time: int64(sec), Unit: unit, Priority: "error", Message: normalized, Count: 1})
}
m.SystemErrors++
if strings.Contains(strings.ToLower(unit), "kernel") {
m.KernelErrors++
}
}
c.mu.Lock()
c.value = m
c.mu.Unlock()
}

177
web.go
View File

@@ -21,10 +21,13 @@ type dashboardMetrics struct {
Backups BackupMetrics `json:"backups"`
Alerts []Alert `json:"alerts"`
Update UpdateStatus `json:"update"`
Maintenance MaintenanceMetrics `json:"maintenance"`
Services ServicesMetrics `json:"services"`
SystemHealth SystemHealthMetrics `json:"systemHealth"`
Timestamp time.Time `json:"timestamp"`
}
func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkCollector, diskCollector *diskCollector, zfsCollector *zfsCollector, upsCollector *upsCollector, guestCollector *guestCollector, backupCollector *backupCollector, 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, store *Store) (dashboardMetrics, error) {
cpu, err := cpuCollector.collect()
if err != nil {
return dashboardMetrics{}, err
@@ -45,9 +48,13 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
CPU: cpu, Memory: memory, Storage: storage,
Disks: diskCollector.collect(), Network: network,
ZFS: zfsCollector.collect(), UPS: upsCollector.collect(),
Guests: guestCollector.collect(), Backups: backupCollector.collect(), Update: readUpdateStatus(), Timestamp: time.Now(),
Guests: guestCollector.collect(), Backups: backupCollector.collect(), Maintenance: maintenanceCollector.collect(), Services: serviceCollector.collect(), SystemHealth: systemHealth.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
}
@@ -59,11 +66,15 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
ups := &upsCollector{}
guests := &guestCollector{}
backups := &backupCollector{}
maintenance := &maintenanceCollector{}
services := newServiceCollector(store)
systemHealth := newSystemHealthCollector()
collect := func() (dashboardMetrics, error) {
return collectDashboard(collector, network, disks, zfs, ups, guests, backups, store)
return collectDashboard(collector, network, disks, zfs, ups, guests, backups, maintenance, services, systemHealth, store)
}
go runHistoryCollector(collect, store)
go runEmailNotifier(collect, store)
go runImmediateNotifier(collect, store)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
@@ -113,6 +124,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:
@@ -145,6 +178,36 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
points, err := store.History(hours)
writeJSON(w, points, err)
})
mux.HandleFunc("/api/services", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
items, err := store.Services()
writeJSON(w, items, err)
case http.MethodPost, http.MethodPut:
var v MonitoredService
if err := json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&v); err != nil {
http.Error(w, "Некорректные данные", 400)
return
}
saved, err := store.SaveService(v)
writeJSON(w, saved, err)
case http.MethodDelete:
id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
if id < 1 {
http.Error(w, "Некорректный сервис", 400)
return
}
writeJSON(w, map[string]bool{"deleted": true}, store.DeleteService(id))
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/api/services/history", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
points, err := store.ServiceHistory(id, hours)
writeJSON(w, points, err)
})
mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
@@ -213,8 +276,10 @@ 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}
@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)}
@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}
.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}
</style>
</head>
<body><main>
@@ -268,19 +333,39 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<div class="quick"><div><span class="label">CPU гостей</span><strong id="guestCPU">—</strong></div><div><span class="label">Память гостей</span><strong id="guestMemory">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card maintenance" data-card="maintenance" data-open="maintenanceDialog" aria-label="Открыть сведения Proxmox">
<div class="card-head"><div><div class="label">Proxmox</div><div class="name" id="maintenanceVersion">Обслуживание хоста</div></div><div class="percent"><span id="maintenanceUpdates">—</span><small> updates</small></div></div>
<div class="bar"><div class="fill" id="maintenanceBar"></div></div>
<div class="quick"><div><span class="label">Перезагрузка</span><strong id="maintenanceReboot">—</strong></div><div><span class="label">Ядро</span><strong id="maintenanceKernel">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть обслуживание →</div>
</button>
<button class="card backups" data-card="backups" data-open="backupsDialog" aria-label="Открыть подробности резервных копий">
<div class="card-head"><div><div class="label">Backups</div><div class="name">Резервные копии</div></div><div class="percent"><span id="backupProtected">—</span><small id="backupTotal"> / —</small></div></div>
<div class="bar"><div class="fill" id="backupBar"></div></div>
<div class="quick"><div><span class="label" id="backupFreshLabel">Свежие · 48 часов</span><strong id="backupFresh">—</strong></div><div><span class="label">Без копии</span><strong id="backupMissing">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card services" data-card="services" data-open="servicesDialog" aria-label="Открыть мониторинг сервисов">
<div class="card-head"><div><div class="label">Homelab services</div><div class="name">Сервисы</div></div><div class="percent"><span id="serviceUp">—</span><small id="serviceTotal"> / —</small></div></div>
<div class="bar"><div class="fill" id="serviceBar"></div></div>
<div class="quick"><div><span class="label">Доступность · сутки</span><strong id="serviceUptime">—</strong></div><div><span class="label">Средний отклик</span><strong id="serviceLatency">—</strong></div></div>
<div class="hint">Нажмите, чтобы управлять проверками →</div>
</button>
<button class="card system-health" data-card="system" data-open="systemDialog" aria-label="Открыть системные события">
<div class="card-head"><div><div class="label">System health</div><div class="name">Система и ядро</div></div><div class="percent"><span id="systemIssues">—</span><small> ошибок</small></div></div>
<div class="bar"><div class="fill" id="systemBar"></div></div>
<div class="quick"><div><span class="label">Упавшие службы</span><strong id="failedUnits">—</strong></div><div><span class="label">Ошибки ядра</span><strong id="kernelErrors">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть журнал →</div>
</button>
</section>
<footer>Обновление каждые 2 секунды · данные читаются напрямую с хоста</footer>
</main>
<dialog id="alertsDialog">
<div class="modal-head"><div><div class="label">Notification center</div><h2>Центр уведомлений</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="alert-list" id="alertList"><span class="empty">Проверка состояния системы…</span></div>
<div class="settings-tabs"><button class="settings-tab active" data-alert-tab="active">Активные</button><button class="settings-tab" data-alert-tab="history">История</button></div>
<div class="alert-list alert-panel" data-alert-panel="active" id="alertList"><span class="empty">Проверка состояния системы…</span></div>
<div class="alert-list alert-panel" data-alert-panel="history" id="alertHistory" hidden><span class="empty">Загрузка истории…</span></div>
</dialog>
<dialog id="cpuDialog">
@@ -320,6 +405,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<div class="detail"><span class="label">Получение сейчас</span><strong id="dNetDown">—</strong></div><div class="detail"><span class="label">Передача сейчас</span><strong id="dNetUp">—</strong></div><div class="detail"><span class="label">Суммарная скорость</span><strong id="dNetTotal">—</strong></div>
<div class="detail"><span class="label">Получено с запуска</span><strong id="dNetReceived">—</strong></div><div class="detail"><span class="label">Передано с запуска</span><strong id="dNetSent">—</strong></div><div class="detail"><span class="label">Интерфейсы</span><strong id="dNetInterfaces">—</strong></div>
</div>
<div class="disk-list" id="networkInterfacesList"></div>
<div class="chart-section"><div class="chart-head"><strong>Входящий и исходящий трафик</strong><span>Последние 7 дней</span></div><canvas class="metric-chart" id="networkChart"></canvas></div>
</dialog>
@@ -351,9 +437,34 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<div class="disk-list" id="backupList"><span class="empty">Получение списка резервных копий…</span></div>
</dialog>
<dialog id="maintenanceDialog">
<div class="modal-head"><div><div class="label">Proxmox maintenance</div><h2>Система и обслуживание</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="detail-grid maintenance-grid">
<div class="detail"><span class="label">Proxmox</span><strong id="dPveVersion">—</strong></div><div class="detail"><span class="label">Запущенное ядро</span><strong id="dKernel">—</strong></div><div class="detail"><span class="label">Новое ядро</span><strong id="dLatestKernel">—</strong></div>
<div class="detail"><span class="label">Обновления</span><strong id="dUpdates">—</strong></div><div class="detail"><span class="label">Перезагрузка</span><strong id="dReboot">—</strong></div><div class="detail"><span class="label">Последнее обновление</span><strong id="dLastUpdate">—</strong></div>
</div>
<div class="wide"><span class="label">Ближайшие задания</span><div class="disk-stats"><div class="disk-stat"><span>Backup</span><b id="dNextBackup">—</b></div><div class="disk-stat"><span>ZFS scrub</span><b id="dNextScrub">—</b></div><div class="disk-stat"><span>Проверено</span><b id="dMaintenanceChecked">—</b></div></div></div>
</dialog>
<dialog id="servicesDialog">
<div class="modal-head"><div><div class="label">Homelab monitoring</div><h2>Сервисы</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="disk-list" id="serviceList"><span class="empty">Добавьте первую проверку в настройках.</span></div>
</dialog>
<dialog id="openServiceDialog">
<div class="modal-head"><div><div class="label">Открыть сервис</div><h2 id="openServiceTitle">Выберите адрес</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="wide"><div class="settings-note" style="margin:0 0 12px">У сервиса указаны два адреса. Выберите, через какой открыть страницу.</div><div class="theme-options" id="openServiceChoices"></div></div>
</dialog>
<dialog id="systemDialog">
<div class="modal-head"><div><div class="label">System health</div><h2>Ошибки системы и ядра</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="detail-grid"><div class="detail"><span class="label">Ошибки · 24 часа</span><strong id="dSystemErrors">—</strong></div><div class="detail"><span class="label">Ядро</span><strong id="dKernelErrors">—</strong></div><div class="detail"><span class="label">Упавшие службы</span><strong id="dFailedUnits">—</strong></div></div>
<div class="system-tabs"><button class="detail-tab active" data-system-tab="issues">Ошибки журнала</button><button class="detail-tab" data-system-tab="services">Упавшие службы</button></div>
<div class="disk-list system-panel" data-system-panel="issues" id="issueList"><span class="empty">Системных ошибок нет.</span></div>
<div class="disk-list system-panel" data-system-panel="services" id="failedServiceList" hidden><span class="empty">Упавших служб нет.</span></div>
</dialog>
<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="email">Почта</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="services">Сервисы</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">
@@ -361,8 +472,9 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<label class="setting-item"><input type="checkbox" data-card-toggle="cpu"> CPU</label><label class="setting-item"><input type="checkbox" data-card-toggle="memory"> Память</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="storage"> Диски</label><label class="setting-item"><input type="checkbox" data-card-toggle="network"> Сеть</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="zfs"> ZFS</label><label class="setting-item"><input type="checkbox" data-card-toggle="ups"> UPS</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="guests"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="backups"> Резервные копии</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="guests"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="maintenance"> Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-card-toggle="services"> Сервисы</label><label class="setting-item"><input type="checkbox" data-card-toggle="system"> Система</label>
</div></div>
<div class="settings-group"><div class="label">Порядок карточек</div><div class="settings-note">Перетаскивайте строки или используйте стрелки.</div><div class="card-order" id="cardOrder"></div></div>
</div>
<div class="settings-body settings-panel" data-settings-panel="alerts" hidden>
<div class="threshold-grid">
@@ -377,8 +489,17 @@ 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="services" hidden>
<div class="settings-group"><div class="label">Новая или изменяемая проверка</div><div class="service-form">
<input type="hidden" id="serviceId"><label class="field"><span>Название сервиса</span><input id="serviceName" placeholder="Например, Gitea"></label><label class="field"><span>Способ проверки</span><select id="serviceType"><option value="http">Открыть HTTP / HTTPS</option><option value="tcp">Подключиться к TCP-порту</option><option value="ping">Проверить ping</option></select></label>
<label class="field"><span>Внешний веб-адрес</span><input id="serviceURL" placeholder="https://git.myown.center"><small>Проверяется отдельно через DNS-имя.</small></label><label class="field"><span>Локальный адрес</span><input id="serviceLocal" placeholder="https://192.168.0.6:3000"><small>Проверяется отдельно из сети Proxmox.</small></label>
<label class="field"><span>Ожидаемый HTTP-код</span><input id="serviceCode" type="number" value="200"><small>Обычно 200 означает, что страница открылась успешно.</small></label><label class="field"><span>Текст, который должен быть на странице</span><input id="serviceText" placeholder="Необязательно"><small>Например, Gitea. Пустое поле отключает проверку текста.</small></label>
<label class="field"><span>Сколько ждать ответа</span><input id="serviceTimeout" type="number" value="5" min="1" max="30"><small>Таймаут в секундах, обычно достаточно 5.</small></label><label class="check-field"><input id="serviceEnabled" type="checkbox" checked><span><b>Проверка включена</b><small style="display:block">Сервис проверяется каждые 15 секунд.</small></span></label>
</div><button class="save-settings" id="saveService">Сохранить сервис</button><div class="settings-note" id="serviceStatus">Проверка выполняется каждые 15 секунд, история хранится 7 дней.</div></div>
<div class="settings-group"><div class="label">Добавленные сервисы</div><div class="disk-list" id="serviceSettingsList"><span class="empty">Список пуст.</span></div></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="setting-list"><label class="setting-item"><input type="checkbox" id="emailEnabled"> Включить почтовые дайджесты</label><label class="setting-item"><input type="checkbox" id="emailSkipTLS"> Не проверять TLS-сертификат</label></div><div class="settings-note">Только для доверенного сервера во внутренней сети.</div></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>
@@ -387,7 +508,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</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>
<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="Proxmox"> Proxmox</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><label class="setting-item"><input type="checkbox" data-email-source="Сервисы"> Сервисы</label><label class="setting-item"><input type="checkbox" data-email-source="Система"> Система</label><label class="setting-item"><input type="checkbox" data-email-source="Сеть"> Сеть</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>
@@ -406,10 +527,13 @@ const rate=n=>size(n)+'/s';
const safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const uptime=s=>{const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?d+'д '+h+'ч':h?h+'ч '+m+'м':m+'м'};
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;
let currentServiceStatuses=[];
function endpointHTML(v){const tls=v.tlsDays==null?'Нет TLS':v.tlsDays<0?'Сертификат истёк':v.tlsDays+' дн. до окончания',cert=v.tlsSubject?'<div class="certificate"><div>Сертификат: <b>'+safe(v.tlsSubject)+'</b></div><div>Домены: <b>'+safe((v.tlsDnsNames||[]).join(', ')||'не указаны')+'</b></div><div>Издатель: <b>'+safe(v.tlsIssuer||'—')+'</b></div><div>Для этого адреса: <b class="'+(v.tlsHostnameValid?'status-up':'status-down')+'">'+(v.tlsHostnameValid?'подходит':'не подходит')+'</b></div></div>':'';return '<div class="endpoint"><div class="endpoint-head"><div><b>'+safe(v.label)+'</b><div class="endpoint-address">'+safe(v.address)+'</div></div><span class="badge '+(v.up?'':'bad')+'">'+(v.up?'Доступен':'Недоступен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Отклик</span><b>'+fixed(v.latencyMs)+' ms</b></div><div class="disk-stat"><span>HTTP</span><b>'+(v.statusCode||'—')+'</b></div><div class="disk-stat"><span>TLS</span><b>'+tls+'</b></div><div class="disk-stat"><span>Uptime · сутки</span><b>'+fixed(v.uptimeDay)+'%</b></div><div class="disk-stat"><span>Uptime · неделя</span><b>'+fixed(v.uptimeWeek)+'%</b></div></div>'+cert+(v.error?'<div class="settings-note status-down">'+safe(v.error)+'</div>':'')+'</div>'}
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=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+(v.endpoints||[]).length+' '+((v.endpoints||[]).length===1?'адрес':'адреса')+' проверяются независимо</div></div><span class="badge '+(v.up?'':'bad')+'">'+(v.up?'Всё доступно':'Есть проблема')+'</span></div><div class="endpoint-list">'+(v.endpoints||[]).map(endpointHTML).join('')+'</div><div class="service-actions"><button data-service-open="'+v.id+'">Открыть сервис ↗</button></div></article>').join(''):'<span class="empty">Добавьте первую проверку в настройках.</span>'}
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)+'%';
el('alertList').innerHTML=alerts.length?alerts.map(a=>'<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div></div></article>').join(''):'<div class="all-good"><strong>Всё в порядке</strong><span>Активных предупреждений сейчас нет.</span></div>';
el('alertList').innerHTML=alerts.length?alerts.map(a=>'<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><button class="ack-alert" data-ack="'+a.eventId+'">Принято</button></div></article>').join(''):'<div class="all-good"><strong>Всё в порядке</strong><span>Новых неподтверждённых событий нет.</span></div>';
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=>'<span class="temp">'+t.label+'<b>'+fixed(t.celsius)+'°C</b></span>').join(''):'<span class="empty">Датчики температуры не найдены</span>';
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)+'%';
@@ -421,9 +545,13 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av
el('zfsList').innerHTML=pools.length?pools.map(p=>{const bad=p.health!=='ONLINE',reason=(p.attentionReasons||[]).join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'';return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(p.name)+'</div><div class="disk-meta">'+safe(p.scan||'Данные scrub отсутствуют')+'</div></div><span class="badge '+(bad?'bad':'')+'"'+tip+'>'+safe(p.health)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Объём</span><b>'+size(p.sizeBytes)+'</b></div><div class="disk-stat"><span>Занято</span><b>'+size(p.allocatedBytes)+'</b></div><div class="disk-stat"><span>Свободно</span><b>'+size(p.freeBytes)+'</b></div><div class="disk-stat"><span>Заполнение</span><b>'+fixed(p.capacityPercent)+'%</b></div><div class="disk-stat"><span>Фрагментация</span><b>'+fixed(p.fragmentationPercent)+'%</b></div><div class="disk-stat"><span>Dedup</span><b>'+safe(p.dedupRatio)+'</b></div></div></article>'}).join(''):'<span class="empty">'+(z.available?'ZFS-пулы не найдены':'Команда zpool недоступна')+'</span>';
const val=(v,suffix='')=>v==null?'—':fixed(v)+suffix;el('upsName').textContent=u.available?(u.model||u.name||'Источник бесперебойного питания'):'UPS не настроен';el('upsCharge').textContent=u.chargePercent==null?'—':fixed(u.chargePercent);el('upsBar').style.width=u.chargePercent==null?'0%':Math.min(u.chargePercent,100)+'%';el('upsStatus').textContent=u.available?u.statusLabel:'Не найден';el('upsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsName').textContent=u.model||u.name||'Источник бесперебойного питания';el('dUpsStatus').textContent=u.available?u.statusLabel:'Не настроен';el('dUpsCharge').textContent=val(u.chargePercent,'%');el('dUpsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsLoad').textContent=val(u.loadPercent,'%');el('dUpsInput').textContent=val(u.inputVoltage,' V');el('dUpsOutput').textContent=val(u.outputVoltage,' V');el('dUpsFrequency').textContent=val(u.inputFrequency,' Hz');el('dUpsBattery').textContent=val(u.batteryVoltage,' V');el('dUpsTemp').textContent=val(u.temperatureCelsius,' °C');el('dUpsSource').textContent=u.available?(u.source+' · '+(u.name||'UPS')):'Установите и настройте NUT или apcupsd';
const guests=(g.guests||[]).filter(v=>!v.template),running=guests.filter(v=>v.status==='running'),guestCores=running.reduce((a,v)=>a+v.maxcpu,0),guestCPU=guestCores?running.reduce((a,v)=>a+v.cpu*v.maxcpu,0)/guestCores*100:0,guestMem=running.reduce((a,v)=>a+v.mem,0),guestMaxMem=running.reduce((a,v)=>a+v.maxmem,0);el('guestRunning').textContent=running.length;el('guestTotal').textContent=' / '+guests.length;el('guestBar').style.width=guests.length?(running.length/guests.length*100)+'%':'0%';el('guestCPU').textContent=g.available?fixed(guestCPU)+'%':'Не найдено';el('guestMemory').textContent=g.available?(size(guestMem)+' / '+size(guestMaxMem)):'—';
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;return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+safe(v.node)+'</div></div><span class="badge '+(live?'':'warn')+'">'+(live?'Запущен':'Остановлен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Тип</span><b>'+type+'</b></div><div class="disk-stat"><span>CPU</span><b>'+(live?fixed(v.cpu*100)+'%':'—')+'</b></div><div class="disk-stat"><span>Ядра</span><b>'+v.maxcpu+'</b></div><div class="disk-stat"><span>Память</span><b>'+(live?size(v.mem)+' · '+fixed(memPercent)+'%':size(v.maxmem))+'</b></div><div class="disk-stat"><span>Лимит RAM</span><b>'+size(v.maxmem)+'</b></div><div class="disk-stat"><span>Uptime</span><b>'+(live?uptime(v.uptime):'—')+'</b></div></div></article>'}).join(''):'<span class="empty">'+(g.available?'VM и LXC не найдены':'Команда pvesh недоступна')+'</span>';
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 '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+safe(v.node)+' · '+safe(ips)+'</div></div><span class="badge '+(live?'':'warn')+'">'+(live?'Запущен':'Остановлен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>CPU</span><b>'+(live?fixed(v.cpu*100)+'%':'—')+' / '+v.maxcpu+'</b></div><div class="disk-stat"><span>Память</span><b>'+(live?size(v.mem)+' · '+fixed(memPercent)+'%':size(v.maxmem))+'</b></div><div class="disk-stat"><span>Диск</span><b>'+(v.maxdisk?size(v.disk)+' · '+fixed(diskPercent)+'%':'—')+'</b></div><div class="disk-stat"><span>Сеть</span><b>'+size(v.netin)+' · ↑ '+size(v.netout)+'</b></div><div class="disk-stat"><span>Guest Agent</span><b>'+(v.agent?'Доступен':'Нет')+'</b></div><div class="disk-stat"><span>Автозапуск</span><b>'+(v.onboot?'Да':'Нет')+'</b></div><div class="disk-stat"><span>Storage</span><b>'+safe((v.storage||[]).join(', ')||'—')+'</b></div><div class="disk-stat"><span>Uptime</span><b>'+(live?uptime(v.uptime):'—')+'</b></div></div></article>'}).join(''):'<span class="empty">'+(g.available?'VM и LXC не найдены':'Команда pvesh недоступна')+'</span>';
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 '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+'</div></div><span class="badge bad">Копии нет</span></div></article>';const age=now-item.ctime,fresh=age<=backupLimit,date=new Date(item.ctime*1000).toLocaleString('ru-RU');return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+date+'</div></div><span class="badge '+(fresh?'':'warn')+'">'+(fresh?'Актуальна':'Старше '+backupLimitHours+' ч')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Хранилище</span><b>'+safe(item.storage)+'</b></div><div class="disk-stat"><span>Размер</span><b>'+size(item.size)+'</b></div><div class="disk-stat"><span>Формат</span><b>'+safe(item.format)+'</b></div><div class="disk-stat"><span>Возраст</span><b>'+uptime(age)+'</b></div><div class="disk-stat"><span>Создана</span><b>'+date+'</b></div><div class="disk-stat"><span>VMID</span><b>'+v.vmid+'</b></div></div></article>'}).join(''):'<span class="empty">'+(b.available?'VM и LXC не найдены':'Данные резервных копий недоступны')+'</span>';
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=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.unit)+'</div><div class="disk-meta">'+new Date(v.time*1000).toLocaleString('ru-RU')+'</div></div><span class="badge bad">Ошибка</span></div><div class="settings-note issue-message">'+safe(v.message)+'</div></article>').join(''):'<span class="empty">Критических сообщений за 24 часа нет.</span>';el('failedServiceList').innerHTML=(sh.failedServices||[]).length?sh.failedServices.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.unit)+'</div><div class="disk-meta">'+safe(v.description||'Описание отсутствует')+'</div></div><span class="badge bad">'+safe(v.state)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Загрузка</span><b>'+safe(v.load)+'</b></div><div class="disk-stat"><span>Состояние</span><b>'+safe(v.active)+'</b></div><div class="disk-stat"><span>Подсостояние</span><b>'+safe(v.state)+'</b></div></div></article>').join(''):'<span class="empty">Упавших systemd-служб нет.</span>';
el('networkInterfacesList').innerHTML=(n.interfaces||[]).map(v=>'<article class="disk-item"><div class="disk-top"><div class="disk-model">'+safe(v.name)+'</div><span class="badge '+((v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops)?'warn':'')+'">'+((v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops)?'Есть потери':'Без ошибок')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Получено</span><b>'+size(v.receiveBytes)+'</b></div><div class="disk-stat"><span>Передано</span><b>'+size(v.transmitBytes)+'</b></div><div class="disk-stat"><span>RX errors / drops</span><b>'+v.receiveErrors+' / '+v.receiveDrops+'</b></div><div class="disk-stat"><span>TX errors / drops</span><b>'+v.transmitErrors+' / '+v.transmitDrops+'</b></div></div></article>').join('');
el('dot').classList.add('live');el('status').textContent='Данные поступают';
}
const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed(v)+' MB/s';
@@ -437,15 +565,28 @@ 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()}});
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();
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=>'<a class="theme-option" href="'+safe(v.address)+'" target="_blank" rel="noopener">'+safe(v.label)+'<small style="display:block;color:var(--muted);margin-top:4px">'+safe(v.address)+'</small></a>').join('');el('openServiceDialog').showModal()};
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 '<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><span class="history-state">'+state+'</span></div></article>'}).join(''):'<div class="all-good"><strong>История пока пуста</strong><span>Новые события появятся здесь автоматически.</span></div>'}
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))];
function renderCardOrder(){el('cardOrder').innerHTML=cardOrder.map(name=>'<div class="order-item" draggable="true" data-order-card="'+name+'"><span class="order-handle">⠿</span><span>'+cardLabels[name]+'</span><button class="order-button" data-move="up" aria-label="Выше">↑</button><button class="order-button" data-move="down" aria-label="Ниже">↓</button></div>').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})}
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()});renderCardOrder();applyCardSettings();
el('cardOrder').onclick=event=>{const button=event.target.closest('[data-move]'),item=event.target.closest('[data-order-card]');if(!button||!item)return;const index=cardOrder.indexOf(item.dataset.orderCard),next=button.dataset.move==='up'?index-1:index+1;if(next<0||next>=cardOrder.length)return;[cardOrder[index],cardOrder[next]]=[cardOrder[next],cardOrder[index]];saveCardOrder()};let draggedCard='';el('cardOrder').addEventListener('dragstart',event=>{const item=event.target.closest('[data-order-card]');if(!item)return;draggedCard=item.dataset.orderCard;item.classList.add('dragging')});el('cardOrder').addEventListener('dragend',event=>{event.target.closest('[data-order-card]')?.classList.remove('dragging');draggedCard=''});el('cardOrder').addEventListener('dragover',event=>event.preventDefault());el('cardOrder').addEventListener('drop',event=>{event.preventDefault();const target=event.target.closest('[data-order-card]');if(!target||!draggedCard||target.dataset.orderCard===draggedCard)return;const from=cardOrder.indexOf(draggedCard),to=cardOrder.indexOf(target.dataset.orderCard);cardOrder.splice(from,1);cardOrder.splice(to,0,draggedCard);saveCardOrder()});
function setTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('proxmox-theme',theme);document.querySelectorAll('[data-theme-choice]').forEach(b=>b.classList.toggle('active',b.dataset.themeChoice===theme));document.querySelectorAll('.chart-section:not([hidden])').forEach(showHistory)}document.querySelectorAll('[data-theme-choice]').forEach(b=>b.onclick=()=>setTheme(b.dataset.themeChoice));setTheme(document.documentElement.dataset.theme);el('settingsButton').onclick=()=>el('settingsDialog').showModal();
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))}
let configuredServices=[];
async function loadServices(){const response=await fetch('/api/services');if(!response.ok)return;configuredServices=await response.json()||[];el('serviceSettingsList').innerHTML=configuredServices.length?configuredServices.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe([v.url&&('Внешний: '+v.url),v.localAddress&&('Локальный: '+v.localAddress)].filter(Boolean).join(' · '))+'</div></div><span class="badge '+(v.enabled?'':'warn')+'">'+(v.enabled?'Включён':'Выключен')+'</span></div><div class="service-actions"><button data-service-edit="'+v.id+'">Изменить</button><button data-service-delete="'+v.id+'">Удалить</button></div></article>').join(''):'<span class="empty">Список пуст.</span>'}
function resetServiceForm(){el('serviceId').value='';el('serviceName').value='';el('serviceType').value='http';el('serviceURL').value='';el('serviceLocal').value='';el('serviceCode').value=200;el('serviceText').value='';el('serviceTimeout').value=5;el('serviceEnabled').checked=true}
el('saveService').onclick=async()=>{const value={id:Number(el('serviceId').value)||0,name:el('serviceName').value.trim(),checkType:el('serviceType').value,url:el('serviceURL').value.trim(),localAddress:el('serviceLocal').value.trim(),expectedCode:Number(el('serviceCode').value)||200,expectedText:el('serviceText').value,timeoutSeconds:Number(el('serviceTimeout').value)||5,enabled:el('serviceEnabled').checked},status=el('serviceStatus');status.textContent='Сохранение…';const response=await fetch('/api/services',{method:value.id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(value)});status.textContent=response.ok?'Сервис сохранён. Первая проверка появится в течение 15 секунд.':await response.text();if(response.ok){resetServiceForm();loadServices()}};
el('serviceSettingsList').onclick=async event=>{const edit=event.target.closest('[data-service-edit]'),del=event.target.closest('[data-service-delete]');if(edit){const v=configuredServices.find(x=>x.id===Number(edit.dataset.serviceEdit));if(!v)return;el('serviceId').value=v.id;el('serviceName').value=v.name;el('serviceType').value=v.checkType;el('serviceURL').value=v.url;el('serviceLocal').value=v.localAddress||'';el('serviceCode').value=v.expectedCode;el('serviceText').value=v.expectedText||'';el('serviceTimeout').value=v.timeoutSeconds;el('serviceEnabled').checked=v.enabled;return}if(del&&confirm('Удалить сервис и его историю?')){const response=await fetch('/api/services?id='+del.dataset.serviceDelete,{method:'DELETE'});if(response.ok)loadServices()}};loadServices().catch(()=>{});
function emailForm(){return {enabled:el('emailEnabled').checked,skipTlsVerify:el('emailSkipTLS').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('emailSkipTLS').checked=!!s.skipTlsVerify;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='Не удалось запустить обновление.'}};