From d3fbbf2a6bc69aed3166410825af5d70002c75a4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 1 Aug 2026 12:21:26 +0300 Subject: [PATCH] Improve service checks and system health details --- alerts.go | 14 ++--- services.go | 138 +++++++++++++++++++++++++++++++++-------------- services_test.go | 11 ++++ store.go | 1 + system_health.go | 52 ++++++++++++++---- web.go | 35 +++++++----- 6 files changed, 184 insertions(+), 67 deletions(-) diff --git a/alerts.go b/alerts.go index 69af10c..9648230 100644 --- a/alerts.go +++ b/alerts.go @@ -141,12 +141,14 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler } } for _, service := range metrics.Services.Services { - if !service.Up { - add(fmt.Sprintf("service-%d", service.ID), "critical", "Сервисы", service.Name+": недоступен", service.Error) - } else if service.TLSDays != nil && *service.TLSDays < 0 { - add(fmt.Sprintf("service-tls-%d", service.ID), "critical", "Сервисы", service.Name+": сертификат истёк", fmt.Sprintf("Истёк %d дн. назад.", -*service.TLSDays)) - } else if service.TLSDays != nil && *service.TLSDays < 14 { - add(fmt.Sprintf("service-tls-%d", service.ID), "warning", "Сервисы", service.Name+": сертификат скоро истечёт", fmt.Sprintf("Осталось %d дн.", *service.TLSDays)) + 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 { diff --git a/services.go b/services.go index 2e178c5..0457a9c 100644 --- a/services.go +++ b/services.go @@ -30,15 +30,36 @@ type MonitoredService struct { type ServiceStatus struct { MonitoredService - 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"` + 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 { @@ -84,13 +105,14 @@ func validateService(v MonitoredService) error { return fmt.Errorf("таймаут должен быть от 1 до 30 секунд") } if v.CheckType == "http" { - target := v.URL - if target == "" { - target = v.LocalAddress - } - u, err := url.Parse(target) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return fmt.Errorf("укажите полный HTTP/HTTPS-адрес") + 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 @@ -121,17 +143,17 @@ 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, v ServiceStatus) { +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) VALUES(?,?,?,?,?,?,?)`, id, v.CheckedAt, v.Up, v.LatencyMS, v.StatusCode, v.Error, expiry) + _, _ = 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=?`, id, since.Unix()).Scan(&value) + _ = 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 } @@ -195,9 +217,16 @@ func (c *serviceCollector) checkAll() { continue } st := checkService(v) - st.UptimeDay = c.store.serviceUptime(v.ID, time.Now().Add(-24*time.Hour)) - st.UptimeWeek = c.store.serviceUptime(v.ID, time.Now().Add(-7*24*time.Hour)) - c.store.addServiceCheck(v.ID, st) + 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++ @@ -210,16 +239,41 @@ func (c *serviceCollector) checkAll() { } func checkService(v MonitoredService) ServiceStatus { - st := ServiceStatus{MonitoredService: v, CheckedAt: time.Now().Unix()} + 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": - target := v.URL - if target == "" { - target = v.LocalAddress - } d := net.Dialer{} conn, err := d.DialContext(ctx, "tcp", target) if err == nil { @@ -229,10 +283,6 @@ func checkService(v MonitoredService) ServiceStatus { st.Error = err.Error() } case "ping": - target := v.URL - if target == "" { - target = v.LocalAddress - } host := strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://") host = strings.Split(host, "/")[0] host = strings.Split(host, ":")[0] @@ -246,13 +296,13 @@ func checkService(v MonitoredService) ServiceStatus { st.Error = strings.TrimSpace(string(out)) } default: - target := v.URL - if target == "" { - target = v.LocalAddress - } tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}} client := &http.Client{Transport: tr, Timeout: time.Duration(v.TimeoutSeconds) * time.Second} - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + 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() @@ -269,10 +319,20 @@ func checkService(v MonitoredService) ServiceStatus { st.Error = fmt.Sprintf("HTTP %d, ожидался %d", resp.StatusCode, v.ExpectedCode) } if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { - expiry := resp.TLS.PeerCertificates[0].NotAfter.Unix() - days := int(time.Until(resp.TLS.PeerCertificates[0].NotAfter).Hours() / 24) + 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() + } } } } diff --git a/services_test.go b/services_test.go index 51599e0..32b5f42 100644 --- a/services_test.go +++ b/services_test.go @@ -27,3 +27,14 @@ func TestValidateServiceAllowsLocalAddress(t *testing.T) { 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) + } +} diff --git a/store.go b/store.go index 534bf0b..d2685e0 100644 --- a/store.go +++ b/store.go @@ -126,6 +126,7 @@ func openStore(path string) (*Store, error) { } // Older databases get this column without requiring a separate migration tool. _, _ = db.Exec(`ALTER TABLE alert_events ADD COLUMN recovery_sent_at INTEGER`) + _, _ = db.Exec(`ALTER TABLE service_checks ADD COLUMN endpoint TEXT NOT NULL DEFAULT 'public'`) _, _ = db.Exec(`PRAGMA foreign_keys=ON`) return &Store{db: db}, nil } diff --git a/system_health.go b/system_health.go index 7692e64..4c53aa9 100644 --- a/system_health.go +++ b/system_health.go @@ -2,6 +2,7 @@ package main import ( "os/exec" + "regexp" "strconv" "strings" "sync" @@ -13,14 +14,23 @@ type SystemIssue struct { 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"` - CheckedAt int64 `json:"checkedAt"` + 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 @@ -56,12 +66,27 @@ func (c *systemHealthCollector) refresh() { 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") { - if strings.TrimSpace(line) != "" { - m.FailedUnits++ + 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 { @@ -71,8 +96,15 @@ func (c *systemHealthCollector) refresh() { sec, _ := strconv.ParseFloat(raw, 64) unit := strings.TrimSuffix(f[1], ":") message := strings.Join(f[2:], " ") - issue := SystemIssue{Time: int64(sec), Unit: unit, Priority: "error", Message: message} - m.Issues = append(m.Issues, issue) + 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++ diff --git a/web.go b/web.go index c75812f..ea0c8bd 100644 --- a/web.go +++ b/web.go @@ -276,10 +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}} +@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:9px}.service-form input,.service-form select{width:100%;box-sizing:border-box;padding:10px;border:1px solid var(--line);border-radius:9px;background:var(--bg);color:var(--text)}.service-actions{display:flex;gap:7px;margin-top:10px}.service-actions button{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer}.status-up{color:var(--green)}.status-down{color:#ff665c}.issue-message{overflow-wrap:anywhere}.service-history{height:160px}.full-span{grid-column:1/-1} +.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}
@@ -439,7 +439,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} -
+
Proxmox
Запущенное ядро
Новое ядро
Обновления
Перезагрузка
Последнее обновление
@@ -450,10 +450,16 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Добавьте первую проверку в настройках.
+ + +
У сервиса указаны два адреса. Выберите, через какой открыть страницу.
+
Ошибки · 24 часа
Ядро
Упавшие службы
-
Системных ошибок нет.
+
+
Системных ошибок нет.
+
@@ -485,10 +491,10 @@ footer{color:#657581;font-size:12px;margin-top:17px} @@ -521,6 +527,9 @@ const rate=n=>size(n)+'/s'; 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+'м'}; let alertSettings={backupMaxAgeHours:48}; +let currentServiceStatuses=[]; +function endpointHTML(v){const tls=v.tlsDays==null?'Нет TLS':v.tlsDays<0?'Сертификат истёк':v.tlsDays+' дн. до окончания',cert=v.tlsSubject?'
Сертификат: '+safe(v.tlsSubject)+'
Домены: '+safe((v.tlsDnsNames||[]).join(', ')||'не указаны')+'
Издатель: '+safe(v.tlsIssuer||'—')+'
Для этого адреса: '+(v.tlsHostnameValid?'подходит':'не подходит')+'
':'';return '
'+safe(v.label)+'
'+safe(v.address)+'
'+(v.up?'Доступен':'Недоступен')+'
Отклик'+fixed(v.latencyMs)+' ms
HTTP'+(v.statusCode||'—')+'
TLS'+tls+'
Uptime · сутки'+fixed(v.uptimeDay)+'%
Uptime · неделя'+fixed(v.uptimeWeek)+'%
'+cert+(v.error?'
'+safe(v.error)+'
':'')+'
'} +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=>'
'+safe(v.name)+'
'+(v.endpoints||[]).length+' '+((v.endpoints||[]).length===1?'адрес':'адреса')+' проверяются независимо
'+(v.up?'Всё доступно':'Есть проблема')+'
'+(v.endpoints||[]).map(endpointHTML).join('')+'
').join(''):'Добавьте первую проверку в настройках.'} 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)+'%'; @@ -537,11 +546,11 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av 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,diskPercent=v.maxdisk?v.disk/v.maxdisk*100:0,ips=(v.ipAddresses||[]).join(', ')||'IP не найден';return '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+' · '+safe(v.node)+' · '+safe(ips)+'
'+(live?'Запущен':'Остановлен')+'
CPU'+(live?fixed(v.cpu*100)+'%':'—')+' / '+v.maxcpu+'
Память'+(live?size(v.mem)+' · '+fixed(memPercent)+'%':size(v.maxmem))+'
Диск'+(v.maxdisk?size(v.disk)+' · '+fixed(diskPercent)+'%':'—')+'
Сеть↓ '+size(v.netin)+' · ↑ '+size(v.netout)+'
Guest Agent'+(v.agent?'Доступен':'Нет')+'
Автозапуск'+(v.onboot?'Да':'Нет')+'
Storage'+safe((v.storage||[]).join(', ')||'—')+'
Uptime'+(live?uptime(v.uptime):'—')+'
'}).join(''):''+(g.available?'VM и LXC не найдены':'Команда pvesh недоступна')+''; - el('maintenanceVersion').textContent=mt.proxmoxVersion||'Proxmox VE';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=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 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 '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+'
Копии нет
';const age=now-item.ctime,fresh=age<=backupLimit,date=new Date(item.ctime*1000).toLocaleString('ru-RU');return '
'+safe(v.name||('Guest '+v.vmid))+'
VMID '+v.vmid+' · '+type+' · '+date+'
'+(fresh?'Актуальна':'Старше '+backupLimitHours+' ч')+'
Хранилище'+safe(item.storage)+'
Размер'+size(item.size)+'
Формат'+safe(item.format)+'
Возраст'+uptime(age)+'
Создана'+date+'
VMID'+v.vmid+'
'}).join(''):''+(b.available?'VM и LXC не найдены':'Данные резервных копий недоступны')+''; - const svc=x.services||{services:[],up:0,total:0},svcItems=svc.services||[],svcAvg=svcItems.length?svcItems.reduce((a,v)=>a+v.uptimeDay,0)/svcItems.length:0,svcLatency=svcItems.filter(v=>v.up).length?svcItems.filter(v=>v.up).reduce((a,v)=>a+v.latencyMs,0)/svcItems.filter(v=>v.up).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(svcAvg)+'%':'—';el('serviceLatency').textContent=svc.total?fixed(svcLatency)+' ms':'—';el('serviceList').innerHTML=svcItems.length?svcItems.map(v=>{const tls=v.tlsDays==null?'—':v.tlsDays<0?'Истёк':v.tlsDays+' дн.',address=v.localAddress||v.url;return '
'+safe(v.name)+'
'+safe(address)+' · '+safe(v.checkType.toUpperCase())+'
'+(v.up?'Доступен':'Недоступен')+'
Отклик'+fixed(v.latencyMs)+' ms
HTTP'+(v.statusCode||'—')+'
TLS'+tls+'
Uptime · сутки'+fixed(v.uptimeDay)+'%
Uptime · неделя'+fixed(v.uptimeWeek)+'%
Проверено'+new Date(v.checkedAt*1000).toLocaleTimeString('ru-RU')+'
'+(v.error?'
'+safe(v.error)+'
':'')+'
'}).join(''):'Добавьте первую проверку в настройках.'; - 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=>'
'+safe(v.unit)+'
'+new Date(v.time*1000).toLocaleString('ru-RU')+'
Ошибка
'+safe(v.message)+'
').join(''):'Критических сообщений за 24 часа нет.'; + 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=>'
'+safe(v.unit)+'
'+new Date(v.time*1000).toLocaleString('ru-RU')+'
Ошибка
'+safe(v.message)+'
').join(''):'Критических сообщений за 24 часа нет.';el('failedServiceList').innerHTML=(sh.failedServices||[]).length?sh.failedServices.map(v=>'
'+safe(v.unit)+'
'+safe(v.description||'Описание отсутствует')+'
'+safe(v.state)+'
Загрузка'+safe(v.load)+'
Состояние'+safe(v.active)+'
Подсостояние'+safe(v.state)+'
').join(''):'Упавших systemd-служб нет.'; el('networkInterfacesList').innerHTML=(n.interfaces||[]).map(v=>'
'+safe(v.name)+'
'+((v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops)?'Есть потери':'Без ошибок')+'
Получено'+size(v.receiveBytes)+'
Передано'+size(v.transmitBytes)+'
RX errors / drops'+v.receiveErrors+' / '+v.receiveDrops+'
TX errors / drops'+v.transmitErrors+' / '+v.transmitDrops+'
').join(''); el('dot').classList.add('live');el('status').textContent='Данные поступают'; } @@ -556,6 +565,8 @@ 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()}}); +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=>''+safe(v.label)+''+safe(v.address)+'').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 '
'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'
'+safe(a.title)+'
'+safe(a.message)+'
'+state+'
'}).join(''):'
История пока пустаНовые события появятся здесь автоматически.
'} 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()}); @@ -570,7 +581,7 @@ document.querySelectorAll('[data-settings-tab]').forEach(tab=>tab.onclick=()=>{d 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(()=>{}); 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=>'
'+safe(v.name)+'
'+safe(v.url)+' · '+safe(v.checkType.toUpperCase())+'
'+(v.enabled?'Включён':'Выключен')+'
').join(''):'Список пуст.'} +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=>'
'+safe(v.name)+'
'+safe([v.url&&('Внешний: '+v.url),v.localAddress&&('Локальный: '+v.localAddress)].filter(Boolean).join(' · '))+'
'+(v.enabled?'Включён':'Выключен')+'
').join(''):'Список пуст.'} 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(()=>{});