Improve service checks and system health details

This commit is contained in:
Maxim
2026-08-01 12:21:26 +03:00
parent 980f69cf8f
commit d3fbbf2a6b
6 changed files with 184 additions and 67 deletions

View File

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