feat(site): enhance monitoring dashboard and public status pages

Implement incident tracking for public status pages, improve the monitoring
dashboard UI with better grouping and loading states, and refine domain
resolution logic.

- feat(hub): add incident support to public status pages
- feat(hub): implement immediate monitor checks on creation and resume
- feat(hub): improve domain status detection using DNS fallback when WHOIS fails
- feat(site): redesign monitoring dashboard with categorized cards
- feat(site): add incident detail view and management in the dashboard
- feat(site): add active incidents section to public status pages
- feat(site): add "Add System" functionality to systems table
- refactor(site): improve calendar view responsiveness and loading states
- style(site): add skeleton components for better UX during data fetching
This commit is contained in:
Tomas Dvorak
2026-05-01 15:07:22 +02:00
parent 7727be166b
commit c7e2c88604
15 changed files with 866 additions and 186 deletions
+34 -3
View File
@@ -115,8 +115,26 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
newData, err := s.whois.LookupDomain(ctx, domainName)
if err != nil {
log.Printf("[domain-scheduler] Failed to lookup %s: %v", domainName, err)
return err
log.Printf("[domain-scheduler] WHOIS lookup failed for %s: %v", domainName, err)
// Don't return early - try DNS resolution independently to verify domain is alive
newData = &domain.Domain{DomainName: domainName}
}
// Independent DNS resolution check: if WHOIS failed, try to resolve the domain directly
if err != nil {
ips, lookupErr := net.LookupHost(domainName)
if lookupErr == nil && len(ips) > 0 {
newData.IPv4Addresses = []string{}
newData.IPv6Addresses = []string{}
for _, ip := range ips {
if strings.Contains(ip, ":") {
newData.IPv6Addresses = append(newData.IPv6Addresses, ip)
} else {
newData.IPv4Addresses = append(newData.IPv4Addresses, ip)
}
}
log.Printf("[domain-scheduler] DNS resolution succeeded for %s despite WHOIS failure", domainName)
}
}
oldRecord := record.Fresh()
@@ -263,7 +281,20 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
status = domain.DomainStatusActive
}
} else {
status = domain.DomainStatusUnknown
// No expiry date from WHOIS - determine status from DNS resolution.
hasDNS := len(newData.IPv4Addresses) > 0 || len(newData.IPv6Addresses) > 0 || len(newData.NameServers) > 0
if hasDNS {
// DNS resolves means the domain is active and functioning.
// If we previously had a valid status (active/expiring), keep it.
// If status was unknown or empty, upgrade to active since DNS proves the domain exists.
if status == domain.DomainStatusUnknown || status == "" {
status = domain.DomainStatusActive
}
// Otherwise keep the existing valid status (active/expiring)
} else {
// No DNS resolution and no expiry date - we can't determine the domain's state
status = domain.DomainStatusUnknown
}
}
record.Set("status", status)
+9 -3
View File
@@ -177,7 +177,10 @@ func (s *LookupService) tryRDAP(ctx context.Context, domainName string) (*domain
// Parse events
var creationDate, expiryDate, updatedDate *time.Time
for _, event := range rdapResp.Events {
t, _ := time.Parse(time.RFC3339, event.EventDate)
t, err := time.Parse(time.RFC3339, event.EventDate)
if err != nil || t.IsZero() {
continue
}
switch event.EventAction {
case "registration":
creationDate = &t
@@ -831,8 +834,11 @@ func hasValidData(data *domain.WHOISData) bool {
if data == nil {
return false
}
// Accept if we got any meaningful data
if data.Dates.ExpiryDate != nil || data.Dates.CreationDate != nil {
// Accept if we got any meaningful date (non-nil and not zero)
if data.Dates.ExpiryDate != nil && !data.Dates.ExpiryDate.IsZero() {
return true
}
if data.Dates.CreationDate != nil && !data.Dates.CreationDate.IsZero() {
return true
}
if data.Registrar.Name != "" && data.Registrar.Name != "Unknown" {
+15
View File
@@ -2,6 +2,7 @@ package monitors
import (
"encoding/json"
"log"
"net/http"
"time"
@@ -251,6 +252,13 @@ func (h *APIHandler) createMonitor(e *core.RequestEvent) error {
// Add to scheduler
h.scheduler.AddMonitor(record)
// Run initial check immediately so the monitor shows real status instead of pending
go func() {
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
log.Printf("[monitor-api] Initial check failed for %s: %v", record.Id, err)
}
}()
return e.JSON(http.StatusCreated, recordToResponse(record))
}
@@ -485,6 +493,13 @@ func (h *APIHandler) resumeMonitor(e *core.RequestEvent) error {
h.scheduler.UpdateMonitor(record)
// Run an immediate check so the monitor shows real status instead of pending
go func() {
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
log.Printf("[monitor-api] Resume check failed for %s: %v", record.Id, err)
}
}()
return e.JSON(http.StatusOK, recordToResponse(record))
}
+7 -1
View File
@@ -390,9 +390,15 @@ func (s *Scheduler) AddMonitor(record *core.Record) {
func (s *Scheduler) UpdateMonitor(record *core.Record) {
m := recordToMonitor(record)
// Get existing scheduled monitor to preserve next check time if appropriate
// Get existing scheduled monitor
if sm, ok := s.monitors.GetOk(m.ID); ok {
sm.mu.Lock()
wasPaused := sm.Monitor.Status == monitor.StatusPaused || !sm.Monitor.Active
nowActive := m.Active && m.Status != monitor.StatusPaused
// If monitor just became active (resumed), reset NextCheck so it's checked immediately
if wasPaused && nowActive {
sm.NextCheck = time.Now()
}
sm.Monitor = m
sm.mu.Unlock()
} else {
+18
View File
@@ -392,6 +392,23 @@ func (h *APIHandler) buildPublicStatusPage(record *core.Record) *statuspage.Publ
})
}
// Get active incidents for this user (not closed)
var publicIncidents []statuspage.PublicIncident
incidentRecords, _ := h.app.FindAllRecords("incidents",
dbx.NewExp("user = {:user} && status != {:status}",
dbx.Params{"user": record.GetString("user"), "status": "closed"}),
)
for _, incident := range incidentRecords {
publicIncidents = append(publicIncidents, statuspage.PublicIncident{
ID: incident.Id,
Title: incident.GetString("title"),
Description: incident.GetString("description"),
Status: incident.GetString("status"),
Severity: incident.GetString("severity"),
StartedAt: incident.GetDateTime("started_at").Time(),
})
}
return &statuspage.PublicStatusPage{
ID: record.Id,
Name: record.GetString("name"),
@@ -402,6 +419,7 @@ func (h *APIHandler) buildPublicStatusPage(record *core.Record) *statuspage.Publ
Theme: record.GetString("theme"),
CustomCSS: record.GetString("custom_css"),
Monitors: publicMonitors,
Incidents: publicIncidents,
OverallStatus: overallStatus,
UpdatedAt: record.GetDateTime("updated").Time(),
}