mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +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"`
|
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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 != "" {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user