feat(hub,site): enhance domain intelligence and monitor performance
Build Docker images / Hub (push) Failing after 1m35s

Implement comprehensive domain data collection including provider detection (DNS, hosting, email, CA), HTTP headers, TLS certificate chains, and SEO metadata. Added PageSpeed Insights integration for monitors to track Core Web Vitals.

- **hub**:
  - Add provider detection logic for DNS, email, and hosting.
  - Expand `Domain` entity to include SEO, headers, certificates, and enhanced registration details.
  - Implement automated collection of TLD, WHOIS raw data, and host country codes.
  - Update scheduler to track changes in providers and security settings (privacy/transfer lock).
  - Add PageSpeed check endpoint to monitor API.
- **site**:
  - Update domain table and detail views to display new intelligence (providers, headers, SEO).
  - Implement PageSpeed metrics visualization with Core Web Vitals status indicators.
  - Add display options for provider information in the domain list.
- **db**:
  - Add migration for new domain collection fields.
This commit is contained in:
Tomas Dvorak
2026-05-14 13:33:03 +02:00
parent 0dd7db8a82
commit fe5c7eaa95
16 changed files with 1712 additions and 146 deletions
BIN
View File
Binary file not shown.
+100
View File
@@ -71,6 +71,33 @@ type Domain struct {
AbuseEmail string `json:"abuse_email" db:"abuse_email"` AbuseEmail string `json:"abuse_email" db:"abuse_email"`
AbusePhone string `json:"abuse_phone" db:"abuse_phone"` AbusePhone string `json:"abuse_phone" db:"abuse_phone"`
// Provider Detection
DNSProvider string `json:"dns_provider" db:"dns_provider"`
HostingProvider string `json:"hosting_provider" db:"hosting_provider"`
EmailProvider string `json:"email_provider" db:"email_provider"`
CAProvider string `json:"ca_provider" db:"ca_provider"`
// HTTP Headers
Headers []Header `json:"headers" db:"headers"`
// Certificate Chain
Certificates []Certificate `json:"certificates" db:"certificates"`
// SEO Metadata
SEOMeta *SEOMeta `json:"seo_meta" db:"seo_meta"`
// Raw WHOIS Response
WHOISRaw string `json:"whois_raw" db:"whois_raw"`
// Registration Details
PrivacyEnabled bool `json:"privacy_enabled" db:"privacy_enabled"`
TransferLock bool `json:"transfer_lock" db:"transfer_lock"`
TLD string `json:"tld" db:"tld"`
DomainStatuses []string `json:"domain_statuses" db:"domain_statuses"`
// Enhanced Geo
HostCountryCode string `json:"host_country_code" db:"host_country_code"`
// Metadata // Metadata
Tags []string `json:"tags" db:"tags"` Tags []string `json:"tags" db:"tags"`
Notes string `json:"notes" db:"notes"` Notes string `json:"notes" db:"notes"`
@@ -176,6 +203,76 @@ type IPInfo struct {
IPv6 []string `json:"ipv6"` IPv6 []string `json:"ipv6"`
} }
// Header represents an HTTP response header
type Header struct {
Name string `json:"name"`
Value string `json:"value"`
}
// Certificate represents a TLS certificate in the chain
type Certificate struct {
Issuer string `json:"issuer"`
Subject string `json:"subject"`
AltNames []string `json:"alt_names"`
ValidFrom time.Time `json:"valid_from"`
ValidTo time.Time `json:"valid_to"`
CAProvider string `json:"ca_provider"`
}
// OpenGraphMeta represents Open Graph metadata
type OpenGraphMeta struct {
URL string `json:"url"`
Type string `json:"type"`
Title string `json:"title"`
Images []string `json:"images"`
Description string `json:"description"`
}
// TwitterMeta represents Twitter card metadata
type TwitterMeta struct {
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
Card string `json:"card"`
}
// GeneralMeta represents general HTML meta tags
type GeneralMeta struct {
Title string `json:"title"`
Author string `json:"author"`
Robots string `json:"robots"`
Keywords string `json:"keywords"`
Canonical string `json:"canonical"`
Description string `json:"description"`
}
// RobotsTxt represents parsed robots.txt data
type RobotsTxt struct {
Fetched bool `json:"fetched"`
Groups []RobotsGroup `json:"groups"`
Sitemaps []string `json:"sitemaps"`
}
// RobotsGroup represents a user-agent group in robots.txt
type RobotsGroup struct {
UserAgents []string `json:"userAgents"`
Rules []RobotsRule `json:"rules"`
}
// RobotsRule represents a single rule in robots.txt
type RobotsRule struct {
Type string `json:"type"`
Value string `json:"value"`
}
// SEOMeta represents all SEO-related metadata
type SEOMeta struct {
OpenGraph OpenGraphMeta `json:"openGraph"`
Twitter TwitterMeta `json:"twitter"`
General GeneralMeta `json:"general"`
Robots RobotsTxt `json:"robots"`
}
// ChangeType constants for domain history // ChangeType constants for domain history
const ( const (
ChangeTypeExpiry = "expiry" ChangeTypeExpiry = "expiry"
@@ -185,6 +282,9 @@ const (
ChangeTypeIP = "ip" ChangeTypeIP = "ip"
ChangeTypeHost = "host" ChangeTypeHost = "host"
ChangeTypeStatus = "status" ChangeTypeStatus = "status"
ChangeTypeProvider = "provider"
ChangeTypeSecurity = "security"
ChangeTypeSEO = "seo"
) )
// Domain status constants // Domain status constants
+34
View File
@@ -635,6 +635,19 @@ func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{
"registrant_state": record.GetString("registrant_state"), "registrant_state": record.GetString("registrant_state"),
"abuse_email": record.GetString("abuse_email"), "abuse_email": record.GetString("abuse_email"),
"abuse_phone": record.GetString("abuse_phone"), "abuse_phone": record.GetString("abuse_phone"),
"dns_provider": record.GetString("dns_provider"),
"hosting_provider": record.GetString("hosting_provider"),
"email_provider": record.GetString("email_provider"),
"ca_provider": record.GetString("ca_provider"),
"headers": record.Get("headers"),
"certificates": record.Get("certificates"),
"seo_meta": record.Get("seo_meta"),
"whois_raw": record.GetString("whois_raw"),
"privacy_enabled": record.GetBool("privacy_enabled"),
"transfer_lock": record.GetBool("transfer_lock"),
"tld": record.GetString("tld"),
"domain_statuses": record.Get("domain_statuses"),
"host_country_code": record.GetString("host_country_code"),
"tags": record.Get("tags"), "tags": record.Get("tags"),
"notes": record.GetString("notes"), "notes": record.GetString("notes"),
"favicon_url": record.GetString("favicon_url"), "favicon_url": record.GetString("favicon_url"),
@@ -717,6 +730,27 @@ func (h *APIHandler) applyLookupData(record *core.Record, domainData *domain.Dom
record.Set("registrant_postal", domainData.RegistrantPostal) record.Set("registrant_postal", domainData.RegistrantPostal)
record.Set("abuse_email", domainData.AbuseEmail) record.Set("abuse_email", domainData.AbuseEmail)
record.Set("abuse_phone", domainData.AbusePhone) record.Set("abuse_phone", domainData.AbusePhone)
record.Set("dns_provider", domainData.DNSProvider)
record.Set("hosting_provider", domainData.HostingProvider)
record.Set("email_provider", domainData.EmailProvider)
record.Set("ca_provider", domainData.CAProvider)
if len(domainData.Headers) > 0 {
record.Set("headers", domainData.Headers)
}
if len(domainData.Certificates) > 0 {
record.Set("certificates", domainData.Certificates)
}
if domainData.SEOMeta != nil {
record.Set("seo_meta", domainData.SEOMeta)
}
record.Set("whois_raw", domainData.WHOISRaw)
record.Set("privacy_enabled", domainData.PrivacyEnabled)
record.Set("transfer_lock", domainData.TransferLock)
record.Set("tld", domainData.TLD)
if len(domainData.DomainStatuses) > 0 {
record.Set("domain_statuses", domainData.DomainStatuses)
}
record.Set("host_country_code", domainData.HostCountryCode)
record.Set("favicon_url", domainData.FaviconURL) record.Set("favicon_url", domainData.FaviconURL)
record.Set("last_checked", time.Now()) record.Set("last_checked", time.Now())
} }
+275
View File
@@ -0,0 +1,275 @@
package detect
import (
"net/http"
"strings"
)
// ProviderInfo holds detected provider name
type ProviderInfo struct {
Name string
}
// DetectDNSProvider detects the DNS provider from NS records
func DetectDNSProvider(nsRecords []string) string {
for _, ns := range nsRecords {
nsLower := strings.ToLower(ns)
switch {
case strings.Contains(nsLower, "cloudflare"):
return "Cloudflare"
case strings.Contains(nsLower, "awsdns"):
return "Amazon Route 53"
case strings.Contains(nsLower, "googledomains") || strings.Contains(nsLower, "google.com"):
return "Google Domains"
case strings.Contains(nsLower, "namecheap") || strings.Contains(nsLower, "namecheaphosting"):
return "Namecheap"
case strings.Contains(nsLower, "godaddy"):
return "GoDaddy"
case strings.Contains(nsLower, "domaincontrol"):
return "GoDaddy"
case strings.Contains(nsLower, "nsone.net"):
return "NS1"
case strings.Contains(nsLower, "digitalocean"):
return "DigitalOcean"
case strings.Contains(nsLower, "linode"):
return "Linode"
case strings.Contains(nsLower, "vultr"):
return "Vultr"
case strings.Contains(nsLower, "he.net"):
return "Hurricane Electric"
case strings.Contains(nsLower, "dyn.com") || strings.Contains(nsLower, "dynect"):
return "Dyn (Oracle)"
case strings.Contains(nsLower, "ultradns"):
return "UltraDNS"
case strings.Contains(nsLower, "dnsimple"):
return "DNSimple"
case strings.Contains(nsLower, "hover"):
return "Hover"
case strings.Contains(nsLower, "register.com"):
return "Register.com"
case strings.Contains(nsLower, "enom"):
return "eNom"
case strings.Contains(nsLower, "worldnic"):
return "Network Solutions"
case strings.Contains(nsLower, "zoneedit"):
return "ZoneEdit"
case strings.Contains(nsLower, "easydns"):
return "EasyDNS"
case strings.Contains(nsLower, "gandi"):
return "Gandi"
case strings.Contains(nsLower, "ovh"):
return "OVH"
case strings.Contains(nsLower, "hetzner"):
return "Hetzner"
case strings.Contains(nsLower, "azure-dns"):
return "Microsoft Azure"
}
}
return ""
}
// DetectEmailProvider detects the email provider from MX records
func DetectEmailProvider(mxRecords []string) string {
for _, mx := range mxRecords {
mxLower := strings.ToLower(mx)
// Extract just the hostname part if it has priority prefix
host := mxLower
if idx := strings.Index(mxLower, " "); idx > 0 {
host = strings.TrimSpace(mxLower[idx+1:])
}
host = strings.TrimSuffix(host, ".")
switch {
case strings.Contains(host, "google") || strings.Contains(host, "gmail"):
return "Google Workspace"
case strings.Contains(host, "outlook") || strings.Contains(host, "microsoft") || strings.Contains(host, "protection.outlook"):
return "Microsoft 365"
case strings.Contains(host, "purelymail"):
return "Purelymail"
case strings.Contains(host, "zoho"):
return "Zoho Mail"
case strings.Contains(host, "protonmail") || strings.Contains(host, "pm.me"):
return "ProtonMail"
case strings.Contains(host, "fastmail"):
return "Fastmail"
case strings.Contains(host, "tutanota"):
return "Tutanota"
case strings.Contains(host, "mxroute"):
return "MXroute"
case strings.Contains(host, "namecheap"):
return "Namecheap"
case strings.Contains(host, "icloud") || strings.Contains(host, "me.com"):
return "iCloud Mail"
case strings.Contains(host, "yahoo"):
return "Yahoo"
case strings.Contains(host, "qq.com"):
return "QQ Mail"
case strings.Contains(host, "mail.ru"):
return "Mail.ru"
case strings.Contains(host, "yandex"):
return "Yandex"
case strings.Contains(host, "hover"):
return "Hover"
case strings.Contains(host, "godaddy") || strings.Contains(host, "domaincontrol"):
return "GoDaddy"
case strings.Contains(host, "pobox"):
return "Pobox"
case strings.Contains(host, "runbox"):
return "Runbox"
case strings.Contains(host, "posteo"):
return "Posteo"
case strings.Contains(host, "mailbox.org"):
return "Mailbox.org"
case strings.Contains(host, "forwardemail"):
return "Forward Email"
case strings.Contains(host, "improvmx"):
return "ImprovMX"
case strings.Contains(host, "cloudflare"):
return "Cloudflare Email Routing"
case strings.Contains(host, "amazonaws") || strings.Contains(host, "aws"):
return "Amazon SES"
case strings.Contains(host, "sendgrid") || strings.Contains(host, "twilio"):
return "SendGrid"
case strings.Contains(host, "mailgun"):
return "Mailgun"
case strings.Contains(host, "postmark"):
return "Postmark"
}
}
return ""
}
// DetectHostingProvider detects the hosting provider from HTTP headers
func DetectHostingProvider(headers http.Header) string {
server := strings.ToLower(headers.Get("Server"))
poweredBy := strings.ToLower(headers.Get("X-Powered-By"))
cfRay := headers.Get("CF-Ray")
vercelCache := headers.Get("X-Vercel-Cache")
vercelID := headers.Get("X-Vercel-Id")
netlifyID := headers.Get("X-NF-Request-Id")
githubRequest := headers.Get("X-GitHub-Request-Id")
switch {
case vercelCache != "" || vercelID != "":
return "Vercel"
case netlifyID != "":
return "Netlify"
case githubRequest != "":
return "GitHub Pages"
case cfRay != "":
return "Cloudflare"
case strings.Contains(server, "cloudflare"):
return "Cloudflare"
case strings.Contains(server, "nginx") && vercelCache != "":
return "Vercel"
case strings.Contains(server, "awselb") || strings.Contains(server, "elb"):
return "AWS"
case strings.Contains(server, "amazon"):
return "AWS"
case strings.Contains(server, "microsoft-iis"):
return "Microsoft Azure"
case strings.Contains(server, "google") || strings.Contains(server, "gws"):
return "Google Cloud"
case strings.Contains(server, "heroku"):
return "Heroku"
case strings.Contains(server, "digitalocean"):
return "DigitalOcean"
case strings.Contains(server, "linode"):
return "Linode"
case strings.Contains(server, "ovh"):
return "OVH"
case strings.Contains(server, "hetzner"):
return "Hetzner"
case strings.Contains(server, "fastly"):
return "Fastly"
case strings.Contains(server, "bunnycdn"):
return "BunnyCDN"
case strings.Contains(server, "keycdn"):
return "KeyCDN"
case strings.Contains(server, "stackpath"):
return "StackPath"
case strings.Contains(server, "sucuri"):
return "Sucuri"
case strings.Contains(poweredBy, "next.js") || strings.Contains(poweredBy, "nextjs"):
return "Vercel"
case strings.Contains(poweredBy, "php"):
return "PHP"
case strings.Contains(server, "apache"):
return "Apache"
case strings.Contains(server, "nginx"):
return "nginx"
case strings.Contains(server, "caddy"):
return "Caddy"
case strings.Contains(server, "lighttpd"):
return "Lighttpd"
case strings.Contains(server, "litespeed"):
return "LiteSpeed"
case strings.Contains(server, "openresty"):
return "OpenResty"
case strings.Contains(server, "jetty"):
return "Jetty"
case strings.Contains(server, "tomcat"):
return "Tomcat"
case strings.Contains(server, "iis"):
return "IIS"
}
return ""
}
// DetectCertificateAuthority detects the CA from an issuer string
func DetectCertificateAuthority(issuer string) string {
issuerLower := strings.ToLower(issuer)
switch {
case strings.Contains(issuerLower, "let's encrypt"):
return "Let's Encrypt"
case strings.Contains(issuerLower, "digicert"):
return "DigiCert"
case strings.Contains(issuerLower, "sectigo") || strings.Contains(issuerLower, "comodoca"):
return "Sectigo"
case strings.Contains(issuerLower, "globalsign"):
return "GlobalSign"
case strings.Contains(issuerLower, "geotrust"):
return "GeoTrust"
case strings.Contains(issuerLower, "thawte"):
return "Thawte"
case strings.Contains(issuerLower, "rapidssl"):
return "RapidSSL"
case strings.Contains(issuerLower, "symantec"):
return "Symantec"
case strings.Contains(issuerLower, "entrust"):
return "Entrust"
case strings.Contains(issuerLower, "certum"):
return "Certum"
case strings.Contains(issuerLower, "go daddy") || strings.Contains(issuerLower, "godaddy"):
return "GoDaddy"
case strings.Contains(issuerLower, "amazon"):
return "Amazon"
case strings.Contains(issuerLower, "google") && strings.Contains(issuerLower, "trust"):
return "Google Trust Services"
case strings.Contains(issuerLower, "cloudflare"):
return "Cloudflare"
case strings.Contains(issuerLower, "zero ssl") || strings.Contains(issuerLower, "zerossl"):
return "ZeroSSL"
case strings.Contains(issuerLower, "ssl.com"):
return "SSL.com"
case strings.Contains(issuerLower, "buypass"):
return "Buypass"
case strings.Contains(issuerLower, "harica"):
return "HARICA"
case strings.Contains(issuerLower, " Actalis "):
return "Actalis"
case strings.Contains(issuerLower, "swisssign"):
return "SwissSign"
case strings.Contains(issuerLower, "telekom"):
return "Telekom"
case strings.Contains(issuerLower, "trustwave"):
return "Trustwave"
case strings.Contains(issuerLower, "identrust"):
return "IdenTrust"
case strings.Contains(issuerLower, "usertrust"):
return "UserTrust"
case strings.Contains(issuerLower, "isrg") || strings.Contains(issuerLower, "internet security research"):
return "Let's Encrypt"
}
return issuer
}
+76 -2
View File
@@ -262,9 +262,28 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
if newData.AbuseEmail != "" { if newData.AbuseEmail != "" {
record.Set("abuse_email", newData.AbuseEmail) record.Set("abuse_email", newData.AbuseEmail)
} }
if newData.AbusePhone != "" { record.Set("abuse_phone", newData.AbusePhone)
record.Set("abuse_phone", newData.AbusePhone) record.Set("dns_provider", newData.DNSProvider)
record.Set("hosting_provider", newData.HostingProvider)
record.Set("email_provider", newData.EmailProvider)
record.Set("ca_provider", newData.CAProvider)
if len(newData.Headers) > 0 {
record.Set("headers", newData.Headers)
} }
if len(newData.Certificates) > 0 {
record.Set("certificates", newData.Certificates)
}
if newData.SEOMeta != nil {
record.Set("seo_meta", newData.SEOMeta)
}
record.Set("whois_raw", newData.WHOISRaw)
record.Set("privacy_enabled", newData.PrivacyEnabled)
record.Set("transfer_lock", newData.TransferLock)
record.Set("tld", newData.TLD)
if len(newData.DomainStatuses) > 0 {
record.Set("domain_statuses", newData.DomainStatuses)
}
record.Set("host_country_code", newData.HostCountryCode)
record.Set("last_checked", time.Now()) record.Set("last_checked", time.Now())
// Update status - fallback to existing record expiry if new lookup didn't return one // Update status - fallback to existing record expiry if new lookup didn't return one
@@ -422,6 +441,61 @@ func (s *Scheduler) trackChanges(oldRecord *core.Record, newData *domain.Domain,
}) })
} }
// Check provider changes
providers := []struct {
field string
value string
}{
{"dns_provider", newData.DNSProvider},
{"hosting_provider", newData.HostingProvider},
{"email_provider", newData.EmailProvider},
{"ca_provider", newData.CAProvider},
}
for _, p := range providers {
oldVal := oldRecord.GetString(p.field)
if p.value != "" && p.value != oldVal && oldVal != "" {
history = append(history, domain.DomainHistory{
ChangeType: domain.ChangeTypeProvider,
FieldName: p.field,
OldValue: oldVal,
NewValue: p.value,
CreatedAt: now,
})
}
}
// Check security changes
if newData.PrivacyEnabled != oldRecord.GetBool("privacy_enabled") {
history = append(history, domain.DomainHistory{
ChangeType: domain.ChangeTypeSecurity,
FieldName: "privacy_enabled",
OldValue: fmt.Sprintf("%t", oldRecord.GetBool("privacy_enabled")),
NewValue: fmt.Sprintf("%t", newData.PrivacyEnabled),
CreatedAt: now,
})
}
if newData.TransferLock != oldRecord.GetBool("transfer_lock") {
history = append(history, domain.DomainHistory{
ChangeType: domain.ChangeTypeSecurity,
FieldName: "transfer_lock",
OldValue: fmt.Sprintf("%t", oldRecord.GetBool("transfer_lock")),
NewValue: fmt.Sprintf("%t", newData.TransferLock),
CreatedAt: now,
})
}
// Check host country code change
oldCountryCode := oldRecord.GetString("host_country_code")
if newData.HostCountryCode != "" && newData.HostCountryCode != oldCountryCode && oldCountryCode != "" {
history = append(history, domain.DomainHistory{
ChangeType: domain.ChangeTypeHost,
FieldName: "host_country_code",
OldValue: oldCountryCode,
NewValue: newData.HostCountryCode,
CreatedAt: now,
})
}
return history return history
} }
+407 -35
View File
@@ -18,6 +18,7 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/domain" "github.com/henrygd/beszel/internal/entities/domain"
"github.com/henrygd/beszel/internal/hub/domains/detect"
) )
// LookupService handles WHOIS lookups with multiple fallback methods // LookupService handles WHOIS lookups with multiple fallback methods
@@ -34,14 +35,22 @@ func NewLookupService(apiKey string) *LookupService {
} }
} }
// LookupDomain performs a comprehensive domain lookup (WHOIS, DNS, SSL, Host) // LookupDomain performs a comprehensive domain lookup (WHOIS, DNS, SSL, Host, Headers, SEO)
func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*domain.Domain, error) { func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*domain.Domain, error) {
// Clean domain name // Clean domain name
domainName = cleanDomain(domainName) domainName = cleanDomain(domainName)
// Extract TLD
parts := strings.Split(domainName, ".")
tld := ""
if len(parts) >= 2 {
tld = strings.ToLower(parts[len(parts)-1])
}
// Initialize domain struct // Initialize domain struct
d := &domain.Domain{ d := &domain.Domain{
DomainName: domainName, DomainName: domainName,
TLD: tld,
Active: true, Active: true,
AlertDaysBefore: 30, // Default: alert 30 days before expiry AlertDaysBefore: 30, // Default: alert 30 days before expiry
Tags: []string{}, Tags: []string{},
@@ -50,25 +59,38 @@ func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*d
TXTRecords: []string{}, TXTRecords: []string{},
IPv4Addresses: []string{}, IPv4Addresses: []string{},
IPv6Addresses: []string{}, IPv6Addresses: []string{},
Headers: []domain.Header{},
Certificates: []domain.Certificate{},
DomainStatuses: []string{},
} }
// Perform WHOIS lookup // Perform WHOIS lookup
whoisData, err := s.LookupWHOIS(ctx, domainName) whoisData, rawWhois, err := s.LookupWHOIS(ctx, domainName)
if err == nil && whoisData != nil { if err == nil && whoisData != nil {
s.applyWHOISData(d, whoisData) s.applyWHOISData(d, whoisData)
d.WHOISRaw = rawWhois
} }
// Perform DNS lookups // Perform DNS lookups
s.lookupDNS(ctx, domainName, d) s.lookupDNS(ctx, domainName, d)
// Perform SSL lookup // Perform SSL lookup (certificate chain)
s.lookupSSL(ctx, domainName, d) s.lookupCertificateChain(ctx, domainName, d)
// Perform host lookup (using first IPv4) // Perform host lookup (using first IPv4)
if len(d.IPv4Addresses) > 0 { if len(d.IPv4Addresses) > 0 {
s.lookupHost(d.IPv4Addresses[0], d) s.lookupHost(d.IPv4Addresses[0], d)
} }
// Fetch HTTP headers for provider detection
s.lookupHeaders(ctx, domainName, d)
// Fetch SEO metadata
s.lookupSEO(ctx, domainName, d)
// Detect providers from gathered data
s.detectProviders(d)
// Fetch favicon // Fetch favicon
d.FaviconURL = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", domainName) d.FaviconURL = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", domainName)
@@ -77,31 +99,31 @@ func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*d
} }
// LookupWHOIS performs WHOIS lookup with multiple fallback methods // LookupWHOIS performs WHOIS lookup with multiple fallback methods
func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) { func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, string, error) {
var lastErr error var lastErr error
// Try RDAP first // Try RDAP first
data, err := s.tryRDAP(ctx, domainName) data, err := s.tryRDAP(ctx, domainName)
if err == nil && data != nil && hasValidData(data) { if err == nil && data != nil && hasValidData(data) {
return data, nil return data, "", nil
} }
if err != nil { if err != nil {
lastErr = err lastErr = err
} }
// Try TCP WHOIS (this should work for .eu domains) // Try TCP WHOIS (this should work for .eu domains)
data, err = s.tryTCPWHOIS(ctx, domainName) data, raw, err := s.tryTCPWHOIS(ctx, domainName)
if err == nil && data != nil && hasValidData(data) { if err == nil && data != nil && hasValidData(data) {
return data, nil return data, raw, nil
} }
if err != nil { if err != nil {
lastErr = err lastErr = err
} }
// Try native whois command (often works when TCP fails) // Try native whois command (often works when TCP fails)
data, err = s.tryNativeWHOIS(ctx, domainName) data, raw, err = s.tryNativeWHOIS(ctx, domainName)
if err == nil && data != nil && hasValidData(data) { if err == nil && data != nil && hasValidData(data) {
return data, nil return data, raw, nil
} }
if err != nil { if err != nil {
lastErr = err lastErr = err
@@ -112,7 +134,7 @@ func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*do
if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" { if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" {
data, err = s.tryEURidWebScraping(ctx, domainName) data, err = s.tryEURidWebScraping(ctx, domainName)
if err == nil && data != nil && hasValidData(data) { if err == nil && data != nil && hasValidData(data) {
return data, nil return data, "", nil
} }
if err != nil { if err != nil {
lastErr = err lastErr = err
@@ -121,7 +143,7 @@ func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*do
// Try alternative WHOIS services for .eu domains // Try alternative WHOIS services for .eu domains
data, err = s.tryAlternativeWHOIS(ctx, domainName) data, err = s.tryAlternativeWHOIS(ctx, domainName)
if err == nil && data != nil && hasValidData(data) { if err == nil && data != nil && hasValidData(data) {
return data, nil return data, "", nil
} }
if err != nil { if err != nil {
lastErr = err lastErr = err
@@ -132,11 +154,11 @@ func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*do
if s.whoisXMLAPIKey != "" { if s.whoisXMLAPIKey != "" {
data, err = s.tryWhoisXML(ctx, domainName) data, err = s.tryWhoisXML(ctx, domainName)
if err == nil && data != nil { if err == nil && data != nil {
return data, nil return data, "", nil
} }
} }
return nil, fmt.Errorf("all WHOIS lookup methods failed for %s: %w", domainName, lastErr) return nil, "", fmt.Errorf("all WHOIS lookup methods failed for %s: %w", domainName, lastErr)
} }
// tryRDAP attempts RDAP lookup // tryRDAP attempts RDAP lookup
@@ -268,11 +290,11 @@ func (s *LookupService) tryRDAP(ctx context.Context, domainName string) (*domain
} }
// tryNativeWHOIS tries the native whois command // tryNativeWHOIS tries the native whois command
func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) { func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, string, error) {
// Check if whois command exists // Check if whois command exists
_, err := exec.LookPath("whois") _, err := exec.LookPath("whois")
if err != nil { if err != nil {
return nil, fmt.Errorf("whois command not found") return nil, "", fmt.Errorf("whois command not found")
} }
// Use longer timeout for .eu domains // Use longer timeout for .eu domains
@@ -289,10 +311,12 @@ func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (
cmd := exec.CommandContext(cmdCtx, "whois", domainName) cmd := exec.CommandContext(cmdCtx, "whois", domainName)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return nil, err return nil, "", err
} }
return s.parseWHOISOutput(string(output), domainName) outStr := string(output)
data, err := s.parseWHOISOutput(outStr, domainName)
return data, outStr, err
} }
// whoisServers maps common TLDs to their WHOIS servers // whoisServers maps common TLDs to their WHOIS servers
@@ -328,10 +352,10 @@ var whoisServers = map[string]string{
} }
// tryTCPWHOIS performs WHOIS lookup via direct TCP connection (port 43) // tryTCPWHOIS performs WHOIS lookup via direct TCP connection (port 43)
func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) { func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, string, error) {
parts := strings.Split(domainName, ".") parts := strings.Split(domainName, ".")
if len(parts) < 2 { if len(parts) < 2 {
return nil, fmt.Errorf("invalid domain format") return nil, "", fmt.Errorf("invalid domain format")
} }
tld := strings.ToLower(parts[len(parts)-1]) tld := strings.ToLower(parts[len(parts)-1])
@@ -352,19 +376,19 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do
dialer := &net.Dialer{Timeout: timeout} dialer := &net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, "tcp", addr) conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil { if err != nil {
return nil, fmt.Errorf("tcp whois dial failed: %w", err) return nil, "", fmt.Errorf("tcp whois dial failed: %w", err)
} }
defer conn.Close() defer conn.Close()
// Some servers require the domain followed by \r\n // Some servers require the domain followed by \r\n
query := domainName + "\r\n" query := domainName + "\r\n"
if _, err := conn.Write([]byte(query)); err != nil { if _, err := conn.Write([]byte(query)); err != nil {
return nil, fmt.Errorf("tcp whois write failed: %w", err) return nil, "", fmt.Errorf("tcp whois write failed: %w", err)
} }
// Read response with deadline // Read response with deadline
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return nil, err return nil, "", err
} }
var output strings.Builder var output strings.Builder
@@ -379,7 +403,8 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do
} }
} }
return s.parseWHOISOutput(output.String(), domainName) data, err := s.parseWHOISOutput(output.String(), domainName)
return data, output.String(), err
} }
// tryWhoisXML tries the WhoisXML API // tryWhoisXML tries the WhoisXML API
@@ -1251,7 +1276,7 @@ func splitHex(value string) []string {
// lookupHost fetches host/geolocation info // lookupHost fetches host/geolocation info
func (s *LookupService) lookupHost(ip string, d *domain.Domain) { func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
// Use ip-api.com (free, no auth required for non-commercial use) // Use ip-api.com (free, no auth required for non-commercial use)
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,regionName,city,lat,lon,isp,org,as", ip) url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,countryCode,regionName,city,lat,lon,isp,org,as", ip)
client := &http.Client{Timeout: 5 * time.Second} client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url) resp, err := client.Get(url)
@@ -1261,16 +1286,17 @@ func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
defer resp.Body.Close() defer resp.Body.Close()
var result struct { var result struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
Country string `json:"country"` Country string `json:"country"`
Region string `json:"regionName"` CountryCode string `json:"countryCode"`
City string `json:"city"` Region string `json:"regionName"`
Lat float64 `json:"lat"` City string `json:"city"`
Lon float64 `json:"lon"` Lat float64 `json:"lat"`
ISP string `json:"isp"` Lon float64 `json:"lon"`
Org string `json:"org"` ISP string `json:"isp"`
AS string `json:"as"` Org string `json:"org"`
AS string `json:"as"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -1279,6 +1305,7 @@ func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
if result.Status == "success" { if result.Status == "success" {
d.HostCountry = result.Country d.HostCountry = result.Country
d.HostCountryCode = result.CountryCode
d.HostRegion = result.Region d.HostRegion = result.Region
d.HostCity = result.City d.HostCity = result.City
d.HostLat = result.Lat d.HostLat = result.Lat
@@ -1301,6 +1328,27 @@ func (s *LookupService) applyWHOISData(d *domain.Domain, whois *domain.WHOISData
d.RegistrarID = whois.Registrar.ID d.RegistrarID = whois.Registrar.ID
d.RegistrarURL = whois.Registrar.URL d.RegistrarURL = whois.Registrar.URL
d.RegistryDomainID = whois.Registrar.RegistryDomainID d.RegistryDomainID = whois.Registrar.RegistryDomainID
d.DomainStatuses = whois.Status
// Detect privacy protection from registrant name
registrantLower := strings.ToLower(whois.Registrant.Name + " " + whois.Registrant.Organization)
d.PrivacyEnabled = strings.Contains(registrantLower, "redacted") ||
strings.Contains(registrantLower, "privacy") ||
strings.Contains(registrantLower, "whoisguard") ||
strings.Contains(registrantLower, "not disclosed") ||
strings.Contains(registrantLower, "hidden") ||
strings.Contains(registrantLower, "data protected") ||
strings.Contains(registrantLower, "gdpr") ||
strings.Contains(registrantLower, "data redacted")
// Detect transfer lock from statuses
for _, status := range whois.Status {
statusLower := strings.ToLower(status)
if strings.Contains(statusLower, "clienttransferprohibited") || strings.Contains(statusLower, "servertransferprohibited") {
d.TransferLock = true
break
}
}
// Apply registrant contact info if available // Apply registrant contact info if available
if whois.Registrant.Name != "" || whois.Registrant.Organization != "" { if whois.Registrant.Name != "" || whois.Registrant.Organization != "" {
@@ -1337,6 +1385,330 @@ func cleanDomain(domain string) string {
return strings.ToLower(strings.TrimSpace(domain)) return strings.ToLower(strings.TrimSpace(domain))
} }
// lookupCertificateChain fetches the full TLS certificate chain
func (s *LookupService) lookupCertificateChain(ctx context.Context, domainName string, d *domain.Domain) {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", domainName+":443", &tls.Config{
ServerName: domainName,
InsecureSkipVerify: true,
})
if err != nil {
return
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
for i, cert := range certs {
issuer := ""
if len(cert.Issuer.Organization) > 0 {
issuer = cert.Issuer.Organization[0]
} else if cert.Issuer.CommonName != "" {
issuer = cert.Issuer.CommonName
}
altNames := make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses)+len(cert.EmailAddresses))
altNames = append(altNames, cert.DNSNames...)
for _, ip := range cert.IPAddresses {
altNames = append(altNames, ip.String())
}
for _, email := range cert.EmailAddresses {
altNames = append(altNames, email)
}
// For leaf cert, also set legacy SSL fields
if i == 0 {
if len(cert.Issuer.Organization) > 0 {
d.SSLIssuer = cert.Issuer.Organization[0]
}
if len(cert.Issuer.Country) > 0 {
d.SSLIssuerCountry = cert.Issuer.Country[0]
}
d.SSLValidFrom = &cert.NotBefore
d.SSLValidTo = &cert.NotAfter
d.SSLSubject = cert.Subject.CommonName
fingerprint := sha256.Sum256(cert.Raw)
d.SSLFingerprint = strings.ToUpper(strings.Join(splitHex(hex.EncodeToString(fingerprint[:])), ":"))
d.SSLSignatureAlgo = cert.SignatureAlgorithm.String()
switch key := cert.PublicKey.(type) {
case *rsa.PublicKey:
d.SSLKeySize = key.N.BitLen()
case *ecdsa.PublicKey:
d.SSLKeySize = key.Curve.Params().BitSize
default:
d.SSLKeySize = 0
}
}
caProvider := detect.DetectCertificateAuthority(issuer)
d.Certificates = append(d.Certificates, domain.Certificate{
Issuer: issuer,
Subject: cert.Subject.CommonName,
AltNames: altNames,
ValidFrom: cert.NotBefore,
ValidTo: cert.NotAfter,
CAProvider: caProvider,
})
}
// Set top-level CA provider from the chain
if len(d.Certificates) > 0 {
d.CAProvider = d.Certificates[len(d.Certificates)-1].CAProvider
}
}
// lookupHeaders fetches HTTP response headers
func (s *LookupService) lookupHeaders(ctx context.Context, domainName string, d *domain.Domain) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
url := "https://" + domainName
req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil)
if err != nil {
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Beszel/1.0; +https://github.com/henrygd/beszel)")
resp, err := client.Do(req)
if err != nil {
// Try HTTP fallback
req, err = http.NewRequestWithContext(ctx, "HEAD", "http://"+domainName, nil)
if err != nil {
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Beszel/1.0; +https://github.com/henrygd/beszel)")
resp, err = client.Do(req)
if err != nil {
return
}
}
defer resp.Body.Close()
for name, values := range resp.Header {
for _, value := range values {
d.Headers = append(d.Headers, domain.Header{
Name: strings.ToLower(name),
Value: value,
})
}
}
}
// lookupSEO fetches and parses SEO metadata
func (s *LookupService) lookupSEO(ctx context.Context, domainName string, d *domain.Domain) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
// Fetch HTML
url := "https://" + domainName
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
// Limit reading to avoid large responses
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if err != nil {
return
}
html := string(body)
seo := &domain.SEOMeta{
OpenGraph: domain.OpenGraphMeta{},
Twitter: domain.TwitterMeta{},
General: domain.GeneralMeta{},
Robots: domain.RobotsTxt{Fetched: false, Groups: []domain.RobotsGroup{}, Sitemaps: []string{}},
}
// Parse general meta tags
seo.General.Title = extractMetaTag(html, "title")
seo.General.Description = extractMetaTag(html, "description")
seo.General.Author = extractMetaTag(html, "author")
seo.General.Robots = extractMetaTag(html, "robots")
seo.General.Keywords = extractMetaTag(html, "keywords")
seo.General.Canonical = extractLinkRel(html, "canonical")
// Parse Open Graph
seo.OpenGraph.URL = extractMetaProperty(html, "og:url")
seo.OpenGraph.Type = extractMetaProperty(html, "og:type")
seo.OpenGraph.Title = extractMetaProperty(html, "og:title")
seo.OpenGraph.Description = extractMetaProperty(html, "og:description")
seo.OpenGraph.Images = extractMetaProperties(html, "og:image")
// Parse Twitter
seo.Twitter.Title = extractMetaProperty(html, "twitter:title")
seo.Twitter.Description = extractMetaProperty(html, "twitter:description")
seo.Twitter.Image = extractMetaProperty(html, "twitter:image")
seo.Twitter.Card = extractMetaProperty(html, "twitter:card")
// Fetch robots.txt
robotsURL := url + "/robots.txt"
robotsReq, err := http.NewRequestWithContext(ctx, "GET", robotsURL, nil)
if err == nil {
robotsResp, err := client.Do(robotsReq)
if err == nil && robotsResp.StatusCode >= 200 && robotsResp.StatusCode < 300 {
robotsBody, err := io.ReadAll(io.LimitReader(robotsResp.Body, 256*1024))
robotsResp.Body.Close()
if err == nil {
seo.Robots = parseRobotsTxt(string(robotsBody))
}
} else if robotsResp != nil {
robotsResp.Body.Close()
}
}
d.SEOMeta = seo
}
// extractMetaTag extracts a meta tag by name attribute
func extractMetaTag(html, name string) string {
// Match <meta name="xxx" content="yyy"> or <meta name='xxx' content='yyy'>
re := regexp.MustCompile(`(?i)<meta\s+name=["']` + regexp.QuoteMeta(name) + `["']\s+content=["']([^"']*)["']`)
match := re.FindStringSubmatch(html)
if len(match) > 1 {
return match[1]
}
// Try reverse order
re = regexp.MustCompile(`(?i)<meta\s+content=["']([^"']*)["']\s+name=["']` + regexp.QuoteMeta(name) + `["']`)
match = re.FindStringSubmatch(html)
if len(match) > 1 {
return match[1]
}
return ""
}
// extractMetaProperty extracts a meta tag by property attribute
func extractMetaProperty(html, prop string) string {
re := regexp.MustCompile(`(?i)<meta\s+property=["']` + regexp.QuoteMeta(prop) + `["']\s+content=["']([^"']*)["']`)
match := re.FindStringSubmatch(html)
if len(match) > 1 {
return match[1]
}
// Try reverse order
re = regexp.MustCompile(`(?i)<meta\s+content=["']([^"']*)["']\s+property=["']` + regexp.QuoteMeta(prop) + `["']`)
match = re.FindStringSubmatch(html)
if len(match) > 1 {
return match[1]
}
return ""
}
// extractMetaProperties extracts all meta tags matching a property prefix
func extractMetaProperties(html, prop string) []string {
re := regexp.MustCompile(`(?i)<meta\s+property=["']` + regexp.QuoteMeta(prop) + `["']\s+content=["']([^"']*)["']`)
matches := re.FindAllStringSubmatch(html, -1)
var results []string
for _, match := range matches {
if len(match) > 1 {
results = append(results, match[1])
}
}
return results
}
// extractLinkRel extracts a link rel href value
func extractLinkRel(html, rel string) string {
re := regexp.MustCompile(`(?i)<link\s+rel=["']` + regexp.QuoteMeta(rel) + `["']\s+href=["']([^"']*)["']`)
match := re.FindStringSubmatch(html)
if len(match) > 1 {
return match[1]
}
return ""
}
// parseRobotsTxt parses robots.txt content
func parseRobotsTxt(content string) domain.RobotsTxt {
result := domain.RobotsTxt{
Fetched: true,
Groups: []domain.RobotsGroup{},
Sitemaps: []string{},
}
lines := strings.Split(content, "\n")
var currentGroup *domain.RobotsGroup
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) < 2 {
continue
}
key := strings.TrimSpace(strings.ToLower(parts[0]))
value := strings.TrimSpace(parts[1])
switch key {
case "user-agent":
if currentGroup != nil {
result.Groups = append(result.Groups, *currentGroup)
}
currentGroup = &domain.RobotsGroup{
UserAgents: []string{value},
Rules: []domain.RobotsRule{},
}
case "allow", "disallow":
if currentGroup == nil {
currentGroup = &domain.RobotsGroup{
UserAgents: []string{"*"},
Rules: []domain.RobotsRule{},
}
}
currentGroup.Rules = append(currentGroup.Rules, domain.RobotsRule{
Type: key,
Value: value,
})
case "sitemap":
result.Sitemaps = append(result.Sitemaps, value)
}
}
if currentGroup != nil {
result.Groups = append(result.Groups, *currentGroup)
}
return result
}
// detectProviders detects DNS, hosting, email, and CA providers
func (s *LookupService) detectProviders(d *domain.Domain) {
d.DNSProvider = detect.DetectDNSProvider(d.NameServers)
d.EmailProvider = detect.DetectEmailProvider(d.MXRecords)
if len(d.Headers) > 0 {
headerMap := make(http.Header)
for _, h := range d.Headers {
headerMap.Add(h.Name, h.Value)
}
d.HostingProvider = detect.DetectHostingProvider(headerMap)
}
}
// hasValidData checks if WHOIS data has useful parsed fields // hasValidData checks if WHOIS data has useful parsed fields
func hasValidData(data *domain.WHOISData) bool { func hasValidData(data *domain.WHOISData) bool {
if data == nil { if data == nil {
+60
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/monitor" "github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/hub/pagespeed"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -50,6 +51,7 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
api.POST("/{id}/resume", h.resumeMonitor) api.POST("/{id}/resume", h.resumeMonitor)
api.GET("/{id}/stats", h.getStats) api.GET("/{id}/stats", h.getStats)
api.GET("/{id}/heartbeats", h.getHeartbeats) api.GET("/{id}/heartbeats", h.getHeartbeats)
api.POST("/{id}/pagespeed", h.runPageSpeedCheck)
} }
// HeartbeatSummary represents a minimal heartbeat for the monitor list // HeartbeatSummary represents a minimal heartbeat for the monitor list
@@ -609,6 +611,64 @@ func (h *APIHandler) getStats(e *core.RequestEvent) error {
}) })
} }
// runPageSpeedCheck runs a PageSpeed Insights check for a monitor
func (h *APIHandler) runPageSpeedCheck(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
if id == "" {
return e.BadRequestError("Monitor ID is required", nil)
}
record, err := h.app.FindRecordById("monitors", id)
if err != nil {
return e.NotFoundError("Monitor not found", err)
}
if record.GetString("user") != e.Auth.Id {
return e.ForbiddenError("Access denied", nil)
}
url := record.GetString("url")
if url == "" {
return e.BadRequestError("Monitor does not have a URL", nil)
}
// Get strategy from query param, default to mobile
strategy := e.Request.URL.Query().Get("strategy")
if strategy == "" {
strategy = "mobile"
}
if strategy != "mobile" && strategy != "desktop" {
return e.BadRequestError("strategy must be 'mobile' or 'desktop'", nil)
}
checker := pagespeed.NewChecker("")
metrics, err := checker.CheckURL(url, strategy)
if err != nil {
return e.InternalServerError("PageSpeed check failed", err)
}
vitals := pagespeed.GetCoreWebVitalsStatus(metrics)
return e.JSON(http.StatusOK, map[string]interface{}{
"performance": metrics.Performance,
"accessibility": metrics.Accessibility,
"bestPractices": metrics.BestPractices,
"seo": metrics.SEO,
"pwa": metrics.PWA,
"fcp": metrics.FCP,
"lcp": metrics.LCP,
"ttfb": metrics.TTFB,
"cls": metrics.CLS,
"tbt": metrics.TBT,
"speedIndex": metrics.SpeedIndex,
"tti": metrics.TTI,
"strategy": metrics.Strategy,
"checkedAt": metrics.CheckedAt,
"url": metrics.URL,
"vitals": vitals,
})
}
// getHeartbeats returns recent heartbeats for a monitor // getHeartbeats returns recent heartbeats for a monitor
func (h *APIHandler) getHeartbeats(e *core.RequestEvent) error { func (h *APIHandler) getHeartbeats(e *core.RequestEvent) error {
id := e.Request.PathValue("id") id := e.Request.PathValue("id")
+29 -18
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"time" "time"
) )
@@ -65,21 +66,21 @@ type PageSpeedResponse struct {
// Metrics represents the extracted performance metrics // Metrics represents the extracted performance metrics
type Metrics struct { type Metrics struct {
Performance float64 `json:"performance"` Performance float64 `json:"performance"`
Accessibility float64 `json:"accessibility"` Accessibility float64 `json:"accessibility"`
BestPractices float64 `json:"bestPractices"` BestPractices float64 `json:"bestPractices"`
SEO float64 `json:"seo"` SEO float64 `json:"seo"`
PWA float64 `json:"pwa"` PWA float64 `json:"pwa"`
FCP float64 `json:"fcp"` // First Contentful Paint (ms) FCP float64 `json:"fcp"` // First Contentful Paint (ms)
LCP float64 `json:"lcp"` // Largest Contentful Paint (ms) LCP float64 `json:"lcp"` // Largest Contentful Paint (ms)
TTFB float64 `json:"ttfb"` // Time to First Byte (ms) TTFB float64 `json:"ttfb"` // Time to First Byte (ms)
CLS float64 `json:"cls"` // Cumulative Layout Shift CLS float64 `json:"cls"` // Cumulative Layout Shift
TBT float64 `json:"tbt"` // Total Blocking Time (ms) TBT float64 `json:"tbt"` // Total Blocking Time (ms)
SpeedIndex float64 `json:"speedIndex"` // Speed Index (ms) SpeedIndex float64 `json:"speedIndex"` // Speed Index (ms)
TTI float64 `json:"tti"` // Time to Interactive (ms) TTI float64 `json:"tti"` // Time to Interactive (ms)
CheckedAt time.Time `json:"checkedAt"` CheckedAt time.Time `json:"checkedAt"`
URL string `json:"url"` URL string `json:"url"`
Strategy string `json:"strategy"` // mobile or desktop Strategy string `json:"strategy"` // mobile or desktop
} }
// Checker handles PageSpeed checks // Checker handles PageSpeed checks
@@ -99,7 +100,7 @@ func NewChecker(apiKey string) *Checker {
} }
// CheckURL runs a PageSpeed check on a URL // CheckURL runs a PageSpeed check on a URL
func (c *Checker) CheckURL(url string, strategy string) (*Metrics, error) { func (c *Checker) CheckURL(pageURL string, strategy string) (*Metrics, error) {
if strategy == "" { if strategy == "" {
strategy = "mobile" strategy = "mobile"
} }
@@ -107,7 +108,7 @@ func (c *Checker) CheckURL(url string, strategy string) (*Metrics, error) {
// Build PageSpeed API URL // Build PageSpeed API URL
apiURL := fmt.Sprintf( apiURL := fmt.Sprintf(
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=%s&strategy=%s&category=PERFORMANCE&category=ACCESSIBILITY&category=BEST_PRACTICES&category=SEO&category=PWA", "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=%s&strategy=%s&category=PERFORMANCE&category=ACCESSIBILITY&category=BEST_PRACTICES&category=SEO&category=PWA",
url, url.QueryEscape(pageURL),
strategy, strategy,
) )
@@ -132,7 +133,7 @@ func (c *Checker) CheckURL(url string, strategy string) (*Metrics, error) {
} }
metrics := &Metrics{ metrics := &Metrics{
URL: url, URL: pageURL,
Strategy: strategy, Strategy: strategy,
CheckedAt: time.Now(), CheckedAt: time.Now(),
Performance: result.LighthouseResult.Categories.Performance.Score * 100, Performance: result.LighthouseResult.Categories.Performance.Score * 100,
@@ -192,6 +193,7 @@ func GetCoreWebVitalsStatus(metrics *Metrics) map[string]string {
"cls": getCLSStatus(metrics.CLS), "cls": getCLSStatus(metrics.CLS),
"fcp": getFCPStatus(metrics.FCP), "fcp": getFCPStatus(metrics.FCP),
"ttfb": getTTFBStatus(metrics.TTFB), "ttfb": getTTFBStatus(metrics.TTFB),
"tti": getTTIStatus(metrics.TTI),
} }
} }
@@ -240,6 +242,15 @@ func getTTFBStatus(value float64) string {
return "poor" return "poor"
} }
func getTTIStatus(value float64) string {
if value <= 3800 {
return "good"
} else if value <= 7300 {
return "needs-improvement"
}
return "poor"
}
// FormatDuration formats milliseconds to readable string // FormatDuration formats milliseconds to readable string
func FormatDuration(ms float64) string { func FormatDuration(ms float64) string {
if ms < 1000 { if ms < 1000 {
@@ -0,0 +1,65 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
if err := enhanceDomainCollection(app); err != nil {
return err
}
return nil
}, func(app core.App) error {
return nil
})
}
func enhanceDomainCollection(app core.App) error {
collection, err := app.FindCollectionByNameOrId("domains")
if err != nil {
return err
}
// Provider detection fields
addTextField3(collection, "dns_provider")
addTextField3(collection, "hosting_provider")
addTextField3(collection, "email_provider")
addTextField3(collection, "ca_provider")
// JSON fields for complex data
addJSONField3(collection, "headers")
addJSONField3(collection, "certificates")
addJSONField3(collection, "seo_meta")
addJSONField3(collection, "domain_statuses")
// WHOIS and registration fields
addTextField3(collection, "whois_raw")
addBoolField3(collection, "privacy_enabled")
addBoolField3(collection, "transfer_lock")
addTextField3(collection, "tld")
// Enhanced geo
addTextField3(collection, "host_country_code")
return app.Save(collection)
}
func addTextField3(collection *core.Collection, name string) {
if collection.Fields.GetByName(name) == nil {
collection.Fields.Add(&core.TextField{Name: name})
}
}
func addBoolField3(collection *core.Collection, name string) {
if collection.Fields.GetByName(name) == nil {
collection.Fields.Add(&core.BoolField{Name: name})
}
}
func addJSONField3(collection *core.Collection, name string) {
if collection.Fields.GetByName(name) == nil {
collection.Fields.Add(&core.JSONField{Name: name})
}
}
@@ -440,6 +440,18 @@ export function DomainDialog({ open, onOpenChange, domain, isEdit = false }: Dom
{lookupData.host_country && ( {lookupData.host_country && (
<p className="text-sm">Location: {lookupData.host_country}</p> <p className="text-sm">Location: {lookupData.host_country}</p>
)} )}
{lookupData.dns_provider && (
<p className="text-sm">DNS: {lookupData.dns_provider}</p>
)}
{lookupData.hosting_provider && (
<p className="text-sm">Hosting: {lookupData.hosting_provider}</p>
)}
{lookupData.email_provider && (
<p className="text-sm">Email: {lookupData.email_provider}</p>
)}
{lookupData.ca_provider && (
<p className="text-sm">CA: {lookupData.ca_provider}</p>
)}
</div> </div>
{/* Manual expiry fallback when WHOIS doesn't return expiry */} {/* Manual expiry fallback when WHOIS doesn't return expiry */}
@@ -49,7 +49,6 @@ import {
getDomainSubdomains, getDomainSubdomains,
formatDate, formatDate,
type Domain, type Domain,
type Subdomain,
} from "@/lib/domains" } from "@/lib/domains"
import { import {
MoreHorizontal, MoreHorizontal,
@@ -76,6 +75,7 @@ type DisplayOptions = {
showRegistrar: boolean showRegistrar: boolean
showExpiryDate: boolean showExpiryDate: boolean
showTags: boolean showTags: boolean
showProviders: boolean
} }
// Days left badge component - big and visible // Days left badge component - big and visible
@@ -155,7 +155,7 @@ export default function DomainsTable() {
const [displayOptions, setDisplayOptions] = useBrowserStorage<DisplayOptions>( const [displayOptions, setDisplayOptions] = useBrowserStorage<DisplayOptions>(
"domainsDisplayOptions", "domainsDisplayOptions",
{ showSSL: true, showRegistrar: true, showExpiryDate: true, showTags: true } { showSSL: true, showRegistrar: true, showExpiryDate: true, showTags: true, showProviders: false }
) )
const { data: domains = [], isLoading } = useQuery({ const { data: domains = [], isLoading } = useQuery({
@@ -463,6 +463,12 @@ function StatusIndicator({ status }: { status: string }) {
> >
Tags Tags
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={displayOptions.showProviders}
onCheckedChange={(checked: boolean) => setDisplayOptions({ ...displayOptions, showProviders: checked })}
>
Providers
</DropdownMenuCheckboxItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -496,6 +502,7 @@ function StatusIndicator({ status }: { status: string }) {
{displayOptions.showRegistrar && <TableHead>Registrar</TableHead>} {displayOptions.showRegistrar && <TableHead>Registrar</TableHead>}
{displayOptions.showSSL && <TableHead>SSL Expiry</TableHead>} {displayOptions.showSSL && <TableHead>SSL Expiry</TableHead>}
{displayOptions.showTags && <TableHead>Tags</TableHead>} {displayOptions.showTags && <TableHead>Tags</TableHead>}
{displayOptions.showProviders && <TableHead>Providers</TableHead>}
<TableHead className="w-[100px]">Actions</TableHead> <TableHead className="w-[100px]">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -554,6 +561,16 @@ function StatusIndicator({ status }: { status: string }) {
</div> </div>
</TableCell> </TableCell>
)} )}
{displayOptions.showProviders && (
<TableCell>
<div className="flex flex-col gap-0.5 text-xs">
{domain.dns_provider && <span className="text-muted-foreground">DNS: <span className="text-foreground">{domain.dns_provider}</span></span>}
{domain.hosting_provider && <span className="text-muted-foreground">Host: <span className="text-foreground">{domain.hosting_provider}</span></span>}
{domain.email_provider && <span className="text-muted-foreground">Email: <span className="text-foreground">{domain.email_provider}</span></span>}
{domain.ca_provider && <span className="text-muted-foreground">CA: <span className="text-foreground">{domain.ca_provider}</span></span>}
</div>
</TableCell>
)}
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -659,6 +676,14 @@ function StatusIndicator({ status }: { status: string }) {
<span className="text-xs text-muted-foreground truncate max-w-[120px]">{domain.registrar_name || "Unknown"}</span> <span className="text-xs text-muted-foreground truncate max-w-[120px]">{domain.registrar_name || "Unknown"}</span>
)} )}
</div> </div>
{displayOptions.showProviders && (
<div className="flex flex-wrap gap-1 text-[10px]">
{domain.dns_provider && <span className="text-muted-foreground">DNS: <span className="text-foreground">{domain.dns_provider}</span></span>}
{domain.hosting_provider && <span className="text-muted-foreground">Host: <span className="text-foreground">{domain.hosting_provider}</span></span>}
{domain.email_provider && <span className="text-muted-foreground">Email: <span className="text-foreground">{domain.email_provider}</span></span>}
{domain.ca_provider && <span className="text-muted-foreground">CA: <span className="text-foreground">{domain.ca_provider}</span></span>}
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<DaysLeftBadge days={domain.days_until_expiry} /> <DaysLeftBadge days={domain.days_until_expiry} />
{displayOptions.showSSL && domain.ssl_valid_to && ( {displayOptions.showSSL && domain.ssl_valid_to && (
+275 -5
View File
@@ -1,4 +1,4 @@
import { memo, useState } from "react" import { useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
@@ -33,6 +33,14 @@ import {
User, User,
Mail, Mail,
Building, Building,
Key,
Eye,
EyeOff,
Network,
Code2,
Search,
Lock,
Unlock,
type LucideIcon, type LucideIcon,
} from "lucide-react" } from "lucide-react"
import { import {
@@ -108,7 +116,6 @@ export default function DomainDetail({ id }: { id: string }) {
const { toast } = useToast() const { toast } = useToast()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [expiryDialogOpen, setExpiryDialogOpen] = useState(false) const [expiryDialogOpen, setExpiryDialogOpen] = useState(false)
const [manualExpiryDate, setManualExpiryDate] = useState("") const [manualExpiryDate, setManualExpiryDate] = useState("")
@@ -266,6 +273,35 @@ export default function DomainDetail({ id }: { id: string }) {
icon={MapPin} icon={MapPin}
/> />
</div> </div>
{/* Provider badges row */}
{(domain.dns_provider || domain.hosting_provider || domain.email_provider || domain.ca_provider) && (
<div className="flex flex-wrap gap-2 mt-2">
{domain.dns_provider && (
<Badge variant="secondary" className="text-xs gap-1">
<Network className="h-3 w-3" />
DNS: {domain.dns_provider}
</Badge>
)}
{domain.hosting_provider && (
<Badge variant="secondary" className="text-xs gap-1">
<Server className="h-3 w-3" />
Hosting: {domain.hosting_provider}
</Badge>
)}
{domain.email_provider && (
<Badge variant="secondary" className="text-xs gap-1">
<Mail className="h-3 w-3" />
Email: {domain.email_provider}
</Badge>
)}
{domain.ca_provider && (
<Badge variant="secondary" className="text-xs gap-1">
<Shield className="h-3 w-3" />
CA: {domain.ca_provider}
</Badge>
)}
</div>
)}
</div> </div>
{/* Expiry Overview - Clean visual cards */} {/* Expiry Overview - Clean visual cards */}
@@ -679,6 +715,163 @@ export default function DomainDetail({ id }: { id: string }) {
</Card> </Card>
</div> </div>
{/* HTTP Headers */}
{domain.headers && domain.headers.length > 0 && (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code2 className="h-5 w-5" />
HTTP Headers
</CardTitle>
<CardDescription>Response headers from the server</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1 max-h-80 overflow-y-auto">
{domain.headers.map((h, i) => (
<div key={i} className="flex items-start gap-2 text-sm py-1 border-b last:border-0">
<code className="text-xs text-muted-foreground shrink-0 w-32 truncate">{h.name}</code>
<code className="text-xs break-all">{h.value}</code>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)}
{/* SEO Metadata */}
{domain.seo_meta && (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
SEO Metadata
</CardTitle>
<CardDescription>Search engine optimization data</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* General Meta */}
{domain.seo_meta.general && (
<div className="space-y-2">
<h4 className="text-sm font-medium">General Meta Tags</h4>
<div className="space-y-1 text-sm">
{domain.seo_meta.general.title && (
<p><span className="text-muted-foreground">Title:</span> {domain.seo_meta.general.title}</p>
)}
{domain.seo_meta.general.description && (
<p><span className="text-muted-foreground">Description:</span> {domain.seo_meta.general.description}</p>
)}
{domain.seo_meta.general.canonical && (
<p><span className="text-muted-foreground">Canonical:</span> <a href={domain.seo_meta.general.canonical} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{domain.seo_meta.general.canonical}</a></p>
)}
{domain.seo_meta.general.robots && (
<p><span className="text-muted-foreground">Robots:</span> {domain.seo_meta.general.robots}</p>
)}
{domain.seo_meta.general.author && (
<p><span className="text-muted-foreground">Author:</span> {domain.seo_meta.general.author}</p>
)}
{domain.seo_meta.general.keywords && (
<p><span className="text-muted-foreground">Keywords:</span> {domain.seo_meta.general.keywords}</p>
)}
</div>
</div>
)}
{/* Open Graph */}
{domain.seo_meta.openGraph && (domain.seo_meta.openGraph.title || domain.seo_meta.openGraph.description) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Globe className="h-4 w-4" />
Open Graph
</h4>
<div className="space-y-1 text-sm">
{domain.seo_meta.openGraph.title && (
<p><span className="text-muted-foreground">Title:</span> {domain.seo_meta.openGraph.title}</p>
)}
{domain.seo_meta.openGraph.description && (
<p><span className="text-muted-foreground">Description:</span> {domain.seo_meta.openGraph.description}</p>
)}
{domain.seo_meta.openGraph.type && (
<p><span className="text-muted-foreground">Type:</span> {domain.seo_meta.openGraph.type}</p>
)}
{domain.seo_meta.openGraph.url && (
<p><span className="text-muted-foreground">URL:</span> <a href={domain.seo_meta.openGraph.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{domain.seo_meta.openGraph.url}</a></p>
)}
{domain.seo_meta.openGraph.images && domain.seo_meta.openGraph.images.length > 0 && (
<div>
<p className="text-muted-foreground">Images:</p>
<div className="flex flex-wrap gap-2 mt-1">
{domain.seo_meta.openGraph.images.map((img, i) => (
<a key={i} href={img} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline truncate max-w-[300px]">{img}</a>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Twitter Cards */}
{domain.seo_meta.twitter && (domain.seo_meta.twitter.title || domain.seo_meta.twitter.description) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Mail className="h-4 w-4" />
Twitter/X Cards
</h4>
<div className="space-y-1 text-sm">
{domain.seo_meta.twitter.title && (
<p><span className="text-muted-foreground">Title:</span> {domain.seo_meta.twitter.title}</p>
)}
{domain.seo_meta.twitter.description && (
<p><span className="text-muted-foreground">Description:</span> {domain.seo_meta.twitter.description}</p>
)}
{domain.seo_meta.twitter.card && (
<p><span className="text-muted-foreground">Card:</span> {domain.seo_meta.twitter.card}</p>
)}
</div>
</div>
)}
{/* Robots.txt */}
{domain.seo_meta.robots && domain.seo_meta.robots.fetched && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
robots.txt
</h4>
{domain.seo_meta.robots.sitemaps && domain.seo_meta.robots.sitemaps.length > 0 && (
<div className="mb-2">
<p className="text-xs text-muted-foreground">Sitemaps:</p>
<div className="flex flex-wrap gap-1 mt-1">
{domain.seo_meta.robots.sitemaps.map((sitemap, i) => (
<a key={i} href={sitemap} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">{sitemap}</a>
))}
</div>
</div>
)}
{domain.seo_meta.robots.groups && domain.seo_meta.robots.groups.length > 0 && (
<div className="space-y-2">
{domain.seo_meta.robots.groups.map((group, i) => (
<div key={i} className="rounded bg-muted p-2 text-xs">
<p className="text-muted-foreground">User-agent: {group.userAgents.join(", ")}</p>
{group.rules.map((rule, j) => (
<p key={j} className={rule.type === "Disallow" ? "text-red-500" : "text-green-500"}>
{rule.type}: {rule.value}
</p>
))}
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
)}
<div className="grid gap-4"> <div className="grid gap-4">
<Card> <Card>
<CardHeader> <CardHeader>
@@ -743,6 +936,47 @@ export default function DomainDetail({ id }: { id: string }) {
</div> </div>
)} )}
</div> </div>
{/* Certificate Chain */}
{domain.certificates && domain.certificates.length > 0 && (
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-3">Certificate Chain ({domain.certificates.length})</h4>
<div className="space-y-3">
{domain.certificates.map((cert, i) => (
<div key={i} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center gap-2">
<Badge variant={i === 0 ? "default" : "secondary"} className="text-[10px]">
{i === 0 ? "Leaf" : i === domain.certificates!.length - 1 ? "Root" : "Intermediate"}
</Badge>
{cert.ca_provider && (
<Badge variant="outline" className="text-[10px]">{cert.ca_provider}</Badge>
)}
</div>
<div className="text-sm">
<p><span className="text-muted-foreground">Subject:</span> {cert.subject}</p>
<p><span className="text-muted-foreground">Issuer:</span> {cert.issuer}</p>
</div>
{cert.alt_names && cert.alt_names.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Subject Alternative Names ({cert.alt_names.length})</p>
<div className="flex flex-wrap gap-1">
{cert.alt_names.slice(0, 8).map((name, j) => (
<code key={j} className="text-[10px] bg-muted px-1.5 py-0.5 rounded">{name}</code>
))}
{cert.alt_names.length > 8 && (
<span className="text-[10px] text-muted-foreground">+{cert.alt_names.length - 8} more</span>
)}
</div>
</div>
)}
<div className="text-xs text-muted-foreground">
Valid: {formatDate(cert.valid_from)} {formatDate(cert.valid_to)}
</div>
</div>
))}
</div>
</div>
)}
</> </>
) : ( ) : (
<div className="text-center py-8"> <div className="text-center py-8">
@@ -787,13 +1021,13 @@ export default function DomainDetail({ id }: { id: string }) {
</div> </div>
</div> </div>
{/* Important Dates */} {/* Important Dates & TLD */}
<div className="space-y-2 pt-4 border-t"> <div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2"> <h4 className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
Important Dates Important Dates
</h4> </h4>
<div className="grid sm:grid-cols-3 gap-4"> <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div> <div>
<p className="text-sm text-muted-foreground">Registration</p> <p className="text-sm text-muted-foreground">Registration</p>
<p className="font-medium">{formatDate(domain.creation_date)}</p> <p className="font-medium">{formatDate(domain.creation_date)}</p>
@@ -806,9 +1040,45 @@ export default function DomainDetail({ id }: { id: string }) {
<p className="text-sm text-muted-foreground">Expires</p> <p className="text-sm text-muted-foreground">Expires</p>
<p className="font-medium">{formatDate(domain.expiry_date)}</p> <p className="font-medium">{formatDate(domain.expiry_date)}</p>
</div> </div>
{domain.tld && (
<div>
<p className="text-sm text-muted-foreground">TLD</p>
<p className="font-medium">.{domain.tld}</p>
</div>
)}
</div> </div>
</div> </div>
{/* Privacy & Security */}
{(domain.privacy_enabled !== undefined || domain.transfer_lock !== undefined || domain.host_country_code) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Key className="h-4 w-4" />
Privacy & Security
</h4>
<div className="flex flex-wrap gap-2">
{domain.privacy_enabled !== undefined && (
<Badge variant={domain.privacy_enabled ? "default" : "secondary"} className="gap-1">
{domain.privacy_enabled ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{domain.privacy_enabled ? "Privacy Protected" : "Privacy Visible"}
</Badge>
)}
{domain.transfer_lock !== undefined && (
<Badge variant={domain.transfer_lock ? "default" : "secondary"} className="gap-1">
{domain.transfer_lock ? <Lock className="h-3 w-3" /> : <Unlock className="h-3 w-3" />}
{domain.transfer_lock ? "Transfer Locked" : "Transfer Unlocked"}
</Badge>
)}
{domain.host_country_code && (
<Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" />
{domain.host_country_code}
</Badge>
)}
</div>
</div>
)}
{/* Registrant Contact */} {/* Registrant Contact */}
{(domain.registrant_name || domain.registrant_org) && ( {(domain.registrant_name || domain.registrant_org) && (
<div className="space-y-2 pt-4 border-t"> <div className="space-y-2 pt-4 border-t">
@@ -970,7 +1240,7 @@ export default function DomainDetail({ id }: { id: string }) {
</Card> </Card>
<DomainDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} domain={domain} isEdit /> <DomainDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} domain={domain} isEdit />
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Domain</AlertDialogTitle> <AlertDialogTitle>Delete Domain</AlertDialogTitle>
+205 -82
View File
@@ -17,7 +17,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { import {
Globe, Globe,
Clock, Clock,
@@ -33,6 +32,9 @@ import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
Plus, Plus,
Zap,
Gauge,
Smartphone,
type LucideIcon, type LucideIcon,
} from "lucide-react" } from "lucide-react"
import { import {
@@ -49,6 +51,7 @@ import {
getMonitorFaviconUrl, getMonitorFaviconUrl,
formatUptime, formatUptime,
formatPing, formatPing,
runPageSpeedCheck,
} from "@/lib/monitors" } from "@/lib/monitors"
import { formatDate } from "@/lib/domains" import { formatDate } from "@/lib/domains"
import { import {
@@ -75,7 +78,7 @@ import { Link, navigate } from "@/components/router"
import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog" import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type HeartbeatRow = Heartbeat & { timestamp?: string } type HeartbeatRow = Heartbeat
// Uptime Bar Component - Visual timeline of recent checks // Uptime Bar Component - Visual timeline of recent checks
function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] }) { function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
@@ -106,7 +109,7 @@ function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] })
hb.status === "down" ? "bg-red-500" : hb.status === "down" ? "bg-red-500" :
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500" hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
)} )}
title={`${hb.status} • ${formatPing(hb.ping)} • ${formatDate(hb.time || hb.timestamp || "")}`} title={`${hb.status} • ${formatPing(hb.ping)} • ${formatDate(hb.time || "")}`}
/> />
))} ))}
</div> </div>
@@ -172,40 +175,142 @@ function ResponseTimeStats({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
) )
} }
// Core Web Vitals placeholder component function getVitalColor(status: string): string {
function CoreWebVitalsCard({ url }: { url?: string }) { switch (status) {
case "good": return "text-green-500"
case "needs-improvement": return "text-yellow-500"
default: return "text-red-500"
}
}
function getVitalBg(status: string): string {
switch (status) {
case "good": return "bg-green-500/10 border-green-500/20"
case "needs-improvement": return "bg-yellow-500/10 border-yellow-500/20"
default: return "bg-red-500/10 border-red-500/20"
}
}
function ScoreRing({ score, label }: { score: number; label: string }) {
const color = score >= 90 ? "text-green-500" : score >= 70 ? "text-yellow-500" : "text-red-500"
const bg = score >= 90 ? "stroke-green-500" : score >= 70 ? "stroke-yellow-500" : "stroke-red-500"
const circumference = 2 * Math.PI * 18
const offset = circumference - (score / 100) * circumference
return (
<div className="flex flex-col items-center gap-1">
<div className="relative w-12 h-12">
<svg className="w-12 h-12 -rotate-90" viewBox="0 0 44 44">
<circle cx="22" cy="22" r="18" fill="none" stroke="currentColor" strokeWidth="4" className="text-muted/30" />
<circle cx="22" cy="22" r="18" fill="none" strokeWidth="4" strokeLinecap="round"
className={bg} strokeDasharray={circumference} strokeDashoffset={offset} />
</svg>
<span className={cn("absolute inset-0 flex items-center justify-center text-xs font-bold", color)}>
{Math.round(score)}
</span>
</div>
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</span>
</div>
)
}
function VitalCard({ label, value, status, detail }: { label: string; value: string; status: string; detail: string }) {
return (
<div className={cn("p-3 rounded-lg border", getVitalBg(status))}>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<div className={cn("w-2 h-2 rounded-full", status === "good" ? "bg-green-500" : status === "needs-improvement" ? "bg-yellow-500" : "bg-red-500")} />
</div>
<div className={cn("text-lg font-bold", getVitalColor(status))}>{value}</div>
<div className="text-[10px] text-muted-foreground">{detail}</div>
</div>
)
}
function formatMs(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function CoreWebVitalsCard({ monitorId, url }: { monitorId: string; url?: string }) {
if (!url) return null if (!url) return null
const [strategy, setStrategy] = useState<"mobile" | "desktop">("mobile")
const { toast } = useToast()
const { data, isPending: isPageSpeedLoading, mutate } = useMutation({
mutationFn: () => runPageSpeedCheck(monitorId, strategy),
onSuccess: () => {
toast({ title: "Lighthouse check complete" })
},
onError: (err: Error) => {
toast({ title: "Check failed", description: err.message, variant: "destructive" })
},
})
return ( return (
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<CardTitle>Core Web Vitals</CardTitle> <div>
<CardDescription>Lighthouse performance metrics (coming soon)</CardDescription> <CardTitle className="flex items-center gap-2">
</CardHeader> <Zap className="h-5 w-5 text-yellow-500" />
Core Web Vitals
</CardTitle>
<CardDescription>
{data ? `Checked ${new Date(data.checkedAt).toLocaleString()}` : "Run a Lighthouse check to get performance metrics"}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border overflow-hidden">
<button
onClick={() => setStrategy("mobile")}
className={cn("px-3 py-1.5 text-xs font-medium transition-colors",
strategy === "mobile" ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80")}
>
<Smartphone className="h-3 w-3 inline mr-1" />Mobile
</button>
<button
onClick={() => setStrategy("desktop")}
className={cn("px-3 py-1.5 text-xs font-medium transition-colors",
strategy === "desktop" ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80")}
>
<Gauge className="h-3 w-3 inline mr-1" />Desktop
</button>
</div>
<Button size="sm" onClick={() => mutate()} disabled={isPageSpeedLoading}>
<RefreshCw className={cn("mr-2 h-4 w-4", isPageSpeedLoading && "animate-spin")} />
{isPageSpeedLoading ? "Running..." : "Run Check"}
</Button>
</div>
</CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-3 gap-4"> {data ? (
<div className="text-center p-4 bg-muted/30 rounded-lg"> <div className="space-y-4">
<div className="text-sm text-muted-foreground mb-1">LCP</div> {/* Lighthouse Scores */}
<div className="text-2xl font-bold text-yellow-500">-</div> <div className="flex items-center gap-4 justify-center sm:justify-start">
<div className="text-xs text-muted-foreground mt-1">Largest Contentful Paint</div> <ScoreRing score={data.performance} label="Perf" />
<ScoreRing score={data.accessibility} label="A11y" />
<ScoreRing score={data.bestPractices} label="BP" />
<ScoreRing score={data.seo} label="SEO" />
</div>
{/* Core Web Vitals */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<VitalCard label="LCP" value={formatMs(data.lcp)} status={data.vitals.lcp || "poor"} detail="Largest Contentful Paint" />
<VitalCard label="FID" value={formatMs(data.tbt)} status={data.vitals.fid || "poor"} detail="Total Blocking Time (proxy)" />
<VitalCard label="CLS" value={data.cls.toFixed(3)} status={data.vitals.cls || "poor"} detail="Cumulative Layout Shift" />
<VitalCard label="FCP" value={formatMs(data.fcp)} status={data.vitals.fcp || "poor"} detail="First Contentful Paint" />
<VitalCard label="TTFB" value={formatMs(data.ttfb)} status={data.vitals.ttfb || "poor"} detail="Time to First Byte" />
<VitalCard label="TTI" value={formatMs(data.tti)} status={data.vitals.tti || "poor"} detail="Time to Interactive" />
</div>
</div> </div>
<div className="text-center p-4 bg-muted/30 rounded-lg"> ) : (
<div className="text-sm text-muted-foreground mb-1">FID</div> <div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<div className="text-2xl font-bold text-green-500">-</div> <div className="p-3 bg-muted/50 rounded-full">
<div className="text-xs text-muted-foreground mt-1">First Input Delay</div> <Gauge className="h-6 w-6 opacity-50" />
</div>
<p className="text-sm">No Lighthouse data yet. Click "Run Check" to analyze performance.</p>
<p className="text-xs text-muted-foreground">Powered by Google PageSpeed Insights</p>
</div> </div>
<div className="text-center p-4 bg-muted/30 rounded-lg"> )}
<div className="text-sm text-muted-foreground mb-1">CLS</div>
<div className="text-2xl font-bold text-green-500">-</div>
<div className="text-xs text-muted-foreground mt-1">Cumulative Layout Shift</div>
</div>
</div>
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div className="flex items-center gap-2 text-sm text-blue-600">
<Activity className="h-4 w-4" />
<span>Core Web Vitals monitoring requires additional configuration</span>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -400,7 +505,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
} }
const cutoff = now - (ranges[timeRange] || ranges["24h"]) const cutoff = now - (ranges[timeRange] || ranges["24h"])
return heartbeats.filter((h: HeartbeatRow) => { return heartbeats.filter((h: HeartbeatRow) => {
const t = new Date(h.time || h.timestamp || "").getTime() const t = new Date(h.time || "").getTime()
return t >= cutoff return t >= cutoff
}) })
}, [heartbeats, timeRange]) }, [heartbeats, timeRange])
@@ -412,7 +517,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
.slice() .slice()
.reverse() .reverse()
.map((h: HeartbeatRow) => ({ .map((h: HeartbeatRow) => ({
time: new Date(h.time || h.timestamp || "").toLocaleTimeString(), time: new Date(h.time || "").toLocaleTimeString(),
responseTime: h.ping || 0, responseTime: h.ping || 0,
status: h.status === "up" ? 1 : 0, status: h.status === "up" ? 1 : 0,
})) }))
@@ -590,7 +695,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</div> </div>
{/* Core Web Vitals */} {/* Core Web Vitals */}
<CoreWebVitalsCard url={monitor.url} /> <CoreWebVitalsCard monitorId={id} url={monitor.url} />
{/* Combined Uptime & Response Chart */} {/* Combined Uptime & Response Chart */}
<Card> <Card>
@@ -813,59 +918,77 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Recent Checks</CardTitle> <CardTitle>Check History</CardTitle>
<CardDescription>Last 50 monitor checks</CardDescription> <CardDescription>Timeline of the last 50 monitor checks</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> {heartbeats?.length ? (
<TableHeader> <div className="space-y-1">
<TableRow> {heartbeats.slice(0, 50).map((hb: Heartbeat, i: number) => {
<TableHead>Time</TableHead> const date = new Date(hb.time || "")
<TableHead>Status</TableHead> const showDate = i === 0 || (
<TableHead>Response Time</TableHead> new Date(heartbeats[i - 1].time || "").toDateString() !== date.toDateString()
<TableHead>Message</TableHead> )
</TableRow> return (
</TableHeader> <div key={hb.id}>
<TableBody> {showDate && (
{heartbeats?.slice(0, 50).map((hb: HeartbeatRow) => ( <div className="text-xs text-muted-foreground font-medium py-1.5 border-b border-border/50 mt-2 first:mt-0">
<TableRow key={hb.id}> {date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
<TableCell>{formatDate(hb.time || hb.timestamp)}</TableCell> </div>
<TableCell>
<Badge variant={hb.status === "up" ? "default" : "destructive"}>{hb.status}</Badge>
</TableCell>
<TableCell>{formatPing(hb.ping)}</TableCell>
<TableCell className="max-w-xs truncate">{hb.msg || "-"}</TableCell>
</TableRow>
))}
{!heartbeats?.length && (
<TableRow>
<TableCell colSpan={4}>
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<div className="p-2 bg-muted/50 rounded-full">
<Clock className="h-5 w-5 opacity-50" />
</div>
<p className="text-sm">
{isPending
? "No checks have been run yet."
: "No check history available for the selected period."}
</p>
{isPending && (
<Button
variant="outline"
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
Run First Check
</Button>
)} )}
<div className="flex items-center gap-3 py-1.5 px-2 rounded-md hover:bg-muted/50 transition-colors">
<div className={cn(
"w-2 h-2 rounded-full flex-shrink-0",
hb.status === "up" ? "bg-green-500" :
hb.status === "down" ? "bg-red-500" :
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"text-xs font-medium",
hb.status === "up" ? "text-green-600" :
hb.status === "down" ? "text-red-600" : "text-muted-foreground"
)}>
{hb.status}
</span>
<span className="text-xs text-muted-foreground">
{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
{hb.msg && hb.msg !== "-" && (
<p className="text-[11px] text-muted-foreground truncate">{hb.msg}</p>
)}
</div>
<div className="text-xs font-mono text-muted-foreground flex-shrink-0">
{formatPing(hb.ping)}
</div>
</div>
</div> </div>
</TableCell> )
</TableRow> })}
)} </div>
</TableBody> ) : (
</Table> <div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<div className="p-2 bg-muted/50 rounded-full">
<Clock className="h-5 w-5 opacity-50" />
</div>
<p className="text-sm">
{isPending ? "No checks have been run yet." : "No check history available."}
</p>
{isPending && (
<Button
variant="outline"
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
Run First Check
</Button>
)}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
+120
View File
@@ -121,6 +121,66 @@ export interface Domain {
dns_spf_records?: string[] dns_spf_records?: string[]
dns_dkim_records?: string[] dns_dkim_records?: string[]
dns_dmarc_records?: string[] dns_dmarc_records?: string[]
// Provider Detection
dns_provider?: string
hosting_provider?: string
email_provider?: string
ca_provider?: string
// HTTP Headers
headers?: { name: string; value: string }[]
// Certificate Chain
certificates?: {
issuer: string
subject: string
alt_names: string[]
valid_from: string
valid_to: string
ca_provider: string
}[]
// SEO Metadata
seo_meta?: {
openGraph: {
url: string
type: string
title: string
images: string[]
description: string
}
twitter: {
title: string
description: string
image: string
card: string
}
general: {
title: string
author: string
robots: string
keywords: string
canonical: string
description: string
}
robots: {
fetched: boolean
groups: {
userAgents: string[]
rules: { type: string; value: string }[]
}[]
sitemaps: string[]
}
}
// Raw WHOIS & Registration Details
whois_raw?: string
privacy_enabled?: boolean
transfer_lock?: boolean
tld?: string
domain_statuses?: string[]
host_country_code?: string
} }
export interface DomainHistory { export interface DomainHistory {
@@ -204,6 +264,66 @@ export interface DomainLookupResult {
host_isp?: string host_isp?: string
favicon_url?: string favicon_url?: string
last_checked?: string last_checked?: string
// Provider Detection
dns_provider?: string
hosting_provider?: string
email_provider?: string
ca_provider?: string
// HTTP Headers
headers?: { name: string; value: string }[]
// Certificate Chain
certificates?: {
issuer: string
subject: string
alt_names: string[]
valid_from: string
valid_to: string
ca_provider: string
}[]
// SEO Metadata
seo_meta?: {
openGraph: {
url: string
type: string
title: string
images: string[]
description: string
}
twitter: {
title: string
description: string
image: string
card: string
}
general: {
title: string
author: string
robots: string
keywords: string
canonical: string
description: string
}
robots: {
fetched: boolean
groups: {
userAgents: string[]
rules: { type: string; value: string }[]
}[]
sitemaps: string[]
}
}
// Raw WHOIS & Registration Details
whois_raw?: string
privacy_enabled?: boolean
transfer_lock?: boolean
tld?: string
domain_statuses?: string[]
host_country_code?: string
} }
const API_BASE = "/api/beszel/domains" const API_BASE = "/api/beszel/domains"
+25
View File
@@ -197,6 +197,25 @@ export interface CheckResult {
time?: string time?: string
} }
export interface PageSpeedMetrics {
performance: number
accessibility: number
bestPractices: number
seo: number
pwa: number
fcp: number
lcp: number
ttfb: number
cls: number
tbt: number
speedIndex: number
tti: number
strategy: string
checkedAt: string
url: string
vitals: Record<string, string>
}
// API Functions // API Functions
export async function listMonitors(): Promise<Monitor[]> { export async function listMonitors(): Promise<Monitor[]> {
const response = await pb.send<{ monitors: Monitor[] }>("/api/beszel/monitors", {}) const response = await pb.send<{ monitors: Monitor[] }>("/api/beszel/monitors", {})
@@ -261,6 +280,12 @@ export function getMonitorHeartbeats(id: string): Promise<{ heartbeats: Heartbea
return pb.send(`/api/beszel/monitors/${id}/heartbeats`, {}) return pb.send(`/api/beszel/monitors/${id}/heartbeats`, {})
} }
export function runPageSpeedCheck(id: string, strategy: string = "mobile"): Promise<PageSpeedMetrics> {
return pb.send(`/api/beszel/monitors/${id}/pagespeed?strategy=${strategy}`, {
method: "POST",
})
}
// Helper functions // Helper functions
export function getMonitorTypeLabel(type: MonitorType): string { export function getMonitorTypeLabel(type: MonitorType): string {
const labels: Record<MonitorType, string> = { const labels: Record<MonitorType, string> = {
+1 -1
View File
@@ -32,7 +32,7 @@ func main() {
start := time.Now() start := time.Now()
// Test WHOIS lookup // Test WHOIS lookup
whoisData, err := lookupService.LookupWHOIS(ctx, domainName) whoisData, _, err := lookupService.LookupWHOIS(ctx, domainName)
duration := time.Since(start) duration := time.Since(start)
fmt.Printf("Lookup duration: %v\n", duration) fmt.Printf("Lookup duration: %v\n", duration)