mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
update
This commit is contained in:
+151
-66
@@ -117,6 +117,12 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
AlertDaysBefore int `json:"alert_days_before"`
|
||||
SSLAlertEnabled bool `json:"ssl_alert_enabled"`
|
||||
SSLAlertDays int `json:"ssl_alert_days"`
|
||||
MonitorType string `json:"monitor_type"`
|
||||
NotifyOnExpiry bool `json:"notify_on_expiry"`
|
||||
NotifyOnSSL bool `json:"notify_on_ssl_expiry"`
|
||||
NotifyOnDNS bool `json:"notify_on_dns_change"`
|
||||
NotifyOnReg bool `json:"notify_on_registrar_change"`
|
||||
}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("invalid request body", err)
|
||||
@@ -159,6 +165,12 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
record.Set("auto_renew", req.AutoRenew)
|
||||
record.Set("alert_days_before", req.AlertDaysBefore)
|
||||
record.Set("ssl_alert_enabled", req.SSLAlertEnabled)
|
||||
record.Set("ssl_alert_days", req.SSLAlertDays)
|
||||
record.Set("monitor_type", req.MonitorType)
|
||||
record.Set("notify_on_expiry", req.NotifyOnExpiry)
|
||||
record.Set("notify_on_ssl_expiry", req.NotifyOnSSL)
|
||||
record.Set("notify_on_dns_change", req.NotifyOnDNS)
|
||||
record.Set("notify_on_registrar_change", req.NotifyOnReg)
|
||||
record.Set("user", authRecord.Id)
|
||||
|
||||
// Auto-lookup if requested
|
||||
@@ -167,40 +179,7 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
ctx := e.Request.Context()
|
||||
domainData, err := lookupSvc.LookupDomain(ctx, domainName)
|
||||
if err == nil && domainData != nil {
|
||||
if domainData.ExpiryDate != nil {
|
||||
record.Set("expiry_date", *domainData.ExpiryDate)
|
||||
} else {
|
||||
record.Set("expiry_date", "")
|
||||
}
|
||||
if domainData.CreationDate != nil {
|
||||
record.Set("creation_date", *domainData.CreationDate)
|
||||
} else {
|
||||
record.Set("creation_date", "")
|
||||
}
|
||||
if domainData.UpdatedDate != nil {
|
||||
record.Set("updated_date", *domainData.UpdatedDate)
|
||||
} else {
|
||||
record.Set("updated_date", "")
|
||||
}
|
||||
record.Set("registrar_name", domainData.RegistrarName)
|
||||
record.Set("registrar_id", domainData.RegistrarID)
|
||||
record.Set("registrar_url", domainData.RegistrarURL)
|
||||
record.Set("dnssec", domainData.DNSSEC)
|
||||
record.Set("name_servers", domainData.NameServers)
|
||||
record.Set("mx_records", domainData.MXRecords)
|
||||
record.Set("txt_records", domainData.TXTRecords)
|
||||
record.Set("ipv4_addresses", domainData.IPv4Addresses)
|
||||
record.Set("ipv6_addresses", domainData.IPv6Addresses)
|
||||
record.Set("ssl_issuer", domainData.SSLIssuer)
|
||||
if domainData.SSLValidTo != nil {
|
||||
record.Set("ssl_valid_to", *domainData.SSLValidTo)
|
||||
} else {
|
||||
record.Set("ssl_valid_to", "")
|
||||
}
|
||||
record.Set("host_country", domainData.HostCountry)
|
||||
record.Set("host_isp", domainData.HostISP)
|
||||
record.Set("favicon_url", domainData.FaviconURL)
|
||||
record.Set("last_checked", time.Now())
|
||||
h.applyLookupData(record, domainData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +260,23 @@ func (h *APIHandler) updateDomain(e *core.RequestEvent) error {
|
||||
if sslAlert, ok := req["ssl_alert_enabled"]; ok {
|
||||
record.Set("ssl_alert_enabled", sslAlert)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"ssl_alert_days",
|
||||
"monitor_type",
|
||||
"notify_on_expiry",
|
||||
"notify_on_ssl_expiry",
|
||||
"notify_on_dns_change",
|
||||
"notify_on_registrar_change",
|
||||
"notify_on_value_change",
|
||||
"value_change_threshold",
|
||||
"quiet_hours_enabled",
|
||||
"quiet_hours_start",
|
||||
"quiet_hours_end",
|
||||
} {
|
||||
if value, ok := req[field]; ok {
|
||||
record.Set(field, value)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to update domain", err)
|
||||
@@ -330,12 +326,18 @@ func (h *APIHandler) refreshDomain(e *core.RequestEvent) error {
|
||||
return e.ForbiddenError("not authorized", nil)
|
||||
}
|
||||
|
||||
// Trigger refresh via scheduler
|
||||
if h.scheduler != nil {
|
||||
h.scheduler.RefreshDomain(id)
|
||||
if err := h.scheduler.RefreshDomain(id); err != nil {
|
||||
return e.InternalServerError("failed to refresh domain", err)
|
||||
}
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "refreshing"})
|
||||
updatedRecord, err := h.app.FindRecordById("domains", id)
|
||||
if err != nil {
|
||||
return e.InternalServerError("failed to fetch refreshed domain", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, h.recordToResponse(updatedRecord))
|
||||
}
|
||||
|
||||
// getDomainHistory gets the change history for a domain
|
||||
@@ -530,35 +532,65 @@ func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": record.Id,
|
||||
"domain_name": record.GetString("domain_name"),
|
||||
"status": record.GetString("status"),
|
||||
"active": record.GetBool("active"),
|
||||
"days_until_expiry": daysUntilExpiry,
|
||||
"registrar_name": record.GetString("registrar_name"),
|
||||
"registrar_id": record.GetString("registrar_id"),
|
||||
"name_servers": record.Get("name_servers"),
|
||||
"ipv4_addresses": record.Get("ipv4_addresses"),
|
||||
"ssl_issuer": record.GetString("ssl_issuer"),
|
||||
"ssl_issuer_country": record.GetString("ssl_issuer_country"),
|
||||
"ssl_subject": record.GetString("ssl_subject"),
|
||||
"ssl_days_until": sslDaysUntil,
|
||||
"ssl_fingerprint": record.GetString("ssl_fingerprint"),
|
||||
"ssl_key_size": record.GetInt("ssl_key_size"),
|
||||
"ssl_signature_algo": record.GetString("ssl_signature_algo"),
|
||||
"host_country": record.GetString("host_country"),
|
||||
"host_isp": record.GetString("host_isp"),
|
||||
"purchase_price": record.GetFloat("purchase_price"),
|
||||
"current_value": record.GetFloat("current_value"),
|
||||
"renewal_cost": record.GetFloat("renewal_cost"),
|
||||
"auto_renew": record.GetBool("auto_renew"),
|
||||
"alert_days_before": record.GetInt("alert_days_before"),
|
||||
"ssl_alert_enabled": record.GetBool("ssl_alert_enabled"),
|
||||
"tags": record.Get("tags"),
|
||||
"notes": record.GetString("notes"),
|
||||
"favicon_url": record.GetString("favicon_url"),
|
||||
"created": record.GetDateTime("created").String(),
|
||||
"updated": record.GetDateTime("updated").String(),
|
||||
"id": record.Id,
|
||||
"domain_name": record.GetString("domain_name"),
|
||||
"status": record.GetString("status"),
|
||||
"active": record.GetBool("active"),
|
||||
"days_until_expiry": daysUntilExpiry,
|
||||
"registrar_name": record.GetString("registrar_name"),
|
||||
"registrar_id": record.GetString("registrar_id"),
|
||||
"registrar_url": record.GetString("registrar_url"),
|
||||
"registry_domain_id": record.GetString("registry_domain_id"),
|
||||
"dnssec": record.GetString("dnssec"),
|
||||
"name_servers": record.Get("name_servers"),
|
||||
"mx_records": record.Get("mx_records"),
|
||||
"txt_records": record.Get("txt_records"),
|
||||
"ipv4_addresses": record.Get("ipv4_addresses"),
|
||||
"ipv6_addresses": record.Get("ipv6_addresses"),
|
||||
"ssl_issuer": record.GetString("ssl_issuer"),
|
||||
"ssl_issuer_country": record.GetString("ssl_issuer_country"),
|
||||
"ssl_subject": record.GetString("ssl_subject"),
|
||||
"ssl_days_until": sslDaysUntil,
|
||||
"ssl_fingerprint": record.GetString("ssl_fingerprint"),
|
||||
"ssl_key_size": record.GetInt("ssl_key_size"),
|
||||
"ssl_signature_algo": record.GetString("ssl_signature_algo"),
|
||||
"host_country": record.GetString("host_country"),
|
||||
"host_region": record.GetString("host_region"),
|
||||
"host_city": record.GetString("host_city"),
|
||||
"host_isp": record.GetString("host_isp"),
|
||||
"host_org": record.GetString("host_org"),
|
||||
"host_as": record.GetString("host_as"),
|
||||
"host_lat": record.GetFloat("host_lat"),
|
||||
"host_lon": record.GetFloat("host_lon"),
|
||||
"purchase_price": record.GetFloat("purchase_price"),
|
||||
"current_value": record.GetFloat("current_value"),
|
||||
"renewal_cost": record.GetFloat("renewal_cost"),
|
||||
"auto_renew": record.GetBool("auto_renew"),
|
||||
"alert_days_before": record.GetInt("alert_days_before"),
|
||||
"ssl_alert_enabled": record.GetBool("ssl_alert_enabled"),
|
||||
"ssl_alert_days": record.GetInt("ssl_alert_days"),
|
||||
"monitor_type": record.GetString("monitor_type"),
|
||||
"notify_on_expiry": record.GetBool("notify_on_expiry"),
|
||||
"notify_on_ssl_expiry": record.GetBool("notify_on_ssl_expiry"),
|
||||
"notify_on_dns_change": record.GetBool("notify_on_dns_change"),
|
||||
"notify_on_registrar_change": record.GetBool("notify_on_registrar_change"),
|
||||
"notify_on_value_change": record.GetBool("notify_on_value_change"),
|
||||
"value_change_threshold": record.GetFloat("value_change_threshold"),
|
||||
"quiet_hours_enabled": record.GetBool("quiet_hours_enabled"),
|
||||
"quiet_hours_start": record.GetString("quiet_hours_start"),
|
||||
"quiet_hours_end": record.GetString("quiet_hours_end"),
|
||||
"registrant_name": record.GetString("registrant_name"),
|
||||
"registrant_org": record.GetString("registrant_org"),
|
||||
"registrant_country": record.GetString("registrant_country"),
|
||||
"registrant_city": record.GetString("registrant_city"),
|
||||
"registrant_state": record.GetString("registrant_state"),
|
||||
"abuse_email": record.GetString("abuse_email"),
|
||||
"abuse_phone": record.GetString("abuse_phone"),
|
||||
"tags": record.Get("tags"),
|
||||
"notes": record.GetString("notes"),
|
||||
"favicon_url": record.GetString("favicon_url"),
|
||||
"created": record.GetDateTime("created").String(),
|
||||
"updated": record.GetDateTime("updated").String(),
|
||||
}
|
||||
|
||||
if !expiryDate.IsZero() {
|
||||
@@ -587,6 +619,59 @@ func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *APIHandler) applyLookupData(record *core.Record, domainData *domain.Domain) {
|
||||
if domainData.ExpiryDate != nil {
|
||||
record.Set("expiry_date", *domainData.ExpiryDate)
|
||||
}
|
||||
if domainData.CreationDate != nil {
|
||||
record.Set("creation_date", *domainData.CreationDate)
|
||||
}
|
||||
if domainData.UpdatedDate != nil {
|
||||
record.Set("updated_date", *domainData.UpdatedDate)
|
||||
}
|
||||
record.Set("registrar_name", domainData.RegistrarName)
|
||||
record.Set("registrar_id", domainData.RegistrarID)
|
||||
record.Set("registrar_url", domainData.RegistrarURL)
|
||||
record.Set("registry_domain_id", domainData.RegistryDomainID)
|
||||
record.Set("dnssec", domainData.DNSSEC)
|
||||
record.Set("name_servers", domainData.NameServers)
|
||||
record.Set("mx_records", domainData.MXRecords)
|
||||
record.Set("txt_records", domainData.TXTRecords)
|
||||
record.Set("ipv4_addresses", domainData.IPv4Addresses)
|
||||
record.Set("ipv6_addresses", domainData.IPv6Addresses)
|
||||
record.Set("ssl_issuer", domainData.SSLIssuer)
|
||||
record.Set("ssl_issuer_country", domainData.SSLIssuerCountry)
|
||||
record.Set("ssl_subject", domainData.SSLSubject)
|
||||
if domainData.SSLValidFrom != nil {
|
||||
record.Set("ssl_valid_from", *domainData.SSLValidFrom)
|
||||
}
|
||||
if domainData.SSLValidTo != nil {
|
||||
record.Set("ssl_valid_to", *domainData.SSLValidTo)
|
||||
}
|
||||
record.Set("ssl_fingerprint", domainData.SSLFingerprint)
|
||||
record.Set("ssl_key_size", domainData.SSLKeySize)
|
||||
record.Set("ssl_signature_algo", domainData.SSLSignatureAlgo)
|
||||
record.Set("host_country", domainData.HostCountry)
|
||||
record.Set("host_region", domainData.HostRegion)
|
||||
record.Set("host_city", domainData.HostCity)
|
||||
record.Set("host_isp", domainData.HostISP)
|
||||
record.Set("host_org", domainData.HostOrg)
|
||||
record.Set("host_as", domainData.HostAS)
|
||||
record.Set("host_lat", domainData.HostLat)
|
||||
record.Set("host_lon", domainData.HostLon)
|
||||
record.Set("registrant_name", domainData.RegistrantName)
|
||||
record.Set("registrant_org", domainData.RegistrantOrg)
|
||||
record.Set("registrant_street", domainData.RegistrantStreet)
|
||||
record.Set("registrant_city", domainData.RegistrantCity)
|
||||
record.Set("registrant_state", domainData.RegistrantState)
|
||||
record.Set("registrant_country", domainData.RegistrantCountry)
|
||||
record.Set("registrant_postal", domainData.RegistrantPostal)
|
||||
record.Set("abuse_email", domainData.AbuseEmail)
|
||||
record.Set("abuse_phone", domainData.AbusePhone)
|
||||
record.Set("favicon_url", domainData.FaviconURL)
|
||||
record.Set("last_checked", time.Now())
|
||||
}
|
||||
|
||||
// cleanDomain cleans and normalizes a domain name
|
||||
func cleanDomain(domain string) string {
|
||||
// Remove protocol
|
||||
|
||||
@@ -26,6 +26,7 @@ type Scheduler struct {
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
alertCallback AlertCallback
|
||||
limit chan struct{}
|
||||
}
|
||||
|
||||
// NewScheduler creates a new domain scheduler
|
||||
@@ -34,6 +35,7 @@ func NewScheduler(app core.App) *Scheduler {
|
||||
app: app,
|
||||
whois: whois.NewLookupService(""), // API key can be configured via env
|
||||
stopChan: make(chan struct{}),
|
||||
limit: make(chan struct{}, 4),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +94,15 @@ func (s *Scheduler) checkDomains() {
|
||||
s.wg.Add(1)
|
||||
go func(r *core.Record) {
|
||||
defer s.wg.Done()
|
||||
s.limit <- struct{}{}
|
||||
defer func() { <-s.limit }()
|
||||
s.checkDomain(r)
|
||||
}(record)
|
||||
}
|
||||
}
|
||||
|
||||
// checkDomain checks a single domain
|
||||
func (s *Scheduler) checkDomain(record *core.Record) {
|
||||
func (s *Scheduler) checkDomain(record *core.Record) error {
|
||||
domainName := record.GetString("domain_name")
|
||||
|
||||
userID := record.GetString("user")
|
||||
@@ -112,11 +116,10 @@ func (s *Scheduler) checkDomain(record *core.Record) {
|
||||
newData, err := s.whois.LookupDomain(ctx, domainName)
|
||||
if err != nil {
|
||||
log.Printf("[domain-scheduler] Failed to lookup %s: %v", domainName, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Track changes
|
||||
history := s.trackChanges(record, newData)
|
||||
oldRecord := record.Fresh()
|
||||
|
||||
// Update record (only overwrite if new data is present to preserve valid data on partial lookups)
|
||||
if newData.ExpiryDate != nil {
|
||||
@@ -137,6 +140,9 @@ func (s *Scheduler) checkDomain(record *core.Record) {
|
||||
if newData.RegistrarURL != "" {
|
||||
record.Set("registrar_url", newData.RegistrarURL)
|
||||
}
|
||||
if newData.RegistryDomainID != "" {
|
||||
record.Set("registry_domain_id", newData.RegistryDomainID)
|
||||
}
|
||||
record.Set("dnssec", newData.DNSSEC)
|
||||
if len(newData.NameServers) > 0 {
|
||||
record.Set("name_servers", newData.NameServers)
|
||||
@@ -179,8 +185,57 @@ func (s *Scheduler) checkDomain(record *core.Record) {
|
||||
if newData.SSLSignatureAlgo != "" {
|
||||
record.Set("ssl_signature_algo", newData.SSLSignatureAlgo)
|
||||
}
|
||||
record.Set("host_country", newData.HostCountry)
|
||||
record.Set("host_isp", newData.HostISP)
|
||||
if newData.HostCountry != "" {
|
||||
record.Set("host_country", newData.HostCountry)
|
||||
}
|
||||
if newData.HostRegion != "" {
|
||||
record.Set("host_region", newData.HostRegion)
|
||||
}
|
||||
if newData.HostCity != "" {
|
||||
record.Set("host_city", newData.HostCity)
|
||||
}
|
||||
if newData.HostISP != "" {
|
||||
record.Set("host_isp", newData.HostISP)
|
||||
}
|
||||
if newData.HostOrg != "" {
|
||||
record.Set("host_org", newData.HostOrg)
|
||||
}
|
||||
if newData.HostAS != "" {
|
||||
record.Set("host_as", newData.HostAS)
|
||||
}
|
||||
if newData.HostLat != 0 {
|
||||
record.Set("host_lat", newData.HostLat)
|
||||
}
|
||||
if newData.HostLon != 0 {
|
||||
record.Set("host_lon", newData.HostLon)
|
||||
}
|
||||
if newData.RegistrantName != "" {
|
||||
record.Set("registrant_name", newData.RegistrantName)
|
||||
}
|
||||
if newData.RegistrantOrg != "" {
|
||||
record.Set("registrant_org", newData.RegistrantOrg)
|
||||
}
|
||||
if newData.RegistrantStreet != "" {
|
||||
record.Set("registrant_street", newData.RegistrantStreet)
|
||||
}
|
||||
if newData.RegistrantCity != "" {
|
||||
record.Set("registrant_city", newData.RegistrantCity)
|
||||
}
|
||||
if newData.RegistrantState != "" {
|
||||
record.Set("registrant_state", newData.RegistrantState)
|
||||
}
|
||||
if newData.RegistrantCountry != "" {
|
||||
record.Set("registrant_country", newData.RegistrantCountry)
|
||||
}
|
||||
if newData.RegistrantPostal != "" {
|
||||
record.Set("registrant_postal", newData.RegistrantPostal)
|
||||
}
|
||||
if newData.AbuseEmail != "" {
|
||||
record.Set("abuse_email", newData.AbuseEmail)
|
||||
}
|
||||
if newData.AbusePhone != "" {
|
||||
record.Set("abuse_phone", newData.AbusePhone)
|
||||
}
|
||||
record.Set("last_checked", time.Now())
|
||||
|
||||
// Update status - fallback to existing record expiry if new lookup didn't return one
|
||||
@@ -212,9 +267,11 @@ func (s *Scheduler) checkDomain(record *core.Record) {
|
||||
}
|
||||
record.Set("status", status)
|
||||
|
||||
history := s.trackChanges(oldRecord, newData, status)
|
||||
|
||||
if err := s.app.Save(record); err != nil {
|
||||
log.Printf("[domain-scheduler] Failed to update %s: %v", domainName, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Save history entries
|
||||
@@ -239,6 +296,7 @@ func (s *Scheduler) checkDomain(record *core.Record) {
|
||||
s.discoverSubdomains(record, domainName, userID)
|
||||
|
||||
log.Printf("[domain-scheduler] Updated domain: %s (status: %s)", domainName, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// discoverSubdomains discovers and saves subdomains for a domain
|
||||
@@ -297,9 +355,10 @@ func (s *Scheduler) discoverSubdomains(record *core.Record, domainName, userID s
|
||||
}
|
||||
|
||||
// trackChanges compares old and new data and returns history entries
|
||||
func (s *Scheduler) trackChanges(oldRecord *core.Record, newData *domain.Domain) []domain.DomainHistory {
|
||||
func (s *Scheduler) trackChanges(oldRecord *core.Record, newData *domain.Domain, finalStatus string) []domain.DomainHistory {
|
||||
var history []domain.DomainHistory
|
||||
now := time.Now()
|
||||
hasPreviousCheck := !oldRecord.GetDateTime("last_checked").IsZero()
|
||||
|
||||
// Check expiry date change
|
||||
oldExpiry := oldRecord.GetDateTime("expiry_date").Time()
|
||||
@@ -327,13 +386,12 @@ func (s *Scheduler) trackChanges(oldRecord *core.Record, newData *domain.Domain)
|
||||
|
||||
// Check status change
|
||||
oldStatus := oldRecord.GetString("status")
|
||||
newStatus := newData.GetStatus()
|
||||
if newStatus != oldStatus {
|
||||
if hasPreviousCheck && finalStatus != oldStatus {
|
||||
history = append(history, domain.DomainHistory{
|
||||
ChangeType: domain.ChangeTypeStatus,
|
||||
FieldName: "status",
|
||||
OldValue: oldStatus,
|
||||
NewValue: newStatus,
|
||||
NewValue: finalStatus,
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
@@ -429,13 +487,9 @@ func (s *Scheduler) RefreshDomain(domainID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.checkDomain(record)
|
||||
}()
|
||||
|
||||
return nil
|
||||
s.limit <- struct{}{}
|
||||
defer func() { <-s.limit }()
|
||||
return s.checkDomain(record)
|
||||
}
|
||||
|
||||
// CheckAllDomains manually triggers a check of all active domains
|
||||
|
||||
@@ -2,8 +2,11 @@ package whois
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -706,37 +709,34 @@ func (s *LookupService) lookupSSL(ctx context.Context, domainName string, d *dom
|
||||
d.SSLValidTo = &cert.NotAfter
|
||||
d.SSLSubject = cert.Subject.CommonName
|
||||
|
||||
// Format fingerprint as colon-separated hex
|
||||
if len(cert.Signature) > 0 {
|
||||
fingerprint := fmt.Sprintf("%X", cert.Signature)
|
||||
// Add colons every 2 characters for standard format
|
||||
if len(fingerprint) > 2 {
|
||||
var formatted []string
|
||||
for i := 0; i < len(fingerprint); i += 2 {
|
||||
if i+2 <= len(fingerprint) {
|
||||
formatted = append(formatted, fingerprint[i:i+2])
|
||||
}
|
||||
}
|
||||
d.SSLFingerprint = strings.Join(formatted, ":")
|
||||
} else {
|
||||
d.SSLFingerprint = fingerprint
|
||||
}
|
||||
}
|
||||
fingerprint := sha256.Sum256(cert.Raw)
|
||||
d.SSLFingerprint = strings.ToUpper(strings.Join(splitHex(hex.EncodeToString(fingerprint[:])), ":"))
|
||||
|
||||
// Extract signature algorithm
|
||||
d.SSLSignatureAlgo = cert.SignatureAlgorithm.String()
|
||||
|
||||
// Safely extract key size for different key types
|
||||
switch key := cert.PublicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
d.SSLKeySize = key.N.BitLen()
|
||||
case *ecdsa.PublicKey:
|
||||
d.SSLKeySize = key.Curve.Params().BitSize
|
||||
default:
|
||||
// For ECC keys, try to determine from curve
|
||||
d.SSLKeySize = 256 // Default for ECC
|
||||
d.SSLKeySize = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitHex(value string) []string {
|
||||
parts := make([]string, 0, len(value)/2)
|
||||
for i := 0; i < len(value); i += 2 {
|
||||
end := i + 2
|
||||
if end > len(value) {
|
||||
end = len(value)
|
||||
}
|
||||
parts = append(parts, value[i:end])
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// lookupHost fetches host/geolocation info
|
||||
func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
|
||||
// Use ip-api.com (free, no auth required for non-commercial use)
|
||||
|
||||
+2
-7
@@ -267,13 +267,8 @@ func (h *Hub) bindDomainHooks() {
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
// On update - refresh if activated
|
||||
h.OnRecordAfterUpdateSuccess("domains").BindFunc(func(e *core.RecordEvent) error {
|
||||
if e.Record.GetBool("active") {
|
||||
h.domainSched.RefreshDomain(e.Record.Id)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
// Manual refresh and resume actions trigger lookups explicitly. Avoid
|
||||
// refreshing on every domain save because scheduler writes would loop.
|
||||
}
|
||||
|
||||
// GetSSHKey generates key pair if it doesn't exist and returns signer
|
||||
|
||||
@@ -426,13 +426,17 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
events := []map[string]interface{}{}
|
||||
from, to := calendarRange(e)
|
||||
|
||||
// Domain expirations
|
||||
domains, _ := h.app.FindAllRecords("domains",
|
||||
dbx.NewExp("user = {:user} && expiry_date != ''", dbx.Params{"user": authRecord.Id}),
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
|
||||
)
|
||||
for _, d := range domains {
|
||||
expiryDate := d.GetDateTime("expiry_date").Time()
|
||||
if expiryDate.IsZero() || !dateInRange(expiryDate, from, to) {
|
||||
continue
|
||||
}
|
||||
domainName := d.GetString("domain_name")
|
||||
daysUntil := int(expiryDate.Sub(time.Now()).Hours() / 24)
|
||||
|
||||
@@ -446,18 +450,23 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
events = append(events, map[string]interface{}{
|
||||
"id": "domain-" + d.Id,
|
||||
"title": "🌐 " + domainName + " expires",
|
||||
"date": expiryDate.Format("2006-01-02"),
|
||||
"type": "domain_expiry",
|
||||
"color": color,
|
||||
"id": "domain-" + d.Id,
|
||||
"title": domainName + " expires",
|
||||
"date": expiryDate.Format("2006-01-02"),
|
||||
"type": "domain_expiry",
|
||||
"color": color,
|
||||
"domain_id": d.Id,
|
||||
"entity_id": d.Id,
|
||||
"entity_name": domainName,
|
||||
"link": "/domain/" + d.Id,
|
||||
"days_until": daysUntil,
|
||||
})
|
||||
}
|
||||
|
||||
// SSL expirations
|
||||
for _, d := range domains {
|
||||
sslExpiry := d.GetDateTime("ssl_valid_to").Time()
|
||||
if sslExpiry.IsZero() {
|
||||
if sslExpiry.IsZero() || !dateInRange(sslExpiry, from, to) {
|
||||
continue
|
||||
}
|
||||
domainName := d.GetString("domain_name")
|
||||
@@ -473,11 +482,16 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
events = append(events, map[string]interface{}{
|
||||
"id": "ssl-" + d.Id,
|
||||
"title": "🔒 " + domainName + " SSL expires",
|
||||
"date": sslExpiry.Format("2006-01-02"),
|
||||
"type": "ssl_expiry",
|
||||
"color": color,
|
||||
"id": "ssl-" + d.Id,
|
||||
"title": domainName + " SSL expires",
|
||||
"date": sslExpiry.Format("2006-01-02"),
|
||||
"type": "ssl_expiry",
|
||||
"color": color,
|
||||
"domain_id": d.Id,
|
||||
"entity_id": d.Id,
|
||||
"entity_name": domainName,
|
||||
"link": "/domain/" + d.Id,
|
||||
"days_until": daysUntil,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -487,6 +501,9 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
|
||||
)
|
||||
for _, i := range incidents {
|
||||
startedAt := i.GetDateTime("started_at").Time()
|
||||
if startedAt.IsZero() || !dateInRange(startedAt, from, to) {
|
||||
continue
|
||||
}
|
||||
title := i.GetString("title")
|
||||
severity := i.GetString("severity")
|
||||
|
||||
@@ -501,17 +518,47 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
events = append(events, map[string]interface{}{
|
||||
"id": "incident-" + i.Id,
|
||||
"title": "⚠️ " + title,
|
||||
"date": startedAt.Format("2006-01-02"),
|
||||
"type": "incident",
|
||||
"color": color,
|
||||
"id": "incident-" + i.Id,
|
||||
"title": title,
|
||||
"date": startedAt.Format("2006-01-02"),
|
||||
"type": "incident",
|
||||
"color": color,
|
||||
"incident_id": i.Id,
|
||||
"entity_id": i.Id,
|
||||
"entity_name": title,
|
||||
"link": "/incidents",
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, events)
|
||||
}
|
||||
|
||||
func calendarRange(e *core.RequestEvent) (*time.Time, *time.Time) {
|
||||
var from, to *time.Time
|
||||
if value := e.Request.URL.Query().Get("from"); value != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", value); err == nil {
|
||||
from = &parsed
|
||||
}
|
||||
}
|
||||
if value := e.Request.URL.Query().Get("to"); value != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", value); err == nil {
|
||||
end := parsed.Add(24*time.Hour - time.Nanosecond)
|
||||
to = &end
|
||||
}
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
|
||||
func dateInRange(value time.Time, from, to *time.Time) bool {
|
||||
if from != nil && value.Before(*from) {
|
||||
return false
|
||||
}
|
||||
if to != nil && value.After(*to) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// addUpdate adds an update record
|
||||
func (h *APIHandler) addUpdate(incidentID, message, updateType string, oldStatus, newStatus *string, createdBy string) {
|
||||
collection, err := h.app.FindCollectionByNameOrId("incident_updates")
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package incidents
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
func TestDateInRange(t *testing.T) {
|
||||
from := mustDate(t, "2026-04-01")
|
||||
to := mustDate(t, "2026-04-30")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
date string
|
||||
want bool
|
||||
}{
|
||||
{name: "before range", date: "2026-03-31", want: false},
|
||||
{name: "start boundary", date: "2026-04-01", want: true},
|
||||
{name: "inside range", date: "2026-04-15", want: true},
|
||||
{name: "end boundary", date: "2026-04-30", want: true},
|
||||
{name: "after range", date: "2026-05-01", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := dateInRange(mustDate(t, tt.date), &from, &to)
|
||||
if got != tt.want {
|
||||
t.Fatalf("dateInRange(%s) = %v, want %v", tt.date, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarRangeParsesDateBounds(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/beszel/incidents/calendar?from=2026-04-01&to=2026-04-30", nil)
|
||||
from, to := calendarRange(&core.RequestEvent{Event: router.Event{Request: request}})
|
||||
|
||||
if from == nil || from.Format("2006-01-02") != "2026-04-01" {
|
||||
t.Fatalf("unexpected from date: %v", from)
|
||||
}
|
||||
if to == nil || !dateInRange(mustDate(t, "2026-04-30").Add(23*time.Hour), from, to) {
|
||||
t.Fatalf("expected to date to include the whole end day, got %v", to)
|
||||
}
|
||||
if dateInRange(mustDate(t, "2026-05-01"), from, to) {
|
||||
t.Fatal("expected range to exclude day after the to bound")
|
||||
}
|
||||
}
|
||||
|
||||
func mustDate(t *testing.T, value string) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := time.Parse("2006-01-02", value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
@@ -408,11 +409,25 @@ func (h *APIHandler) manualCheck(e *core.RequestEvent) error {
|
||||
return e.InternalServerError("Check failed", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]interface{}{
|
||||
response := map[string]interface{}{
|
||||
"status": result.Status,
|
||||
"ping": result.Ping,
|
||||
"msg": result.Msg,
|
||||
})
|
||||
}
|
||||
|
||||
if hbs, err := h.app.FindRecordsByFilter(
|
||||
"monitor_heartbeats",
|
||||
"monitor = {:monitorId}",
|
||||
"-time",
|
||||
1,
|
||||
0,
|
||||
dbx.Params{"monitorId": id},
|
||||
); err == nil && len(hbs) > 0 {
|
||||
response["heartbeat_id"] = hbs[0].Id
|
||||
response["time"] = hbs[0].GetDateTime("time").String()
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// pauseMonitor pauses a monitor
|
||||
@@ -493,11 +508,37 @@ func (h *APIHandler) getStats(e *core.RequestEvent) error {
|
||||
stats24h, _ := h.scheduler.GetUptimeStats(id, 24)
|
||||
stats7d, _ := h.scheduler.GetUptimeStats(id, 168)
|
||||
stats30d, _ := h.scheduler.GetUptimeStats(id, 720)
|
||||
avg24h := 0.0
|
||||
if stats24h.Total > 0 {
|
||||
records, _ := h.app.FindRecordsByFilter(
|
||||
"monitor_heartbeats",
|
||||
"monitor = {:monitorId} && time >= {:since} && status = {:status}",
|
||||
"-time",
|
||||
0,
|
||||
0,
|
||||
dbx.Params{
|
||||
"monitorId": id,
|
||||
"since": time.Now().Add(-24 * time.Hour).Format("2006-01-02 15:04:05"),
|
||||
"status": string(monitor.StatusUp),
|
||||
},
|
||||
)
|
||||
if len(records) > 0 {
|
||||
totalPing := 0
|
||||
for _, record := range records {
|
||||
totalPing += record.GetInt("ping")
|
||||
}
|
||||
avg24h = float64(totalPing) / float64(len(records))
|
||||
}
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]interface{}{
|
||||
"uptime_24h": stats24h,
|
||||
"uptime_7d": stats7d,
|
||||
"uptime_30d": stats30d,
|
||||
"uptime_24h": stats24h,
|
||||
"uptime_7d": stats7d,
|
||||
"uptime_30d": stats30d,
|
||||
"uptime_percent_24h": percent(stats24h),
|
||||
"uptime_percent_7d": percent(stats7d),
|
||||
"uptime_percent_30d": percent(stats30d),
|
||||
"avg_ping_24h": avg24h,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -582,16 +623,14 @@ func recordToResponse(record *core.Record) MonitorResponse {
|
||||
Updated: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
|
||||
// Handle last_check
|
||||
if lc := record.Get("last_check"); lc != nil {
|
||||
if t, ok := lc.(time.Time); ok {
|
||||
resp.LastCheck = &t
|
||||
}
|
||||
if lc := record.GetDateTime("last_check"); !lc.IsZero() {
|
||||
t := lc.Time()
|
||||
resp.LastCheck = &t
|
||||
}
|
||||
|
||||
// Handle uptime_stats
|
||||
if stats := record.Get("uptime_stats"); stats != nil {
|
||||
if s, ok := stats.(map[string]float64); ok {
|
||||
var s map[string]float64
|
||||
if raw, err := json.Marshal(stats); err == nil && json.Unmarshal(raw, &s) == nil {
|
||||
resp.UptimeStats = s
|
||||
}
|
||||
}
|
||||
@@ -605,3 +644,10 @@ func recordToResponse(record *core.Record) MonitorResponse {
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func percent(stats *monitor.UptimeStats) float64 {
|
||||
if stats == nil || stats.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(stats.Up) / float64(stats.Total) * 100
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
)
|
||||
|
||||
func TestHTTPCheckerReportsUpForSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result := (&HTTPChecker{}).Check(context.Background(), &monitor.Monitor{
|
||||
URL: server.URL,
|
||||
Timeout: 2,
|
||||
})
|
||||
|
||||
if result.Status != monitor.StatusUp {
|
||||
t.Fatalf("expected status up, got %s: %s", result.Status, result.Msg)
|
||||
}
|
||||
if result.Ping < 0 {
|
||||
t.Fatalf("expected non-negative ping, got %d", result.Ping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPCheckerReportsDownForServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "broken", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result := (&HTTPChecker{}).Check(context.Background(), &monitor.Monitor{
|
||||
URL: server.URL,
|
||||
Timeout: 2,
|
||||
})
|
||||
|
||||
if result.Status != monitor.StatusDown {
|
||||
t.Fatalf("expected status down, got %s", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeywordCheckerHonorsKeywordAndInvert(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("service status ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result := (&KeywordChecker{}).Check(context.Background(), &monitor.Monitor{
|
||||
URL: server.URL,
|
||||
Timeout: 2,
|
||||
Keyword: "status ok",
|
||||
})
|
||||
if result.Status != monitor.StatusUp {
|
||||
t.Fatalf("expected keyword match to be up, got %s: %s", result.Status, result.Msg)
|
||||
}
|
||||
|
||||
result = (&KeywordChecker{}).Check(context.Background(), &monitor.Monitor{
|
||||
URL: server.URL,
|
||||
Timeout: 2,
|
||||
Keyword: "status ok",
|
||||
InvertKeyword: true,
|
||||
})
|
||||
if result.Status != monitor.StatusDown {
|
||||
t.Fatalf("expected inverted keyword match to be down, got %s", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONQueryCheckerMatchesNestedValue(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"ok","version":2}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result := (&JSONQueryChecker{}).Check(context.Background(), &monitor.Monitor{
|
||||
URL: server.URL,
|
||||
Timeout: 2,
|
||||
JSONQuery: "data.status",
|
||||
ExpectedValue: "ok",
|
||||
})
|
||||
if result.Status != monitor.StatusUp {
|
||||
t.Fatalf("expected json query match to be up, got %s: %s", result.Status, result.Msg)
|
||||
}
|
||||
|
||||
result = (&JSONQueryChecker{}).Check(context.Background(), &monitor.Monitor{
|
||||
URL: server.URL,
|
||||
Timeout: 2,
|
||||
JSONQuery: "data.status",
|
||||
ExpectedValue: "down",
|
||||
})
|
||||
if result.Status != monitor.StatusDown {
|
||||
t.Fatalf("expected json query mismatch to be down, got %s", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPCheckerUsesConfiguredHostAndPort(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
result := (&TCPChecker{}).Check(context.Background(), &monitor.Monitor{
|
||||
Hostname: "127.0.0.1",
|
||||
Port: listener.Addr().(*net.TCPAddr).Port,
|
||||
Timeout: 2,
|
||||
})
|
||||
if result.Status != monitor.StatusUp {
|
||||
t.Fatalf("expected tcp check to be up, got %s: %s", result.Status, result.Msg)
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestDNSCheckerResolvesLocalhost(t *testing.T) {
|
||||
result := (&DNSChecker{}).Check(context.Background(), &monitor.Monitor{
|
||||
Hostname: "localhost",
|
||||
DNSResolverMode: "A",
|
||||
Timeout: 2,
|
||||
})
|
||||
if result.Status != monitor.StatusUp {
|
||||
t.Fatalf("expected localhost DNS to resolve, got %s: %s", result.Status, result.Msg)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@ package monitors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/incident"
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
"github.com/henrygd/beszel/internal/hub/monitors/checks"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
)
|
||||
@@ -194,50 +197,15 @@ func (s *Scheduler) runCheck(m *monitor.Monitor) {
|
||||
|
||||
// saveResult saves the check result to the database and sends notifications on status change
|
||||
func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult) error {
|
||||
// Update monitor record
|
||||
record, err := s.app.FindRecordById("monitors", m.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find monitor: %w", err)
|
||||
}
|
||||
|
||||
// Get previous status for change detection
|
||||
prevStatus := monitor.Status(record.GetString("status"))
|
||||
newStatus := result.Status
|
||||
now := time.Now()
|
||||
|
||||
// Update status
|
||||
record.Set("status", string(newStatus))
|
||||
record.Set("last_check", time.Now())
|
||||
|
||||
// Track status changes and send notifications
|
||||
if prevStatus != newStatus {
|
||||
s.handleStatusChange(m, record, prevStatus, newStatus, result)
|
||||
}
|
||||
|
||||
// Calculate uptime stats (simplified - in production would aggregate from heartbeats)
|
||||
if m.UptimeStats == nil {
|
||||
m.UptimeStats = make(map[string]float64)
|
||||
}
|
||||
|
||||
// Simple rolling uptime calculation (can be improved)
|
||||
if result.Status == monitor.StatusUp {
|
||||
m.UptimeStats["total"] = m.UptimeStats["total"] + 1
|
||||
m.UptimeStats["up"] = m.UptimeStats["up"] + 1
|
||||
} else {
|
||||
m.UptimeStats["total"] = m.UptimeStats["total"] + 1
|
||||
m.UptimeStats["down"] = m.UptimeStats["down"] + 1
|
||||
}
|
||||
|
||||
if total := m.UptimeStats["total"]; total > 0 {
|
||||
m.UptimeStats["uptime_24h"] = (m.UptimeStats["up"] / total) * 100
|
||||
}
|
||||
|
||||
record.Set("uptime_stats", m.UptimeStats)
|
||||
|
||||
if err := s.app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to update monitor: %w", err)
|
||||
}
|
||||
|
||||
// Create heartbeat record
|
||||
hbCollection, err := s.app.FindCollectionByNameOrId("monitor_heartbeats")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find heartbeats collection: %w", err)
|
||||
@@ -250,12 +218,34 @@ func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult)
|
||||
hbRecord.Set("msg", result.Msg)
|
||||
hbRecord.Set("cert_expiry", result.CertExpiry)
|
||||
hbRecord.Set("cert_valid", result.CertValid)
|
||||
hbRecord.Set("time", time.Now())
|
||||
hbRecord.Set("time", now)
|
||||
|
||||
if err := s.app.Save(hbRecord); err != nil {
|
||||
return fmt.Errorf("failed to save heartbeat: %w", err)
|
||||
}
|
||||
|
||||
stats, err := s.calculateUptimeStats(m.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to calculate uptime stats: %w", err)
|
||||
}
|
||||
stats["last_ping"] = float64(result.Ping)
|
||||
|
||||
record.Set("status", string(newStatus))
|
||||
record.Set("last_check", now)
|
||||
record.Set("uptime_stats", stats)
|
||||
|
||||
if err := s.app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to update monitor: %w", err)
|
||||
}
|
||||
|
||||
m.Status = newStatus
|
||||
m.LastCheck = now
|
||||
m.UptimeStats = stats
|
||||
|
||||
if prevStatus != newStatus {
|
||||
s.handleStatusChange(m, record, prevStatus, newStatus, result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -270,23 +260,17 @@ func (s *Scheduler) handleStatusChange(m *monitor.Monitor, record *core.Record,
|
||||
isRecovery := false
|
||||
|
||||
switch {
|
||||
case prevStatus == monitor.StatusUp && newStatus == monitor.StatusDown:
|
||||
case newStatus == monitor.StatusDown && prevStatus != monitor.StatusDown:
|
||||
title = fmt.Sprintf("Monitor Down: %s", m.Name)
|
||||
message = fmt.Sprintf("The monitor %s (%s) is now DOWN.\n\nError: %s", m.Name, m.URL, result.Msg)
|
||||
case prevStatus == monitor.StatusDown && newStatus == monitor.StatusUp:
|
||||
title = fmt.Sprintf("Monitor Recovered: %s", m.Name)
|
||||
message = fmt.Sprintf("The monitor %s (%s) is now UP.\n\nResponse time: %dms", m.Name, m.URL, result.Ping)
|
||||
isRecovery = true
|
||||
case newStatus == monitor.StatusDown:
|
||||
// Still down after retry
|
||||
title = fmt.Sprintf("Monitor Still Down: %s", m.Name)
|
||||
message = fmt.Sprintf("The monitor %s (%s) remains DOWN.\n\nError: %s", m.Name, m.URL, result.Msg)
|
||||
default:
|
||||
// Other status changes, don't notify
|
||||
return
|
||||
}
|
||||
|
||||
// Create incident record for status change
|
||||
s.createIncident(m, prevStatus, newStatus, result, isRecovery)
|
||||
|
||||
// Send notification via AlertManager if available
|
||||
@@ -301,23 +285,72 @@ func (s *Scheduler) handleStatusChange(m *monitor.Monitor, record *core.Record,
|
||||
|
||||
// createIncident creates an incident record for the status change
|
||||
func (s *Scheduler) createIncident(m *monitor.Monitor, prevStatus, newStatus monitor.Status, result *monitor.CheckResult, isRecovery bool) {
|
||||
incidentCollection, err := s.app.FindCollectionByNameOrId("monitor_incidents")
|
||||
if isRecovery {
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
"incidents",
|
||||
"monitor = {:monitor} && type = {:type} && (status = {:open} || status = {:acknowledged})",
|
||||
"-started_at",
|
||||
0,
|
||||
0,
|
||||
dbx.Params{
|
||||
"monitor": m.ID,
|
||||
"type": incident.TypeMonitorDown,
|
||||
"open": incident.StatusOpen,
|
||||
"acknowledged": incident.StatusAcknowledged,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[monitor-scheduler] Failed to find open incident: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, record := range records {
|
||||
record.Set("status", incident.StatusResolved)
|
||||
record.Set("resolved_at", now)
|
||||
record.Set("resolution", fmt.Sprintf("Monitor recovered: %s", result.Msg))
|
||||
if err := s.app.Save(record); err != nil {
|
||||
log.Printf("[monitor-scheduler] Failed to resolve incident: %v", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if newStatus != monitor.StatusDown || prevStatus == monitor.StatusDown {
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := s.app.FindFirstRecordByFilter(
|
||||
"incidents",
|
||||
"monitor = {:monitor} && type = {:type} && (status = {:open} || status = {:acknowledged})",
|
||||
dbx.Params{
|
||||
"monitor": m.ID,
|
||||
"type": incident.TypeMonitorDown,
|
||||
"open": incident.StatusOpen,
|
||||
"acknowledged": incident.StatusAcknowledged,
|
||||
},
|
||||
)
|
||||
if err == nil && existing != nil {
|
||||
return
|
||||
}
|
||||
|
||||
incidentCollection, err := s.app.FindCollectionByNameOrId("incidents")
|
||||
if err != nil {
|
||||
// Collection might not exist, just log
|
||||
log.Printf("[monitor-scheduler] Could not create incident: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
incident := core.NewRecord(incidentCollection)
|
||||
incident.Set("monitor", m.ID)
|
||||
incident.Set("prev_status", string(prevStatus))
|
||||
incident.Set("new_status", string(newStatus))
|
||||
incident.Set("message", result.Msg)
|
||||
incident.Set("ping", result.Ping)
|
||||
incident.Set("is_recovery", isRecovery)
|
||||
incident.Set("time", time.Now())
|
||||
record := core.NewRecord(incidentCollection)
|
||||
record.Set("title", fmt.Sprintf("Monitor Down: %s", m.Name))
|
||||
record.Set("description", result.Msg)
|
||||
record.Set("type", incident.TypeMonitorDown)
|
||||
record.Set("severity", incident.SeverityHigh)
|
||||
record.Set("status", incident.StatusOpen)
|
||||
record.Set("monitor", m.ID)
|
||||
record.Set("started_at", time.Now())
|
||||
record.Set("user", m.UserID)
|
||||
|
||||
if err := s.app.Save(incident); err != nil {
|
||||
if err := s.app.Save(record); err != nil {
|
||||
log.Printf("[monitor-scheduler] Failed to save incident: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -403,6 +436,9 @@ func (s *Scheduler) RunManualCheck(monitorID string) (*monitor.CheckResult, erro
|
||||
defer cancel()
|
||||
|
||||
result := checker.Check(ctx, m)
|
||||
if err := s.saveResult(m, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -453,6 +489,61 @@ func (s *Scheduler) GetUptimeStats(monitorID string, hours int) (*monitor.Uptime
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) calculateUptimeStats(monitorID string) (map[string]float64, error) {
|
||||
stats := make(map[string]float64)
|
||||
for _, window := range []struct {
|
||||
hours int
|
||||
key string
|
||||
}{
|
||||
{24, "uptime_24h"},
|
||||
{168, "uptime_7d"},
|
||||
{720, "uptime_30d"},
|
||||
} {
|
||||
windowStats, err := s.GetUptimeStats(monitorID, window.hours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if windowStats.Total > 0 {
|
||||
stats[window.key] = float64(windowStats.Up) / float64(windowStats.Total) * 100
|
||||
}
|
||||
stats[fmt.Sprintf("checks_%s", window.key)] = float64(windowStats.Total)
|
||||
}
|
||||
|
||||
avgPing, err := s.averagePing(monitorID, 24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["avg_ping_24h"] = avgPing
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) averagePing(monitorID string, hours int) (float64, error) {
|
||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
"monitor_heartbeats",
|
||||
"monitor = {:monitorId} && time >= {:since} && status = {:status}",
|
||||
"-time",
|
||||
0,
|
||||
0,
|
||||
dbx.Params{
|
||||
"monitorId": monitorID,
|
||||
"since": since.Format("2006-01-02 15:04:05"),
|
||||
"status": string(monitor.StatusUp),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
total := 0
|
||||
for _, record := range records {
|
||||
total += record.GetInt("ping")
|
||||
}
|
||||
return float64(total) / float64(len(records)), nil
|
||||
}
|
||||
|
||||
// recordToMonitor converts a PocketBase record to a Monitor struct
|
||||
func recordToMonitor(record *core.Record) *monitor.Monitor {
|
||||
m := &monitor.Monitor{
|
||||
@@ -493,8 +584,11 @@ func recordToMonitor(record *core.Record) *monitor.Monitor {
|
||||
}
|
||||
|
||||
if statsData := record.Get("uptime_stats"); statsData != nil {
|
||||
if stats, ok := statsData.(map[string]float64); ok {
|
||||
m.UptimeStats = stats
|
||||
stats := map[string]float64{}
|
||||
if raw, err := json.Marshal(statsData); err == nil {
|
||||
if err := json.Unmarshal(raw, &stats); err == nil {
|
||||
m.UptimeStats = stats
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user