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`, time.Now().Add(-historyRetention).Unix())
}
-func (s *Store) serviceUptime(id int64, since time.Time) float64 {
+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 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}