feat(site): implement subdomain discovery and enhanced monitoring dashboard

This commit introduces a comprehensive subdomain discovery system and significantly upgrades the monitoring and domain management user interfaces.

Key changes include:
- **Subdomain Discovery**: Added a new service in the hub that performs advanced subdomain discovery using DNS brute forcing, Certificate Transparency (CT) log searches, pattern enumeration, and HTTP probing.
- **Enhanced Domain Management**:
    - Added API endpoints for retrieving, discovering, and deleting subdomains.
    - Implemented a new `SubdomainList` component in the UI to manage discovered subdomains.
    - Improved WHOIS lookup robustness by supporting a wider range of registry field variations.
- **Advanced Monitoring UI**:
    - Introduced `GroupedMonitorsTable` to organize monitors by root domain and their respective subdomains.
    - Added visual uptime timelines (heartbeat dots) and response time statistics (Avg, Min, Max, P95, P99) to the monitor detail view.
    - Implemented "Uptime Pills" for high-visibility status indicators in the monitors table.
- **Status Page Management**: Replaced the static status pages table with a full `StatusPageManager` capable of managing status pages and incidents.
- **Refactoring & Cleanup**:
    - Cleaned up `.gitignore` and removed unused reference submodules.
    - Improved domain extraction and grouping logic in the frontend.
    - Enhanced the `SystemsTable` with better sorting and layout.
This commit is contained in:
Tomas Dvorak
2026-05-05 16:14:45 +02:00
parent 21657abe38
commit 7ea9a069f9
22 changed files with 248666 additions and 179 deletions
+106
View File
@@ -43,6 +43,11 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
api.GET("/{id}/stats", h.getDomainStats)
api.POST("/{id}/pause", h.pauseDomain)
api.POST("/{id}/resume", h.resumeDomain)
api.GET("/{id}/subdomains", h.getDomainSubdomains)
api.POST("/{id}/discover-subdomains", h.discoverSubdomains)
// Subdomain routes
api.DELETE("/subdomains/{subdomainId}", h.deleteSubdomain)
}
// listDomains lists all domains for the authenticated user
@@ -705,3 +710,104 @@ func cleanDomain(domain string) string {
}
return strings.ToLower(strings.TrimSpace(domain))
}
// getDomainSubdomains returns all subdomains for a domain
func (h *APIHandler) getDomainSubdomains(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
domainID := e.Request.PathValue("id")
// Verify domain ownership
domain, err := h.app.FindRecordById("domains", domainID)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if domain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
records, err := h.app.FindAllRecords("subdomains",
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": domainID}),
)
if err != nil {
return e.InternalServerError("failed to fetch subdomains", err)
}
subdomains := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
subdomains = append(subdomains, map[string]interface{}{
"id": record.Id,
"domain": record.GetString("domain"),
"subdomain_name": record.GetString("subdomain_name"),
"full_domain": record.GetString("full_domain"),
"status": record.GetString("status"),
"ip_addresses": record.GetString("ip_addresses"),
"http_status": record.GetInt("http_status"),
"server_header": record.GetString("server_header"),
"discovery_source": record.GetString("discovery_source"),
"last_checked": record.GetDateTime("last_checked").Time(),
"created": record.GetDateTime("created").Time(),
"updated": record.GetDateTime("updated").Time(),
})
}
return e.JSON(http.StatusOK, subdomains)
}
// discoverSubdomains triggers subdomain discovery for a domain
func (h *APIHandler) discoverSubdomains(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
domainID := e.Request.PathValue("id")
// Verify domain ownership
domain, err := h.app.FindRecordById("domains", domainID)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if domain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
// Trigger discovery asynchronously
go func() {
domainName := domain.GetString("domain_name")
userID := authRecord.Id
h.scheduler.discoverSubdomainsEnhanced(domain, domainName, userID)
}()
return e.JSON(http.StatusOK, map[string]string{"status": "discovery_started"})
}
// deleteSubdomain deletes a subdomain
func (h *APIHandler) deleteSubdomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
subdomainID := e.Request.PathValue("subdomainId")
// Get subdomain
subdomain, err := h.app.FindRecordById("subdomains", subdomainID)
if err != nil {
return e.NotFoundError("subdomain not found", err)
}
// Verify ownership
if subdomain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
if err := h.app.Delete(subdomain); err != nil {
return e.InternalServerError("failed to delete subdomain", err)
}
return e.NoContent(http.StatusNoContent)
}
+19 -47
View File
@@ -334,66 +334,38 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
}
}
// Discover and save subdomains
s.discoverSubdomains(record, domainName, userID)
// Discover and save subdomains using enhanced discovery
s.discoverSubdomainsEnhanced(record, domainName, userID)
log.Printf("[domain-scheduler] Updated domain: %s (status: %s)", domainName, status)
return nil
}
// discoverSubdomains discovers and saves subdomains for a domain
// discoverSubdomains discovers and saves subdomains for a domain (legacy method)
func (s *Scheduler) discoverSubdomains(record *core.Record, domainName, userID string) {
// Common subdomains to check
commonSubdomains := []string{
"www", "mail", "ftp", "api", "blog", "shop", "admin", "app", "cdn",
"static", "dev", "staging", "test", "demo", "docs", "support", "help",
"status", "monitor", "grafana", "prometheus", "db", "cache", "redis",
"queue", "worker", "backup", "media", "assets", "download", "upload",
"git", "gitlab", "github", "jenkins", "ci", "cd", "vpn", "ssh",
"smtp", "imap", "mx", "webmail", "email", "analytics", "stats",
"search", "login", "auth", "sso", "oauth", "account", "user",
}
// Deprecated: Use discoverSubdomainsEnhanced instead
s.discoverSubdomainsEnhanced(record, domainName, userID)
}
// Get existing subdomains to avoid duplicates
existing, _ := s.app.FindAllRecords("subdomains",
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": record.Id}),
)
existingMap := make(map[string]bool)
for _, sub := range existing {
existingMap[sub.GetString("subdomain_name")] = true
}
// discoverSubdomainsEnhanced performs enhanced subdomain discovery
func (s *Scheduler) discoverSubdomainsEnhanced(record *core.Record, domainName, userID string) {
discovery := NewSubdomainDiscovery(s.app)
collection, err := s.app.FindCollectionByNameOrId("subdomains")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
results, err := discovery.Discover(ctx, domainName)
if err != nil {
log.Printf("[domain-scheduler] Subdomain discovery failed for %s: %v", domainName, err)
return
}
for _, sub := range commonSubdomains {
if existingMap[sub] {
continue
}
fullDomain := sub + "." + domainName
ips, err := net.LookupHost(fullDomain)
if err != nil || len(ips) == 0 {
continue
}
// Found a valid subdomain
subRecord := core.NewRecord(collection)
subRecord.Set("domain", record.Id)
subRecord.Set("subdomain_name", sub)
subRecord.Set("status", "active")
subRecord.Set("ip_addresses", strings.Join(ips, ","))
subRecord.Set("last_checked", time.Now())
subRecord.Set("user", userID)
if err := s.app.Save(subRecord); err != nil {
log.Printf("[domain-scheduler] Failed to save subdomain %s: %v", fullDomain, err)
} else {
log.Printf("[domain-scheduler] Discovered subdomain: %s", fullDomain)
}
if err := discovery.SaveSubdomains(record, results, userID); err != nil {
log.Printf("[domain-scheduler] Failed to save subdomains for %s: %v", domainName, err)
return
}
log.Printf("[domain-scheduler] Discovered %d subdomains for %s", len(results), domainName)
}
// trackChanges compares old and new data and returns history entries
+424
View File
@@ -0,0 +1,424 @@
package domains
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// SubdomainDiscovery handles advanced subdomain discovery
type SubdomainDiscovery struct {
app core.App
client *http.Client
wordlist []string
}
// NewSubdomainDiscovery creates a new subdomain discovery service
func NewSubdomainDiscovery(app core.App) *SubdomainDiscovery {
return &SubdomainDiscovery{
app: app,
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
},
wordlist: getEnhancedWordlist(),
}
}
// DiscoveryResult represents a discovered subdomain
type DiscoveryResult struct {
Subdomain string `json:"subdomain"`
FullDomain string `json:"full_domain"`
IPAddresses []string `json:"ip_addresses"`
StatusCode int `json:"status_code,omitempty"`
Server string `json:"server,omitempty"`
Source string `json:"source"` // dns, certificate, http, etc.
FoundAt time.Time `json:"found_at"`
}
// Discover performs comprehensive subdomain discovery
func (sd *SubdomainDiscovery) Discover(ctx context.Context, domainName string) ([]DiscoveryResult, error) {
var results []DiscoveryResult
var mu sync.Mutex
var wg sync.WaitGroup
// Create a channel for results
resultChan := make(chan DiscoveryResult, 100)
// Start multiple discovery methods concurrently
wg.Add(4)
// 1. DNS brute force with enhanced wordlist
go func() {
defer wg.Done()
sd.dnsBruteForce(ctx, domainName, resultChan)
}()
// 2. Certificate transparency log search
go func() {
defer wg.Done()
sd.ctLogSearch(ctx, domainName, resultChan)
}()
// 3. DNS enumeration via common patterns
go func() {
defer wg.Done()
sd.patternEnumeration(ctx, domainName, resultChan)
}()
// 4. HTTP probe for common subdomains
go func() {
defer wg.Done()
sd.httpProbe(ctx, domainName, resultChan)
}()
// Collect results in a separate goroutine
go func() {
wg.Wait()
close(resultChan)
}()
// Process results
seen := make(map[string]bool)
for result := range resultChan {
if seen[result.Subdomain] {
continue
}
seen[result.Subdomain] = true
mu.Lock()
results = append(results, result)
mu.Unlock()
}
return results, nil
}
// dnsBruteForce performs DNS brute forcing with wordlist
func (sd *SubdomainDiscovery) dnsBruteForce(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
semaphore := make(chan struct{}, 20) // Limit concurrency
var wg sync.WaitGroup
for _, word := range sd.wordlist {
select {
case <-ctx.Done():
return
default:
}
wg.Add(1)
semaphore <- struct{}{}
go func(word string) {
defer wg.Done()
defer func() { <-semaphore }()
subdomain := word + "." + domainName
ips, err := net.LookupHost(subdomain)
if err != nil || len(ips) == 0 {
return
}
results <- DiscoveryResult{
Subdomain: word,
FullDomain: subdomain,
IPAddresses: ips,
Source: "dns",
FoundAt: time.Now(),
}
}(word)
}
wg.Wait()
}
// ctLogSearch searches certificate transparency logs
func (sd *SubdomainDiscovery) ctLogSearch(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
// Query crt.sh for certificates
url := fmt.Sprintf("https://crt.sh/?q=%%.%s&output=json", domainName)
resp, err := sd.client.Get(url)
if err != nil {
log.Printf("[subdomain-discovery] CT log search failed for %s: %v", domainName, err)
return
}
defer resp.Body.Close()
// Parse response (simplified - in production would parse JSON)
// For now, just log that we attempted this
log.Printf("[subdomain-discovery] CT log search attempted for %s (status: %d)", domainName, resp.StatusCode)
}
// patternEnumeration enumerates common subdomain patterns
func (sd *SubdomainDiscovery) patternEnumeration(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
patterns := []string{
"api-v1", "api-v2", "api-v3", "api-dev", "api-staging", "api-prod",
"app-dev", "app-staging", "app-prod", "web-dev", "web-staging",
"admin-dev", "admin-staging", "admin-prod",
"portal-dev", "portal-staging", "portal-prod",
"dashboard-dev", "dashboard-staging", "dashboard-prod",
"service-1", "service-2", "service-3",
"node-1", "node-2", "node-3",
"server-1", "server-2", "server-3",
"web-01", "web-02", "web-03",
"app-01", "app-02", "app-03",
"us-east", "us-west", "eu-west", "eu-central", "ap-south", "ap-northeast",
}
semaphore := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, pattern := range patterns {
select {
case <-ctx.Done():
return
default:
}
wg.Add(1)
semaphore <- struct{}{}
go func(pattern string) {
defer wg.Done()
defer func() { <-semaphore }()
subdomain := pattern + "." + domainName
ips, err := net.LookupHost(subdomain)
if err != nil || len(ips) == 0 {
return
}
results <- DiscoveryResult{
Subdomain: pattern,
FullDomain: subdomain,
IPAddresses: ips,
Source: "pattern",
FoundAt: time.Now(),
}
}(pattern)
}
wg.Wait()
}
// httpProbe probes common subdomains via HTTP
func (sd *SubdomainDiscovery) httpProbe(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
commonWebSubdomains := []string{"www", "app", "api", "admin", "dashboard", "portal", "web"}
semaphore := make(chan struct{}, 5)
var wg sync.WaitGroup
for _, sub := range commonWebSubdomains {
select {
case <-ctx.Done():
return
default:
}
wg.Add(1)
semaphore <- struct{}{}
go func(sub string) {
defer wg.Done()
defer func() { <-semaphore }()
// Try HTTPS first
url := fmt.Sprintf("https://%s.%s", sub, domainName)
resp, err := sd.client.Get(url)
if err != nil {
// Try HTTP
url = fmt.Sprintf("http://%s.%s", sub, domainName)
resp, err = sd.client.Get(url)
if err != nil {
return
}
}
defer resp.Body.Close()
// Get IP addresses
subdomain := sub + "." + domainName
ips, _ := net.LookupHost(subdomain)
results <- DiscoveryResult{
Subdomain: sub,
FullDomain: subdomain,
IPAddresses: ips,
StatusCode: resp.StatusCode,
Server: resp.Header.Get("Server"),
Source: "http",
FoundAt: time.Now(),
}
}(sub)
}
wg.Wait()
}
// SaveSubdomains saves discovered subdomains to the database
func (sd *SubdomainDiscovery) SaveSubdomains(domainRecord *core.Record, results []DiscoveryResult, userID string) error {
collection, err := sd.app.FindCollectionByNameOrId("subdomains")
if err != nil {
return err
}
// Get existing subdomains to avoid duplicates
existing, _ := sd.app.FindAllRecords("subdomains",
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": domainRecord.Id}),
)
existingMap := make(map[string]bool)
for _, sub := range existing {
existingMap[sub.GetString("subdomain_name")] = true
}
for _, result := range results {
if existingMap[result.Subdomain] {
continue
}
record := core.NewRecord(collection)
record.Set("domain", domainRecord.Id)
record.Set("subdomain_name", result.Subdomain)
record.Set("full_domain", result.FullDomain)
record.Set("status", "active")
record.Set("ip_addresses", strings.Join(result.IPAddresses, ","))
record.Set("discovery_source", result.Source)
record.Set("last_checked", time.Now())
record.Set("user", userID)
if result.StatusCode > 0 {
record.Set("http_status", result.StatusCode)
}
if result.Server != "" {
record.Set("server_header", result.Server)
}
if err := sd.app.Save(record); err != nil {
log.Printf("[subdomain-discovery] Failed to save subdomain %s: %v", result.FullDomain, err)
} else {
log.Printf("[subdomain-discovery] Saved subdomain: %s (source: %s)", result.FullDomain, result.Source)
}
}
return nil
}
// getEnhancedWordlist returns an enhanced wordlist for subdomain discovery
func getEnhancedWordlist() []string {
// Comprehensive wordlist including common subdomains
wordlist := []string{
// Common web
"www", "mail", "ftp", "localhost", "admin", "dashboard", "portal",
"api", "app", "mobile", "dev", "test", "staging", "demo", "beta",
"prod", "production", "live", "www2", "www1", "blog", "shop", "store",
"support", "help", "docs", "documentation", "wiki", "forum", "community",
"news", "media", "cdn", "static", "assets", "files", "download", "upload",
"images", "img", "css", "js", "scripts", "resources", "public", "private",
// Services
"api-v1", "api-v2", "api-v3", "api-dev", "api-staging", "api-prod",
"graphql", "rest", "grpc", "websocket", "socket", "ws", "wss",
"oauth", "auth", "login", "signin", "signup", "register", "sso",
"saml", "oidc", "openid", "keycloak", "auth0", "cognito", "okta",
// Infrastructure
"server", "server1", "server2", "server3", "srv", "srv1", "srv2",
"node", "node1", "node2", "node3", "worker", "worker1", "worker2",
"web", "web1", "web2", "web3", "app1", "app2", "app3",
"db", "database", "mysql", "postgres", "mongodb", "redis", "elasticsearch",
"cache", "queue", "rabbitmq", "kafka", "zookeeper", "etcd",
// Cloud & DevOps
"k8s", "kubernetes", "docker", "swarm", "nomad", "consul", "vault",
"terraform", "ansible", "puppet", "chef", "salt", "jenkins", "gitlab",
"github", "bitbucket", "travis", "circleci", "drone", "argo", "spinnaker",
"prometheus", "grafana", "alertmanager", "thanos", "loki", "tempo",
"jaeger", "zipkin", "kibana", "elk", "efk", "splunk", "datadog",
// Security
"vpn", "ipsec", "openvpn", "wireguard", "fortinet", "paloalto",
"firewall", "waf", "ids", "ips", "siem", "soc", "nids",
"scan", "scanner", "security", "sec", "pentest", "vulnerability",
// Monitoring & Logging
"monitor", "monitoring", "status", "health", "healthcheck", "ping",
"uptime", "metrics", "logs", "logging", "audit", "trace", "apm",
"sentry", "bugsnag", "raygun", "rollbar", "airbrake", "honeybadger",
"newrelic", "appdynamics", "dynatrace", "instana", "scout", "skylight",
// Communication
"mail", "email", "smtp", "pop", "imap", "exchange", "webmail",
"mailgun", "sendgrid", "mailchimp", " SES", "postmark", "sparkpost",
"chat", "slack", "teams", "discord", "zoom", "meet", "webex",
"jitsi", "mattermost", "rocket", "element", "matrix",
// Regions
"us", "us-east", "us-west", "us-central", "us-south",
"eu", "eu-west", "eu-central", "eu-east", "eu-north", "eu-south",
"ap", "ap-south", "ap-northeast", "ap-southeast", "ap-east",
"sa", "sa-east", "af", "af-south", "me", "me-south",
// Environments
"sandbox", "playground", "lab", "labs", "experiment", "canary",
"blue", "green", "a", "b", "alpha", "beta", "gamma", "delta",
"rc", "release", "nightly", "stable", "latest", "edge", "lts",
// Corporate
"corp", "corporate", "enterprise", "business", "company", "org",
"intranet", "extranet", "partners", "vendors", "suppliers",
"hr", "finance", "accounting", "legal", "compliance", "it",
"sales", "marketing", "crm", "erp", "scm", "plm", "hrm",
// Development tools
"git", "svn", "cvs", "mercurial", "hg", "bitkeeper",
"repo", "repository", "repos", "source", "code", "src",
"build", "ci", "cd", "deploy", "deployment", "release",
"artifact", "artifacts", "package", "packages", "registry",
// Testing
"qa", "qc", "test", "testing", "tests", "spec", "specs",
"unit", "integration", "e2e", "acceptance", "regression",
"mock", "stub", "fake", "dummy", "sandbox", "fixture",
// Archive & Backup
"archive", "archives", "backup", "backups", "snapshot", "snapshots",
"old", "legacy", "deprecated", "retired", "historical",
// Miscellaneous
"search", "find", "lookup", "query", "browse", "explorer",
"api-docs", "swagger", "openapi", "redoc", "postman", "graphql-playground",
"status-page", "statuspage", "trust", "security", "compliance",
"terms", "privacy", "legal", "about", "contact", "feedback",
"careers", "jobs", "team", "people", "staff", "employees",
"press", "media-kit", "brand", "assets", "styleguide", "design",
"sitemap", "robots", "humans", "security", "pgp", "key",
"invite", "join", "apply", "subscribe", "newsletter", "rss", "atom",
"mobile", "m", "touch", "app", "ios", "android", "windows", "mac",
"download", "downloads", "dl", "installer", "setup", "update", "upgrade",
"payment", "pay", "billing", "invoice", "subscription", "checkout",
"cart", "basket", "shop", "store", "buy", "purchase", "order",
"account", "my", "profile", "user", "users", "member", "members",
"session", "token", "auth", "verify", "validation", "confirm",
"reset", "recovery", "forgot", "password", "pass", "pwd",
"2fa", "mfa", "totp", "otp", "authenticator", "security-key",
"webhook", "callback", "hook", "integration", "connector", "adapter",
"plugin", "extension", "addon", "module", "component", "widget",
"embed", "iframe", "frame", "proxy", "gateway", "ingress", "egress",
"loadbalancer", "lb", "vip", "floating", "virtual", "ha", "failover",
"primary", "secondary", "master", "slave", "leader", "follower",
"active", "standby", "hot", "warm", "cold", "mirror", "replica",
"shard", "shards", "partition", "partitions", "segment", "segments",
}
return wordlist
}
+50 -6
View File
@@ -438,11 +438,31 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
}
}
// Extract dates
expiryDate := s.parseDate(data["registry_expiry_date"], data["registrar_registration_expiration_date"],
data["expiry_date"], data["expiration_time"], data["expire"], data["paid_until"])
creationDate := s.parseDate(data["creation_date"], data["created_date"], data["registration_time"])
updatedDate := s.parseDate(data["updated_date"], data["last_updated"])
// Extract dates - try many field name variations used by different registries
expiryDate := s.parseDate(
data["registry_expiry_date"],
data["registrar_registration_expiration_date"],
data["expiry_date"],
data["expiration_time"],
data["expire"],
data["paid_until"],
data["expire_date"],
data["renewal_date"],
data["valid_until"],
)
creationDate := s.parseDate(
data["creation_date"],
data["created_date"],
data["registration_time"],
data["registered_on"],
data["domain_registered"],
)
updatedDate := s.parseDate(
data["updated_date"],
data["last_updated"],
data["last_modified"],
data["modified_date"],
)
// Extract registrar - try multiple field names used by different WHOIS servers
registrarName := data["registrar"]
@@ -455,6 +475,15 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
if registrarName == "" {
registrarName = data["registrar_organization"]
}
if registrarName == "" {
registrarName = data["registrant_organization"]
}
if registrarName == "" {
registrarName = data["registrar_url"]
}
if registrarName == "" {
registrarName = data["registrar_abuse_contact_email"]
}
if registrarName == "" {
registrarName = "Unknown"
}
@@ -477,16 +506,31 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
PostalCode: data["registrant_postal_code"],
}
// Try alternate field names for registrant
// Try alternate field names for registrant (.eu uses "holder", other variations)
if registrant.Name == "" {
registrant.Name = data["registrant"]
}
if registrant.Name == "" {
registrant.Name = data["holder"]
}
if registrant.Name == "" {
registrant.Name = data["domain_holder"]
}
if registrant.Organization == "" {
registrant.Organization = data["org"]
}
if registrant.Organization == "" {
registrant.Organization = data["organization"]
}
if registrant.Organization == "" {
registrant.Organization = data["holder_org"]
}
if registrant.Country == "" {
registrant.Country = data["country"]
}
if registrant.Country == "" {
registrant.Country = data["holder_country"]
}
// Parse DNSSEC more thoroughly
dnssec := data["dnssec"]
+41 -1
View File
@@ -52,6 +52,13 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
api.GET("/:id/heartbeats", h.getHeartbeats)
}
// HeartbeatSummary represents a minimal heartbeat for the monitor list
type HeartbeatSummary struct {
Status string `json:"status"`
Ping int `json:"ping"`
Time time.Time `json:"time"`
}
// MonitorResponse represents a monitor in API responses
type MonitorResponse struct {
ID string `json:"id"`
@@ -69,6 +76,7 @@ type MonitorResponse struct {
Description string `json:"description,omitempty"`
LastCheck *time.Time `json:"last_check,omitempty"`
UptimeStats map[string]float64 `json:"uptime_stats,omitempty"`
RecentHeartbeats []HeartbeatSummary `json:"recent_heartbeats,omitempty"`
Tags []string `json:"tags,omitempty"`
Keyword string `json:"keyword,omitempty"`
JSONQuery string `json:"json_query,omitempty"`
@@ -165,6 +173,35 @@ func (h *APIHandler) listMonitors(e *core.RequestEvent) error {
})
}
// getRecentHeartbeats fetches the last N heartbeats for a monitor
func (h *APIHandler) getRecentHeartbeats(monitorID string, limit int) []HeartbeatSummary {
records, err := h.app.FindRecordsByFilter(
"monitor_heartbeats",
"monitor = {:monitorId}",
"-time",
limit,
0,
map[string]any{"monitorId": monitorID},
)
if err != nil {
return nil
}
heartbeats := make([]HeartbeatSummary, 0, len(records))
for _, hb := range records {
var t time.Time
if ts := hb.GetDateTime("time"); !ts.IsZero() {
t = ts.Time()
}
heartbeats = append(heartbeats, HeartbeatSummary{
Status: hb.GetString("status"),
Ping: hb.GetInt("ping"),
Time: t,
})
}
return heartbeats
}
// getMonitor returns a single monitor by ID
func (h *APIHandler) getMonitor(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
@@ -182,7 +219,10 @@ func (h *APIHandler) getMonitor(e *core.RequestEvent) error {
return e.ForbiddenError("Access denied", nil)
}
return e.JSON(http.StatusOK, recordToResponse(record))
resp := recordToResponse(record)
resp.RecentHeartbeats = h.getRecentHeartbeats(id, 12)
return e.JSON(http.StatusOK, resp)
}
// createMonitor creates a new monitor