mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
Compare commits
4 Commits
1af18872d5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 18046aee71 | |||
| fe5c7eaa95 | |||
| 0dd7db8a82 | |||
| b6f40af67f |
@@ -14,6 +14,7 @@ MAX_STATUS_PAGES=10
|
||||
|
||||
# Optional Features
|
||||
PAGESPEED_ENABLED=true
|
||||
# PAGESPEED_API_KEY=your_google_api_key_here
|
||||
SUBDOMAIN_DISCOVERY=true
|
||||
STATUS_PAGES_ENABLED=true
|
||||
BADGES_ENABLED=true
|
||||
|
||||
@@ -71,6 +71,33 @@ type Domain struct {
|
||||
AbuseEmail string `json:"abuse_email" db:"abuse_email"`
|
||||
AbusePhone string `json:"abuse_phone" db:"abuse_phone"`
|
||||
|
||||
// Provider Detection
|
||||
DNSProvider string `json:"dns_provider" db:"dns_provider"`
|
||||
HostingProvider string `json:"hosting_provider" db:"hosting_provider"`
|
||||
EmailProvider string `json:"email_provider" db:"email_provider"`
|
||||
CAProvider string `json:"ca_provider" db:"ca_provider"`
|
||||
|
||||
// HTTP Headers
|
||||
Headers []Header `json:"headers" db:"headers"`
|
||||
|
||||
// Certificate Chain
|
||||
Certificates []Certificate `json:"certificates" db:"certificates"`
|
||||
|
||||
// SEO Metadata
|
||||
SEOMeta *SEOMeta `json:"seo_meta" db:"seo_meta"`
|
||||
|
||||
// Raw WHOIS Response
|
||||
WHOISRaw string `json:"whois_raw" db:"whois_raw"`
|
||||
|
||||
// Registration Details
|
||||
PrivacyEnabled bool `json:"privacy_enabled" db:"privacy_enabled"`
|
||||
TransferLock bool `json:"transfer_lock" db:"transfer_lock"`
|
||||
TLD string `json:"tld" db:"tld"`
|
||||
DomainStatuses []string `json:"domain_statuses" db:"domain_statuses"`
|
||||
|
||||
// Enhanced Geo
|
||||
HostCountryCode string `json:"host_country_code" db:"host_country_code"`
|
||||
|
||||
// Metadata
|
||||
Tags []string `json:"tags" db:"tags"`
|
||||
Notes string `json:"notes" db:"notes"`
|
||||
@@ -176,6 +203,76 @@ type IPInfo struct {
|
||||
IPv6 []string `json:"ipv6"`
|
||||
}
|
||||
|
||||
// Header represents an HTTP response header
|
||||
type Header struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Certificate represents a TLS certificate in the chain
|
||||
type Certificate struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"subject"`
|
||||
AltNames []string `json:"alt_names"`
|
||||
ValidFrom time.Time `json:"valid_from"`
|
||||
ValidTo time.Time `json:"valid_to"`
|
||||
CAProvider string `json:"ca_provider"`
|
||||
}
|
||||
|
||||
// OpenGraphMeta represents Open Graph metadata
|
||||
type OpenGraphMeta struct {
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Images []string `json:"images"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TwitterMeta represents Twitter card metadata
|
||||
type TwitterMeta struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
Card string `json:"card"`
|
||||
}
|
||||
|
||||
// GeneralMeta represents general HTML meta tags
|
||||
type GeneralMeta struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Robots string `json:"robots"`
|
||||
Keywords string `json:"keywords"`
|
||||
Canonical string `json:"canonical"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// RobotsTxt represents parsed robots.txt data
|
||||
type RobotsTxt struct {
|
||||
Fetched bool `json:"fetched"`
|
||||
Groups []RobotsGroup `json:"groups"`
|
||||
Sitemaps []string `json:"sitemaps"`
|
||||
}
|
||||
|
||||
// RobotsGroup represents a user-agent group in robots.txt
|
||||
type RobotsGroup struct {
|
||||
UserAgents []string `json:"userAgents"`
|
||||
Rules []RobotsRule `json:"rules"`
|
||||
}
|
||||
|
||||
// RobotsRule represents a single rule in robots.txt
|
||||
type RobotsRule struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// SEOMeta represents all SEO-related metadata
|
||||
type SEOMeta struct {
|
||||
OpenGraph OpenGraphMeta `json:"openGraph"`
|
||||
Twitter TwitterMeta `json:"twitter"`
|
||||
General GeneralMeta `json:"general"`
|
||||
Robots RobotsTxt `json:"robots"`
|
||||
}
|
||||
|
||||
// ChangeType constants for domain history
|
||||
const (
|
||||
ChangeTypeExpiry = "expiry"
|
||||
@@ -185,6 +282,9 @@ const (
|
||||
ChangeTypeIP = "ip"
|
||||
ChangeTypeHost = "host"
|
||||
ChangeTypeStatus = "status"
|
||||
ChangeTypeProvider = "provider"
|
||||
ChangeTypeSecurity = "security"
|
||||
ChangeTypeSEO = "seo"
|
||||
)
|
||||
|
||||
// Domain status constants
|
||||
|
||||
@@ -128,6 +128,9 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
NotifyOnSSL bool `json:"notify_on_ssl_expiry"`
|
||||
NotifyOnDNS bool `json:"notify_on_dns_change"`
|
||||
NotifyOnReg bool `json:"notify_on_registrar_change"`
|
||||
// Manual expiry override when WHOIS fails
|
||||
ExpiryDate string `json:"expiry_date,omitempty"`
|
||||
CreationDate string `json:"creation_date,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||
return e.BadRequestError("invalid request body", err)
|
||||
@@ -179,6 +182,7 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
record.Set("user", authRecord.Id)
|
||||
|
||||
// Auto-lookup if requested
|
||||
lookupHadExpiry := false
|
||||
if req.AutoLookup {
|
||||
lookupSvc := whois.NewLookupService("")
|
||||
ctx := e.Request.Context()
|
||||
@@ -188,6 +192,7 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
// Calculate status based on lookup results
|
||||
status := domain.DomainStatusUnknown
|
||||
if domainData.ExpiryDate != nil {
|
||||
lookupHadExpiry = true
|
||||
daysUntil := int(time.Until(*domainData.ExpiryDate).Hours() / 24)
|
||||
if daysUntil < 0 {
|
||||
status = domain.DomainStatusExpired
|
||||
@@ -204,6 +209,29 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply manual expiry/creation dates if WHOIS didn't find them
|
||||
if !lookupHadExpiry {
|
||||
if req.ExpiryDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", req.ExpiryDate); err == nil {
|
||||
record.Set("expiry_date", t)
|
||||
// Recalculate status with manual expiry
|
||||
daysUntil := int(time.Until(t).Hours() / 24)
|
||||
status := domain.DomainStatusActive
|
||||
if daysUntil < 0 {
|
||||
status = domain.DomainStatusExpired
|
||||
} else if daysUntil <= req.AlertDaysBefore {
|
||||
status = domain.DomainStatusExpiring
|
||||
}
|
||||
record.Set("status", status)
|
||||
}
|
||||
}
|
||||
if req.CreationDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", req.CreationDate); err == nil {
|
||||
record.Set("creation_date", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.app.Save(record); err != nil {
|
||||
return e.InternalServerError("failed to create domain", err)
|
||||
}
|
||||
@@ -607,6 +635,19 @@ func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{
|
||||
"registrant_state": record.GetString("registrant_state"),
|
||||
"abuse_email": record.GetString("abuse_email"),
|
||||
"abuse_phone": record.GetString("abuse_phone"),
|
||||
"dns_provider": record.GetString("dns_provider"),
|
||||
"hosting_provider": record.GetString("hosting_provider"),
|
||||
"email_provider": record.GetString("email_provider"),
|
||||
"ca_provider": record.GetString("ca_provider"),
|
||||
"headers": record.Get("headers"),
|
||||
"certificates": record.Get("certificates"),
|
||||
"seo_meta": record.Get("seo_meta"),
|
||||
"whois_raw": record.GetString("whois_raw"),
|
||||
"privacy_enabled": record.GetBool("privacy_enabled"),
|
||||
"transfer_lock": record.GetBool("transfer_lock"),
|
||||
"tld": record.GetString("tld"),
|
||||
"domain_statuses": record.Get("domain_statuses"),
|
||||
"host_country_code": record.GetString("host_country_code"),
|
||||
"tags": record.Get("tags"),
|
||||
"notes": record.GetString("notes"),
|
||||
"favicon_url": record.GetString("favicon_url"),
|
||||
@@ -689,6 +730,27 @@ func (h *APIHandler) applyLookupData(record *core.Record, domainData *domain.Dom
|
||||
record.Set("registrant_postal", domainData.RegistrantPostal)
|
||||
record.Set("abuse_email", domainData.AbuseEmail)
|
||||
record.Set("abuse_phone", domainData.AbusePhone)
|
||||
record.Set("dns_provider", domainData.DNSProvider)
|
||||
record.Set("hosting_provider", domainData.HostingProvider)
|
||||
record.Set("email_provider", domainData.EmailProvider)
|
||||
record.Set("ca_provider", domainData.CAProvider)
|
||||
if len(domainData.Headers) > 0 {
|
||||
record.Set("headers", domainData.Headers)
|
||||
}
|
||||
if len(domainData.Certificates) > 0 {
|
||||
record.Set("certificates", domainData.Certificates)
|
||||
}
|
||||
if domainData.SEOMeta != nil {
|
||||
record.Set("seo_meta", domainData.SEOMeta)
|
||||
}
|
||||
record.Set("whois_raw", domainData.WHOISRaw)
|
||||
record.Set("privacy_enabled", domainData.PrivacyEnabled)
|
||||
record.Set("transfer_lock", domainData.TransferLock)
|
||||
record.Set("tld", domainData.TLD)
|
||||
if len(domainData.DomainStatuses) > 0 {
|
||||
record.Set("domain_statuses", domainData.DomainStatuses)
|
||||
}
|
||||
record.Set("host_country_code", domainData.HostCountryCode)
|
||||
record.Set("favicon_url", domainData.FaviconURL)
|
||||
record.Set("last_checked", time.Now())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package detect
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProviderInfo holds detected provider name
|
||||
type ProviderInfo struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// DetectDNSProvider detects the DNS provider from NS records
|
||||
func DetectDNSProvider(nsRecords []string) string {
|
||||
for _, ns := range nsRecords {
|
||||
nsLower := strings.ToLower(ns)
|
||||
switch {
|
||||
case strings.Contains(nsLower, "cloudflare"):
|
||||
return "Cloudflare"
|
||||
case strings.Contains(nsLower, "awsdns"):
|
||||
return "Amazon Route 53"
|
||||
case strings.Contains(nsLower, "googledomains") || strings.Contains(nsLower, "google.com"):
|
||||
return "Google Domains"
|
||||
case strings.Contains(nsLower, "namecheap") || strings.Contains(nsLower, "namecheaphosting"):
|
||||
return "Namecheap"
|
||||
case strings.Contains(nsLower, "godaddy"):
|
||||
return "GoDaddy"
|
||||
case strings.Contains(nsLower, "domaincontrol"):
|
||||
return "GoDaddy"
|
||||
case strings.Contains(nsLower, "nsone.net"):
|
||||
return "NS1"
|
||||
case strings.Contains(nsLower, "digitalocean"):
|
||||
return "DigitalOcean"
|
||||
case strings.Contains(nsLower, "linode"):
|
||||
return "Linode"
|
||||
case strings.Contains(nsLower, "vultr"):
|
||||
return "Vultr"
|
||||
case strings.Contains(nsLower, "he.net"):
|
||||
return "Hurricane Electric"
|
||||
case strings.Contains(nsLower, "dyn.com") || strings.Contains(nsLower, "dynect"):
|
||||
return "Dyn (Oracle)"
|
||||
case strings.Contains(nsLower, "ultradns"):
|
||||
return "UltraDNS"
|
||||
case strings.Contains(nsLower, "dnsimple"):
|
||||
return "DNSimple"
|
||||
case strings.Contains(nsLower, "hover"):
|
||||
return "Hover"
|
||||
case strings.Contains(nsLower, "register.com"):
|
||||
return "Register.com"
|
||||
case strings.Contains(nsLower, "enom"):
|
||||
return "eNom"
|
||||
case strings.Contains(nsLower, "worldnic"):
|
||||
return "Network Solutions"
|
||||
case strings.Contains(nsLower, "zoneedit"):
|
||||
return "ZoneEdit"
|
||||
case strings.Contains(nsLower, "easydns"):
|
||||
return "EasyDNS"
|
||||
case strings.Contains(nsLower, "gandi"):
|
||||
return "Gandi"
|
||||
case strings.Contains(nsLower, "ovh"):
|
||||
return "OVH"
|
||||
case strings.Contains(nsLower, "hetzner"):
|
||||
return "Hetzner"
|
||||
case strings.Contains(nsLower, "azure-dns"):
|
||||
return "Microsoft Azure"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DetectEmailProvider detects the email provider from MX records
|
||||
func DetectEmailProvider(mxRecords []string) string {
|
||||
for _, mx := range mxRecords {
|
||||
mxLower := strings.ToLower(mx)
|
||||
// Extract just the hostname part if it has priority prefix
|
||||
host := mxLower
|
||||
if idx := strings.Index(mxLower, " "); idx > 0 {
|
||||
host = strings.TrimSpace(mxLower[idx+1:])
|
||||
}
|
||||
host = strings.TrimSuffix(host, ".")
|
||||
|
||||
switch {
|
||||
case strings.Contains(host, "google") || strings.Contains(host, "gmail"):
|
||||
return "Google Workspace"
|
||||
case strings.Contains(host, "outlook") || strings.Contains(host, "microsoft") || strings.Contains(host, "protection.outlook"):
|
||||
return "Microsoft 365"
|
||||
case strings.Contains(host, "purelymail"):
|
||||
return "Purelymail"
|
||||
case strings.Contains(host, "zoho"):
|
||||
return "Zoho Mail"
|
||||
case strings.Contains(host, "protonmail") || strings.Contains(host, "pm.me"):
|
||||
return "ProtonMail"
|
||||
case strings.Contains(host, "fastmail"):
|
||||
return "Fastmail"
|
||||
case strings.Contains(host, "tutanota"):
|
||||
return "Tutanota"
|
||||
case strings.Contains(host, "mxroute"):
|
||||
return "MXroute"
|
||||
case strings.Contains(host, "namecheap"):
|
||||
return "Namecheap"
|
||||
case strings.Contains(host, "icloud") || strings.Contains(host, "me.com"):
|
||||
return "iCloud Mail"
|
||||
case strings.Contains(host, "yahoo"):
|
||||
return "Yahoo"
|
||||
case strings.Contains(host, "qq.com"):
|
||||
return "QQ Mail"
|
||||
case strings.Contains(host, "mail.ru"):
|
||||
return "Mail.ru"
|
||||
case strings.Contains(host, "yandex"):
|
||||
return "Yandex"
|
||||
case strings.Contains(host, "hover"):
|
||||
return "Hover"
|
||||
case strings.Contains(host, "godaddy") || strings.Contains(host, "domaincontrol"):
|
||||
return "GoDaddy"
|
||||
case strings.Contains(host, "pobox"):
|
||||
return "Pobox"
|
||||
case strings.Contains(host, "runbox"):
|
||||
return "Runbox"
|
||||
case strings.Contains(host, "posteo"):
|
||||
return "Posteo"
|
||||
case strings.Contains(host, "mailbox.org"):
|
||||
return "Mailbox.org"
|
||||
case strings.Contains(host, "forwardemail"):
|
||||
return "Forward Email"
|
||||
case strings.Contains(host, "improvmx"):
|
||||
return "ImprovMX"
|
||||
case strings.Contains(host, "cloudflare"):
|
||||
return "Cloudflare Email Routing"
|
||||
case strings.Contains(host, "amazonaws") || strings.Contains(host, "aws"):
|
||||
return "Amazon SES"
|
||||
case strings.Contains(host, "sendgrid") || strings.Contains(host, "twilio"):
|
||||
return "SendGrid"
|
||||
case strings.Contains(host, "mailgun"):
|
||||
return "Mailgun"
|
||||
case strings.Contains(host, "postmark"):
|
||||
return "Postmark"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DetectHostingProvider detects the hosting provider from HTTP headers
|
||||
func DetectHostingProvider(headers http.Header) string {
|
||||
server := strings.ToLower(headers.Get("Server"))
|
||||
poweredBy := strings.ToLower(headers.Get("X-Powered-By"))
|
||||
cfRay := headers.Get("CF-Ray")
|
||||
vercelCache := headers.Get("X-Vercel-Cache")
|
||||
vercelID := headers.Get("X-Vercel-Id")
|
||||
netlifyID := headers.Get("X-NF-Request-Id")
|
||||
githubRequest := headers.Get("X-GitHub-Request-Id")
|
||||
|
||||
switch {
|
||||
case vercelCache != "" || vercelID != "":
|
||||
return "Vercel"
|
||||
case netlifyID != "":
|
||||
return "Netlify"
|
||||
case githubRequest != "":
|
||||
return "GitHub Pages"
|
||||
case cfRay != "":
|
||||
return "Cloudflare"
|
||||
case strings.Contains(server, "cloudflare"):
|
||||
return "Cloudflare"
|
||||
case strings.Contains(server, "nginx") && vercelCache != "":
|
||||
return "Vercel"
|
||||
case strings.Contains(server, "awselb") || strings.Contains(server, "elb"):
|
||||
return "AWS"
|
||||
case strings.Contains(server, "amazon"):
|
||||
return "AWS"
|
||||
case strings.Contains(server, "microsoft-iis"):
|
||||
return "Microsoft Azure"
|
||||
case strings.Contains(server, "google") || strings.Contains(server, "gws"):
|
||||
return "Google Cloud"
|
||||
case strings.Contains(server, "heroku"):
|
||||
return "Heroku"
|
||||
case strings.Contains(server, "digitalocean"):
|
||||
return "DigitalOcean"
|
||||
case strings.Contains(server, "linode"):
|
||||
return "Linode"
|
||||
case strings.Contains(server, "ovh"):
|
||||
return "OVH"
|
||||
case strings.Contains(server, "hetzner"):
|
||||
return "Hetzner"
|
||||
case strings.Contains(server, "fastly"):
|
||||
return "Fastly"
|
||||
case strings.Contains(server, "bunnycdn"):
|
||||
return "BunnyCDN"
|
||||
case strings.Contains(server, "keycdn"):
|
||||
return "KeyCDN"
|
||||
case strings.Contains(server, "stackpath"):
|
||||
return "StackPath"
|
||||
case strings.Contains(server, "sucuri"):
|
||||
return "Sucuri"
|
||||
case strings.Contains(poweredBy, "next.js") || strings.Contains(poweredBy, "nextjs"):
|
||||
return "Vercel"
|
||||
case strings.Contains(poweredBy, "php"):
|
||||
return "PHP"
|
||||
case strings.Contains(server, "apache"):
|
||||
return "Apache"
|
||||
case strings.Contains(server, "nginx"):
|
||||
return "nginx"
|
||||
case strings.Contains(server, "caddy"):
|
||||
return "Caddy"
|
||||
case strings.Contains(server, "lighttpd"):
|
||||
return "Lighttpd"
|
||||
case strings.Contains(server, "litespeed"):
|
||||
return "LiteSpeed"
|
||||
case strings.Contains(server, "openresty"):
|
||||
return "OpenResty"
|
||||
case strings.Contains(server, "jetty"):
|
||||
return "Jetty"
|
||||
case strings.Contains(server, "tomcat"):
|
||||
return "Tomcat"
|
||||
case strings.Contains(server, "iis"):
|
||||
return "IIS"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DetectCertificateAuthority detects the CA from an issuer string
|
||||
func DetectCertificateAuthority(issuer string) string {
|
||||
issuerLower := strings.ToLower(issuer)
|
||||
switch {
|
||||
case strings.Contains(issuerLower, "let's encrypt"):
|
||||
return "Let's Encrypt"
|
||||
case strings.Contains(issuerLower, "digicert"):
|
||||
return "DigiCert"
|
||||
case strings.Contains(issuerLower, "sectigo") || strings.Contains(issuerLower, "comodoca"):
|
||||
return "Sectigo"
|
||||
case strings.Contains(issuerLower, "globalsign"):
|
||||
return "GlobalSign"
|
||||
case strings.Contains(issuerLower, "geotrust"):
|
||||
return "GeoTrust"
|
||||
case strings.Contains(issuerLower, "thawte"):
|
||||
return "Thawte"
|
||||
case strings.Contains(issuerLower, "rapidssl"):
|
||||
return "RapidSSL"
|
||||
case strings.Contains(issuerLower, "symantec"):
|
||||
return "Symantec"
|
||||
case strings.Contains(issuerLower, "entrust"):
|
||||
return "Entrust"
|
||||
case strings.Contains(issuerLower, "certum"):
|
||||
return "Certum"
|
||||
case strings.Contains(issuerLower, "go daddy") || strings.Contains(issuerLower, "godaddy"):
|
||||
return "GoDaddy"
|
||||
case strings.Contains(issuerLower, "amazon"):
|
||||
return "Amazon"
|
||||
case strings.Contains(issuerLower, "google") && strings.Contains(issuerLower, "trust"):
|
||||
return "Google Trust Services"
|
||||
case strings.Contains(issuerLower, "cloudflare"):
|
||||
return "Cloudflare"
|
||||
case strings.Contains(issuerLower, "zero ssl") || strings.Contains(issuerLower, "zerossl"):
|
||||
return "ZeroSSL"
|
||||
case strings.Contains(issuerLower, "ssl.com"):
|
||||
return "SSL.com"
|
||||
case strings.Contains(issuerLower, "buypass"):
|
||||
return "Buypass"
|
||||
case strings.Contains(issuerLower, "harica"):
|
||||
return "HARICA"
|
||||
case strings.Contains(issuerLower, " Actalis "):
|
||||
return "Actalis"
|
||||
case strings.Contains(issuerLower, "swisssign"):
|
||||
return "SwissSign"
|
||||
case strings.Contains(issuerLower, "telekom"):
|
||||
return "Telekom"
|
||||
case strings.Contains(issuerLower, "trustwave"):
|
||||
return "Trustwave"
|
||||
case strings.Contains(issuerLower, "identrust"):
|
||||
return "IdenTrust"
|
||||
case strings.Contains(issuerLower, "usertrust"):
|
||||
return "UserTrust"
|
||||
case strings.Contains(issuerLower, "isrg") || strings.Contains(issuerLower, "internet security research"):
|
||||
return "Let's Encrypt"
|
||||
}
|
||||
return issuer
|
||||
}
|
||||
@@ -151,13 +151,13 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
|
||||
oldRecord := record.Fresh()
|
||||
|
||||
// Update record (only overwrite if new data is present to preserve valid data on partial lookups)
|
||||
if newData.ExpiryDate != nil {
|
||||
if newData.ExpiryDate != nil && newData.ExpiryDate.After(time.Time{}) {
|
||||
record.Set("expiry_date", *newData.ExpiryDate)
|
||||
}
|
||||
if newData.CreationDate != nil {
|
||||
if newData.CreationDate != nil && newData.CreationDate.After(time.Time{}) {
|
||||
record.Set("creation_date", *newData.CreationDate)
|
||||
}
|
||||
if newData.UpdatedDate != nil {
|
||||
if newData.UpdatedDate != nil && newData.UpdatedDate.After(time.Time{}) {
|
||||
record.Set("updated_date", *newData.UpdatedDate)
|
||||
}
|
||||
if newData.RegistrarName != "" {
|
||||
@@ -262,9 +262,28 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
|
||||
if newData.AbuseEmail != "" {
|
||||
record.Set("abuse_email", newData.AbuseEmail)
|
||||
}
|
||||
if newData.AbusePhone != "" {
|
||||
record.Set("abuse_phone", newData.AbusePhone)
|
||||
record.Set("abuse_phone", newData.AbusePhone)
|
||||
record.Set("dns_provider", newData.DNSProvider)
|
||||
record.Set("hosting_provider", newData.HostingProvider)
|
||||
record.Set("email_provider", newData.EmailProvider)
|
||||
record.Set("ca_provider", newData.CAProvider)
|
||||
if len(newData.Headers) > 0 {
|
||||
record.Set("headers", newData.Headers)
|
||||
}
|
||||
if len(newData.Certificates) > 0 {
|
||||
record.Set("certificates", newData.Certificates)
|
||||
}
|
||||
if newData.SEOMeta != nil {
|
||||
record.Set("seo_meta", newData.SEOMeta)
|
||||
}
|
||||
record.Set("whois_raw", newData.WHOISRaw)
|
||||
record.Set("privacy_enabled", newData.PrivacyEnabled)
|
||||
record.Set("transfer_lock", newData.TransferLock)
|
||||
record.Set("tld", newData.TLD)
|
||||
if len(newData.DomainStatuses) > 0 {
|
||||
record.Set("domain_statuses", newData.DomainStatuses)
|
||||
}
|
||||
record.Set("host_country_code", newData.HostCountryCode)
|
||||
record.Set("last_checked", time.Now())
|
||||
|
||||
// Update status - fallback to existing record expiry if new lookup didn't return one
|
||||
@@ -422,6 +441,61 @@ func (s *Scheduler) trackChanges(oldRecord *core.Record, newData *domain.Domain,
|
||||
})
|
||||
}
|
||||
|
||||
// Check provider changes
|
||||
providers := []struct {
|
||||
field string
|
||||
value string
|
||||
}{
|
||||
{"dns_provider", newData.DNSProvider},
|
||||
{"hosting_provider", newData.HostingProvider},
|
||||
{"email_provider", newData.EmailProvider},
|
||||
{"ca_provider", newData.CAProvider},
|
||||
}
|
||||
for _, p := range providers {
|
||||
oldVal := oldRecord.GetString(p.field)
|
||||
if p.value != "" && p.value != oldVal && oldVal != "" {
|
||||
history = append(history, domain.DomainHistory{
|
||||
ChangeType: domain.ChangeTypeProvider,
|
||||
FieldName: p.field,
|
||||
OldValue: oldVal,
|
||||
NewValue: p.value,
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check security changes
|
||||
if newData.PrivacyEnabled != oldRecord.GetBool("privacy_enabled") {
|
||||
history = append(history, domain.DomainHistory{
|
||||
ChangeType: domain.ChangeTypeSecurity,
|
||||
FieldName: "privacy_enabled",
|
||||
OldValue: fmt.Sprintf("%t", oldRecord.GetBool("privacy_enabled")),
|
||||
NewValue: fmt.Sprintf("%t", newData.PrivacyEnabled),
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
if newData.TransferLock != oldRecord.GetBool("transfer_lock") {
|
||||
history = append(history, domain.DomainHistory{
|
||||
ChangeType: domain.ChangeTypeSecurity,
|
||||
FieldName: "transfer_lock",
|
||||
OldValue: fmt.Sprintf("%t", oldRecord.GetBool("transfer_lock")),
|
||||
NewValue: fmt.Sprintf("%t", newData.TransferLock),
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Check host country code change
|
||||
oldCountryCode := oldRecord.GetString("host_country_code")
|
||||
if newData.HostCountryCode != "" && newData.HostCountryCode != oldCountryCode && oldCountryCode != "" {
|
||||
history = append(history, domain.DomainHistory{
|
||||
ChangeType: domain.ChangeTypeHost,
|
||||
FieldName: "host_country_code",
|
||||
OldValue: oldCountryCode,
|
||||
NewValue: newData.HostCountryCode,
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package domains
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -143,21 +144,73 @@ func (sd *SubdomainDiscovery) dnsBruteForce(ctx context.Context, domainName stri
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ctLogSearch searches certificate transparency logs
|
||||
// ctLogSearch searches certificate transparency logs via crt.sh
|
||||
func (sd *SubdomainDiscovery) ctLogSearch(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
|
||||
// Query crt.sh for certificates
|
||||
url := fmt.Sprintf("https://crt.sh/?q=%%.%s&output=json", domainName)
|
||||
|
||||
resp, err := sd.client.Get(url)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
log.Printf("[subdomain-discovery] CT log search failed for %s: %v", domainName, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := sd.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[subdomain-discovery] CT log search failed for %s: %v", domainName, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse response (simplified - in production would parse JSON)
|
||||
// For now, just log that we attempted this
|
||||
log.Printf("[subdomain-discovery] CT log search attempted for %s (status: %d)", domainName, resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("[subdomain-discovery] CT log search returned status %d for %s", resp.StatusCode, domainName)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse crt.sh JSON response
|
||||
var entries []struct {
|
||||
NameValue string `json:"name_value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
||||
log.Printf("[subdomain-discovery] Failed to parse CT log response for %s: %v", domainName, err)
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, entry := range entries {
|
||||
// crt.sh returns one name_value per line, may contain wildcards or multiple names
|
||||
names := strings.Split(entry.NameValue, "\n")
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == domainName {
|
||||
continue
|
||||
}
|
||||
// Remove wildcard prefix
|
||||
name = strings.TrimPrefix(name, "*.")
|
||||
// Only include subdomains of the target domain
|
||||
if !strings.HasSuffix(name, "."+domainName) {
|
||||
continue
|
||||
}
|
||||
subdomain := strings.TrimSuffix(name, "."+domainName)
|
||||
if subdomain == "" || seen[subdomain] {
|
||||
continue
|
||||
}
|
||||
seen[subdomain] = true
|
||||
|
||||
// Try to resolve IPs
|
||||
ips, _ := net.LookupHost(name)
|
||||
|
||||
results <- DiscoveryResult{
|
||||
Subdomain: subdomain,
|
||||
FullDomain: name,
|
||||
IPAddresses: ips,
|
||||
Source: "certificate",
|
||||
FoundAt: time.Now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[subdomain-discovery] CT log search found %d unique subdomains for %s", len(seen), domainName)
|
||||
}
|
||||
|
||||
// patternEnumeration enumerates common subdomain patterns
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
"github.com/henrygd/beszel/internal/hub/pagespeed"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
@@ -40,16 +42,17 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
|
||||
// CRUD endpoints
|
||||
api.GET("", h.listMonitors)
|
||||
api.POST("", h.createMonitor)
|
||||
api.GET("/:id", h.getMonitor)
|
||||
api.PATCH("/:id", h.updateMonitor)
|
||||
api.DELETE("/:id", h.deleteMonitor)
|
||||
api.GET("/{id}", h.getMonitor)
|
||||
api.PATCH("/{id}", h.updateMonitor)
|
||||
api.DELETE("/{id}", h.deleteMonitor)
|
||||
|
||||
// Action endpoints
|
||||
api.POST("/:id/check", h.manualCheck)
|
||||
api.POST("/:id/pause", h.pauseMonitor)
|
||||
api.POST("/:id/resume", h.resumeMonitor)
|
||||
api.GET("/:id/stats", h.getStats)
|
||||
api.GET("/:id/heartbeats", h.getHeartbeats)
|
||||
api.POST("/{id}/check", h.manualCheck)
|
||||
api.POST("/{id}/pause", h.pauseMonitor)
|
||||
api.POST("/{id}/resume", h.resumeMonitor)
|
||||
api.GET("/{id}/stats", h.getStats)
|
||||
api.GET("/{id}/heartbeats", h.getHeartbeats)
|
||||
api.POST("/{id}/pagespeed", h.runPageSpeedCheck)
|
||||
}
|
||||
|
||||
// HeartbeatSummary represents a minimal heartbeat for the monitor list
|
||||
@@ -293,8 +296,12 @@ func (h *APIHandler) createMonitor(e *core.RequestEvent) error {
|
||||
h.scheduler.AddMonitor(record)
|
||||
|
||||
// Run initial check synchronously so the monitor shows real status immediately
|
||||
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
|
||||
result, err := h.scheduler.RunManualCheck(record.Id)
|
||||
if err != nil {
|
||||
log.Printf("[monitor-api] Initial check failed for %s: %v", record.Id, err)
|
||||
// Note: The monitor will remain in pending status and the scheduler will retry
|
||||
} else {
|
||||
log.Printf("[monitor-api] Initial check completed for %s: status=%s, ping=%v", record.Id, result.Status, result.Ping)
|
||||
}
|
||||
|
||||
// Re-fetch the updated record to get the new status
|
||||
@@ -605,6 +612,65 @@ 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)
|
||||
}
|
||||
|
||||
apiKey, _ := utils.GetEnv("PAGESPEED_API_KEY")
|
||||
checker := pagespeed.NewChecker(apiKey)
|
||||
metrics, err := checker.CheckURL(url, strategy)
|
||||
if err != nil {
|
||||
return e.BadRequestError("PageSpeed check failed: "+err.Error(), err)
|
||||
}
|
||||
|
||||
vitals := pagespeed.GetCoreWebVitalsStatus(metrics)
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]interface{}{
|
||||
"performance": metrics.Performance,
|
||||
"accessibility": metrics.Accessibility,
|
||||
"bestPractices": metrics.BestPractices,
|
||||
"seo": metrics.SEO,
|
||||
"pwa": metrics.PWA,
|
||||
"fcp": metrics.FCP,
|
||||
"lcp": metrics.LCP,
|
||||
"ttfb": metrics.TTFB,
|
||||
"cls": metrics.CLS,
|
||||
"tbt": metrics.TBT,
|
||||
"speedIndex": metrics.SpeedIndex,
|
||||
"tti": metrics.TTI,
|
||||
"strategy": metrics.Strategy,
|
||||
"checkedAt": metrics.CheckedAt,
|
||||
"url": metrics.URL,
|
||||
"vitals": vitals,
|
||||
})
|
||||
}
|
||||
|
||||
// getHeartbeats returns recent heartbeats for a monitor
|
||||
func (h *APIHandler) getHeartbeats(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -65,21 +66,21 @@ type PageSpeedResponse struct {
|
||||
|
||||
// Metrics represents the extracted performance metrics
|
||||
type Metrics struct {
|
||||
Performance float64 `json:"performance"`
|
||||
Accessibility float64 `json:"accessibility"`
|
||||
BestPractices float64 `json:"bestPractices"`
|
||||
SEO float64 `json:"seo"`
|
||||
PWA float64 `json:"pwa"`
|
||||
FCP float64 `json:"fcp"` // First Contentful Paint (ms)
|
||||
LCP float64 `json:"lcp"` // Largest Contentful Paint (ms)
|
||||
TTFB float64 `json:"ttfb"` // Time to First Byte (ms)
|
||||
CLS float64 `json:"cls"` // Cumulative Layout Shift
|
||||
TBT float64 `json:"tbt"` // Total Blocking Time (ms)
|
||||
SpeedIndex float64 `json:"speedIndex"` // Speed Index (ms)
|
||||
TTI float64 `json:"tti"` // Time to Interactive (ms)
|
||||
CheckedAt time.Time `json:"checkedAt"`
|
||||
URL string `json:"url"`
|
||||
Strategy string `json:"strategy"` // mobile or desktop
|
||||
Performance float64 `json:"performance"`
|
||||
Accessibility float64 `json:"accessibility"`
|
||||
BestPractices float64 `json:"bestPractices"`
|
||||
SEO float64 `json:"seo"`
|
||||
PWA float64 `json:"pwa"`
|
||||
FCP float64 `json:"fcp"` // First Contentful Paint (ms)
|
||||
LCP float64 `json:"lcp"` // Largest Contentful Paint (ms)
|
||||
TTFB float64 `json:"ttfb"` // Time to First Byte (ms)
|
||||
CLS float64 `json:"cls"` // Cumulative Layout Shift
|
||||
TBT float64 `json:"tbt"` // Total Blocking Time (ms)
|
||||
SpeedIndex float64 `json:"speedIndex"` // Speed Index (ms)
|
||||
TTI float64 `json:"tti"` // Time to Interactive (ms)
|
||||
CheckedAt time.Time `json:"checkedAt"`
|
||||
URL string `json:"url"`
|
||||
Strategy string `json:"strategy"` // mobile or desktop
|
||||
}
|
||||
|
||||
// Checker handles PageSpeed checks
|
||||
@@ -99,7 +100,7 @@ func NewChecker(apiKey string) *Checker {
|
||||
}
|
||||
|
||||
// CheckURL runs a PageSpeed check on a URL
|
||||
func (c *Checker) CheckURL(url string, strategy string) (*Metrics, error) {
|
||||
func (c *Checker) CheckURL(pageURL string, strategy string) (*Metrics, error) {
|
||||
if strategy == "" {
|
||||
strategy = "mobile"
|
||||
}
|
||||
@@ -107,7 +108,7 @@ func (c *Checker) CheckURL(url string, strategy string) (*Metrics, error) {
|
||||
// Build PageSpeed API URL
|
||||
apiURL := fmt.Sprintf(
|
||||
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=%s&strategy=%s&category=PERFORMANCE&category=ACCESSIBILITY&category=BEST_PRACTICES&category=SEO&category=PWA",
|
||||
url,
|
||||
url.QueryEscape(pageURL),
|
||||
strategy,
|
||||
)
|
||||
|
||||
@@ -132,7 +133,7 @@ func (c *Checker) CheckURL(url string, strategy string) (*Metrics, error) {
|
||||
}
|
||||
|
||||
metrics := &Metrics{
|
||||
URL: url,
|
||||
URL: pageURL,
|
||||
Strategy: strategy,
|
||||
CheckedAt: time.Now(),
|
||||
Performance: result.LighthouseResult.Categories.Performance.Score * 100,
|
||||
@@ -192,6 +193,7 @@ func GetCoreWebVitalsStatus(metrics *Metrics) map[string]string {
|
||||
"cls": getCLSStatus(metrics.CLS),
|
||||
"fcp": getFCPStatus(metrics.FCP),
|
||||
"ttfb": getTTFBStatus(metrics.TTFB),
|
||||
"tti": getTTIStatus(metrics.TTI),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +242,15 @@ func getTTFBStatus(value float64) string {
|
||||
return "poor"
|
||||
}
|
||||
|
||||
func getTTIStatus(value float64) string {
|
||||
if value <= 3800 {
|
||||
return "good"
|
||||
} else if value <= 7300 {
|
||||
return "needs-improvement"
|
||||
}
|
||||
return "poor"
|
||||
}
|
||||
|
||||
// FormatDuration formats milliseconds to readable string
|
||||
func FormatDuration(ms float64) string {
|
||||
if ms < 1000 {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
if err := enhanceDomainCollection(app); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func enhanceDomainCollection(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("domains")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Provider detection fields
|
||||
addTextField3(collection, "dns_provider")
|
||||
addTextField3(collection, "hosting_provider")
|
||||
addTextField3(collection, "email_provider")
|
||||
addTextField3(collection, "ca_provider")
|
||||
|
||||
// JSON fields for complex data
|
||||
addJSONField3(collection, "headers")
|
||||
addJSONField3(collection, "certificates")
|
||||
addJSONField3(collection, "seo_meta")
|
||||
addJSONField3(collection, "domain_statuses")
|
||||
|
||||
// WHOIS and registration fields
|
||||
addTextField3(collection, "whois_raw")
|
||||
addBoolField3(collection, "privacy_enabled")
|
||||
addBoolField3(collection, "transfer_lock")
|
||||
addTextField3(collection, "tld")
|
||||
|
||||
// Enhanced geo
|
||||
addTextField3(collection, "host_country_code")
|
||||
|
||||
return app.Save(collection)
|
||||
}
|
||||
|
||||
func addTextField3(collection *core.Collection, name string) {
|
||||
if collection.Fields.GetByName(name) == nil {
|
||||
collection.Fields.Add(&core.TextField{Name: name})
|
||||
}
|
||||
}
|
||||
|
||||
func addBoolField3(collection *core.Collection, name string) {
|
||||
if collection.Fields.GetByName(name) == nil {
|
||||
collection.Fields.Add(&core.BoolField{Name: name})
|
||||
}
|
||||
}
|
||||
|
||||
func addJSONField3(collection *core.Collection, name string) {
|
||||
if collection.Fields.GetByName(name) == nil {
|
||||
collection.Fields.Add(&core.JSONField{Name: name})
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,25 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Link } from "@/components/router"
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield, Filter, X } from "lucide-react"
|
||||
import { getCalendarEvents, type CalendarEvent } from "@/lib/incidents"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function CalendarView() {
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [eventFilters, setEventFilters] = useState({
|
||||
domain_expiry: true,
|
||||
ssl_expiry: true,
|
||||
incident: true,
|
||||
})
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
|
||||
@@ -46,20 +60,22 @@ export function CalendarView() {
|
||||
// Days of month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||
const dayEvents = events?.filter((e) => e.date === dateStr) || []
|
||||
const dayEvents = events?.filter((e) =>
|
||||
e.date === dateStr && eventFilters[e.type as keyof typeof eventFilters]
|
||||
) || []
|
||||
d.push({ day, events: dayEvents })
|
||||
}
|
||||
|
||||
return d
|
||||
}, [year, month, daysInMonth, firstDayOfMonth, events])
|
||||
}, [year, month, daysInMonth, firstDayOfMonth, events, eventFilters])
|
||||
|
||||
const upcomingEvents = useMemo(() => {
|
||||
const today = toDateString(new Date())
|
||||
return (events || [])
|
||||
.filter((event) => event.date >= today)
|
||||
.filter((event) => event.date >= today && eventFilters[event.type as keyof typeof eventFilters])
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(0, 8)
|
||||
}, [events])
|
||||
}, [events, eventFilters])
|
||||
|
||||
const prevMonth = () => {
|
||||
setCurrentDate(new Date(year, month - 1, 1))
|
||||
@@ -123,26 +139,116 @@ export function CalendarView() {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<CardTitle className="flex items-center gap-2 text-lg sm:text-xl">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<CalendarIcon className="h-5 w-5 text-primary" />
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Title Row */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<CardTitle className="flex items-center gap-2 text-lg sm:text-xl">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<CalendarIcon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<span>Calendar View</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentDate(new Date())} className="h-8 text-xs">
|
||||
Today
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={prevMonth} className="h-8 w-8">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-semibold min-w-[120px] sm:min-w-[160px] text-center text-sm sm:text-base px-2">
|
||||
{monthNames[month]} {year}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" onClick={nextMonth} className="h-8 w-8">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Controls Row */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Show:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button
|
||||
variant={eventFilters.domain_expiry ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setEventFilters(prev => ({ ...prev, domain_expiry: !prev.domain_expiry }))}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<Globe className="h-3 w-3" />
|
||||
Domain
|
||||
</Button>
|
||||
<Button
|
||||
variant={eventFilters.ssl_expiry ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setEventFilters(prev => ({ ...prev, ssl_expiry: !prev.ssl_expiry }))}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
SSL
|
||||
</Button>
|
||||
<Button
|
||||
variant={eventFilters.incident ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setEventFilters(prev => ({ ...prev, incident: !prev.incident }))}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Incidents
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
<Filter className="h-3 w-3 mr-1" />
|
||||
Quick Filters
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>Event Types</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={eventFilters.domain_expiry}
|
||||
onCheckedChange={(checked) => setEventFilters(prev => ({ ...prev, domain_expiry: checked }))}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-3 w-3" />
|
||||
Domain Expiry
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={eventFilters.ssl_expiry}
|
||||
onCheckedChange={(checked) => setEventFilters(prev => ({ ...prev, ssl_expiry: checked }))}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-3 w-3" />
|
||||
SSL Expiry
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={eventFilters.incident}
|
||||
onCheckedChange={(checked) => setEventFilters(prev => ({ ...prev, incident: checked }))}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Incidents
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setEventFilters({ domain_expiry: true, ssl_expiry: true, incident: true })}>
|
||||
Show All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEventFilters({ domain_expiry: true, ssl_expiry: false, incident: false })}>
|
||||
Domain Only
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEventFilters({ domain_expiry: false, ssl_expiry: true, incident: false })}>
|
||||
SSL Only
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<span>Calendar View</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setCurrentDate(new Date())} className="h-8 text-xs">
|
||||
Today
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={prevMonth} className="h-8 w-8">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-semibold min-w-[120px] sm:min-w-[160px] text-center text-sm sm:text-base px-2">
|
||||
{monthNames[month]} {year}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" onClick={nextMonth} className="h-8 w-8">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
type UpdateDomainRequest,
|
||||
type DomainLookupResult,
|
||||
} from "@/lib/domains"
|
||||
import { Loader2, Search } from "lucide-react"
|
||||
import { Loader2, Search, AlertTriangle, Calendar } from "lucide-react"
|
||||
|
||||
const formSchema = z.object({
|
||||
domain_name: z.string().min(1, "Domain name is required"),
|
||||
@@ -79,6 +79,13 @@ export function DomainDialog({ open, onOpenChange, domain, isEdit = false }: Dom
|
||||
const [activeTab, setActiveTab] = useState("basic")
|
||||
const [lookupData, setLookupData] = useState<DomainLookupResult | null>(null)
|
||||
const [isLookingUp, setIsLookingUp] = useState(false)
|
||||
// Manual expiry inputs when WHOIS fails
|
||||
const [manualRegDate, setManualRegDate] = useState(() => {
|
||||
const today = new Date()
|
||||
return today.toISOString().split("T")[0]
|
||||
})
|
||||
const [manualRegPeriod, setManualRegPeriod] = useState<number>(1)
|
||||
const [manualPurchasePrice, setManualPurchasePrice] = useState<number>(0)
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -163,6 +170,10 @@ export function DomainDialog({ open, onOpenChange, domain, isEdit = false }: Dom
|
||||
quiet_hours_end: "08:00",
|
||||
})
|
||||
setLookupData(null)
|
||||
const today = new Date().toISOString().split("T")[0]
|
||||
setManualRegDate(today)
|
||||
setManualRegPeriod(1)
|
||||
setManualPurchasePrice(0)
|
||||
}
|
||||
}, [open, isEdit, domain, form])
|
||||
|
||||
@@ -207,6 +218,11 @@ export function DomainDialog({ open, onOpenChange, domain, isEdit = false }: Dom
|
||||
try {
|
||||
const data = await lookupDomain(domainName)
|
||||
setLookupData(data)
|
||||
// Reset manual inputs on new lookup
|
||||
const today = new Date().toISOString().split("T")[0]
|
||||
setManualRegDate(today)
|
||||
setManualRegPeriod(1)
|
||||
setManualPurchasePrice(0)
|
||||
toast({ title: "Domain info retrieved successfully" })
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -219,6 +235,17 @@ export function DomainDialog({ open, onOpenChange, domain, isEdit = false }: Dom
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiry date from registration date + period in years
|
||||
const calculateExpiryDate = (regDateStr: string, years: number): string | null => {
|
||||
const regDate = new Date(regDateStr)
|
||||
if (isNaN(regDate.getTime())) return null
|
||||
const expiry = new Date(regDate)
|
||||
expiry.setFullYear(expiry.getFullYear() + years)
|
||||
// Subtract 1 day (expiry is typically the day before the anniversary)
|
||||
expiry.setDate(expiry.getDate() - 1)
|
||||
return expiry.toISOString().split("T")[0]
|
||||
}
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
const payload: CreateDomainRequest = {
|
||||
domain_name: cleanDomain(data.domain_name),
|
||||
@@ -247,6 +274,19 @@ export function DomainDialog({ open, onOpenChange, domain, isEdit = false }: Dom
|
||||
quiet_hours_end: data.quiet_hours_enabled ? data.quiet_hours_end : undefined,
|
||||
}
|
||||
|
||||
// If lookup returned no expiry, attach manual dates
|
||||
if (!isEdit && lookupData && !lookupData.expiry_date) {
|
||||
const calculatedExpiry = calculateExpiryDate(manualRegDate, manualRegPeriod)
|
||||
if (calculatedExpiry) {
|
||||
payload.expiry_date = calculatedExpiry
|
||||
payload.creation_date = manualRegDate
|
||||
}
|
||||
// Use the manual purchase price if set (overrides form value when WHOIS fails)
|
||||
if (manualPurchasePrice > 0) {
|
||||
payload.purchase_price = manualPurchasePrice
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdit && domain) {
|
||||
updateMutation.mutate({
|
||||
id: domain.id,
|
||||
@@ -384,19 +424,103 @@ export function DomainDialog({ open, onOpenChange, domain, isEdit = false }: Dom
|
||||
/>
|
||||
|
||||
{lookupData && !isEdit && (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h4 className="font-medium">Lookup Results</h4>
|
||||
{lookupData.registrar_name && (
|
||||
<p className="text-sm">Registrar: {lookupData.registrar_name}</p>
|
||||
)}
|
||||
{lookupData.expiry_date && (
|
||||
<p className="text-sm">Expires: {lookupData.expiry_date}</p>
|
||||
)}
|
||||
{lookupData.ssl_valid_to && (
|
||||
<p className="text-sm">SSL Expires: {lookupData.ssl_valid_to}</p>
|
||||
)}
|
||||
{lookupData.host_country && (
|
||||
<p className="text-sm">Location: {lookupData.host_country}</p>
|
||||
<div className="space-y-3">
|
||||
{/* Lookup Results */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h4 className="font-medium">Lookup Results</h4>
|
||||
{lookupData.registrar_name && (
|
||||
<p className="text-sm">Registrar: {lookupData.registrar_name}</p>
|
||||
)}
|
||||
{lookupData.expiry_date && (
|
||||
<p className="text-sm">Expires: {lookupData.expiry_date}</p>
|
||||
)}
|
||||
{lookupData.ssl_valid_to && (
|
||||
<p className="text-sm">SSL Expires: {lookupData.ssl_valid_to}</p>
|
||||
)}
|
||||
{lookupData.host_country && (
|
||||
<p className="text-sm">Location: {lookupData.host_country}</p>
|
||||
)}
|
||||
{lookupData.dns_provider && (
|
||||
<p className="text-sm">DNS: {lookupData.dns_provider}</p>
|
||||
)}
|
||||
{lookupData.hosting_provider && (
|
||||
<p className="text-sm">Hosting: {lookupData.hosting_provider}</p>
|
||||
)}
|
||||
{lookupData.email_provider && (
|
||||
<p className="text-sm">Email: {lookupData.email_provider}</p>
|
||||
)}
|
||||
{lookupData.ca_provider && (
|
||||
<p className="text-sm">CA: {lookupData.ca_provider}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual expiry fallback when WHOIS doesn't return expiry */}
|
||||
{!lookupData.expiry_date && (
|
||||
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-yellow-700">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<h4 className="font-medium text-sm">Expiry date not found in WHOIS</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter your registration details below and we'll calculate the expiry date.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Registration Date</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="date"
|
||||
value={manualRegDate}
|
||||
onChange={(e) => setManualRegDate(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Registration Period</label>
|
||||
<select
|
||||
value={manualRegPeriod}
|
||||
onChange={(e) => setManualRegPeriod(Number(e.target.value))}
|
||||
className="w-full h-8 px-2 rounded-md border border-input bg-background text-sm"
|
||||
>
|
||||
<option value={1}>1 year</option>
|
||||
<option value={2}>2 years</option>
|
||||
<option value={3}>3 years</option>
|
||||
<option value={5}>5 years</option>
|
||||
<option value={10}>10 years</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Purchase Price (total for selected period)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={manualPurchasePrice || ""}
|
||||
placeholder="e.g. 29.99"
|
||||
onChange={(e) => setManualPurchasePrice(Number(e.target.value))}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
{manualPurchasePrice > 0 && manualRegPeriod > 1 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
~{(manualPurchasePrice / manualRegPeriod).toFixed(2)} per year
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{manualRegDate && manualRegPeriod > 0 && (
|
||||
<div className="rounded-md bg-muted p-2 text-xs">
|
||||
<p className="font-medium">Calculated Expiry:</p>
|
||||
<p className="text-muted-foreground">
|
||||
{calculateExpiryDate(manualRegDate, manualRegPeriod)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -41,14 +41,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
getDomains,
|
||||
deleteDomain,
|
||||
refreshDomain,
|
||||
getStatusBadgeColor,
|
||||
getStatusLabel,
|
||||
getDomainSubdomains,
|
||||
formatDate,
|
||||
type Domain,
|
||||
} from "@/lib/domains"
|
||||
@@ -67,7 +65,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { DomainDialog } from "./domain-dialog"
|
||||
import { Link } from "@/components/router"
|
||||
import { useBrowserStorage } from "@/lib/utils"
|
||||
import { cn, useBrowserStorage } from "@/lib/utils"
|
||||
|
||||
type ViewMode = "table" | "grid"
|
||||
type StatusFilter = "all" | "active" | "expiring" | "expired" | "unknown" | "paused"
|
||||
@@ -77,6 +75,7 @@ type DisplayOptions = {
|
||||
showRegistrar: boolean
|
||||
showExpiryDate: boolean
|
||||
showTags: boolean
|
||||
showProviders: boolean
|
||||
}
|
||||
|
||||
// Days left badge component - big and visible
|
||||
@@ -103,6 +102,41 @@ function DaysLeftBadge({ days, label = "days" }: { days: number | undefined; lab
|
||||
)
|
||||
}
|
||||
|
||||
// Subdomain indicator component
|
||||
function SubdomainIndicator({ domainId }: { domainId: string }) {
|
||||
const { data: subdomains, isLoading } = useQuery({
|
||||
queryKey: ["domain-subdomains", domainId],
|
||||
queryFn: () => getDomainSubdomains(domainId),
|
||||
enabled: !!domainId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})
|
||||
|
||||
if (isLoading || !subdomains || subdomains.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activeCount = subdomains.filter(s => s.status === "active").length
|
||||
const totalCount = subdomains.length
|
||||
const hasIssues = subdomains.some(s => s.status === "error")
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border",
|
||||
hasIssues
|
||||
? "bg-orange-500/15 text-orange-600 border-orange-500/30"
|
||||
: "bg-blue-500/15 text-blue-600 border-blue-500/30"
|
||||
)}>
|
||||
<Globe className="h-3 w-3" />
|
||||
<span>{activeCount}/{totalCount}</span>
|
||||
</div>
|
||||
{hasIssues && (
|
||||
<AlertTriangle className="h-3 w-3 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DomainsTable() {
|
||||
const { t } = useLingui()
|
||||
const { toast } = useToast()
|
||||
@@ -121,7 +155,7 @@ export default function DomainsTable() {
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useBrowserStorage<DisplayOptions>(
|
||||
"domainsDisplayOptions",
|
||||
{ showSSL: true, showRegistrar: true, showExpiryDate: true, showTags: true }
|
||||
{ showSSL: true, showRegistrar: true, showExpiryDate: true, showTags: true, showProviders: false }
|
||||
)
|
||||
|
||||
const { data: domains = [], isLoading } = useQuery({
|
||||
@@ -203,19 +237,35 @@ export default function DomainsTable() {
|
||||
refreshMutation.mutate(id)
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
case "expiring":
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />
|
||||
case "expired":
|
||||
return <AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
default:
|
||||
return <Globe className="h-4 w-4 text-gray-500" />
|
||||
}
|
||||
// Status indicator component matching monitors table style
|
||||
function StatusIndicator({ status }: { status: string }) {
|
||||
const colors = {
|
||||
active: "bg-green-500",
|
||||
expiring: "bg-yellow-500",
|
||||
expired: "bg-red-500",
|
||||
unknown: "bg-gray-500",
|
||||
paused: "bg-blue-500",
|
||||
}
|
||||
|
||||
const icons = {
|
||||
active: CheckCircle2,
|
||||
expiring: Clock,
|
||||
expired: AlertTriangle,
|
||||
unknown: AlertTriangle,
|
||||
paused: Clock,
|
||||
}
|
||||
|
||||
const Icon = icons[status as keyof typeof icons] || AlertTriangle
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("h-2.5 w-2.5 rounded-full", colors[status as keyof typeof colors] || "bg-gray-500")} />
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="capitalize text-sm">{status === "active" ? "Active" : status === "expiring" ? "Expiring Soon" : status === "expired" ? "Expired" : status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
@@ -413,6 +463,12 @@ export default function DomainsTable() {
|
||||
>
|
||||
Tags
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={displayOptions.showProviders}
|
||||
onCheckedChange={(checked: boolean) => setDisplayOptions({ ...displayOptions, showProviders: checked })}
|
||||
>
|
||||
Providers
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -446,6 +502,7 @@ export default function DomainsTable() {
|
||||
{displayOptions.showRegistrar && <TableHead>Registrar</TableHead>}
|
||||
{displayOptions.showSSL && <TableHead>SSL Expiry</TableHead>}
|
||||
{displayOptions.showTags && <TableHead>Tags</TableHead>}
|
||||
{displayOptions.showProviders && <TableHead>Providers</TableHead>}
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -458,20 +515,16 @@ export default function DomainsTable() {
|
||||
<img
|
||||
src={domain.favicon_url}
|
||||
alt=""
|
||||
className="h-4 w-4"
|
||||
className="h-4 w-4 rounded-sm"
|
||||
onError={(e) => (e.currentTarget.style.display = "none")}
|
||||
/>
|
||||
)}
|
||||
<span className="hover:underline">{domain.domain_name}</span>
|
||||
<SubdomainIndicator domainId={domain.id} />
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(domain.status)}
|
||||
<Badge className={getStatusBadgeColor(domain.status)}>
|
||||
{getStatusLabel(domain.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<StatusIndicator status={domain.status} />
|
||||
</TableCell>
|
||||
{displayOptions.showExpiryDate && (
|
||||
<TableCell>
|
||||
@@ -508,6 +561,16 @@ export default function DomainsTable() {
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
{displayOptions.showProviders && (
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{domain.dns_provider && <span className="text-muted-foreground">DNS: <span className="text-foreground">{domain.dns_provider}</span></span>}
|
||||
{domain.hosting_provider && <span className="text-muted-foreground">Host: <span className="text-foreground">{domain.hosting_provider}</span></span>}
|
||||
{domain.email_provider && <span className="text-muted-foreground">Email: <span className="text-foreground">{domain.email_provider}</span></span>}
|
||||
{domain.ca_provider && <span className="text-muted-foreground">CA: <span className="text-foreground">{domain.ca_provider}</span></span>}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -566,6 +629,7 @@ export default function DomainsTable() {
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate hover:underline">{domain.domain_name}</div>
|
||||
<SubdomainIndicator domainId={domain.id} />
|
||||
</div>
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
@@ -587,12 +651,7 @@ export default function DomainsTable() {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(domain.status)}
|
||||
<Badge className={getStatusBadgeColor(domain.status)}>
|
||||
{getStatusLabel(domain.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<StatusIndicator status={domain.status} />
|
||||
|
||||
{displayOptions.showTags && domain.tags && domain.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -617,6 +676,14 @@ export default function DomainsTable() {
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[120px]">{domain.registrar_name || "Unknown"}</span>
|
||||
)}
|
||||
</div>
|
||||
{displayOptions.showProviders && (
|
||||
<div className="flex flex-wrap gap-1 text-[10px]">
|
||||
{domain.dns_provider && <span className="text-muted-foreground">DNS: <span className="text-foreground">{domain.dns_provider}</span></span>}
|
||||
{domain.hosting_provider && <span className="text-muted-foreground">Host: <span className="text-foreground">{domain.hosting_provider}</span></span>}
|
||||
{domain.email_provider && <span className="text-muted-foreground">Email: <span className="text-foreground">{domain.email_provider}</span></span>}
|
||||
{domain.ca_provider && <span className="text-muted-foreground">CA: <span className="text-foreground">{domain.ca_provider}</span></span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<DaysLeftBadge days={domain.days_until_expiry} />
|
||||
{displayOptions.showSSL && domain.ssl_valid_to && (
|
||||
|
||||
@@ -22,13 +22,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { memo, useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -40,24 +34,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
deleteMonitor,
|
||||
getMonitorTypeLabel,
|
||||
getMonitorFaviconUrl,
|
||||
listMonitors,
|
||||
manualCheck,
|
||||
pauseMonitor,
|
||||
@@ -70,9 +53,7 @@ import {
|
||||
} from "@/lib/monitors"
|
||||
import { cn, useBrowserStorage } from "@/lib/utils"
|
||||
import { AddMonitorDialog } from "./add-monitor-dialog"
|
||||
import { GroupedMonitorsTable } from "./grouped-monitors-table"
|
||||
import { Link } from "@/components/router"
|
||||
import { Network } from "lucide-react"
|
||||
|
||||
// Status indicator component
|
||||
function StatusIndicator({ status }: { status: MonitorStatus }) {
|
||||
@@ -103,13 +84,35 @@ function StatusIndicator({ status }: { status: MonitorStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Favicon component
|
||||
function MonitorFavicon({ monitor, className }: { monitor: Monitor; className?: string }) {
|
||||
const [error, setError] = useState(false)
|
||||
const faviconUrl = getMonitorFaviconUrl(monitor)
|
||||
|
||||
if (!faviconUrl || error) {
|
||||
return <GlobeIcon className={cn("h-4 w-4 text-muted-foreground", className)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className={cn("h-4 w-4 object-contain", className)}
|
||||
onError={() => setError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Monitor Card component for grid view
|
||||
function MonitorCard({
|
||||
monitor,
|
||||
onEdit,
|
||||
displayOptions,
|
||||
}: {
|
||||
monitor: Monitor
|
||||
onEdit: (m: Monitor) => void
|
||||
displayOptions: DisplayOptions
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -148,7 +151,10 @@ function MonitorCard({
|
||||
<div className="flex items-start justify-between">
|
||||
<Link href={`/monitor/${monitor.id}`} className="flex items-center gap-3 cursor-pointer min-w-0">
|
||||
<div className="shrink-0">
|
||||
<StatusIndicator status={monitor.status} />
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorFavicon monitor={monitor} className="h-5 w-5" />
|
||||
<StatusIndicator status={monitor.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate hover:underline">{monitor.name}</div>
|
||||
@@ -169,10 +175,7 @@ function MonitorCard({
|
||||
<Edit3Icon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteMutation.mutate(monitor.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => deleteMutation.mutate(monitor.id)} className="text-destructive">
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -187,18 +190,24 @@ function MonitorCard({
|
||||
{getMonitorTypeLabel(monitor.type)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Uptime - Prominent pill display */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<UptimePill uptime={monitor.uptime_stats?.uptime_24h ?? 100} label="24h" />
|
||||
{monitor.uptime_stats?.uptime_7d !== undefined && monitor.uptime_stats.uptime_7d !== monitor.uptime_stats?.uptime_24h && (
|
||||
<UptimePill uptime={monitor.uptime_stats.uptime_7d} label="7d" />
|
||||
)}
|
||||
{displayOptions.showUptimePills && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{displayOptions.showUptimePercentage && (
|
||||
<UptimePill uptime={monitor.uptime_stats?.uptime_24h ?? 100} label="24h" />
|
||||
)}
|
||||
{displayOptions.showUptimePercentage &&
|
||||
monitor.uptime_stats?.uptime_7d !== undefined &&
|
||||
monitor.uptime_stats.uptime_7d !== monitor.uptime_stats?.uptime_24h && (
|
||||
<UptimePill uptime={monitor.uptime_stats.uptime_7d} label="7d" />
|
||||
)}
|
||||
</div>
|
||||
{displayOptions.showHeartbeatDots && <UptimeDots heartbeats={monitor.recent_heartbeats} />}
|
||||
</div>
|
||||
<UptimeDots heartbeats={monitor.recent_heartbeats} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="text-xs text-muted-foreground">Response</div>
|
||||
<div>
|
||||
@@ -236,12 +245,7 @@ function MonitorCard({
|
||||
onClick={() => checkMutation.mutate(monitor.id)}
|
||||
disabled={checkMutation.isPending}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"h-4 w-4 mr-1",
|
||||
checkMutation.isPending && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
<RefreshCwIcon className={cn("h-4 w-4 mr-1", checkMutation.isPending && "animate-spin")} />
|
||||
Check
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -261,9 +265,13 @@ function MonitorCard({
|
||||
disabled={pauseMutation.isPending}
|
||||
>
|
||||
{monitor.status === "paused" ? (
|
||||
<><PlayIcon className="h-4 w-4 mr-1" /> Resume</>
|
||||
<>
|
||||
<PlayIcon className="h-4 w-4 mr-1" /> Resume
|
||||
</>
|
||||
) : (
|
||||
<><PauseIcon className="h-4 w-4 mr-1" /> Pause</>
|
||||
<>
|
||||
<PauseIcon className="h-4 w-4 mr-1" /> Pause
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -277,28 +285,42 @@ function MonitorCard({
|
||||
)
|
||||
}
|
||||
|
||||
// Uptime pill badge component - big and visible
|
||||
// Uptime pill badge component - styled like domainstack.io status badges
|
||||
function UptimePill({ uptime, label = "24h" }: { uptime: number; label?: string }) {
|
||||
let colorClass = "bg-green-500/15 text-green-600 border-green-500/30"
|
||||
let icon = <CheckCircleIcon className="h-3.5 w-3.5" />
|
||||
|
||||
let colorClass = "bg-green-500/10 text-green-700 border-green-500/20 dark:text-green-400"
|
||||
let icon = <CheckCircleIcon className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
|
||||
let ringClass = "ring-green-500/20"
|
||||
|
||||
if (uptime < 99.9) {
|
||||
colorClass = "bg-green-500/15 text-green-600 border-green-500/30"
|
||||
colorClass = "bg-green-500/10 text-green-700 border-green-500/20 dark:text-green-400"
|
||||
ringClass = "ring-green-500/20"
|
||||
}
|
||||
if (uptime < 95) {
|
||||
colorClass = "bg-yellow-500/15 text-yellow-600 border-yellow-500/30"
|
||||
icon = <AlertTriangle className="h-3.5 w-3.5" />
|
||||
colorClass = "bg-yellow-500/10 text-yellow-700 border-yellow-500/20 dark:text-yellow-400"
|
||||
icon = <AlertTriangle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400" />
|
||||
ringClass = "ring-yellow-500/20"
|
||||
}
|
||||
if (uptime < 90) {
|
||||
colorClass = "bg-red-500/15 text-red-600 border-red-500/30"
|
||||
icon = <XCircle className="h-3.5 w-3.5" />
|
||||
colorClass = "bg-red-500/10 text-red-700 border-red-500/20 dark:text-red-400"
|
||||
icon = <XCircle className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
|
||||
ringClass = "ring-red-500/20"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border-2 ${colorClass}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-semibold shadow-sm",
|
||||
"transition-all hover:scale-105",
|
||||
colorClass,
|
||||
ringClass,
|
||||
"ring-1"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-sm font-bold">{formatUptime(uptime)}</span>
|
||||
<span className="text-[10px] font-medium uppercase opacity-70">{label}</span>
|
||||
<span>{formatUptime(uptime)}</span>
|
||||
<span className="text-[10px] font-medium uppercase opacity-60 border-l border-current/20 pl-1.5 ml-0.5">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -309,17 +331,11 @@ function UptimeBar({ stats }: { stats?: Record<string, number> }) {
|
||||
const uptime7d = stats?.uptime_7d ?? 100
|
||||
const uptime30d = stats?.uptime_30d ?? 100
|
||||
|
||||
let color = "bg-green-500"
|
||||
if (uptime24h < 95) color = "bg-yellow-500"
|
||||
if (uptime24h < 90) color = "bg-red-500"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<UptimePill uptime={uptime24h} label="24h" />
|
||||
{uptime7d !== 100 && uptime7d !== uptime24h && (
|
||||
<UptimePill uptime={uptime7d} label="7d" />
|
||||
)}
|
||||
{uptime7d !== 100 && uptime7d !== uptime24h && <UptimePill uptime={uptime7d} label="7d" />}
|
||||
{uptime30d !== 100 && uptime30d !== uptime24h && uptime30d !== uptime7d && (
|
||||
<UptimePill uptime={uptime30d} label="30d" />
|
||||
)}
|
||||
@@ -328,39 +344,52 @@ function UptimeBar({ stats }: { stats?: Record<string, number> }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Mini uptime dots visualization
|
||||
// Mini uptime dots visualization - styled as a clean status bar
|
||||
function UptimeDots({ heartbeats }: { heartbeats?: Array<{ status: string; time: string }> }) {
|
||||
if (!heartbeats || heartbeats.length === 0) {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="h-3 w-2 rounded-sm bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
const totalSlots = 16
|
||||
const recent = heartbeats?.slice(-totalSlots) || []
|
||||
const emptySlots = totalSlots - recent.length
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return "bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]"
|
||||
case "down":
|
||||
return "bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.4)]"
|
||||
case "paused":
|
||||
return "bg-gray-400"
|
||||
default:
|
||||
return "bg-yellow-500"
|
||||
}
|
||||
}
|
||||
|
||||
// Take last 12 heartbeats
|
||||
const recent = heartbeats.slice(-12)
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{recent.map((hb, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-3 w-2 rounded-sm transition-colors",
|
||||
hb.status === "up" ? "bg-green-500" :
|
||||
hb.status === "down" ? "bg-red-500" :
|
||||
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
|
||||
)}
|
||||
title={`${hb.status} at ${new Date(hb.time).toLocaleString()}`}
|
||||
/>
|
||||
))}
|
||||
{recent.length < 12 && Array.from({ length: 12 - recent.length }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-3 w-2 rounded-sm bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex gap-[3px] p-1 rounded-md bg-muted/50">
|
||||
{recent.map((hb, i) => (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"h-4 w-[6px] rounded-full transition-all cursor-pointer hover:scale-125",
|
||||
getStatusColor(hb.status)
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p className="capitalize font-medium">{hb.status}</p>
|
||||
<p className="text-muted-foreground">{new Date(hb.time).toLocaleString()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
{emptySlots > 0 &&
|
||||
Array.from({ length: emptySlots }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-4 w-[6px] rounded-full bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,9 +397,11 @@ function UptimeDots({ heartbeats }: { heartbeats?: Array<{ status: string; time:
|
||||
function MonitorRow({
|
||||
monitor,
|
||||
onEdit,
|
||||
displayOptions,
|
||||
}: {
|
||||
monitor: Monitor
|
||||
onEdit: (m: Monitor) => void
|
||||
displayOptions: DisplayOptions
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -414,7 +445,7 @@ function MonitorRow({
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Link href={`/monitor/${monitor.id}`} className="flex items-center gap-3 cursor-pointer">
|
||||
<GlobeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<MonitorFavicon monitor={monitor} className="h-5 w-5" />
|
||||
<div>
|
||||
<div className="font-medium hover:underline">{monitor.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -434,15 +465,17 @@ function MonitorRow({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{monitor.last_check ? (
|
||||
<div className="text-sm">
|
||||
{formatPing(monitor.uptime_stats?.last_ping || 0)}
|
||||
</div>
|
||||
<div className="text-sm">{formatPing(monitor.uptime_stats?.last_ping || 0)}</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UptimeBar stats={monitor.uptime_stats} />
|
||||
{displayOptions.showUptimePills ? (
|
||||
<UptimeBar stats={monitor.uptime_stats} />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -469,12 +502,7 @@ function MonitorRow({
|
||||
onClick={() => checkMutation.mutate(monitor.id)}
|
||||
disabled={checkMutation.isPending}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
checkMutation.isPending && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
<RefreshCwIcon className={cn("h-4 w-4", checkMutation.isPending && "animate-spin")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -493,11 +521,7 @@ function MonitorRow({
|
||||
onClick={() => pauseMutation.mutate(monitor.id)}
|
||||
disabled={pauseMutation.isPending}
|
||||
>
|
||||
{monitor.status === "paused" ? (
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PauseIcon className="h-4 w-4" />
|
||||
)}
|
||||
{monitor.status === "paused" ? <PlayIcon className="h-4 w-4" /> : <PauseIcon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -517,10 +541,7 @@ function MonitorRow({
|
||||
<Edit3Icon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => deleteMutation.mutate(monitor.id)}
|
||||
>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(monitor.id)}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -532,10 +553,16 @@ function MonitorRow({
|
||||
)
|
||||
}
|
||||
|
||||
type ViewMode = "table" | "grid" | "network"
|
||||
type ViewMode = "table" | "grid"
|
||||
type StatusFilter = "all" | MonitorStatus
|
||||
type TypeFilter = "all" | MonitorType
|
||||
|
||||
interface DisplayOptions {
|
||||
showUptimePills: boolean
|
||||
showUptimePercentage: boolean
|
||||
showHeartbeatDots: boolean
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default memo(function MonitorsTable() {
|
||||
const { t } = useLingui()
|
||||
@@ -545,12 +572,18 @@ export default memo(function MonitorsTable() {
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [editingMonitor, setEditingMonitor] = useState<Monitor | null>(null)
|
||||
|
||||
|
||||
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
|
||||
"monitorsViewMode",
|
||||
window.innerWidth < 1024 ? "grid" : "table"
|
||||
)
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useBrowserStorage<DisplayOptions>("monitorsDisplayOptions", {
|
||||
showUptimePills: true,
|
||||
showUptimePercentage: true,
|
||||
showHeartbeatDots: true,
|
||||
})
|
||||
|
||||
const { data: monitors = [], isLoading } = useQuery({
|
||||
queryKey: ["monitors"],
|
||||
queryFn: listMonitors,
|
||||
@@ -626,8 +659,7 @@ export default memo(function MonitorsTable() {
|
||||
{stats.down > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
{stats.down}{" "}
|
||||
<ArrowDownIcon className="inline h-3 w-3 text-red-500" />
|
||||
{stats.down} <ArrowDownIcon className="inline h-3 w-3 text-red-500" />
|
||||
</>
|
||||
)}
|
||||
{stats.paused > 0 && (
|
||||
@@ -745,13 +777,45 @@ export default memo(function MonitorsTable() {
|
||||
<LayoutGridIcon className="size-4" />
|
||||
<Trans>Grid</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="network" className="gap-2">
|
||||
<Network className="size-4" />
|
||||
<Trans>Network (Grouped)</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Display Options */}
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<Settings2Icon className="size-4" />
|
||||
<Trans>Display</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displayOptions.showUptimePills}
|
||||
onChange={(e) => setDisplayOptions({ ...displayOptions, showUptimePills: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span>Show uptime pills</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displayOptions.showUptimePercentage}
|
||||
onChange={(e) => setDisplayOptions({ ...displayOptions, showUptimePercentage: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span>Show percentage</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displayOptions.showHeartbeatDots}
|
||||
onChange={(e) => setDisplayOptions({ ...displayOptions, showHeartbeatDots: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span>Show heartbeat dots</span>
|
||||
</label>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Status Filter */}
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<FilterIcon className="size-4" />
|
||||
@@ -807,8 +871,6 @@ export default memo(function MonitorsTable() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "network" ? (
|
||||
<GroupedMonitorsTable />
|
||||
) : viewMode === "table" ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -842,6 +904,7 @@ export default memo(function MonitorsTable() {
|
||||
key={monitor.id}
|
||||
monitor={monitor}
|
||||
onEdit={setEditingMonitor}
|
||||
displayOptions={displayOptions}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -853,6 +916,7 @@ export default memo(function MonitorsTable() {
|
||||
key={monitor.id}
|
||||
monitor={monitor}
|
||||
onEdit={setEditingMonitor}
|
||||
displayOptions={displayOptions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -860,10 +924,7 @@ export default memo(function MonitorsTable() {
|
||||
</CardContent>
|
||||
|
||||
{/* Add Monitor Dialog */}
|
||||
<AddMonitorDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
/>
|
||||
<AddMonitorDialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
|
||||
|
||||
{/* Edit Monitor Dialog */}
|
||||
{editingMonitor && (
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
import type { Domain } from "@/lib/domains"
|
||||
import { formatDate, formatDays } from "@/lib/domains"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Globe,
|
||||
Shield,
|
||||
Server,
|
||||
MapPin,
|
||||
FileText,
|
||||
Building2,
|
||||
User,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Mail,
|
||||
Network,
|
||||
Search,
|
||||
Code2,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Info,
|
||||
} from "lucide-react"
|
||||
|
||||
// --- Reusable section wrapper inspired by domainstack.io ---
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
children,
|
||||
accent = "slate",
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
icon: React.ElementType
|
||||
children: React.ReactNode
|
||||
accent?: "blue" | "green" | "orange" | "purple" | "slate" | "red" | "yellow"
|
||||
}) {
|
||||
const accentBorder = {
|
||||
blue: "border-t-blue-500/40",
|
||||
green: "border-t-green-500/40",
|
||||
orange: "border-t-orange-500/40",
|
||||
purple: "border-t-purple-500/40",
|
||||
slate: "border-t-slate-500/30",
|
||||
red: "border-t-red-500/40",
|
||||
yellow: "border-t-yellow-500/40",
|
||||
}
|
||||
const accentIcon = {
|
||||
blue: "text-blue-500",
|
||||
green: "text-green-500",
|
||||
orange: "text-orange-500",
|
||||
purple: "text-purple-500",
|
||||
slate: "text-slate-500",
|
||||
red: "text-red-500",
|
||||
yellow: "text-yellow-500",
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden rounded-xl border-t-2", accentBorder[accent])}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className={cn("h-5 w-5", accentIcon[accent])} />
|
||||
<div>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
{description && <CardDescription className="text-xs">{description}</CardDescription>}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function KV({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
leading,
|
||||
}: {
|
||||
label: string
|
||||
value?: string | null
|
||||
suffix?: React.ReactNode
|
||||
leading?: React.ReactNode
|
||||
}) {
|
||||
if (!value && value !== "0") return null
|
||||
return (
|
||||
<div className="flex h-14 min-w-0 items-center justify-between gap-3 rounded-lg border bg-background/60 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[10px] leading-none tracking-wider uppercase text-foreground/60">{label}</div>
|
||||
<div className="inline-flex min-w-0 items-center gap-1.5 text-[13px] text-foreground/90 mt-1">
|
||||
{leading ? <span className="shrink-0">{leading}</span> : null}
|
||||
<span className="truncate">{value}</span>
|
||||
{suffix ? <span className="shrink-0">{suffix}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KVGrid({ children, cols = 2 }: { children: React.ReactNode; cols?: 1 | 2 | 3 | 4 }) {
|
||||
const colClass =
|
||||
cols === 4
|
||||
? "lg:grid-cols-4 md:grid-cols-2"
|
||||
: cols === 3
|
||||
? "sm:grid-cols-3"
|
||||
: cols === 1
|
||||
? "grid-cols-1"
|
||||
: "sm:grid-cols-2"
|
||||
return <div className={cn("grid grid-cols-1 gap-2", colClass)}>{children}</div>
|
||||
}
|
||||
|
||||
function DnsGroup({
|
||||
type,
|
||||
records,
|
||||
ttl,
|
||||
}: {
|
||||
type: string
|
||||
records: Array<{ value: string; ttl?: string | number }>
|
||||
ttl?: string | number
|
||||
}) {
|
||||
if (!records?.length) return null
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="outline" className="font-mono text-[10px] px-1.5 py-0.5">
|
||||
{type}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{records.length} record{records.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{records.map((rec, i) => (
|
||||
<div key={i} className="flex items-center gap-2 rounded-md border bg-background/40 px-2.5 py-1.5 text-sm">
|
||||
<code className="text-xs font-mono text-foreground/80 truncate flex-1">{rec.value}</code>
|
||||
{(rec.ttl || ttl) && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">TTL {rec.ttl || ttl}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MapEmbed({ lat, lon, title }: { lat: number; lon: number; title?: string }) {
|
||||
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.8}%2C${lat - 0.8}%2C${lon + 0.8}%2C${lat + 0.8}&layer=mapnik&marker=${lat}%2C${lon}`
|
||||
return (
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-lg border mt-2">
|
||||
<iframe title={title || "Location map"} src={mapUrl} className="h-full w-full border-0" loading="lazy" />
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=12/${lat}/${lon}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute bottom-2 right-2 rounded-md bg-white/90 px-2 py-1 text-[10px] font-medium text-foreground shadow-sm hover:bg-white border"
|
||||
>
|
||||
Open Map
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
up: "bg-green-500",
|
||||
down: "bg-red-500",
|
||||
paused: "bg-gray-400",
|
||||
active: "bg-green-500",
|
||||
expiring: "bg-yellow-500",
|
||||
expired: "bg-red-500",
|
||||
unknown: "bg-gray-400",
|
||||
}
|
||||
return <div className={cn("h-2.5 w-2.5 rounded-full", colors[status] || "bg-yellow-500")} />
|
||||
}
|
||||
|
||||
function CertificateCard({
|
||||
cert,
|
||||
index,
|
||||
total,
|
||||
}: {
|
||||
cert: NonNullable<Domain["certificates"]> extends Array<infer T> ? T : never
|
||||
index: number
|
||||
total: number
|
||||
}) {
|
||||
const label = index === 0 ? "Leaf" : index === total - 1 ? "Root" : "Intermediate"
|
||||
return (
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={index === 0 ? "default" : "secondary"} className="text-[10px]">
|
||||
{label}
|
||||
</Badge>
|
||||
{cert.ca_provider && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{cert.ca_provider}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground text-xs">Subject:</span>{" "}
|
||||
<span className="font-medium">{cert.subject}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground text-xs">Issuer:</span> {cert.issuer}
|
||||
</p>
|
||||
</div>
|
||||
{cert.alt_names && cert.alt_names.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">SANs ({cert.alt_names.length})</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cert.alt_names.slice(0, 6).map((name, j) => (
|
||||
<code key={j} className="text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{name}
|
||||
</code>
|
||||
))}
|
||||
{cert.alt_names.length > 6 && (
|
||||
<span className="text-[10px] text-muted-foreground">+{cert.alt_names.length - 6}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-muted-foreground pt-1 border-t">
|
||||
{cert.valid_from} → {cert.valid_to}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main exported sections ---
|
||||
|
||||
export function RegistrationSection({ domain }: { domain: Domain }) {
|
||||
return (
|
||||
<SectionCard title="Registration" description="Registrar and registrant details" icon={Building2} accent="blue">
|
||||
<KVGrid>
|
||||
<KV
|
||||
label="Registrar"
|
||||
value={domain.registrar_name || "Unknown"}
|
||||
leading={<Building2 className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
<KV
|
||||
label="Registrant"
|
||||
value={
|
||||
domain.privacy_enabled
|
||||
? "Hidden (Privacy Protected)"
|
||||
: domain.registrant_name || domain.registrant_org || "Unknown"
|
||||
}
|
||||
leading={
|
||||
domain.privacy_enabled ? (
|
||||
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)
|
||||
}
|
||||
suffix={
|
||||
domain.privacy_enabled !== undefined && (
|
||||
<Badge variant={domain.privacy_enabled ? "default" : "outline"} className="text-[10px]">
|
||||
{domain.privacy_enabled ? "Privacy On" : "Privacy Off"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<KV label="Created" value={formatDate(domain.creation_date) || "Unknown"} />
|
||||
<KV
|
||||
label="Expires"
|
||||
value={formatDate(domain.expiry_date) || "Unknown"}
|
||||
suffix={
|
||||
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 ? (
|
||||
<Badge
|
||||
variant={
|
||||
domain.days_until_expiry <= 7
|
||||
? "destructive"
|
||||
: domain.days_until_expiry <= 30
|
||||
? "outline"
|
||||
: "secondary"
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{formatDays(domain.days_until_expiry)}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{domain.registrar_id && <KV label="Registrar IANA ID" value={domain.registrar_id} />}
|
||||
{domain.whois_server && <KV label="WHOIS Server" value={domain.whois_server} />}
|
||||
</KVGrid>
|
||||
{domain.whois_status && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-[10px] uppercase tracking-wider text-foreground/60 mb-2">EPP Status Codes</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{domain.whois_status.split(", ").map((s, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-[10px]">
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function HostingSection({ domain }: { domain: Domain }) {
|
||||
const location = [domain.host_city, domain.host_region, domain.host_country].filter(Boolean).join(", ") || null
|
||||
const hasCoords = domain.host_lat !== undefined && domain.host_lon !== undefined
|
||||
|
||||
return (
|
||||
<SectionCard title="Hosting & Email" description="Providers and IP geolocation" icon={Server} accent="green">
|
||||
<KVGrid>
|
||||
{domain.dns_provider && (
|
||||
<KV
|
||||
label="DNS"
|
||||
value={domain.dns_provider}
|
||||
leading={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{domain.hosting_provider && (
|
||||
<KV
|
||||
label="Hosting"
|
||||
value={domain.hosting_provider}
|
||||
leading={<Server className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{domain.email_provider && (
|
||||
<KV
|
||||
label="Email"
|
||||
value={domain.email_provider}
|
||||
leading={<Mail className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{domain.ca_provider && (
|
||||
<KV
|
||||
label="Certificate Authority"
|
||||
value={domain.ca_provider}
|
||||
leading={<Shield className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{location && (
|
||||
<KV
|
||||
label="Location"
|
||||
value={location}
|
||||
leading={
|
||||
<span className="text-sm">
|
||||
{domain.host_country_code ? (
|
||||
<span title={domain.host_country_code}>
|
||||
{String.fromCodePoint(
|
||||
...domain.host_country_code
|
||||
.toUpperCase()
|
||||
.split("")
|
||||
.map((c) => 127397 + c.charCodeAt(0))
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</KVGrid>
|
||||
{hasCoords && domain.host_lat && domain.host_lon && (
|
||||
<MapEmbed lat={domain.host_lat} lon={domain.host_lon} title={`Map for ${domain.domain_name}`} />
|
||||
)}
|
||||
{/* IP Addresses */}
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-[10px] uppercase tracking-wider text-foreground/60 mb-2">IP Addresses</p>
|
||||
<div className="space-y-1">
|
||||
{domain.ipv4_addresses?.map((ip) => (
|
||||
<div key={ip} className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
IPv4
|
||||
</Badge>
|
||||
<code className="text-sm font-mono">{ip}</code>
|
||||
</div>
|
||||
))}
|
||||
{domain.ipv6_addresses?.map((ip) => (
|
||||
<div key={ip} className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
IPv6
|
||||
</Badge>
|
||||
<code className="text-sm font-mono break-all">{ip}</code>
|
||||
</div>
|
||||
))}
|
||||
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
|
||||
<p className="text-sm text-muted-foreground">No IP addresses found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DnsSection({ domain }: { domain: Domain }) {
|
||||
const aRecords =
|
||||
domain.dns_a_records?.map((v) => ({ value: v })) || domain.ipv4_addresses?.map((v) => ({ value: v })) || []
|
||||
const aaaaRecords =
|
||||
domain.dns_aaaa_records?.map((v) => ({ value: v })) || domain.ipv6_addresses?.map((v) => ({ value: v })) || []
|
||||
const mxRecords =
|
||||
domain.dns_mx_records?.map((v) => ({ value: v })) || domain.mx_records?.map((v) => ({ value: v })) || []
|
||||
const nsRecords =
|
||||
domain.dns_ns_records?.map((v) => ({ value: v })) || domain.name_servers?.map((v) => ({ value: v })) || []
|
||||
const txtRecords =
|
||||
domain.dns_txt_records?.map((v) => ({ value: v })) || domain.txt_records?.map((v) => ({ value: v })) || []
|
||||
|
||||
if (
|
||||
!aRecords.length &&
|
||||
!aaaaRecords.length &&
|
||||
!mxRecords.length &&
|
||||
!nsRecords.length &&
|
||||
!txtRecords.length &&
|
||||
!domain.cname_record &&
|
||||
!domain.srv_records?.length
|
||||
) {
|
||||
return (
|
||||
<SectionCard title="DNS Records" description="A, AAAA, MX, CNAME, TXT, NS" icon={Network} accent="orange">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No DNS records available</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="DNS Records" description="A, AAAA, MX, CNAME, TXT, NS" icon={Network} accent="orange">
|
||||
<div className="space-y-4">
|
||||
<DnsGroup type="A" records={aRecords} />
|
||||
<DnsGroup type="AAAA" records={aaaaRecords} />
|
||||
{domain.cname_record && <DnsGroup type="CNAME" records={[{ value: domain.cname_record }]} />}
|
||||
<DnsGroup type="MX" records={mxRecords} />
|
||||
<DnsGroup type="TXT" records={txtRecords} />
|
||||
<DnsGroup type="NS" records={nsRecords} />
|
||||
{domain.srv_records && domain.srv_records.length > 0 && (
|
||||
<DnsGroup type="SRV" records={domain.srv_records.map((v) => ({ value: v }))} />
|
||||
)}
|
||||
{domain.dnssec && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
<span className="text-xs text-muted-foreground">DNSSEC</span>
|
||||
<Badge variant={domain.dnssec === "signed" ? "default" : "secondary"} className="text-[10px]">
|
||||
{domain.dnssec}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function SslSection({ domain }: { domain: Domain }) {
|
||||
if (!domain.ssl_valid_to) {
|
||||
return (
|
||||
<SectionCard title="SSL Certificates" description="Issuer and validity" icon={Shield} accent="purple">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No SSL certificate information available</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="SSL Certificates" description="Issuer and validity" icon={Shield} accent="purple">
|
||||
<div className="space-y-3">
|
||||
<KVGrid>
|
||||
<KV
|
||||
label="Status"
|
||||
value={domain.ssl_days_until && domain.ssl_days_until > 0 ? "Valid" : "Expired"}
|
||||
leading={<StatusDot status={domain.ssl_days_until && domain.ssl_days_until > 0 ? "up" : "down"} />}
|
||||
suffix={
|
||||
domain.ssl_days_until !== undefined && (
|
||||
<Badge
|
||||
variant={
|
||||
domain.ssl_days_until <= 7 ? "destructive" : domain.ssl_days_until <= 30 ? "outline" : "secondary"
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{formatDays(domain.ssl_days_until)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<KV label="Subject" value={domain.ssl_subject || domain.domain_name} />
|
||||
<KV label="Issuer" value={domain.ssl_issuer || "Unknown"} />
|
||||
<KV label="Valid From" value={formatDate(domain.ssl_valid_from) || "Unknown"} />
|
||||
<KV label="Valid To" value={formatDate(domain.ssl_valid_to) || "Unknown"} />
|
||||
{domain.ssl_key_size && <KV label="Key Size" value={`${domain.ssl_key_size} bits`} />}
|
||||
{domain.ssl_signature_algo && <KV label="Algorithm" value={domain.ssl_signature_algo} />}
|
||||
</KVGrid>
|
||||
{domain.certificates && domain.certificates.length > 0 && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<p className="text-[10px] uppercase tracking-wider text-foreground/60">
|
||||
Certificate Chain ({domain.certificates.length})
|
||||
</p>
|
||||
{domain.certificates.map((cert, i) => (
|
||||
<CertificateCard key={i} cert={cert} index={i} total={domain.certificates?.length ?? 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function SeoSection({ domain }: { domain: Domain }) {
|
||||
const seo = domain.seo_meta
|
||||
if (!seo) {
|
||||
return (
|
||||
<SectionCard title="SEO & Social" description="Meta tags, previews, robots.txt" icon={Search} accent="slate">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No SEO data available</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
const metaTags = seo.general
|
||||
const og = seo.openGraph
|
||||
const twitter = seo.twitter
|
||||
const robots = seo.robots
|
||||
|
||||
return (
|
||||
<SectionCard title="SEO & Social" description="Meta tags, previews, robots.txt" icon={Search} accent="slate">
|
||||
<div className="space-y-4">
|
||||
{/* Meta Tags */}
|
||||
{metaTags && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Meta Tags</span>
|
||||
{Object.values(metaTags).filter(Boolean).length > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{Object.values(metaTags).filter(Boolean).length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{metaTags.title && <KV label="Title" value={metaTags.title} />}
|
||||
{metaTags.description && <KV label="Description" value={metaTags.description} />}
|
||||
{metaTags.canonical && <KV label="Canonical" value={metaTags.canonical} />}
|
||||
{metaTags.robots && <KV label="Robots" value={metaTags.robots} />}
|
||||
{metaTags.author && <KV label="Author" value={metaTags.author} />}
|
||||
{metaTags.keywords && (
|
||||
<div className="sm:col-span-2">
|
||||
<KV
|
||||
label="Keywords"
|
||||
value={metaTags.keywords.substring(0, 120) + (metaTags.keywords.length > 120 ? "..." : "")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open Graph Preview */}
|
||||
{og && (og.title || og.description) && (
|
||||
<div className="pt-3 border-t">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Open Graph</span>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background/40 p-3 space-y-1">
|
||||
{og.images && og.images.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<img
|
||||
src={og.images[0]}
|
||||
alt="OG preview"
|
||||
className="max-h-32 rounded-md object-cover w-full"
|
||||
loading="lazy"
|
||||
onError={(e) => ((e.target as HTMLImageElement).style.display = "none")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground/90">{og.title}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{og.description}</p>
|
||||
{og.url && (
|
||||
<a
|
||||
href={og.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-primary hover:underline truncate block"
|
||||
>
|
||||
{og.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Twitter Card Preview */}
|
||||
{twitter && (twitter.title || twitter.description) && (
|
||||
<div className="pt-3 border-t">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Twitter/X Card</span>
|
||||
{twitter.card && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{twitter.card}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background/40 p-3 space-y-1">
|
||||
{twitter.image && (
|
||||
<img
|
||||
src={twitter.image}
|
||||
alt="Twitter preview"
|
||||
className="max-h-32 rounded-md object-cover w-full"
|
||||
loading="lazy"
|
||||
onError={(e) => ((e.target as HTMLImageElement).style.display = "none")}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground/90">{twitter.title}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{twitter.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* robots.txt */}
|
||||
{robots?.fetched && (
|
||||
<div className="pt-3 border-t">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Code2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">robots.txt</span>
|
||||
</div>
|
||||
{robots.sitemaps && robots.sitemaps.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Sitemaps</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{robots.sitemaps.map((s, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={s}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-primary hover:underline truncate max-w-[300px]"
|
||||
>
|
||||
{s}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{robots.groups && robots.groups.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{robots.groups.map((group, i) => (
|
||||
<div key={i} className="rounded-md bg-muted/50 p-2 text-xs space-y-1">
|
||||
<p className="text-muted-foreground font-medium">User-agent: {group.userAgents.join(", ")}</p>
|
||||
{group.rules.map((rule, j) => (
|
||||
<div key={j} className="flex items-center gap-1.5">
|
||||
{rule.type === "Allow" ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3 w-3 text-yellow-500" />
|
||||
)}
|
||||
<span className={rule.type === "Allow" ? "text-green-600" : "text-yellow-600"}>
|
||||
{rule.type}: {rule.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DomainTypeBadge({ type }: { type?: string }) {
|
||||
if (!type) return null
|
||||
const configs: Record<string, { color: string; icon: React.ElementType; label: string }> = {
|
||||
expiry: { color: "bg-blue-500/10 text-blue-600 border-blue-500/20", icon: Clock, label: "Expiry Monitor" },
|
||||
watchlist: { color: "bg-purple-500/10 text-purple-600 border-purple-500/20", icon: Eye, label: "Watchlist" },
|
||||
portfolio: { color: "bg-green-500/10 text-green-600 border-green-500/20", icon: Globe, label: "Portfolio" },
|
||||
}
|
||||
const config = configs[type] || configs.expiry
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<Badge variant="outline" className={cn("gap-1 text-[10px]", config.color)}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function ValuationSection({ domain }: { domain: Domain }) {
|
||||
const hasData = (domain.purchase_price ?? 0) > 0 || (domain.current_value ?? 0) > 0 || (domain.renewal_cost ?? 0) > 0
|
||||
if (!hasData) return null
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Valuation & Costs"
|
||||
description="Financial information and renewal settings"
|
||||
icon={FileText}
|
||||
accent="yellow"
|
||||
>
|
||||
<KVGrid>
|
||||
{(domain.purchase_price ?? 0) > 0 && <KV label="Purchase Price" value={`$${domain.purchase_price}`} />}
|
||||
{(domain.current_value ?? 0) > 0 && <KV label="Current Value" value={`$${domain.current_value}`} />}
|
||||
{(domain.renewal_cost ?? 0) > 0 && <KV label="Renewal Cost" value={`$${domain.renewal_cost}`} />}
|
||||
<KV
|
||||
label="Auto-renew"
|
||||
value={domain.auto_renew ? "Enabled" : "Disabled"}
|
||||
leading={
|
||||
domain.auto_renew ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</KVGrid>
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DomainExpiryOverview({ domain }: { domain: Domain }) {
|
||||
return (
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{/* Domain Expiry */}
|
||||
<Card
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 7
|
||||
? "border-red-500/30"
|
||||
: domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
|
||||
? "border-yellow-500/30"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1",
|
||||
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 7
|
||||
? "bg-red-500"
|
||||
: domain.days_until_expiry !== undefined &&
|
||||
domain.days_until_expiry >= 0 &&
|
||||
domain.days_until_expiry <= 30
|
||||
? "bg-yellow-500"
|
||||
: "bg-green-500"
|
||||
)}
|
||||
/>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl",
|
||||
domain.days_until_expiry !== undefined &&
|
||||
domain.days_until_expiry >= 0 &&
|
||||
domain.days_until_expiry <= 7
|
||||
? "bg-red-500/10"
|
||||
: domain.days_until_expiry !== undefined &&
|
||||
domain.days_until_expiry >= 0 &&
|
||||
domain.days_until_expiry <= 30
|
||||
? "bg-yellow-500/10"
|
||||
: "bg-green-500/10"
|
||||
)}
|
||||
>
|
||||
<Globe
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
domain.days_until_expiry !== undefined &&
|
||||
domain.days_until_expiry >= 0 &&
|
||||
domain.days_until_expiry <= 7
|
||||
? "text-red-500"
|
||||
: domain.days_until_expiry !== undefined &&
|
||||
domain.days_until_expiry >= 0 &&
|
||||
domain.days_until_expiry <= 30
|
||||
? "text-yellow-500"
|
||||
: "text-green-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Domain Expires</p>
|
||||
<p className="font-semibold">{formatDate(domain.expiry_date) || "N/A"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xl font-bold",
|
||||
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 7
|
||||
? "text-red-500"
|
||||
: domain.days_until_expiry !== undefined &&
|
||||
domain.days_until_expiry >= 0 &&
|
||||
domain.days_until_expiry <= 30
|
||||
? "text-yellow-500"
|
||||
: "text-green-500"
|
||||
)}
|
||||
>
|
||||
{typeof domain.days_until_expiry === "number" && domain.days_until_expiry >= 0
|
||||
? formatDays(domain.days_until_expiry)
|
||||
: domain.days_until_expiry === -1
|
||||
? "No data"
|
||||
: "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SSL Expiry */}
|
||||
<Card
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
|
||||
? "border-red-500/30"
|
||||
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
|
||||
? "border-yellow-500/30"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1",
|
||||
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
|
||||
? "bg-red-500"
|
||||
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
|
||||
? "bg-yellow-500"
|
||||
: "bg-green-500"
|
||||
)}
|
||||
/>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl",
|
||||
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
|
||||
? "bg-red-500/10"
|
||||
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
|
||||
? "bg-yellow-500/10"
|
||||
: "bg-green-500/10"
|
||||
)}
|
||||
>
|
||||
<Shield
|
||||
className={cn(
|
||||
"h-5 w-5",
|
||||
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
|
||||
? "text-red-500"
|
||||
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
|
||||
? "text-yellow-500"
|
||||
: "text-green-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">SSL Expires</p>
|
||||
<p className="font-semibold">{formatDate(domain.ssl_valid_to) || "No SSL"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xl font-bold",
|
||||
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
|
||||
? "text-red-500"
|
||||
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
|
||||
? "text-yellow-500"
|
||||
: "text-green-500"
|
||||
)}
|
||||
>
|
||||
{typeof domain.ssl_days_until === "number" && domain.ssl_days_until >= 0
|
||||
? formatDays(domain.ssl_days_until)
|
||||
: "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Globe, Shield, Server, MapPin, FileText, Info, AlertTriangle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { formatDate } from "@/lib/domains"
|
||||
import type { Monitor, Heartbeat } from "@/lib/monitors"
|
||||
|
||||
// --- Styled components inspired by domainstack.io ---
|
||||
|
||||
function InfoSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
accent = "slate",
|
||||
}: {
|
||||
title: string
|
||||
icon: React.ElementType
|
||||
children: React.ReactNode
|
||||
accent?: "blue" | "green" | "orange" | "purple" | "slate"
|
||||
}) {
|
||||
const accentColors = {
|
||||
blue: "border-blue-500/10 bg-blue-500/5",
|
||||
green: "border-green-500/10 bg-green-500/5",
|
||||
orange: "border-orange-500/10 bg-orange-500/5",
|
||||
purple: "border-purple-500/10 bg-purple-500/5",
|
||||
slate: "border-border bg-background/60",
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("relative overflow-hidden rounded-xl border", accentColors[accent])}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function KV({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
leading,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
suffix?: React.ReactNode
|
||||
leading?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-14 min-w-0 items-center justify-between gap-3 rounded-lg border bg-background/60 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[10px] leading-none tracking-wider uppercase text-foreground/70">{label}</div>
|
||||
<div className="inline-flex min-w-0 items-center gap-1.5 text-[13px] text-foreground/95 mt-1">
|
||||
{leading ? <span className="shrink-0">{leading}</span> : null}
|
||||
<span className="truncate">{value}</span>
|
||||
{suffix ? <span className="shrink-0">{suffix}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KVGrid({ children, cols = 2 }: { children: React.ReactNode; cols?: 1 | 2 | 3 }) {
|
||||
const colClass = cols === 3 ? "sm:grid-cols-3" : cols === 1 ? "grid-cols-1" : "sm:grid-cols-2"
|
||||
return <div className={cn("grid grid-cols-1 gap-2", colClass)}>{children}</div>
|
||||
}
|
||||
|
||||
// --- Data fetching hooks ---
|
||||
|
||||
interface DnsRecord {
|
||||
type: string
|
||||
value: string
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
interface DomainInfo {
|
||||
hostname: string | null
|
||||
rootDomain: string | null
|
||||
dnsRecords: DnsRecord[]
|
||||
geo: { city?: string; region?: string; country?: string; lat?: number; lon?: number } | null
|
||||
ssl: { valid: boolean; expiry?: string; daysLeft?: number } | null
|
||||
seo: {
|
||||
title?: string
|
||||
description?: string
|
||||
canonical?: string
|
||||
robots?: string
|
||||
generator?: string
|
||||
} | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
function useDomainInfo(monitor: Monitor | undefined, heartbeats: Heartbeat[] | undefined) {
|
||||
const [info, setInfo] = useState<DomainInfo>({
|
||||
hostname: null,
|
||||
rootDomain: null,
|
||||
dnsRecords: [],
|
||||
geo: null,
|
||||
ssl: null,
|
||||
seo: null,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
const hostname = useMemo(() => {
|
||||
if (!monitor) return null
|
||||
if (monitor.hostname) return monitor.hostname.toLowerCase()
|
||||
if (monitor.url) {
|
||||
try {
|
||||
const url = new URL(monitor.url.startsWith("http") ? monitor.url : `https://${monitor.url}`)
|
||||
return url.hostname.toLowerCase()
|
||||
} catch {
|
||||
return monitor.url.toLowerCase()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [monitor])
|
||||
|
||||
const rootDomain = useMemo(() => {
|
||||
if (!hostname) return null
|
||||
const clean = hostname.replace(/^www\./, "")
|
||||
const parts = clean.split(".")
|
||||
if (parts.length <= 2) return clean
|
||||
const specialTLDs = ["co.uk", "com.au", "co.jp", "com.br", "co.nz", "co.za", "co.in", "com.cn"]
|
||||
const lastTwo = parts.slice(-2).join(".")
|
||||
const lastThree = parts.slice(-3).join(".")
|
||||
if (specialTLDs.includes(lastThree)) return lastThree
|
||||
return lastTwo
|
||||
}, [hostname])
|
||||
|
||||
// SSL from latest heartbeat
|
||||
useEffect(() => {
|
||||
if (!heartbeats?.length) {
|
||||
setInfo((prev) => ({ ...prev, ssl: null }))
|
||||
return
|
||||
}
|
||||
const latest = heartbeats[0]
|
||||
if (latest.cert_expiry) {
|
||||
const expiry = new Date(latest.cert_expiry * 1000)
|
||||
const daysLeft = Math.ceil((expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
setInfo((prev) => ({
|
||||
...prev,
|
||||
ssl: { valid: latest.cert_valid ?? true, expiry: expiry.toISOString(), daysLeft },
|
||||
}))
|
||||
}
|
||||
}, [heartbeats])
|
||||
|
||||
// Fetch DNS, geo, SEO
|
||||
useEffect(() => {
|
||||
if (!hostname) {
|
||||
setInfo((prev) => ({ ...prev, loading: false }))
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function fetchData() {
|
||||
// DNS via Cloudflare DoH
|
||||
const dnsPromise = fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`, {
|
||||
headers: { Accept: "application/dns-json" },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const records: DnsRecord[] = []
|
||||
if (data.Answer) {
|
||||
for (const ans of data.Answer) {
|
||||
records.push({ type: "A", value: ans.data, ttl: ans.TTL })
|
||||
}
|
||||
}
|
||||
return records
|
||||
})
|
||||
.catch(() => [] as DnsRecord[])
|
||||
|
||||
// Geolocation via ipapi.co (free, no key needed for basic)
|
||||
const geoPromise = fetch(`https://ipapi.co/${encodeURIComponent(hostname)}/json/`, {
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("geo failed")
|
||||
return r.json()
|
||||
})
|
||||
.then((data) => ({
|
||||
city: data.city,
|
||||
region: data.region,
|
||||
country: data.country_name,
|
||||
lat: data.latitude,
|
||||
lon: data.longitude,
|
||||
}))
|
||||
.catch(() => null)
|
||||
|
||||
// SEO meta tags
|
||||
const seoPromise = monitor?.url
|
||||
? fetch(monitor.url, { method: "GET", mode: "cors" })
|
||||
.then((r) => r.text())
|
||||
.then((html) => {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, "text/html")
|
||||
const getMeta = (name: string) =>
|
||||
doc.querySelector(`meta[name="${name}"]`)?.getAttribute("content") ||
|
||||
doc.querySelector(`meta[property="og:${name}"]`)?.getAttribute("content") ||
|
||||
undefined
|
||||
return {
|
||||
title: doc.querySelector("title")?.textContent || undefined,
|
||||
description: getMeta("description"),
|
||||
canonical: doc.querySelector('link[rel="canonical"]')?.getAttribute("href") || undefined,
|
||||
robots: getMeta("robots"),
|
||||
generator: getMeta("generator"),
|
||||
}
|
||||
})
|
||||
.catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
|
||||
const [dnsRecords, geo, seo] = await Promise.all([dnsPromise, geoPromise, seoPromise])
|
||||
|
||||
if (!cancelled) {
|
||||
setInfo((prev) => ({
|
||||
...prev,
|
||||
hostname,
|
||||
rootDomain,
|
||||
dnsRecords,
|
||||
geo,
|
||||
seo,
|
||||
loading: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hostname, monitor?.url, rootDomain])
|
||||
|
||||
return { ...info, hostname, rootDomain }
|
||||
}
|
||||
|
||||
// --- Map component ---
|
||||
|
||||
function MapEmbed({ lat, lon, hostname }: { lat: number; lon: number; hostname?: string | null }) {
|
||||
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.5}%2C${lat - 0.5}%2C${lon + 0.5}%2C${lat + 0.5}&layer=mapnik&marker=${lat}%2C${lon}`
|
||||
return (
|
||||
<div className="relative h-[220px] w-full overflow-hidden rounded-lg border">
|
||||
<iframe
|
||||
title={`Map for ${hostname || "location"}`}
|
||||
src={mapUrl}
|
||||
className="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=12/${lat}/${lon}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute bottom-2 right-2 rounded bg-white/90 px-2 py-1 text-[10px] font-medium text-foreground shadow hover:bg-white"
|
||||
>
|
||||
View Larger Map
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main composite component ---
|
||||
|
||||
export function MonitorInfoSections({
|
||||
monitor,
|
||||
heartbeats,
|
||||
}: {
|
||||
monitor: Monitor | undefined
|
||||
heartbeats: Heartbeat[] | undefined
|
||||
}) {
|
||||
const { hostname, rootDomain, dnsRecords, geo, ssl, seo, loading } = useDomainInfo(monitor, heartbeats)
|
||||
|
||||
if (!monitor) return null
|
||||
|
||||
const showSeo = seo && (seo.title || seo.description)
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{/* Registration / Domain Overview */}
|
||||
<InfoSection title="Domain Overview" icon={Globe} accent="blue">
|
||||
<KVGrid>
|
||||
<KV
|
||||
label="Hostname"
|
||||
value={hostname || "N/A"}
|
||||
leading={<Globe className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
<KV
|
||||
label="Root Domain"
|
||||
value={rootDomain || "N/A"}
|
||||
leading={<Server className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
<KV label="Type" value={monitor.type.toUpperCase()} />
|
||||
<KV label="Created" value={formatDate(monitor.created)} />
|
||||
</KVGrid>
|
||||
</InfoSection>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{/* Hosting & Geolocation */}
|
||||
<InfoSection title="Hosting & Location" icon={MapPin} accent="green">
|
||||
{geo ? (
|
||||
<div className="space-y-3">
|
||||
<KVGrid cols={1}>
|
||||
<KV
|
||||
label="Location"
|
||||
value={[geo.city, geo.region, geo.country].filter(Boolean).join(", ") || "Unknown"}
|
||||
leading={<MapPin className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
</KVGrid>
|
||||
{geo.lat && geo.lon ? <MapEmbed lat={geo.lat} lon={geo.lon} hostname={hostname} /> : null}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
|
||||
Looking up location...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm text-muted-foreground">No geolocation data available.</div>
|
||||
</div>
|
||||
)}
|
||||
</InfoSection>
|
||||
|
||||
{/* SSL Certificate */}
|
||||
<InfoSection title="SSL Certificate" icon={Shield} accent="purple">
|
||||
{ssl ? (
|
||||
<div className="space-y-2">
|
||||
<KVGrid cols={1}>
|
||||
<KV
|
||||
label="Status"
|
||||
value={ssl.valid ? "Valid" : "Invalid"}
|
||||
leading={
|
||||
<div className={cn("h-2.5 w-2.5 rounded-full", ssl.valid ? "bg-green-500" : "bg-red-500")} />
|
||||
}
|
||||
/>
|
||||
<KV label="Expires" value={ssl.expiry ? formatDate(ssl.expiry) : "Unknown"} />
|
||||
{ssl.daysLeft !== undefined && (
|
||||
<KV
|
||||
label="Days Left"
|
||||
value={`${ssl.daysLeft} days`}
|
||||
suffix={
|
||||
ssl.daysLeft <= 7 ? (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
Expiring Soon
|
||||
</Badge>
|
||||
) : ssl.daysLeft <= 30 ? (
|
||||
<Badge variant="outline" className="text-[10px] border-yellow-500/50 text-yellow-600">
|
||||
Warning
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</KVGrid>
|
||||
</div>
|
||||
) : monitor.type === "https" || monitor.url?.startsWith("https") ? (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm text-muted-foreground">No SSL data yet. It will appear after the next check.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm text-muted-foreground">SSL not applicable for this monitor type.</div>
|
||||
</div>
|
||||
)}
|
||||
</InfoSection>
|
||||
</div>
|
||||
|
||||
{/* DNS Records */}
|
||||
{dnsRecords.length > 0 && (
|
||||
<InfoSection title="DNS Records" icon={Server} accent="orange">
|
||||
<div className="space-y-2">
|
||||
{dnsRecords.map((rec, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border bg-background/60 px-3 py-2">
|
||||
<Badge variant="outline" className="shrink-0 font-mono text-[10px]">
|
||||
{rec.type}
|
||||
</Badge>
|
||||
<span className="text-[13px] text-foreground/90 truncate">{rec.value}</span>
|
||||
{rec.ttl && <span className="ml-auto text-[10px] text-muted-foreground">TTL {rec.ttl}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{/* SEO & Meta */}
|
||||
{showSeo && (
|
||||
<InfoSection title="SEO & Social" icon={FileText} accent="slate">
|
||||
<KVGrid>
|
||||
{seo?.title && <KV label="Title" value={seo.title} />}
|
||||
{seo?.description && <KV label="Description" value={seo.description} />}
|
||||
{seo?.canonical && <KV label="Canonical" value={seo.canonical} />}
|
||||
{seo?.robots && <KV label="Robots" value={seo.robots} />}
|
||||
{seo?.generator && <KV label="Generator" value={seo.generator} />}
|
||||
</KVGrid>
|
||||
</InfoSection>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import {
|
||||
Globe,
|
||||
Clock,
|
||||
@@ -33,10 +33,17 @@ import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Plus,
|
||||
Zap,
|
||||
Gauge,
|
||||
Smartphone,
|
||||
Lock,
|
||||
Eye,
|
||||
LayoutDashboard,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
type Heartbeat,
|
||||
type Monitor,
|
||||
getMonitor,
|
||||
getMonitorStats,
|
||||
getMonitorHeartbeats,
|
||||
@@ -45,8 +52,10 @@ import {
|
||||
resumeMonitor,
|
||||
deleteMonitor,
|
||||
getMonitorTypeLabel,
|
||||
getMonitorFaviconUrl,
|
||||
formatUptime,
|
||||
formatPing,
|
||||
runPageSpeedCheck,
|
||||
} from "@/lib/monitors"
|
||||
import { formatDate } from "@/lib/domains"
|
||||
import {
|
||||
@@ -57,23 +66,13 @@ import {
|
||||
getStatusPageUrl,
|
||||
removeMonitorFromStatusPage,
|
||||
} from "@/lib/statuspages"
|
||||
import {
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
} from "recharts"
|
||||
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, LineChart } from "recharts"
|
||||
import { Link, navigate } from "@/components/router"
|
||||
import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MonitorInfoSections } from "./monitor-info-sections"
|
||||
|
||||
type HeartbeatRow = Heartbeat & { timestamp?: string }
|
||||
type HeartbeatRow = Heartbeat
|
||||
|
||||
// Uptime Bar Component - Visual timeline of recent checks
|
||||
function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
|
||||
@@ -100,11 +99,15 @@ function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] })
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex-1 rounded-sm transition-all hover:opacity-80 cursor-pointer",
|
||||
hb.status === "up" ? "bg-green-500" :
|
||||
hb.status === "down" ? "bg-red-500" :
|
||||
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
|
||||
hb.status === "up"
|
||||
? "bg-green-500"
|
||||
: hb.status === "down"
|
||||
? "bg-red-500"
|
||||
: hb.status === "paused"
|
||||
? "bg-gray-400"
|
||||
: "bg-yellow-500"
|
||||
)}
|
||||
title={`${hb.status} • ${formatPing(hb.ping)} • ${formatDate(hb.time || hb.timestamp || "")}`}
|
||||
title={`${hb.status} • ${formatPing(hb.ping)} • ${formatDate(hb.time || "")}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -113,11 +116,11 @@ function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] })
|
||||
<span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
{recent.filter(h => h.status === "up").length} up
|
||||
{recent.filter((h) => h.status === "up").length} up
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 ml-3">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||
{recent.filter(h => h.status === "down").length} down
|
||||
{recent.filter((h) => h.status === "down").length} down
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -129,16 +132,16 @@ function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] })
|
||||
function ResponseTimeStats({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
|
||||
const stats = useMemo(() => {
|
||||
if (!heartbeats?.length) return null
|
||||
const pings = heartbeats.filter(h => h.ping && h.ping > 0).map(h => h.ping)
|
||||
const pings = heartbeats.filter((h) => h.ping && h.ping > 0).map((h) => h.ping)
|
||||
if (!pings.length) return null
|
||||
|
||||
|
||||
const sorted = [...pings].sort((a, b) => a - b)
|
||||
const avg = Math.round(pings.reduce((a, b) => a + b, 0) / pings.length)
|
||||
const min = sorted[0]
|
||||
const max = sorted[sorted.length - 1]
|
||||
const p95 = sorted[Math.floor(sorted.length * 0.95)]
|
||||
const p99 = sorted[Math.floor(sorted.length * 0.99)]
|
||||
|
||||
|
||||
return { avg, min, max, p95, p99, count: pings.length }
|
||||
}, [heartbeats])
|
||||
|
||||
@@ -170,40 +173,213 @@ function ResponseTimeStats({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Core Web Vitals placeholder component
|
||||
function CoreWebVitalsCard({ url }: { url?: string }) {
|
||||
function getVitalColor(status: string): string {
|
||||
switch (status) {
|
||||
case "good":
|
||||
return "text-green-500"
|
||||
case "needs-improvement":
|
||||
return "text-yellow-500"
|
||||
default:
|
||||
return "text-red-500"
|
||||
}
|
||||
}
|
||||
|
||||
function getVitalBg(status: string): string {
|
||||
switch (status) {
|
||||
case "good":
|
||||
return "bg-green-500/10 border-green-500/20"
|
||||
case "needs-improvement":
|
||||
return "bg-yellow-500/10 border-yellow-500/20"
|
||||
default:
|
||||
return "bg-red-500/10 border-red-500/20"
|
||||
}
|
||||
}
|
||||
|
||||
function ScoreRing({ score, label }: { score: number; label: string }) {
|
||||
const color = score >= 90 ? "text-green-500" : score >= 70 ? "text-yellow-500" : "text-red-500"
|
||||
const bg = score >= 90 ? "stroke-green-500" : score >= 70 ? "stroke-yellow-500" : "stroke-red-500"
|
||||
const circumference = 2 * Math.PI * 18
|
||||
const offset = circumference - (score / 100) * circumference
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="relative w-12 h-12">
|
||||
<svg
|
||||
className="w-12 h-12 -rotate-90"
|
||||
viewBox="0 0 44 44"
|
||||
role="img"
|
||||
aria-label={`Score ${Math.round(score)} for ${label}`}
|
||||
>
|
||||
<title>
|
||||
Score {Math.round(score)} for {label}
|
||||
</title>
|
||||
<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 }) {
|
||||
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" })
|
||||
},
|
||||
})
|
||||
|
||||
if (!url) return null
|
||||
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Core Web Vitals</CardTitle>
|
||||
<CardDescription>Lighthouse performance metrics (coming soon)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-yellow-500" />
|
||||
Core Web Vitals
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{data
|
||||
? `Checked ${new Date(data.checkedAt).toLocaleString()}`
|
||||
: "Run a Lighthouse check to get performance metrics"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border overflow-hidden">
|
||||
<button
|
||||
onClick={() => setStrategy("mobile")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
strategy === "mobile" ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
<Smartphone className="h-3 w-3 inline mr-1" />
|
||||
Mobile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStrategy("desktop")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
strategy === "desktop" ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
<Gauge className="h-3 w-3 inline mr-1" />
|
||||
Desktop
|
||||
</button>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => mutate()} disabled={isPageSpeedLoading}>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", isPageSpeedLoading && "animate-spin")} />
|
||||
{isPageSpeedLoading ? "Running..." : "Run Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-muted/30 rounded-lg">
|
||||
<div className="text-sm text-muted-foreground mb-1">LCP</div>
|
||||
<div className="text-2xl font-bold text-yellow-500">-</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Largest Contentful Paint</div>
|
||||
{data ? (
|
||||
<div className="space-y-4">
|
||||
{/* Lighthouse Scores */}
|
||||
<div className="flex items-center gap-4 justify-center sm:justify-start">
|
||||
<ScoreRing score={data.performance} label="Perf" />
|
||||
<ScoreRing score={data.accessibility} label="A11y" />
|
||||
<ScoreRing score={data.bestPractices} label="BP" />
|
||||
<ScoreRing score={data.seo} label="SEO" />
|
||||
</div>
|
||||
|
||||
{/* Core Web Vitals */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<VitalCard
|
||||
label="LCP"
|
||||
value={formatMs(data.lcp)}
|
||||
status={data.vitals.lcp || "poor"}
|
||||
detail="Largest Contentful Paint"
|
||||
/>
|
||||
<VitalCard
|
||||
label="FID"
|
||||
value={formatMs(data.tbt)}
|
||||
status={data.vitals.fid || "poor"}
|
||||
detail="Total Blocking Time (proxy)"
|
||||
/>
|
||||
<VitalCard
|
||||
label="CLS"
|
||||
value={data.cls.toFixed(3)}
|
||||
status={data.vitals.cls || "poor"}
|
||||
detail="Cumulative Layout Shift"
|
||||
/>
|
||||
<VitalCard
|
||||
label="FCP"
|
||||
value={formatMs(data.fcp)}
|
||||
status={data.vitals.fcp || "poor"}
|
||||
detail="First Contentful Paint"
|
||||
/>
|
||||
<VitalCard
|
||||
label="TTFB"
|
||||
value={formatMs(data.ttfb)}
|
||||
status={data.vitals.ttfb || "poor"}
|
||||
detail="Time to First Byte"
|
||||
/>
|
||||
<VitalCard
|
||||
label="TTI"
|
||||
value={formatMs(data.tti)}
|
||||
status={data.vitals.tti || "poor"}
|
||||
detail="Time to Interactive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-muted/30 rounded-lg">
|
||||
<div className="text-sm text-muted-foreground mb-1">FID</div>
|
||||
<div className="text-2xl font-bold text-green-500">-</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">First Input Delay</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
|
||||
<div className="p-3 bg-muted/50 rounded-full">
|
||||
<Gauge className="h-6 w-6 opacity-50" />
|
||||
</div>
|
||||
<p className="text-sm">No Lighthouse data yet. Click "Run Check" to analyze performance.</p>
|
||||
<p className="text-xs text-muted-foreground">Powered by Google PageSpeed Insights</p>
|
||||
</div>
|
||||
<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>
|
||||
</Card>
|
||||
)
|
||||
@@ -270,6 +446,15 @@ function StatCard({
|
||||
)
|
||||
}
|
||||
|
||||
function MonitorFaviconImage({ monitor, iconColor }: { monitor: Monitor; iconColor: string }) {
|
||||
const [error, setError] = useState(false)
|
||||
const faviconUrl = getMonitorFaviconUrl(monitor)
|
||||
if (!faviconUrl || error) {
|
||||
return <Globe className={cn("h-6 w-6", iconColor)} />
|
||||
}
|
||||
return <img src={faviconUrl} alt="" className="h-6 w-6 object-contain" onError={() => setError(true)} />
|
||||
}
|
||||
|
||||
export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -331,6 +516,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
const [isCreateStatusPageOpen, setIsCreateStatusPageOpen] = useState(false)
|
||||
const [statusPageName, setStatusPageName] = useState("")
|
||||
const [statusPageSlug, setStatusPageSlug] = useState("")
|
||||
const [statusPagePublic, setStatusPagePublic] = useState(true)
|
||||
|
||||
const { data: statusPages } = useQuery({
|
||||
queryKey: ["status-pages"],
|
||||
@@ -367,19 +553,29 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
})
|
||||
|
||||
const createStatusPageMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createStatusPage({
|
||||
mutationFn: async () => {
|
||||
const page = await createStatusPage({
|
||||
name: statusPageName || `${monitor?.name} Status`,
|
||||
slug: statusPageSlug || monitor?.name?.toLowerCase().replace(/\s+/g, "-") || "status",
|
||||
title: statusPageName || `${monitor?.name} Status Page`,
|
||||
public: true,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
public: statusPagePublic,
|
||||
})
|
||||
// Auto-link this monitor to the newly created status page
|
||||
await addMonitorToStatusPage(page.id, { monitor: id })
|
||||
return page
|
||||
},
|
||||
onSuccess: (page) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["status-pages"] })
|
||||
toast({ title: "Status page created" })
|
||||
queryClient.invalidateQueries({ queryKey: ["monitor-status-page-links", id] })
|
||||
toast({ title: "Status page created and monitor linked" })
|
||||
setIsCreateStatusPageOpen(false)
|
||||
setStatusPageName("")
|
||||
setStatusPageSlug("")
|
||||
setStatusPagePublic(true)
|
||||
// Open private pages in new tab since user is authenticated
|
||||
if (!page.public) {
|
||||
window.open(getStatusPageUrl(page.slug), "_blank", "noopener,noreferrer")
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -398,7 +594,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
}
|
||||
const cutoff = now - (ranges[timeRange] || ranges["24h"])
|
||||
return heartbeats.filter((h: HeartbeatRow) => {
|
||||
const t = new Date(h.time || h.timestamp || "").getTime()
|
||||
const t = new Date(h.time || "").getTime()
|
||||
return t >= cutoff
|
||||
})
|
||||
}, [heartbeats, timeRange])
|
||||
@@ -410,7 +606,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((h: HeartbeatRow) => ({
|
||||
time: new Date(h.time || h.timestamp || "").toLocaleTimeString(),
|
||||
time: new Date(h.time || "").toLocaleTimeString(),
|
||||
responseTime: h.ping || 0,
|
||||
status: h.status === "up" ? 1 : 0,
|
||||
}))
|
||||
@@ -454,8 +650,20 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
const isPaused = monitor.status === "paused"
|
||||
const isPending = monitor.status === "pending"
|
||||
|
||||
const headerIconColor = isUp ? "text-green-500" : isPaused ? "text-gray-500" : isPending ? "text-yellow-500" : "text-red-500"
|
||||
const headerBgColor = isUp ? "bg-green-500/10" : isPaused ? "bg-gray-500/10" : isPending ? "bg-yellow-500/10" : "bg-red-500/10"
|
||||
const headerIconColor = isUp
|
||||
? "text-green-500"
|
||||
: isPaused
|
||||
? "text-gray-500"
|
||||
: isPending
|
||||
? "text-yellow-500"
|
||||
: "text-red-500"
|
||||
const headerBgColor = isUp
|
||||
? "bg-green-500/10"
|
||||
: isPaused
|
||||
? "bg-gray-500/10"
|
||||
: isPending
|
||||
? "bg-yellow-500/10"
|
||||
: "bg-red-500/10"
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 mb-14">
|
||||
@@ -464,13 +672,8 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-full flex items-center justify-center",
|
||||
headerBgColor
|
||||
)}
|
||||
>
|
||||
<Globe className={cn("h-6 w-6", headerIconColor)} />
|
||||
<div className={cn("h-12 w-12 rounded-full flex items-center justify-center", headerBgColor)}>
|
||||
<MonitorFaviconImage monitor={monitor} iconColor={headerIconColor} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{monitor.name}</h1>
|
||||
@@ -578,15 +781,18 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
</div>
|
||||
|
||||
{/* Core Web Vitals */}
|
||||
<CoreWebVitalsCard url={monitor.url} />
|
||||
<CoreWebVitalsCard monitorId={id} url={monitor.url} />
|
||||
|
||||
{/* Combined Uptime & Response Chart */}
|
||||
{/* Domain Info Sections */}
|
||||
<MonitorInfoSections monitor={monitor} heartbeats={heartbeats} />
|
||||
|
||||
{/* Response Time Chart */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Uptime & Response Time</CardTitle>
|
||||
<CardTitle>Response Time</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans>Status and response time over the selected period</Trans>
|
||||
<Trans>Response time over the selected period</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -606,45 +812,36 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
<div className="h-[300px]">
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData}>
|
||||
<LineChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorResponse" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12 }} unit="ms" />
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
domain={[0, 1]}
|
||||
tickFormatter={(v) => (v === 1 ? "Up" : "Down")}
|
||||
/>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickLine={false} axisLine={false} unit="ms" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
formatter={(value: number) => [`${value}ms`, "Response Time"]}
|
||||
/>
|
||||
<Legend />
|
||||
<Area
|
||||
yAxisId="left"
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="responseTime"
|
||||
stroke="#3b82f6"
|
||||
stroke="hsl(var(--chart-1))"
|
||||
strokeWidth={1.5}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorResponse)"
|
||||
name="Response Time (ms)"
|
||||
name="Response Time"
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
<Bar yAxisId="right" dataKey="status" barSize={4} name="Status">
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.status === 1 ? "#22c55e" : "#ef4444"} />
|
||||
))}
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
@@ -666,7 +863,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
|
||||
Run First Check
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -706,28 +903,46 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Page</CardTitle>
|
||||
<CardDescription>Link this monitor to public status pages</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Status Page</CardTitle>
|
||||
<CardDescription>Create or link to status pages</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsCreateStatusPageOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create New
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{statusPages && statusPages.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{statusPages.map((page) => {
|
||||
const isLinked = linkedStatusPageMonitors?.some((link) => link.status_page_id === page.id) || false
|
||||
const linkInfo = linkedStatusPageMonitors?.find((link) => link.status_page_id === page.id)
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={page.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
isLinked ? 'bg-primary/5 border-primary/20' : 'bg-muted/30'
|
||||
}`}
|
||||
<div
|
||||
key={page.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-3 rounded-lg border transition-colors",
|
||||
isLinked ? "bg-primary/5 border-primary/20" : "bg-muted/30 border-border"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutDashboard className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium text-sm truncate">{page.name}</span>
|
||||
{page.public && (
|
||||
<Globe className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
{page.public ? (
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
Public
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isLinked && linkInfo && (
|
||||
@@ -736,30 +951,24 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
{linkInfo.group && ` • Group: ${linkInfo.group}`}
|
||||
</p>
|
||||
)}
|
||||
{!isLinked && page.public && (
|
||||
{!isLinked && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{page.monitor_count} monitor{page.monitor_count !== 1 ? 's' : ''} linked
|
||||
{page.monitor_count} monitor{page.monitor_count !== 1 ? "s" : ""} linked
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
{isLinked && page.public && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={getStatusPageUrl(page.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View public status page"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
asChild
|
||||
title={page.public ? "View public status page" : "View private status page"}
|
||||
>
|
||||
<a href={getStatusPageUrl(page.slug)} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant={isLinked ? "default" : "outline"}
|
||||
size="sm"
|
||||
@@ -788,82 +997,22 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground">No status pages yet.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Create one to share your service status publicly.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Create one to share your service status or keep it private for internal use.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={() => setIsCreateStatusPageOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Status Page
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Checks</CardTitle>
|
||||
<CardDescription>Last 50 monitor checks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Response Time</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{heartbeats?.slice(0, 50).map((hb: HeartbeatRow) => (
|
||||
<TableRow key={hb.id}>
|
||||
<TableCell>{formatDate(hb.time || hb.timestamp)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={hb.status === "up" ? "default" : "destructive"}>{hb.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatPing(hb.ping)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{hb.msg || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!heartbeats?.length && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Status Page Dialog */}
|
||||
{isCreateStatusPageOpen && (
|
||||
<AlertDialog open={isCreateStatusPageOpen} onOpenChange={setIsCreateStatusPageOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Create Status Page</AlertDialogTitle>
|
||||
<AlertDialogDescription>Create a public status page for this monitor.</AlertDialogDescription>
|
||||
<AlertDialogDescription>Create a status page and optionally link this monitor.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
@@ -884,6 +1033,15 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
||||
placeholder={monitor.name?.toLowerCase().replace(/\s+/g, "-")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sp-public" className="text-sm font-medium">
|
||||
Public Status Page
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">Anyone can view this page without authentication.</p>
|
||||
</div>
|
||||
<Switch id="sp-public" checked={statusPagePublic} onCheckedChange={setStatusPagePublic} />
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setIsCreateStatusPageOpen(false)}>Cancel</AlertDialogCancel>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArrowUpIcon,
|
||||
EyeIcon,
|
||||
FilterIcon,
|
||||
GripVertical,
|
||||
LayoutGridIcon,
|
||||
LayoutListIcon,
|
||||
PlusIcon,
|
||||
@@ -96,6 +97,58 @@ export default function SystemsTable() {
|
||||
window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table"
|
||||
)
|
||||
|
||||
// Drag and drop state
|
||||
const [draggedItem, setDraggedItem] = useState<SystemRecord | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = (e: React.DragEvent, item: SystemRecord) => {
|
||||
setDraggedItem(item)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/html', e.currentTarget.outerHTML)
|
||||
}
|
||||
|
||||
// Handle drag over
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverIndex(index)
|
||||
}
|
||||
|
||||
// Handle drag leave
|
||||
const handleDragLeave = () => {
|
||||
setDragOverIndex(null)
|
||||
}
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverIndex(null)
|
||||
|
||||
if (!draggedItem) return
|
||||
|
||||
// Find the dragged item's current index
|
||||
const draggedIndex = filteredData.findIndex(item => item.id === draggedItem.id)
|
||||
if (draggedIndex === dropIndex) return
|
||||
|
||||
// Reorder the data
|
||||
const reorderedData = [...filteredData]
|
||||
reorderedData.splice(draggedIndex, 1)
|
||||
reorderedData.splice(dropIndex, 0, draggedItem)
|
||||
|
||||
// Update the systems store with new order
|
||||
// This would require backend support to persist the order
|
||||
console.log('Reordered systems:', reorderedData.map(item => ({ id: item.id, name: item.name })))
|
||||
|
||||
setDraggedItem(null)
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
const handleDragEnd = () => {
|
||||
setDraggedItem(null)
|
||||
setDragOverIndex(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== undefined) {
|
||||
table.getColumn("system")?.setFilterValue(filter)
|
||||
@@ -138,17 +191,25 @@ export default function SystemsTable() {
|
||||
const CardHead = useMemo(() => {
|
||||
return (
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>All Systems</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>Click on a system to view more information.</Trans>
|
||||
</CardDescription>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Title and Add Button Row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>All Systems</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>Click on a system to view more information.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add System</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ms-auto w-full md:w-96">
|
||||
{/* Filter and View Controls Row */}
|
||||
<div className="flex gap-2 w-full md:w-96">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
@@ -246,11 +307,12 @@ export default function SystemsTable() {
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
|
||||
}}
|
||||
key={column.id}
|
||||
onClick={() => {
|
||||
const isDesc = sorting[0]?.id === column.id && !sorting[0]?.desc
|
||||
setSorting([{ id: column.id, desc: isDesc }])
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
{Icon}
|
||||
{/* @ts-ignore */}
|
||||
@@ -264,34 +326,29 @@ export default function SystemsTable() {
|
||||
<div>
|
||||
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||
<EyeIcon className="size-4" />
|
||||
<Trans>Visible Fields</Trans>
|
||||
<Trans>Columns</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-1.5 pb-1">
|
||||
{columns
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{column.columnDef.name()}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
<div className="px-1 pb-1">
|
||||
{columns.map((column) => {
|
||||
if (column.id === "select") return null
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{column.columnDef.name()}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add System</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -315,7 +372,18 @@ export default function SystemsTable() {
|
||||
{viewMode === "table" ? (
|
||||
// table layout
|
||||
<div className="rounded-md">
|
||||
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||
<AllSystemsTable
|
||||
table={table}
|
||||
rows={rows}
|
||||
colLength={visibleColumns.length}
|
||||
draggedItem={draggedItem}
|
||||
dragOverIndex={dragOverIndex}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragOver={handleDragOver}
|
||||
handleDragLeave={handleDragLeave}
|
||||
handleDrop={handleDrop}
|
||||
handleDragEnd={handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// grid layout
|
||||
@@ -338,7 +406,29 @@ export default function SystemsTable() {
|
||||
}
|
||||
|
||||
const AllSystemsTable = memo(
|
||||
({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
|
||||
({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
draggedItem,
|
||||
dragOverIndex,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleDragEnd
|
||||
}: {
|
||||
table: TableType<SystemRecord>;
|
||||
rows: Row<SystemRecord>[];
|
||||
colLength: number
|
||||
draggedItem: SystemRecord | null
|
||||
dragOverIndex: number | null
|
||||
handleDragStart: (e: React.DragEvent, item: SystemRecord) => void
|
||||
handleDragOver: (e: React.DragEvent, index: number) => void
|
||||
handleDragLeave: () => void
|
||||
handleDrop: (e: React.DragEvent, index: number) => void
|
||||
handleDragEnd: () => void
|
||||
}) => {
|
||||
// The virtualizer will need a reference to the scrollable container element
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -377,6 +467,13 @@ const AllSystemsTable = memo(
|
||||
virtualRow={virtualRow}
|
||||
length={rows.length}
|
||||
colLength={colLength}
|
||||
draggedItem={draggedItem}
|
||||
dragOverIndex={dragOverIndex}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragOver={handleDragOver}
|
||||
handleDragLeave={handleDragLeave}
|
||||
handleDrop={handleDrop}
|
||||
handleDragEnd={handleDragEnd}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -418,32 +515,73 @@ const SystemTableRow = memo(
|
||||
row,
|
||||
virtualRow,
|
||||
colLength,
|
||||
draggedItem,
|
||||
dragOverIndex,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleDragEnd,
|
||||
}: {
|
||||
row: Row<SystemRecord>
|
||||
virtualRow: VirtualItem
|
||||
length: number
|
||||
colLength: number
|
||||
draggedItem: SystemRecord | null
|
||||
dragOverIndex: number | null
|
||||
handleDragStart: (e: React.DragEvent, item: SystemRecord) => void
|
||||
handleDragOver: (e: React.DragEvent, index: number) => void
|
||||
handleDragLeave: () => void
|
||||
handleDrop: (e: React.DragEvent, index: number) => void
|
||||
handleDragEnd: () => void
|
||||
}) => {
|
||||
const system = row.original
|
||||
const { t } = useLingui()
|
||||
const isDragged = draggedItem?.id === system.id
|
||||
const isDragOver = dragOverIndex === virtualRow.index
|
||||
|
||||
return useMemo(() => {
|
||||
return (
|
||||
<TableRow
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, system)}
|
||||
onDragOver={(e) => handleDragOver(e, virtualRow.index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, virtualRow.index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
// data-state={row.getIsSelected() && "selected"}
|
||||
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
|
||||
"opacity-50": system.status === SystemStatus.Paused,
|
||||
"opacity-30": isDragged,
|
||||
"border-t-2 border-b-2 border-blue-500 bg-blue-50": isDragOver,
|
||||
})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
{row.getVisibleCells().map((cell, index) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
className="py-0 ps-4.5"
|
||||
className={cn("py-0", index === 0 ? "ps-2" : "ps-4.5")}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{index === 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-1 hover:bg-muted rounded"
|
||||
onDragStart={(e) => handleDragStart(e, system)}
|
||||
onDragOver={(e) => handleDragOver(e, virtualRow.index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, virtualRow.index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
@@ -121,6 +121,66 @@ export interface Domain {
|
||||
dns_spf_records?: string[]
|
||||
dns_dkim_records?: string[]
|
||||
dns_dmarc_records?: string[]
|
||||
|
||||
// Provider Detection
|
||||
dns_provider?: string
|
||||
hosting_provider?: string
|
||||
email_provider?: string
|
||||
ca_provider?: string
|
||||
|
||||
// HTTP Headers
|
||||
headers?: { name: string; value: string }[]
|
||||
|
||||
// Certificate Chain
|
||||
certificates?: {
|
||||
issuer: string
|
||||
subject: string
|
||||
alt_names: string[]
|
||||
valid_from: string
|
||||
valid_to: string
|
||||
ca_provider: string
|
||||
}[]
|
||||
|
||||
// SEO Metadata
|
||||
seo_meta?: {
|
||||
openGraph: {
|
||||
url: string
|
||||
type: string
|
||||
title: string
|
||||
images: string[]
|
||||
description: string
|
||||
}
|
||||
twitter: {
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
card: string
|
||||
}
|
||||
general: {
|
||||
title: string
|
||||
author: string
|
||||
robots: string
|
||||
keywords: string
|
||||
canonical: string
|
||||
description: string
|
||||
}
|
||||
robots: {
|
||||
fetched: boolean
|
||||
groups: {
|
||||
userAgents: string[]
|
||||
rules: { type: string; value: string }[]
|
||||
}[]
|
||||
sitemaps: string[]
|
||||
}
|
||||
}
|
||||
|
||||
// Raw WHOIS & Registration Details
|
||||
whois_raw?: string
|
||||
privacy_enabled?: boolean
|
||||
transfer_lock?: boolean
|
||||
tld?: string
|
||||
domain_statuses?: string[]
|
||||
host_country_code?: string
|
||||
}
|
||||
|
||||
export interface DomainHistory {
|
||||
@@ -154,6 +214,9 @@ export interface CreateDomainRequest {
|
||||
quiet_hours_enabled?: boolean
|
||||
quiet_hours_start?: string
|
||||
quiet_hours_end?: string
|
||||
// Manual expiry override when WHOIS fails
|
||||
expiry_date?: string
|
||||
creation_date?: string
|
||||
}
|
||||
|
||||
export interface UpdateDomainRequest {
|
||||
@@ -201,6 +264,66 @@ export interface DomainLookupResult {
|
||||
host_isp?: string
|
||||
favicon_url?: string
|
||||
last_checked?: string
|
||||
|
||||
// Provider Detection
|
||||
dns_provider?: string
|
||||
hosting_provider?: string
|
||||
email_provider?: string
|
||||
ca_provider?: string
|
||||
|
||||
// HTTP Headers
|
||||
headers?: { name: string; value: string }[]
|
||||
|
||||
// Certificate Chain
|
||||
certificates?: {
|
||||
issuer: string
|
||||
subject: string
|
||||
alt_names: string[]
|
||||
valid_from: string
|
||||
valid_to: string
|
||||
ca_provider: string
|
||||
}[]
|
||||
|
||||
// SEO Metadata
|
||||
seo_meta?: {
|
||||
openGraph: {
|
||||
url: string
|
||||
type: string
|
||||
title: string
|
||||
images: string[]
|
||||
description: string
|
||||
}
|
||||
twitter: {
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
card: string
|
||||
}
|
||||
general: {
|
||||
title: string
|
||||
author: string
|
||||
robots: string
|
||||
keywords: string
|
||||
canonical: string
|
||||
description: string
|
||||
}
|
||||
robots: {
|
||||
fetched: boolean
|
||||
groups: {
|
||||
userAgents: string[]
|
||||
rules: { type: string; value: string }[]
|
||||
}[]
|
||||
sitemaps: string[]
|
||||
}
|
||||
}
|
||||
|
||||
// Raw WHOIS & Registration Details
|
||||
whois_raw?: string
|
||||
privacy_enabled?: boolean
|
||||
transfer_lock?: boolean
|
||||
tld?: string
|
||||
domain_statuses?: string[]
|
||||
host_country_code?: string
|
||||
}
|
||||
|
||||
const API_BASE = "/api/beszel/domains"
|
||||
|
||||
@@ -197,6 +197,25 @@ export interface CheckResult {
|
||||
time?: string
|
||||
}
|
||||
|
||||
export interface PageSpeedMetrics {
|
||||
performance: number
|
||||
accessibility: number
|
||||
bestPractices: number
|
||||
seo: number
|
||||
pwa: number
|
||||
fcp: number
|
||||
lcp: number
|
||||
ttfb: number
|
||||
cls: number
|
||||
tbt: number
|
||||
speedIndex: number
|
||||
tti: number
|
||||
strategy: string
|
||||
checkedAt: string
|
||||
url: string
|
||||
vitals: Record<string, string>
|
||||
}
|
||||
|
||||
// API Functions
|
||||
export async function listMonitors(): Promise<Monitor[]> {
|
||||
const response = await pb.send<{ monitors: Monitor[] }>("/api/beszel/monitors", {})
|
||||
@@ -261,6 +280,12 @@ export function getMonitorHeartbeats(id: string): Promise<{ heartbeats: Heartbea
|
||||
return pb.send(`/api/beszel/monitors/${id}/heartbeats`, {})
|
||||
}
|
||||
|
||||
export function runPageSpeedCheck(id: string, strategy: string = "mobile"): Promise<PageSpeedMetrics> {
|
||||
return pb.send(`/api/beszel/monitors/${id}/pagespeed?strategy=${strategy}`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
export function getMonitorTypeLabel(type: MonitorType): string {
|
||||
const labels: Record<MonitorType, string> = {
|
||||
@@ -340,6 +365,13 @@ export function formatPing(ping: number): string {
|
||||
return `${(ping / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// Favicon URL helper - uses Google's favicon service as fallback
|
||||
export function getMonitorFaviconUrl(monitor: Monitor): string | null {
|
||||
const hostname = extractHostnameFromMonitor(monitor)
|
||||
if (!hostname) return null
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=32`
|
||||
}
|
||||
|
||||
// Domain extraction and grouping utilities
|
||||
export function extractHostnameFromMonitor(monitor: Monitor): string | null {
|
||||
if (monitor.hostname) {
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "عرض"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "الشبكة"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "تم حلها"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "الحالة"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "عرض أحدث 200 تنبيه."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "الأعمدة الظاهرة"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "الأعمدة الظاهرة"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Показване"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Мрежа"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Решен"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Статус"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Прегледайте последните си 200 сигнала."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Видими полета"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Видими полета"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Zobrazení"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Síť"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Vyřešeno"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Stav"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Zobrazit vašich 200 nejnovějších upozornění."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Viditelné sloupce"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Viditelné sloupce"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Visning"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Løst"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Se dine 200 nyeste alarmer."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Synlige felter"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Synlige felter"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Anzeige"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Netzwerk"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Gelöst"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Sieh dir die neusten 200 Alarme an."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Sichtbare Spalten"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Sichtbare Spalten"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -507,8 +507,8 @@ msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr "Columns"
|
||||
msgid "Columns"
|
||||
msgstr "Columns"
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -788,6 +788,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Display"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr "Display"
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr "Display Columns"
|
||||
@@ -1455,8 +1459,8 @@ msgid "Net"
|
||||
msgstr "Net"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr "Network (Grouped)"
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr "Network (Grouped)"
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1821,6 +1825,10 @@ msgstr "Resolved"
|
||||
msgid "Response"
|
||||
msgstr "Response"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr "Response time over the selected period"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr "Response Times"
|
||||
@@ -2032,8 +2040,8 @@ msgid "Status"
|
||||
msgstr "Status"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr "Status and response time over the selected period"
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr "Status and response time over the selected period"
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2456,8 +2464,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "View your 200 most recent alerts."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Visible Fields"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Visible Fields"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Pantalla"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Red"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Resuelto"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Estado"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Ver tus 200 alertas más recientes."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Columnas visibles"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Columnas visibles"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "نمایش"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "شبکه"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "حل شده"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "وضعیت"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "۲۰۰ هشدار اخیر خود را مشاهده کنید."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "فیلدهای قابل مشاهده"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "فیلدهای قابل مشاهده"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Affichage"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Rés"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Résolu"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Statut"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Voir vos 200 dernières alertes."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Colonnes visibles"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Colonnes visibles"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "תצוגה"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "רשת"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "נפתר"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "סטטוס"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "צפה ב-200 ההתראות האחרונות שלך."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "שדות גלויים"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "שדות גלויים"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Prikaz"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Mreža"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Razrješeno"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Pogledajte posljednjih 200 upozorenja."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Vidljiva polja"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Vidljiva polja"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Megjelenítés"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Hálózat"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Megoldva"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Állapot"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Legfrissebb 200 riasztásod áttekintése."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Látható mezők"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Látható mezők"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Tampilan"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Jaringan"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Diselesaikan"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Lihat 200 peringatan terbaru anda."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Metrik yang Terlihat"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Metrik yang Terlihat"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Rete"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Risolto"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Stato"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Visualizza i tuoi 200 avvisi più recenti."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Colonne visibili"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Colonne visibili"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "表示"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "帯域"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "解決済み"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "ステータス"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "直近200件のアラートを表示します。"
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "表示列"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "表示列"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "표시"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "네트워크"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "해결됨"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "상태"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "최근 200개의 알림을 봅니다."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "표시할 열"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "표시할 열"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Weergave"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Netwerk"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Opgelost"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Bekijk je 200 meest recente meldingen."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Zichtbare kolommen"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Zichtbare kolommen"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Vis"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Nett"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Løst"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Vis de 200 siste varslene."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Synlige Felter"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Synlige Felter"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Widok"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Sieć"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Rozwiązany"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Wyświetl 200 ostatnich alertów."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Widoczne kolumny"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Widoczne kolumny"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Ecrã"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Rede"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Resolvido"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Estado"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Veja os seus 200 alertas mais recentes."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Campos Visíveis"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Campos Visíveis"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Отображение"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Сеть"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Завершено"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Статус"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Просмотреть 200 последних оповещений."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Видимые столбцы"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Видимые столбцы"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Zaslon"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Mreža"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Rešeno"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Stanje"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Oglejte si svojih 200 najnovejših opozoril."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Vidna polja"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Vidna polja"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Приказ"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Мрежа"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Решено"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Статус"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Погледајте ваших 200 најновијих упозорења."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Видљива поља"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Видљива поља"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Visa"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Nät"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Löst"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Visa dina 200 senaste larm."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Synliga fält"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Synliga fält"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Görünüm"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Ağ"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Çözüldü"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Durum"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "En son 200 uyarınızı görüntüleyin."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Görünür Alanlar"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Görünür Alanlar"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Відображення"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Мережа"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Вирішено"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Статус"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Переглянути 200 останніх сповіщень."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Видимі стовпці"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Видимі стовпці"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "Hiển thị"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "Mạng"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "Đã giải quyết"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "Trạng thái"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "Xem 200 cảnh báo gần đây nhất của bạn."
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "Các cột hiển thị"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "Các cột hiển thị"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "显示"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "网络"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "已解决"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "状态"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "查看您最近的200个警报。"
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "可见列"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "可见列"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "顯示"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "網絡"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "已解決"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "狀態"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "檢視最近 200 則警報。"
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "可見欄位"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "可見欄位"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -512,8 +512,8 @@ msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
#~ msgid "Columns"
|
||||
#~ msgstr ""
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
@@ -793,6 +793,10 @@ msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr "顯示"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/domains-table/domains-table.tsx
|
||||
msgid "Display Columns"
|
||||
msgstr ""
|
||||
@@ -1460,8 +1464,8 @@ msgid "Net"
|
||||
msgstr "網路"
|
||||
|
||||
#: src/components/monitors-table/monitors-table.tsx
|
||||
msgid "Network (Grouped)"
|
||||
msgstr ""
|
||||
#~ msgid "Network (Grouped)"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/system/charts/network-charts.tsx
|
||||
msgid "Network traffic of docker containers"
|
||||
@@ -1826,6 +1830,10 @@ msgstr "已解決"
|
||||
msgid "Response"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Response time over the selected period"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
#~ msgid "Response Times"
|
||||
#~ msgstr ""
|
||||
@@ -2037,8 +2045,8 @@ msgid "Status"
|
||||
msgstr "狀態"
|
||||
|
||||
#: src/components/routes/monitor.tsx
|
||||
msgid "Status and response time over the selected period"
|
||||
msgstr ""
|
||||
#~ msgid "Status and response time over the selected period"
|
||||
#~ msgstr ""
|
||||
|
||||
#: src/components/routes/status-pages.tsx
|
||||
msgid "Status Page Manager"
|
||||
@@ -2461,8 +2469,8 @@ msgid "View your 200 most recent alerts."
|
||||
msgstr "檢視最近 200 則警報。"
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx
|
||||
msgid "Visible Fields"
|
||||
msgstr "顯示欄位"
|
||||
#~ msgid "Visible Fields"
|
||||
#~ msgstr "顯示欄位"
|
||||
|
||||
#: src/components/routes/domain.tsx
|
||||
#: src/components/routes/monitor.tsx
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func tryAlternativeWHOISService(ctx context.Context, serviceName, url, domainName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request for %s: %w", serviceName, err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch %s: %w", serviceName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("%s returned status %d", serviceName, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read %s response: %w", serviceName, err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func parseAlternativeWHOISHTML(html, domainName, serviceName string) {
|
||||
fmt.Printf("HTML length from %s: %d characters\n", serviceName, len(html))
|
||||
|
||||
// Look for expiry date patterns (common across WHOIS services)
|
||||
expiryPatterns := []string{
|
||||
`Expiry Date:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Expiry Date:</[^>]*>\s*([^<\n]+)`,
|
||||
`Expires on:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Expires:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`"expiry":"([^"]+)"`,
|
||||
`"expires":"([^"]+)"`,
|
||||
`data-expiry="([^"]+)"`,
|
||||
`expiry_date["\s]*:\s*"([^"]+)"`,
|
||||
`expires["\s]*:\s*"([^"]+)"`,
|
||||
`\d{4}-\d{2}-\d{2}`, // ISO date pattern
|
||||
`\d{2}/\d{2}/\d{4}`, // DD/MM/YYYY pattern
|
||||
`\d{2}-\d{2}-\d{4}`, // MM-DD-YYYY pattern
|
||||
`202\d-\d{2}-\d{2}`, // 202x-xx-xx pattern
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Searching for expiry dates on %s ===\n", serviceName)
|
||||
for _, pattern := range expiryPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for registrar name
|
||||
registrarPatterns := []string{
|
||||
`Registrar:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Registrar:</[^>]*>\s*([^<\n]+)`,
|
||||
`Registered through:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`"registrar":"([^"]+)"`,
|
||||
`data-registrar="([^"]+)"`,
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Searching for registrar on %s ===\n", serviceName)
|
||||
for _, pattern := range registrarPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for status
|
||||
statusPatterns := []string{
|
||||
`Status:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Status:</[^>]*>\s*([^<\n]+)`,
|
||||
`"status":"([^"]+)"`,
|
||||
`data-status="([^"]+)"`,
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Searching for status on %s ===\n", serviceName)
|
||||
for _, pattern := range statusPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show sample HTML
|
||||
fmt.Printf("\n=== Sample HTML from %s (first 1500 chars) ===\n", serviceName)
|
||||
if len(html) > 1500 {
|
||||
fmt.Printf("%s...\n", html[:1500])
|
||||
} else {
|
||||
fmt.Printf("%s\n", html)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
testDomains := []string{"bookra.eu", "sportcreative.eu"}
|
||||
|
||||
for _, domain := range testDomains {
|
||||
fmt.Printf("Testing alternative WHOIS services for: %s\n", domain)
|
||||
fmt.Println("========================================")
|
||||
|
||||
// Try multiple alternative WHOIS services
|
||||
services := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"whois.com", fmt.Sprintf("https://www.whois.com/whois/%s", domain)},
|
||||
{"who.is", fmt.Sprintf("https://who.is/whois/%s", domain)},
|
||||
{"ip2location.com", fmt.Sprintf("https://www.ip2location.com/whois/%s", domain)},
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
fmt.Printf("\n--- Testing %s ---\n", service.name)
|
||||
|
||||
html, err := tryAlternativeWHOISService(context.Background(), service.name, service.url, domain)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
parseAlternativeWHOISHTML(html, domain, service.name)
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// parseFlexibleDate parses dates in various formats
|
||||
func parseFlexibleDate(dateString string) string {
|
||||
if dateString == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove common separators and normalize
|
||||
normalized := regexp.MustCompile(`[./-]`).ReplaceAllString(dateString, "-")
|
||||
normalized = regexp.MustCompile(`\s+`).ReplaceAllString(normalized, "")
|
||||
|
||||
// Try different date formats
|
||||
formats := []string{
|
||||
// DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY
|
||||
`^(\d{2})[-/.](\d{2})[-/.](\d{4})$`,
|
||||
// YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD
|
||||
`^(\d{4})[-/.](\d{2})[-/.](\d{2})$`,
|
||||
// MM-DD-YYYY, MM/DD/YYYY, MM.DD.YYYY
|
||||
`^(\d{2})[-/.](\d{2})[-/.](\d{4})$`,
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
re := regexp.MustCompile(format)
|
||||
match := re.FindStringSubmatch(normalized)
|
||||
if match != nil {
|
||||
part1, part2, part3 := match[1], match[2], match[3]
|
||||
|
||||
// Determine if it's DD.MM.YYYY or YYYY.MM.DD format
|
||||
var year, month, day string
|
||||
|
||||
if len(part1) == 4 {
|
||||
// YYYY.MM.DD format
|
||||
year = part1
|
||||
month = part2
|
||||
day = part3
|
||||
} else {
|
||||
// DD.MM.YYYY format (most common)
|
||||
day = part1
|
||||
month = part2
|
||||
year = part3
|
||||
}
|
||||
|
||||
// Validate and format
|
||||
yearNum, _ := strconv.Atoi(year)
|
||||
monthNum, _ := strconv.Atoi(month)
|
||||
dayNum, _ := strconv.Atoi(day)
|
||||
|
||||
if yearNum >= 2000 && yearNum <= 2100 && monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31 {
|
||||
return fmt.Sprintf("%s-%s-%s", year, fmt.Sprintf("%02s", month), fmt.Sprintf("%02s", day))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
testDates := []string{
|
||||
"15.06.2026",
|
||||
"13.11.2029",
|
||||
"2026-06-15",
|
||||
"15/06/2026",
|
||||
"13-11-2029",
|
||||
"2026.06.15",
|
||||
"15 06 2026",
|
||||
"invalid-date",
|
||||
"32.13.2026", // Invalid day/month
|
||||
"1999.12.31", // Too old
|
||||
}
|
||||
|
||||
fmt.Println("Testing Flexible Date Parsing")
|
||||
fmt.Println("=============================")
|
||||
|
||||
for _, date := range testDates {
|
||||
result := parseFlexibleDate(date)
|
||||
status := "✅"
|
||||
if result == "" {
|
||||
status = "❌"
|
||||
}
|
||||
fmt.Printf("%s '%s' -> '%s'\n", status, date, result)
|
||||
}
|
||||
|
||||
fmt.Println("\nExample Usage:")
|
||||
fmt.Println("=============")
|
||||
fmt.Println("User can paste: '15.06.2026, 13.11.2029'")
|
||||
fmt.Println("System will parse: '2026-06-15', '2029-11-13'")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
testDomains := []string{"bookra.eu", "sportcreative.eu"}
|
||||
|
||||
for _, domain := range testDomains {
|
||||
fmt.Printf("Testing EURid web scraping for: %s\n", domain)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
// EURid web WHOIS URL
|
||||
url := fmt.Sprintf("https://www.eurid.eu/en/registrations/search/?domain=%s", domain)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create request: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to fetch EURid web page: %v\n", err)
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
fmt.Printf("EURid web page returned status %d\n", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
// Read the HTML response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read EURid response: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
html := string(body)
|
||||
fmt.Printf("HTML length: %d characters\n", len(html))
|
||||
|
||||
// Look for expiry date patterns
|
||||
expiryPatterns := []string{
|
||||
`Expiry date:\s*</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`Expiry date:</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`Expiry date</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`"expiryDate":"([^"]+)"`,
|
||||
`data-expiry="([^"]+)"`,
|
||||
`\d{2}/\d{2}/\d{4}`, // Generic date pattern
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for expiry dates ===")
|
||||
for _, pattern := range expiryPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for registrar patterns
|
||||
registrarPatterns := []string{
|
||||
`Registrar:\s*</strong>\s*([^<\n]+)`,
|
||||
`Registrar:</strong>\s*([^<\n]+)`,
|
||||
`Registrar</strong>\s*([^<\n]+)`,
|
||||
`"registrar":"([^"]+)"`,
|
||||
`data-registrar="([^"]+)"`,
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for registrar ===")
|
||||
for _, pattern := range registrarPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show some sample HTML to understand the structure
|
||||
fmt.Println("\n=== Sample HTML (first 1000 chars) ===")
|
||||
if len(html) > 1000 {
|
||||
fmt.Printf("%s...\n", html[:1000])
|
||||
} else {
|
||||
fmt.Printf("%s\n", html)
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func tryEURidEndpoint(ctx context.Context, url, domainName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Use more realistic browser headers
|
||||
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,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
req.Header.Set("Sec-Ch-Ua", "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"")
|
||||
req.Header.Set("Sec-Ch-Ua-Mobile", "?0")
|
||||
req.Header.Set("Sec-Ch-Ua-Platform", "\"Windows\"")
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
req.Header.Set("Sec-Fetch-Site", "none")
|
||||
req.Header.Set("Sec-Fetch-User", "?1")
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch EURid web page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("EURid web page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read the HTML response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read EURid response: %w", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func parseEURidWebHTML(html, domainName string) {
|
||||
fmt.Printf("HTML length: %d characters\n", len(html))
|
||||
|
||||
// Look for expiry date patterns
|
||||
expiryPatterns := []string{
|
||||
`Expiry date:\s*</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`Expiry date:</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`Expiry date</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`"expiryDate":"([^"]+)"`,
|
||||
`data-expiry="([^"]+)"`,
|
||||
`\d{2}/\d{2}/\d{4}`, // Generic date pattern
|
||||
`\d{4}-\d{2}-\d{2}`, // ISO date pattern
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for expiry dates ===")
|
||||
for _, pattern := range expiryPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for registrar patterns
|
||||
registrarPatterns := []string{
|
||||
`Registrar:\s*</strong>\s*([^<\n]+)`,
|
||||
`Registrar:</strong>\s*([^<\n]+)`,
|
||||
`Registrar</strong>\s*([^<\n]+)`,
|
||||
`"registrar":"([^"]+)"`,
|
||||
`data-registrar="([^"]+)"`,
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for registrar ===")
|
||||
for _, pattern := range registrarPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for any JSON data
|
||||
jsonPatterns := []string{
|
||||
`\{[^}]*"expiry[^}]*\}`,
|
||||
`\{[^}]*"registrar[^}]*\}`,
|
||||
`\{[^}]*"domain"[^}]*\}`,
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for JSON data ===")
|
||||
for _, pattern := range jsonPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllString(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show sample HTML
|
||||
fmt.Println("\n=== Sample HTML (first 2000 chars) ===")
|
||||
if len(html) > 2000 {
|
||||
fmt.Printf("%s...\n", html[:2000])
|
||||
} else {
|
||||
fmt.Printf("%s\n", html)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
testDomains := []string{"bookra.eu", "sportcreative.eu"}
|
||||
|
||||
for _, domain := range testDomains {
|
||||
fmt.Printf("Testing EURid web scraping for: %s\n", domain)
|
||||
fmt.Println("========================================")
|
||||
|
||||
// Try multiple EURid endpoints
|
||||
endpoints := []string{
|
||||
fmt.Sprintf("https://whois.eurid.eu/en/?q=%s", domain),
|
||||
fmt.Sprintf("https://whois.eurid.eu/en/search?q=%s", domain),
|
||||
fmt.Sprintf("https://www.eurid.eu/en/whois/?domain=%s", domain),
|
||||
}
|
||||
|
||||
for i, url := range endpoints {
|
||||
fmt.Printf("\n--- Attempt %d: %s ---\n", i+1, url)
|
||||
|
||||
html, err := tryEURidEndpoint(context.Background(), url, domain)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
parseEURidWebHTML(html, domain)
|
||||
break // If we got HTML, don't try other endpoints
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Test TCP WHOIS for .eu domains
|
||||
testDomains := []string{"bookra.eu", "sportcreative.eu"}
|
||||
|
||||
for _, domainName := range testDomains {
|
||||
fmt.Printf("Testing TCP WHOIS for: %s\n", domainName)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
// Extract TLD
|
||||
parts := strings.Split(domainName, ".")
|
||||
if len(parts) < 2 {
|
||||
fmt.Printf("Invalid domain format: %s\n", domainName)
|
||||
continue
|
||||
}
|
||||
tld := strings.ToLower(parts[len(parts)-1])
|
||||
|
||||
// WHOIS server for .eu
|
||||
server := "whois.eu"
|
||||
if tld != "eu" {
|
||||
fmt.Printf("Skipping non-eu domain: %s\n", domainName)
|
||||
continue
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(server, "43")
|
||||
fmt.Printf("Connecting to: %s\n", addr)
|
||||
|
||||
// Use longer timeout for .eu domains
|
||||
timeout := 20 * time.Second
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
|
||||
start := time.Now()
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
fmt.Printf("TCP WHOIS dial failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
fmt.Printf("Connected in: %v\n", time.Since(start))
|
||||
|
||||
// Send query
|
||||
query := domainName + "\r\n"
|
||||
fmt.Printf("Sending query: %q\n", query)
|
||||
|
||||
writeStart := time.Now()
|
||||
if _, err := conn.Write([]byte(query)); err != nil {
|
||||
fmt.Printf("TCP WHOIS write failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Write completed in: %v\n", time.Since(writeStart))
|
||||
|
||||
// Set read deadline
|
||||
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
fmt.Printf("Failed to set read deadline: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Read response
|
||||
var output strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
totalRead := 0
|
||||
|
||||
readStart := time.Now()
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
output.Write(buf[:n])
|
||||
totalRead += n
|
||||
fmt.Printf("Read %d bytes (total: %d)\n", n, totalRead)
|
||||
}
|
||||
if err != nil {
|
||||
if err.Error() != "EOF" {
|
||||
fmt.Printf("Read error: %v\n", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
// Prevent infinite loop
|
||||
if totalRead > 10000 {
|
||||
fmt.Printf("Stopping read after 10KB\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Read completed in: %v\n", time.Since(readStart))
|
||||
fmt.Printf("Total bytes read: %d\n", totalRead)
|
||||
|
||||
response := output.String()
|
||||
if len(response) > 0 {
|
||||
fmt.Printf("✅ WHOIS Response (first 500 chars):\n")
|
||||
if len(response) > 500 {
|
||||
fmt.Printf("%s...\n", response[:500])
|
||||
} else {
|
||||
fmt.Printf("%s\n", response)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ No response received\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/hub/domains/whois"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a new lookup service with a dummy API key
|
||||
lookupService := whois.NewLookupService("dummy-api-key")
|
||||
|
||||
// Test domains including .eu domain
|
||||
testDomains := []string{
|
||||
"google.com",
|
||||
"bookra.eu", // Your .eu domain
|
||||
"sportcreative.eu", // Another .eu domain
|
||||
"github.com",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
fmt.Println("=== WHOIS Lookup Test ===")
|
||||
fmt.Println()
|
||||
|
||||
for _, domainName := range testDomains {
|
||||
fmt.Printf("Testing domain: %s\n", domainName)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Test WHOIS lookup
|
||||
whoisData, _, err := lookupService.LookupWHOIS(ctx, domainName)
|
||||
|
||||
duration := time.Since(start)
|
||||
fmt.Printf("Lookup duration: %v\n", duration)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: %v\n", err)
|
||||
} else if whoisData != nil {
|
||||
fmt.Printf("✅ WHOIS Data Found:\n")
|
||||
fmt.Printf(" Domain: %s\n", whoisData.DomainName)
|
||||
fmt.Printf(" Status: %v\n", whoisData.Status)
|
||||
fmt.Printf(" DNSSEC: %s\n", whoisData.DNSSEC)
|
||||
|
||||
if whoisData.Dates.ExpiryDate != nil && !whoisData.Dates.ExpiryDate.IsZero() {
|
||||
fmt.Printf(" Expiry Date: %s\n", whoisData.Dates.ExpiryDate.Format("2006-01-02"))
|
||||
}
|
||||
if whoisData.Dates.CreationDate != nil && !whoisData.Dates.CreationDate.IsZero() {
|
||||
fmt.Printf(" Creation Date: %s\n", whoisData.Dates.CreationDate.Format("2006-01-02"))
|
||||
}
|
||||
if whoisData.Dates.UpdatedDate != nil && !whoisData.Dates.UpdatedDate.IsZero() {
|
||||
fmt.Printf(" Updated Date: %s\n", whoisData.Dates.UpdatedDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
if whoisData.Registrar.Name != "" {
|
||||
fmt.Printf(" Registrar: %s\n", whoisData.Registrar.Name)
|
||||
}
|
||||
if whoisData.Registrar.ID != "" {
|
||||
fmt.Printf(" Registrar ID: %s\n", whoisData.Registrar.ID)
|
||||
}
|
||||
|
||||
if whoisData.Registrant.Name != "" || whoisData.Registrant.Organization != "" {
|
||||
fmt.Printf(" Registrant: %s (%s)\n", whoisData.Registrant.Name, whoisData.Registrant.Organization)
|
||||
}
|
||||
if whoisData.Registrant.Country != "" {
|
||||
fmt.Printf(" Registrant Country: %s\n", whoisData.Registrant.Country)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ No WHOIS data returned\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("=== Full Domain Lookup Test ===")
|
||||
|
||||
// Test full domain lookup
|
||||
fullDomain, err := lookupService.LookupDomain(ctx, domainName)
|
||||
if err != nil {
|
||||
fmt.Printf("Full lookup ERROR: %v\n", err)
|
||||
} else if fullDomain != nil {
|
||||
fmt.Printf("✅ Full Domain Data:\n")
|
||||
fmt.Printf(" Domain: %s\n", fullDomain.DomainName)
|
||||
fmt.Printf(" Status: %s\n", fullDomain.Status)
|
||||
fmt.Printf(" Active: %t\n", fullDomain.Active)
|
||||
if fullDomain.ExpiryDate != nil {
|
||||
fmt.Printf(" Expiry: %s\n", fullDomain.ExpiryDate.Format("2006-01-02"))
|
||||
daysUntil := int(fullDomain.ExpiryDate.Sub(time.Now()).Hours() / 24)
|
||||
fmt.Printf(" Days Until Expiry: %d\n", daysUntil)
|
||||
}
|
||||
fmt.Printf(" Registrar: %s\n", fullDomain.RegistrarName)
|
||||
fmt.Printf(" DNSSEC: %s\n", fullDomain.DNSSEC)
|
||||
|
||||
if len(fullDomain.IPv4Addresses) > 0 {
|
||||
fmt.Printf(" IPv4 Addresses: %v\n", fullDomain.IPv4Addresses)
|
||||
}
|
||||
if len(fullDomain.IPv6Addresses) > 0 {
|
||||
fmt.Printf(" IPv6 Addresses: %v\n", fullDomain.IPv6Addresses)
|
||||
}
|
||||
if len(fullDomain.NameServers) > 0 {
|
||||
fmt.Printf(" Name Servers: %v\n", fullDomain.NameServers)
|
||||
}
|
||||
if len(fullDomain.MXRecords) > 0 {
|
||||
fmt.Printf(" MX Records: %v\n", fullDomain.MXRecords)
|
||||
}
|
||||
|
||||
if fullDomain.SSLValidTo != nil {
|
||||
fmt.Printf(" SSL Valid Until: %s\n", fullDomain.SSLValidTo.Format("2006-01-02"))
|
||||
sslDaysUntil := int(fullDomain.SSLValidTo.Sub(time.Now()).Hours() / 24)
|
||||
fmt.Printf(" SSL Days Until: %d\n", sslDaysUntil)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Test WhoisXML API for .eu domains
|
||||
const testWhoisXML = async (domain) => {
|
||||
try {
|
||||
// Note: This would require a real API key to test
|
||||
console.log(`Testing WhoisXML API for: ${domain}`);
|
||||
console.log('Note: WhoisXML API requires an API key to test properly');
|
||||
|
||||
// URL structure they use
|
||||
const url = `https://www.whoisxmlapi.com/whoisserver/WhoisService?apiKey=YOUR_API_KEY&outputFormat=json&domainName=${domain}`;
|
||||
console.log(`WhoisXML URL: ${url}`);
|
||||
|
||||
// Based on their code, WhoisXML should return expiry dates for .eu domains
|
||||
console.log('WhoisXML API typically provides expiry dates for .eu domains that TCP WHOIS does not');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
testWhoisXML('bookra.eu');
|
||||
testWhoisXML('sportcreative.eu');
|
||||
Reference in New Issue
Block a user