Add unified notification center
This commit is contained in:
@@ -24,6 +24,10 @@
|
|||||||
Через кнопку настроек можно скрывать неиспользуемые карточки и переключать
|
Через кнопку настроек можно скрывать неиспользуемые карточки и переключать
|
||||||
светлую или тёмную тему. Выбор сохраняется локально в браузере.
|
светлую или тёмную тему. Выбор сохраняется локально в браузере.
|
||||||
|
|
||||||
|
Центр уведомлений объединяет критические события, предупреждения и
|
||||||
|
информационные сообщения по CPU, памяти, storage, SMART, ZFS, UPS, VM/LXC и
|
||||||
|
резервным копиям. Критические события всегда отображаются первыми.
|
||||||
|
|
||||||
На главном экране находятся компактные карточки CPU, памяти, диска и сети.
|
На главном экране находятся компактные карточки CPU, памяти, диска и сети.
|
||||||
Нажатие на карточку открывает подробные показатели.
|
Нажатие на карточку открывает подробные показатели.
|
||||||
|
|
||||||
|
|||||||
147
alerts.go
Normal file
147
alerts.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Alert struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateAlerts(metrics dashboardMetrics) []Alert {
|
||||||
|
var alerts []Alert
|
||||||
|
add := func(id, severity, source, title, message string) {
|
||||||
|
alerts = append(alerts, Alert{ID: id, Severity: severity, Source: source, Title: title, Message: message})
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics.CPU.UsagePercent >= 95 {
|
||||||
|
add("cpu-usage", "critical", "CPU", "Очень высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent))
|
||||||
|
} else if metrics.CPU.UsagePercent >= 85 {
|
||||||
|
add("cpu-usage", "warning", "CPU", "Высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent))
|
||||||
|
}
|
||||||
|
for _, temperature := range metrics.CPU.Temperatures {
|
||||||
|
if temperature.Celsius >= 90 {
|
||||||
|
add("cpu-temp-"+temperature.Label, "critical", "CPU", "Критическая температура CPU", fmt.Sprintf("%s: %.1f °C.", temperature.Label, temperature.Celsius))
|
||||||
|
} else if temperature.Celsius >= 80 {
|
||||||
|
add("cpu-temp-"+temperature.Label, "warning", "CPU", "Высокая температура CPU", fmt.Sprintf("%s: %.1f °C.", temperature.Label, temperature.Celsius))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics.Memory.UsagePercent >= 95 {
|
||||||
|
add("memory", "critical", "Память", "Почти закончилась оперативная память", fmt.Sprintf("Используется %.1f%% RAM.", metrics.Memory.UsagePercent))
|
||||||
|
} else if metrics.Memory.UsagePercent >= 85 {
|
||||||
|
add("memory", "warning", "Память", "Высокое использование памяти", fmt.Sprintf("Используется %.1f%% RAM.", metrics.Memory.UsagePercent))
|
||||||
|
}
|
||||||
|
if metrics.Memory.SwapTotalBytes > 0 && metrics.Memory.SwapPercent >= 50 {
|
||||||
|
add("swap", "warning", "Память", "Активно используется swap", fmt.Sprintf("Занято %.1f%% swap.", metrics.Memory.SwapPercent))
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics.Storage.UsagePercent >= 95 {
|
||||||
|
add("root-storage", "critical", "Storage", "Системный раздел почти заполнен", fmt.Sprintf("Раздел / заполнен на %.1f%%.", metrics.Storage.UsagePercent))
|
||||||
|
} else if metrics.Storage.UsagePercent >= 85 {
|
||||||
|
add("root-storage", "warning", "Storage", "Мало места на системном разделе", fmt.Sprintf("Раздел / заполнен на %.1f%%.", metrics.Storage.UsagePercent))
|
||||||
|
}
|
||||||
|
for _, disk := range metrics.Disks {
|
||||||
|
if disk.SMARTStatus == "Ошибка" {
|
||||||
|
add("disk-smart-"+disk.Name, "critical", "Диски", disk.Model+": ошибка SMART", joinReasons(disk.AttentionReasons, "SMART сообщает о неисправности диска."))
|
||||||
|
} else if disk.SMARTStatus == "Требует внимания" {
|
||||||
|
add("disk-smart-"+disk.Name, "warning", "Диски", disk.Model+": SMART требует внимания", joinReasons(disk.AttentionReasons, "Проверьте SMART-показатели диска."))
|
||||||
|
}
|
||||||
|
if disk.Temperature != nil {
|
||||||
|
limit := 60.0
|
||||||
|
if strings.Contains(disk.Type, "NVMe") {
|
||||||
|
limit = 75
|
||||||
|
}
|
||||||
|
if *disk.Temperature >= limit {
|
||||||
|
add("disk-temp-"+disk.Name, "warning", "Диски", disk.Model+": высокая температура", fmt.Sprintf("Температура %.1f °C.", *disk.Temperature))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pool := range metrics.ZFS.Pools {
|
||||||
|
if pool.Health != "ONLINE" {
|
||||||
|
add("zfs-health-"+pool.Name, "critical", "ZFS", pool.Name+": пул не ONLINE", joinReasons(pool.AttentionReasons, "Текущее состояние: "+pool.Health+"."))
|
||||||
|
}
|
||||||
|
if pool.CapacityPercent >= 90 {
|
||||||
|
add("zfs-capacity-"+pool.Name, "critical", "ZFS", pool.Name+": пул почти заполнен", fmt.Sprintf("Заполнение %.1f%%.", pool.CapacityPercent))
|
||||||
|
} else if pool.CapacityPercent >= 80 {
|
||||||
|
add("zfs-capacity-"+pool.Name, "warning", "ZFS", pool.Name+": мало свободного места", fmt.Sprintf("Заполнение %.1f%%.", pool.CapacityPercent))
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(pool.Scan), "in progress") {
|
||||||
|
add("zfs-scan-"+pool.Name, "info", "ZFS", pool.Name+": выполняется scrub/resilver", pool.Scan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics.UPS.Available {
|
||||||
|
status := metrics.UPS.Status
|
||||||
|
if strings.Contains(status, "LB") {
|
||||||
|
add("ups-low", "critical", "UPS", "Низкий заряд UPS", metrics.UPS.StatusLabel+".")
|
||||||
|
} else if strings.Contains(status, "OB") || status == "ONBATT" {
|
||||||
|
add("ups-battery", "warning", "UPS", "Сервер работает от UPS", "Основное питание отсутствует, используется батарея.")
|
||||||
|
}
|
||||||
|
if metrics.UPS.ChargePercent != nil && *metrics.UPS.ChargePercent < 30 {
|
||||||
|
add("ups-charge", "critical", "UPS", "Низкий заряд батареи", fmt.Sprintf("Осталось %.1f%%.", *metrics.UPS.ChargePercent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics.Backups.Available && metrics.Guests.Available {
|
||||||
|
latest := make(map[int]BackupItem)
|
||||||
|
for _, backup := range metrics.Backups.Items {
|
||||||
|
latest[backup.VMID] = backup
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for _, guest := range metrics.Guests.Guests {
|
||||||
|
if guest.Template != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := guest.Name
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("VMID %d", guest.VMID)
|
||||||
|
}
|
||||||
|
backup, ok := latest[guest.VMID]
|
||||||
|
if !ok {
|
||||||
|
add(fmt.Sprintf("backup-missing-%d", guest.VMID), "critical", "Backups", name+": резервной копии нет", fmt.Sprintf("Для VMID %d не найдена резервная копия.", guest.VMID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
age := now.Sub(time.Unix(backup.CreatedAt, 0))
|
||||||
|
if age > 48*time.Hour {
|
||||||
|
add(fmt.Sprintf("backup-old-%d", guest.VMID), "warning", "Backups", name+": копия старше 48 часов", fmt.Sprintf("Последняя копия создана %.0f часов назад на %s.", age.Hours(), backup.Storage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics.Guests.Available {
|
||||||
|
stopped := 0
|
||||||
|
for _, guest := range metrics.Guests.Guests {
|
||||||
|
if guest.Template == 0 && guest.Status != "running" {
|
||||||
|
stopped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stopped > 0 {
|
||||||
|
add("guests-stopped", "info", "VM/LXC", "Есть остановленные гости", fmt.Sprintf("Остановлено VM/LXC: %d. Это уведомление информационное.", stopped))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
priority := map[string]int{"critical": 0, "warning": 1, "info": 2}
|
||||||
|
sort.SliceStable(alerts, func(i, j int) bool {
|
||||||
|
if priority[alerts[i].Severity] != priority[alerts[j].Severity] {
|
||||||
|
return priority[alerts[i].Severity] < priority[alerts[j].Severity]
|
||||||
|
}
|
||||||
|
return alerts[i].Title < alerts[j].Title
|
||||||
|
})
|
||||||
|
return alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinReasons(reasons []string, fallback string) string {
|
||||||
|
if len(reasons) == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return strings.Join(reasons, " · ")
|
||||||
|
}
|
||||||
18
alerts_test.go
Normal file
18
alerts_test.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEvaluateAlerts(t *testing.T) {
|
||||||
|
metrics := dashboardMetrics{
|
||||||
|
Memory: MemoryMetrics{UsagePercent: 91},
|
||||||
|
Storage: StorageMetrics{UsagePercent: 96},
|
||||||
|
Backups: BackupMetrics{}, Guests: GuestsMetrics{},
|
||||||
|
}
|
||||||
|
alerts := evaluateAlerts(metrics)
|
||||||
|
if len(alerts) != 2 {
|
||||||
|
t.Fatalf("ожидалось два уведомления, получено: %+v", alerts)
|
||||||
|
}
|
||||||
|
if alerts[0].Severity != "critical" || alerts[1].Severity != "warning" {
|
||||||
|
t.Fatalf("неверная сортировка уведомлений: %+v", alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
web.go
30
web.go
@@ -17,6 +17,7 @@ type dashboardMetrics struct {
|
|||||||
UPS UPSMetrics `json:"ups"`
|
UPS UPSMetrics `json:"ups"`
|
||||||
Guests GuestsMetrics `json:"guests"`
|
Guests GuestsMetrics `json:"guests"`
|
||||||
Backups BackupMetrics `json:"backups"`
|
Backups BackupMetrics `json:"backups"`
|
||||||
|
Alerts []Alert `json:"alerts"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +38,14 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return dashboardMetrics{}, err
|
return dashboardMetrics{}, err
|
||||||
}
|
}
|
||||||
return dashboardMetrics{
|
metrics := dashboardMetrics{
|
||||||
CPU: cpu, Memory: memory, Storage: storage,
|
CPU: cpu, Memory: memory, Storage: storage,
|
||||||
Disks: diskCollector.collect(), Network: network,
|
Disks: diskCollector.collect(), Network: network,
|
||||||
ZFS: zfsCollector.collect(), UPS: upsCollector.collect(),
|
ZFS: zfsCollector.collect(), UPS: upsCollector.collect(),
|
||||||
Guests: guestCollector.collect(), Backups: backupCollector.collect(), Timestamp: time.Now(),
|
Guests: guestCollector.collect(), Backups: backupCollector.collect(), Timestamp: time.Now(),
|
||||||
}, nil
|
}
|
||||||
|
metrics.Alerts = evaluateAlerts(metrics)
|
||||||
|
return metrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func routes(collector *cpuCollector) http.Handler {
|
func routes(collector *cpuCollector) http.Handler {
|
||||||
@@ -131,13 +134,14 @@ h1{font-size:clamp(28px,5vw,44px);letter-spacing:-.045em;margin:9px 0 0}.header-
|
|||||||
.cards{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:1fr;gap:16px}.card{appearance:none;display:flex;flex-direction:column;text-align:left;color:inherit;width:100%;padding:0;background:linear-gradient(145deg,rgba(18,28,36,.98),rgba(12,19,25,.98));border:1px solid var(--line);border-radius:20px;overflow:hidden;cursor:pointer;transition:transform .18s,border-color .18s,box-shadow .18s}.card:hover{transform:translateY(-2px);border-color:#3d4e5c;box-shadow:0 20px 55px rgba(0,0,0,.22)}.card:focus-visible{outline:2px solid var(--green);outline-offset:3px}
|
.cards{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:1fr;gap:16px}.card{appearance:none;display:flex;flex-direction:column;text-align:left;color:inherit;width:100%;padding:0;background:linear-gradient(145deg,rgba(18,28,36,.98),rgba(12,19,25,.98));border:1px solid var(--line);border-radius:20px;overflow:hidden;cursor:pointer;transition:transform .18s,border-color .18s,box-shadow .18s}.card:hover{transform:translateY(-2px);border-color:#3d4e5c;box-shadow:0 20px 55px rgba(0,0,0,.22)}.card:focus-visible{outline:2px solid var(--green);outline-offset:3px}
|
||||||
.card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;min-height:106px;padding:23px 24px 18px}.card-head>div:first-child{min-width:0}.label{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.1em}.name{font-size:17px;font-weight:650;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:350px}.percent{flex:0 0 auto;font:700 35px/.95 ui-monospace,monospace;letter-spacing:-.07em}.percent small{font-size:16px;color:var(--green)}
|
.card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;min-height:106px;padding:23px 24px 18px}.card-head>div:first-child{min-width:0}.label{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.1em}.name{font-size:17px;font-weight:650;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:350px}.percent{flex:0 0 auto;font:700 35px/.95 ui-monospace,monospace;letter-spacing:-.07em}.percent small{font-size:16px;color:var(--green)}
|
||||||
.bar{height:5px;background:#222e37;margin:0 24px;overflow:hidden;border-radius:20px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--green),#afe36d);transition:width .5s ease}.memory .fill{background:linear-gradient(90deg,var(--violet),#72b9ff)}
|
.bar{height:5px;background:#222e37;margin:0 24px;overflow:hidden;border-radius:20px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--green),#afe36d);transition:width .5s ease}.memory .fill{background:linear-gradient(90deg,var(--violet),#72b9ff)}
|
||||||
.storage .fill{background:linear-gradient(90deg,#ffb35c,#ff755c)}.network .fill{background:linear-gradient(90deg,#43c6d9,#65ddaa)}.zfs .fill{background:linear-gradient(90deg,#46c0ff,#6577ff)}.ups .fill{background:linear-gradient(90deg,#d5e85a,#55d58a)}.guests .fill{background:linear-gradient(90deg,#ff78b8,#9d8cff)}.backups .fill{background:linear-gradient(90deg,#ffcf5c,#55d58a)}
|
.storage .fill{background:linear-gradient(90deg,#ffb35c,#ff755c)}.network .fill{background:linear-gradient(90deg,#43c6d9,#65ddaa)}.zfs .fill{background:linear-gradient(90deg,#46c0ff,#6577ff)}.ups .fill{background:linear-gradient(90deg,#d5e85a,#55d58a)}.guests .fill{background:linear-gradient(90deg,#ff78b8,#9d8cff)}.backups .fill{background:linear-gradient(90deg,#ffcf5c,#55d58a)}.alert-center{grid-column:1/-1}.alert-center .fill{background:linear-gradient(90deg,#ffb35c,#ff665c)}
|
||||||
.quick{display:grid;grid-template-columns:1fr 1fr;margin-top:19px;border-top:1px solid var(--line)}.quick div{padding:16px 24px}.quick div+div{border-left:1px solid var(--line)}.quick strong{display:block;font:650 20px/1.2 ui-monospace,monospace;margin-top:5px}.hint{color:#657581;font-size:11px;margin:auto 24px 18px;padding-top:14px}
|
.quick{display:grid;grid-template-columns:1fr 1fr;margin-top:19px;border-top:1px solid var(--line)}.quick div{padding:16px 24px}.quick div+div{border-left:1px solid var(--line)}.quick strong{display:block;font:650 20px/1.2 ui-monospace,monospace;margin-top:5px}.hint{color:#657581;font-size:11px;margin:auto 24px 18px;padding-top:14px}
|
||||||
dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding:0;color:var(--text);background:#101820;border:1px solid #34434f;border-radius:22px;box-shadow:0 30px 100px #000b;overflow:auto}dialog::backdrop{background:rgba(3,7,10,.75);backdrop-filter:blur(6px)}
|
dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding:0;color:var(--text);background:#101820;border:1px solid #34434f;border-radius:22px;box-shadow:0 30px 100px #000b;overflow:auto}dialog::backdrop{background:rgba(3,7,10,.75);backdrop-filter:blur(6px)}
|
||||||
.modal-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;padding:26px 28px;background:rgba(16,24,32,.94);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}.modal-head h2{font-size:24px;letter-spacing:-.03em;margin:5px 0 0}.close{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:#17222b;color:var(--text);font-size:22px;cursor:pointer}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr)}.detail{padding:22px 27px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.detail:nth-child(3n){border-right:0}.detail strong{display:block;font:650 24px/1.2 ui-monospace,monospace;margin-top:7px}.wide{padding:24px 28px}.temps{display:flex;flex-wrap:wrap;gap:9px;margin-top:12px}.temp{padding:7px 11px;border:1px solid var(--line);border-radius:99px}.temp b{color:var(--green);margin-left:7px}.empty{color:var(--muted);font-size:13px}
|
.modal-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;padding:26px 28px;background:rgba(16,24,32,.94);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}.modal-head h2{font-size:24px;letter-spacing:-.03em;margin:5px 0 0}.close{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:#17222b;color:var(--text);font-size:22px;cursor:pointer}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr)}.detail{padding:22px 27px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.detail:nth-child(3n){border-right:0}.detail strong{display:block;font:650 24px/1.2 ui-monospace,monospace;margin-top:7px}.wide{padding:24px 28px}.temps{display:flex;flex-wrap:wrap;gap:9px;margin-top:12px}.temp{padding:7px 11px;border:1px solid var(--line);border-radius:99px}.temp b{color:var(--green);margin-left:7px}.empty{color:var(--muted);font-size:13px}
|
||||||
.disk-list{display:grid;gap:12px;padding:16px}.disk-item{padding:19px;border:1px solid var(--line);border-radius:15px;background:#0c141a}.disk-top{display:flex;justify-content:space-between;gap:20px}.disk-model{font-size:17px;font-weight:650}.disk-meta{color:var(--muted);font:12px/1.5 ui-monospace,monospace;margin-top:4px;overflow-wrap:anywhere}.badge{position:relative;height:max-content;padding:5px 9px;border:1px solid #2d6245;border-radius:99px;color:var(--green);font-size:11px;white-space:nowrap}.badge.warn{color:#ffb35c;border-color:#6d4c2c}.badge.bad{color:#ff755c;border-color:#71362f}.badge[data-reason]:hover::after,.badge[data-reason]:focus::after{content:attr(data-reason);position:absolute;z-index:5;right:0;bottom:calc(100% + 10px);width:280px;padding:11px 13px;border:1px solid #4c5963;border-radius:10px;background:#1a242c;color:var(--text);font:12px/1.45 Inter,system-ui,sans-serif;white-space:normal;box-shadow:0 14px 35px #0008}.disk-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-top:18px}.disk-stat span{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.disk-stat b{display:block;margin-top:5px;font:600 14px ui-monospace,monospace}
|
.disk-list{display:grid;gap:12px;padding:16px}.disk-item{padding:19px;border:1px solid var(--line);border-radius:15px;background:#0c141a}.disk-top{display:flex;justify-content:space-between;gap:20px}.disk-model{font-size:17px;font-weight:650}.disk-meta{color:var(--muted);font:12px/1.5 ui-monospace,monospace;margin-top:4px;overflow-wrap:anywhere}.badge{position:relative;height:max-content;padding:5px 9px;border:1px solid #2d6245;border-radius:99px;color:var(--green);font-size:11px;white-space:nowrap}.badge.warn{color:#ffb35c;border-color:#6d4c2c}.badge.bad{color:#ff755c;border-color:#71362f}.badge[data-reason]:hover::after,.badge[data-reason]:focus::after{content:attr(data-reason);position:absolute;z-index:5;right:0;bottom:calc(100% + 10px);width:280px;padding:11px 13px;border:1px solid #4c5963;border-radius:10px;background:#1a242c;color:var(--text);font:12px/1.45 Inter,system-ui,sans-serif;white-space:normal;box-shadow:0 14px 35px #0008}.disk-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-top:18px}.disk-stat span{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.disk-stat b{display:block;margin-top:5px;font:600 14px ui-monospace,monospace}
|
||||||
.settings-body{padding:22px 26px 28px}.settings-group+.settings-group{margin-top:25px}.theme-options{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:11px}.theme-option{padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--text);cursor:pointer}.theme-option.active{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green)}.setting-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:11px}.setting-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.setting-item input{accent-color:var(--green);width:17px;height:17px}.card[hidden]{display:none}
|
.settings-body{padding:22px 26px 28px}.settings-group+.settings-group{margin-top:25px}.theme-options{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:11px}.theme-option{padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--text);cursor:pointer}.theme-option.active{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green)}.setting-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:11px}.setting-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.setting-item input{accent-color:var(--green);width:17px;height:17px}.card[hidden]{display:none}
|
||||||
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{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{background:#fff}
|
.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{background:#fff}
|
||||||
footer{color:#657581;font-size:12px;margin-top:17px}
|
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{grid-template-columns:repeat(2,1fr)}}
|
@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{grid-template-columns:repeat(2,1fr)}}
|
||||||
</style>
|
</style>
|
||||||
@@ -145,6 +149,12 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
|||||||
<body><main>
|
<body><main>
|
||||||
<header><div><div class="eyebrow">Proxmox host telemetry</div><h1>Host overview</h1></div><div class="header-actions"><div class="status"><i class="dot" id="dot"></i><span id="status">Подключение…</span></div><button class="settings-button" id="settingsButton" aria-label="Настройки">⚙</button></div></header>
|
<header><div><div class="eyebrow">Proxmox host telemetry</div><h1>Host overview</h1></div><div class="header-actions"><div class="status"><i class="dot" id="dot"></i><span id="status">Подключение…</span></div><button class="settings-button" id="settingsButton" aria-label="Настройки">⚙</button></div></header>
|
||||||
<section class="cards">
|
<section class="cards">
|
||||||
|
<button class="card alert-center" data-card="alerts" data-open="alertsDialog" aria-label="Открыть центр уведомлений">
|
||||||
|
<div class="card-head"><div><div class="label">Notification center</div><div class="name" id="alertHeadline">Проверка состояния…</div></div><div class="percent"><span id="alertCount">—</span><small> событий</small></div></div>
|
||||||
|
<div class="bar"><div class="fill" id="alertBar"></div></div>
|
||||||
|
<div class="quick"><div><span class="label">Критические</span><strong id="alertCritical">—</strong></div><div><span class="label">Предупреждения</span><strong id="alertWarnings">—</strong></div></div>
|
||||||
|
<div class="hint">Нажмите, чтобы посмотреть все уведомления →</div>
|
||||||
|
</button>
|
||||||
<button class="card" data-card="cpu" data-open="cpuDialog" aria-label="Открыть подробности процессора">
|
<button class="card" data-card="cpu" data-open="cpuDialog" aria-label="Открыть подробности процессора">
|
||||||
<div class="card-head"><div><div class="label">CPU</div><div class="name" id="cpuName">Загрузка данных…</div></div><div class="percent"><span id="cpuUsage">—</span><small>%</small></div></div>
|
<div class="card-head"><div><div class="label">CPU</div><div class="name" id="cpuName">Загрузка данных…</div></div><div class="percent"><span id="cpuUsage">—</span><small>%</small></div></div>
|
||||||
<div class="bar"><div class="fill" id="cpuBar"></div></div>
|
<div class="bar"><div class="fill" id="cpuBar"></div></div>
|
||||||
@@ -197,6 +207,11 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
|||||||
<footer>Обновление каждые 2 секунды · данные читаются напрямую с хоста</footer>
|
<footer>Обновление каждые 2 секунды · данные читаются напрямую с хоста</footer>
|
||||||
</main>
|
</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>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="cpuDialog">
|
<dialog id="cpuDialog">
|
||||||
<div class="modal-head"><div><div class="label">CPU details</div><h2 id="cpuDetailName">Процессор</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
<div class="modal-head"><div><div class="label">CPU details</div><h2 id="cpuDetailName">Процессор</h2></div><button class="close" aria-label="Закрыть">×</button></div>
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
@@ -263,6 +278,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
|||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
<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="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">
|
<div class="settings-group"><div class="label">Видимые карточки</div><div class="setting-list">
|
||||||
|
<label class="setting-item"><input type="checkbox" data-card-toggle="alerts"> Центр уведомлений</label>
|
||||||
<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="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="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="zfs"> ZFS</label><label class="setting-item"><input type="checkbox" data-card-toggle="ups"> UPS</label>
|
||||||
@@ -277,7 +293,9 @@ const size=n=>{if(!n)return '0 B';const u=['B','KB','MB','GB','TB'],i=Math.min(M
|
|||||||
const rate=n=>size(n)+'/s';
|
const rate=n=>size(n)+'/s';
|
||||||
const safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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+'м'};
|
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+'м'};
|
||||||
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:[]},disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
|
function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
|
||||||
|
const 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('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('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('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)+'%';
|
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)+'%';
|
||||||
@@ -296,7 +314,7 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av
|
|||||||
}
|
}
|
||||||
document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>el(b.dataset.open).showModal());
|
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()}});
|
document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclick=()=>d.close();d.onclick=e=>{if(e.target===d)d.close()}});
|
||||||
const cardNames=['cpu','memory','storage','network','zfs','ups','guests','backups'];let hiddenCards=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]')}catch(e){}
|
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})}
|
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-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();
|
||||||
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('[data-theme-choice]').forEach(b=>b.onclick=()=>setTheme(b.dataset.themeChoice));setTheme(document.documentElement.dataset.theme);el('settingsButton').onclick=()=>el('settingsDialog').showModal();
|
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('[data-theme-choice]').forEach(b=>b.onclick=()=>setTheme(b.dataset.themeChoice));setTheme(document.documentElement.dataset.theme);el('settingsButton').onclick=()=>el('settingsDialog').showModal();
|
||||||
|
|||||||
Reference in New Issue
Block a user