mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-07-28 23:03:48 +00:00
feat(hub,site): enhance domain intelligence and monitor performance
Build Docker images / Hub (push) Failing after 1m35s
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:
@@ -71,6 +71,33 @@ type Domain struct {
|
||||
AbuseEmail string `json:"abuse_email" db:"abuse_email"`
|
||||
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
|
||||
Tags []string `json:"tags" db:"tags"`
|
||||
Notes string `json:"notes" db:"notes"`
|
||||
@@ -176,6 +203,76 @@ type IPInfo struct {
|
||||
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
|
||||
const (
|
||||
ChangeTypeExpiry = "expiry"
|
||||
@@ -185,6 +282,9 @@ const (
|
||||
ChangeTypeIP = "ip"
|
||||
ChangeTypeHost = "host"
|
||||
ChangeTypeStatus = "status"
|
||||
ChangeTypeProvider = "provider"
|
||||
ChangeTypeSecurity = "security"
|
||||
ChangeTypeSEO = "seo"
|
||||
)
|
||||
|
||||
// Domain status constants
|
||||
|
||||
@@ -635,6 +635,19 @@ func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{
|
||||
"registrant_state": record.GetString("registrant_state"),
|
||||
"abuse_email": record.GetString("abuse_email"),
|
||||
"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"),
|
||||
"notes": record.GetString("notes"),
|
||||
"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("abuse_email", domainData.AbuseEmail)
|
||||
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("last_checked", time.Now())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -262,9 +262,28 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
|
||||
if newData.AbuseEmail != "" {
|
||||
record.Set("abuse_email", newData.AbuseEmail)
|
||||
}
|
||||
if 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())
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/domain"
|
||||
"github.com/henrygd/beszel/internal/hub/domains/detect"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// Clean domain name
|
||||
domainName = cleanDomain(domainName)
|
||||
|
||||
// Extract TLD
|
||||
parts := strings.Split(domainName, ".")
|
||||
tld := ""
|
||||
if len(parts) >= 2 {
|
||||
tld = strings.ToLower(parts[len(parts)-1])
|
||||
}
|
||||
|
||||
// Initialize domain struct
|
||||
d := &domain.Domain{
|
||||
DomainName: domainName,
|
||||
TLD: tld,
|
||||
Active: true,
|
||||
AlertDaysBefore: 30, // Default: alert 30 days before expiry
|
||||
Tags: []string{},
|
||||
@@ -50,25 +59,38 @@ func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*d
|
||||
TXTRecords: []string{},
|
||||
IPv4Addresses: []string{},
|
||||
IPv6Addresses: []string{},
|
||||
Headers: []domain.Header{},
|
||||
Certificates: []domain.Certificate{},
|
||||
DomainStatuses: []string{},
|
||||
}
|
||||
|
||||
// Perform WHOIS lookup
|
||||
whoisData, err := s.LookupWHOIS(ctx, domainName)
|
||||
whoisData, rawWhois, err := s.LookupWHOIS(ctx, domainName)
|
||||
if err == nil && whoisData != nil {
|
||||
s.applyWHOISData(d, whoisData)
|
||||
d.WHOISRaw = rawWhois
|
||||
}
|
||||
|
||||
// Perform DNS lookups
|
||||
s.lookupDNS(ctx, domainName, d)
|
||||
|
||||
// Perform SSL lookup
|
||||
s.lookupSSL(ctx, domainName, d)
|
||||
// Perform SSL lookup (certificate chain)
|
||||
s.lookupCertificateChain(ctx, domainName, d)
|
||||
|
||||
// Perform host lookup (using first IPv4)
|
||||
if len(d.IPv4Addresses) > 0 {
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
// Try RDAP first
|
||||
data, err := s.tryRDAP(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
return data, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return data, nil
|
||||
return data, raw, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return data, nil
|
||||
return data, raw, nil
|
||||
}
|
||||
if err != nil {
|
||||
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" {
|
||||
data, err = s.tryEURidWebScraping(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
return data, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
@@ -121,7 +143,7 @@ func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*do
|
||||
// Try alternative WHOIS services for .eu domains
|
||||
data, err = s.tryAlternativeWHOIS(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
return data, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
@@ -132,11 +154,11 @@ func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*do
|
||||
if s.whoisXMLAPIKey != "" {
|
||||
data, err = s.tryWhoisXML(ctx, domainName)
|
||||
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
|
||||
@@ -268,11 +290,11 @@ func (s *LookupService) tryRDAP(ctx context.Context, domainName string) (*domain
|
||||
}
|
||||
|
||||
// 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
|
||||
_, err := exec.LookPath("whois")
|
||||
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
|
||||
@@ -289,10 +311,12 @@ func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (
|
||||
cmd := exec.CommandContext(cmdCtx, "whois", domainName)
|
||||
output, err := cmd.Output()
|
||||
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
|
||||
@@ -328,10 +352,10 @@ var whoisServers = map[string]string{
|
||||
}
|
||||
|
||||
// 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, ".")
|
||||
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])
|
||||
|
||||
@@ -352,19 +376,19 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
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()
|
||||
|
||||
// Some servers require the domain followed by \r\n
|
||||
query := domainName + "\r\n"
|
||||
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
|
||||
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1251,7 +1276,7 @@ func splitHex(value string) []string {
|
||||
// lookupHost fetches host/geolocation info
|
||||
func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
|
||||
// Use ip-api.com (free, no auth required for non-commercial use)
|
||||
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}
|
||||
resp, err := client.Get(url)
|
||||
@@ -1264,6 +1289,7 @@ func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
Region string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
Lat float64 `json:"lat"`
|
||||
@@ -1279,6 +1305,7 @@ func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
|
||||
|
||||
if result.Status == "success" {
|
||||
d.HostCountry = result.Country
|
||||
d.HostCountryCode = result.CountryCode
|
||||
d.HostRegion = result.Region
|
||||
d.HostCity = result.City
|
||||
d.HostLat = result.Lat
|
||||
@@ -1301,6 +1328,27 @@ func (s *LookupService) applyWHOISData(d *domain.Domain, whois *domain.WHOISData
|
||||
d.RegistrarID = whois.Registrar.ID
|
||||
d.RegistrarURL = whois.Registrar.URL
|
||||
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
|
||||
if whois.Registrant.Name != "" || whois.Registrant.Organization != "" {
|
||||
@@ -1337,6 +1385,330 @@ func cleanDomain(domain string) string {
|
||||
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
|
||||
func hasValidData(data *domain.WHOISData) bool {
|
||||
if data == nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
"github.com/henrygd/beszel/internal/hub/pagespeed"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
@@ -50,6 +51,7 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
|
||||
api.POST("/{id}/resume", h.resumeMonitor)
|
||||
api.GET("/{id}/stats", h.getStats)
|
||||
api.GET("/{id}/heartbeats", h.getHeartbeats)
|
||||
api.POST("/{id}/pagespeed", h.runPageSpeedCheck)
|
||||
}
|
||||
|
||||
// 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
|
||||
func (h *APIHandler) getHeartbeats(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -99,7 +100,7 @@ func NewChecker(apiKey string) *Checker {
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
strategy = "mobile"
|
||||
}
|
||||
@@ -107,7 +108,7 @@ func (c *Checker) CheckURL(url string, strategy string) (*Metrics, error) {
|
||||
// Build PageSpeed API URL
|
||||
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",
|
||||
url,
|
||||
url.QueryEscape(pageURL),
|
||||
strategy,
|
||||
)
|
||||
|
||||
@@ -132,7 +133,7 @@ func (c *Checker) CheckURL(url string, strategy string) (*Metrics, error) {
|
||||
}
|
||||
|
||||
metrics := &Metrics{
|
||||
URL: url,
|
||||
URL: pageURL,
|
||||
Strategy: strategy,
|
||||
CheckedAt: time.Now(),
|
||||
Performance: result.LighthouseResult.Categories.Performance.Score * 100,
|
||||
@@ -192,6 +193,7 @@ func GetCoreWebVitalsStatus(metrics *Metrics) map[string]string {
|
||||
"cls": getCLSStatus(metrics.CLS),
|
||||
"fcp": getFCPStatus(metrics.FCP),
|
||||
"ttfb": getTTFBStatus(metrics.TTFB),
|
||||
"tti": getTTIStatus(metrics.TTI),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +242,15 @@ func getTTFBStatus(value float64) string {
|
||||
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
|
||||
func FormatDuration(ms float64) string {
|
||||
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 && (
|
||||
<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>
|
||||
|
||||
{/* Manual expiry fallback when WHOIS doesn't return expiry */}
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
getDomainSubdomains,
|
||||
formatDate,
|
||||
type Domain,
|
||||
type Subdomain,
|
||||
} from "@/lib/domains"
|
||||
import {
|
||||
MoreHorizontal,
|
||||
@@ -76,6 +75,7 @@ type DisplayOptions = {
|
||||
showRegistrar: boolean
|
||||
showExpiryDate: boolean
|
||||
showTags: boolean
|
||||
showProviders: boolean
|
||||
}
|
||||
|
||||
// Days left badge component - big and visible
|
||||
@@ -155,7 +155,7 @@ export default function DomainsTable() {
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useBrowserStorage<DisplayOptions>(
|
||||
"domainsDisplayOptions",
|
||||
{ showSSL: true, showRegistrar: true, showExpiryDate: true, showTags: true }
|
||||
{ showSSL: true, showRegistrar: true, showExpiryDate: true, showTags: true, showProviders: false }
|
||||
)
|
||||
|
||||
const { data: domains = [], isLoading } = useQuery({
|
||||
@@ -463,6 +463,12 @@ function StatusIndicator({ status }: { status: string }) {
|
||||
>
|
||||
Tags
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={displayOptions.showProviders}
|
||||
onCheckedChange={(checked: boolean) => setDisplayOptions({ ...displayOptions, showProviders: checked })}
|
||||
>
|
||||
Providers
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -496,6 +502,7 @@ function StatusIndicator({ status }: { status: string }) {
|
||||
{displayOptions.showRegistrar && <TableHead>Registrar</TableHead>}
|
||||
{displayOptions.showSSL && <TableHead>SSL Expiry</TableHead>}
|
||||
{displayOptions.showTags && <TableHead>Tags</TableHead>}
|
||||
{displayOptions.showProviders && <TableHead>Providers</TableHead>}
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -554,6 +561,16 @@ function StatusIndicator({ status }: { status: string }) {
|
||||
</div>
|
||||
</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>
|
||||
<DropdownMenu>
|
||||
<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>
|
||||
)}
|
||||
</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">
|
||||
<DaysLeftBadge days={domain.days_until_expiry} />
|
||||
{displayOptions.showSSL && domain.ssl_valid_to && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
@@ -33,6 +33,14 @@ import {
|
||||
User,
|
||||
Mail,
|
||||
Building,
|
||||
Key,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Network,
|
||||
Code2,
|
||||
Search,
|
||||
Lock,
|
||||
Unlock,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
@@ -108,7 +116,6 @@ export default function DomainDetail({ id }: { id: string }) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [expiryDialogOpen, setExpiryDialogOpen] = useState(false)
|
||||
const [manualExpiryDate, setManualExpiryDate] = useState("")
|
||||
@@ -266,6 +273,35 @@ export default function DomainDetail({ id }: { id: string }) {
|
||||
icon={MapPin}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Expiry Overview - Clean visual cards */}
|
||||
@@ -679,6 +715,163 @@ export default function DomainDetail({ id }: { id: string }) {
|
||||
</Card>
|
||||
</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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -743,6 +936,47 @@ export default function DomainDetail({ id }: { id: string }) {
|
||||
</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">
|
||||
@@ -787,13 +1021,13 @@ export default function DomainDetail({ id }: { id: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Important Dates */}
|
||||
{/* Important Dates & TLD */}
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Important Dates
|
||||
</h4>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Registration</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="font-medium">{formatDate(domain.expiry_date)}</p>
|
||||
</div>
|
||||
{domain.tld && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">TLD</p>
|
||||
<p className="font-medium">.{domain.tld}</p>
|
||||
</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 */}
|
||||
{(domain.registrant_name || domain.registrant_org) && (
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
@@ -970,7 +1240,7 @@ export default function DomainDetail({ id }: { id: string }) {
|
||||
</Card>
|
||||
<DomainDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} domain={domain} isEdit />
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Domain</AlertDialogTitle>
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Globe,
|
||||
Clock,
|
||||
@@ -33,6 +32,9 @@ import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Plus,
|
||||
Zap,
|
||||
Gauge,
|
||||
Smartphone,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
getMonitorFaviconUrl,
|
||||
formatUptime,
|
||||
formatPing,
|
||||
runPageSpeedCheck,
|
||||
} from "@/lib/monitors"
|
||||
import { formatDate } from "@/lib/domains"
|
||||
import {
|
||||
@@ -75,7 +78,7 @@ import { Link, navigate } from "@/components/router"
|
||||
import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type HeartbeatRow = Heartbeat & { timestamp?: string }
|
||||
type HeartbeatRow = Heartbeat
|
||||
|
||||
// Uptime Bar Component - Visual timeline of recent checks
|
||||
function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
|
||||
@@ -106,7 +109,7 @@ function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] })
|
||||
hb.status === "down" ? "bg-red-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>
|
||||
@@ -172,40 +175,142 @@ function ResponseTimeStats({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Core Web Vitals placeholder component
|
||||
function CoreWebVitalsCard({ url }: { url?: string }) {
|
||||
function getVitalColor(status: string): 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
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Core Web Vitals</CardTitle>
|
||||
<CardDescription>Lighthouse performance metrics (coming soon)</CardDescription>
|
||||
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<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>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-muted/30 rounded-lg">
|
||||
<div className="text-sm text-muted-foreground mb-1">LCP</div>
|
||||
<div className="text-2xl font-bold text-yellow-500">-</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Largest Contentful Paint</div>
|
||||
{data ? (
|
||||
<div className="space-y-4">
|
||||
{/* Lighthouse Scores */}
|
||||
<div className="flex items-center gap-4 justify-center sm:justify-start">
|
||||
<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>
|
||||
<div className="text-center p-4 bg-muted/30 rounded-lg">
|
||||
<div className="text-sm text-muted-foreground mb-1">FID</div>
|
||||
<div className="text-2xl font-bold text-green-500">-</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">First Input Delay</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>
|
||||
|
||||
{/* 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 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 className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
|
||||
<div className="p-3 bg-muted/50 rounded-full">
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -400,7 +505,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
}
|
||||
const cutoff = now - (ranges[timeRange] || ranges["24h"])
|
||||
return heartbeats.filter((h: HeartbeatRow) => {
|
||||
const t = new Date(h.time || h.timestamp || "").getTime()
|
||||
const t = new Date(h.time || "").getTime()
|
||||
return t >= cutoff
|
||||
})
|
||||
}, [heartbeats, timeRange])
|
||||
@@ -412,7 +517,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((h: HeartbeatRow) => ({
|
||||
time: new Date(h.time || h.timestamp || "").toLocaleTimeString(),
|
||||
time: new Date(h.time || "").toLocaleTimeString(),
|
||||
responseTime: h.ping || 0,
|
||||
status: h.status === "up" ? 1 : 0,
|
||||
}))
|
||||
@@ -590,7 +695,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
</div>
|
||||
|
||||
{/* Core Web Vitals */}
|
||||
<CoreWebVitalsCard url={monitor.url} />
|
||||
<CoreWebVitalsCard monitorId={id} url={monitor.url} />
|
||||
|
||||
{/* Combined Uptime & Response Chart */}
|
||||
<Card>
|
||||
@@ -813,41 +918,63 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Checks</CardTitle>
|
||||
<CardDescription>Last 50 monitor checks</CardDescription>
|
||||
<CardTitle>Check History</CardTitle>
|
||||
<CardDescription>Timeline of the last 50 monitor checks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Response Time</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{heartbeats?.slice(0, 50).map((hb: HeartbeatRow) => (
|
||||
<TableRow key={hb.id}>
|
||||
<TableCell>{formatDate(hb.time || hb.timestamp)}</TableCell>
|
||||
<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}>
|
||||
{heartbeats?.length ? (
|
||||
<div className="space-y-1">
|
||||
{heartbeats.slice(0, 50).map((hb: Heartbeat, i: number) => {
|
||||
const date = new Date(hb.time || "")
|
||||
const showDate = i === 0 || (
|
||||
new Date(heartbeats[i - 1].time || "").toDateString() !== date.toDateString()
|
||||
)
|
||||
return (
|
||||
<div key={hb.id}>
|
||||
{showDate && (
|
||||
<div className="text-xs text-muted-foreground font-medium py-1.5 border-b border-border/50 mt-2 first:mt-0">
|
||||
{date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
) : (
|
||||
<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."}
|
||||
{isPending ? "No checks have been run yet." : "No check history available."}
|
||||
</p>
|
||||
{isPending && (
|
||||
<Button
|
||||
@@ -861,11 +988,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -121,6 +121,66 @@ export interface Domain {
|
||||
dns_spf_records?: string[]
|
||||
dns_dkim_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 {
|
||||
@@ -204,6 +264,66 @@ export interface DomainLookupResult {
|
||||
host_isp?: string
|
||||
favicon_url?: 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"
|
||||
|
||||
@@ -197,6 +197,25 @@ export interface CheckResult {
|
||||
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
|
||||
export async function listMonitors(): Promise<Monitor[]> {
|
||||
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`, {})
|
||||
}
|
||||
|
||||
export function runPageSpeedCheck(id: string, strategy: string = "mobile"): Promise<PageSpeedMetrics> {
|
||||
return pb.send(`/api/beszel/monitors/${id}/pagespeed?strategy=${strategy}`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
export function getMonitorTypeLabel(type: MonitorType): string {
|
||||
const labels: Record<MonitorType, string> = {
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ func main() {
|
||||
start := time.Now()
|
||||
|
||||
// Test WHOIS lookup
|
||||
whoisData, err := lookupService.LookupWHOIS(ctx, domainName)
|
||||
whoisData, _, err := lookupService.LookupWHOIS(ctx, domainName)
|
||||
|
||||
duration := time.Since(start)
|
||||
fmt.Printf("Lookup duration: %v\n", duration)
|
||||
|
||||
Reference in New Issue
Block a user