Compare commits

..

4 Commits

Author SHA1 Message Date
Tomas Dvorak 18046aee71 feat(site): overhaul domain and monitor detail views
Build Docker images / Hub (push) Failing after 1m21s
Refactor the domain and monitor detail pages to use a new modular
component architecture. This includes:

- Implementing `domain-info-sections.tsx` and `monitor-info-sections.tsx`
  to provide structured, card-based information layouts.
- Enhancing domain details with new sections for DNS, SSL, SEO, and
  hosting information.
- Improving monitor details with enhanced uptime visualizations and
  response time statistics.
- Updating the PageSpeed check API to support an optional API key from
  environment variables.
- Cleaning up UI components and improving code formatting in the
  monitors table.
- Updating localization files to support new UI strings.
2026-05-18 18:27:12 +02:00
Tomas Dvorak fe5c7eaa95 feat(hub,site): enhance domain intelligence and monitor performance
Build Docker images / Hub (push) Failing after 1m35s
Implement comprehensive domain data collection including provider detection (DNS, hosting, email, CA), HTTP headers, TLS certificate chains, and SEO metadata. Added PageSpeed Insights integration for monitors to track Core Web Vitals.

- **hub**:
  - Add provider detection logic for DNS, email, and hosting.
  - Expand `Domain` entity to include SEO, headers, certificates, and enhanced registration details.
  - Implement automated collection of TLD, WHOIS raw data, and host country codes.
  - Update scheduler to track changes in providers and security settings (privacy/transfer lock).
  - Add PageSpeed check endpoint to monitor API.
- **site**:
  - Update domain table and detail views to display new intelligence (providers, headers, SEO).
  - Implement PageSpeed metrics visualization with Core Web Vitals status indicators.
  - Add display options for provider information in the domain list.
- **db**:
  - Add migration for new domain collection fields.
2026-05-14 13:33:03 +02:00
Tomas Dvorak 0dd7db8a82 feat(hub,site): enhance domain management and monitor UI
Build Docker images / Hub (push) Failing after 54s
Implement manual domain expiry overrides, improve subdomain discovery via CT logs, and enhance the monitoring dashboard with favicons and configurable display options.

hub:
- allow manual expiry and creation date overrides in domain API when WHOIS lookup fails
- implement JSON parsing for crt.sh certificate transparency log searches in subdomain discovery
- update monitor API routes to use curly brace syntax for path parameters

site:
- add manual registration date and period inputs to domain dialog
- implement monitor favicon support using Google's favicon service
- add configurable display options (uptime pills, heartbeat dots) to monitors table
- update localization files to include new UI elements
2026-05-10 10:24:28 +02:00
Tomas Dvorak b6f40af67f feat(hub): improve WHOIS lookup reliability and enhance site UI
Build Docker images / Hub (push) Failing after 52s
Implement enhanced WHOIS lookup strategies, specifically targeting .eu
domains through EURid web scraping and alternative services to
improve data accuracy for expiry dates.

- Add EURid web scraping and alternative WHOIS service support for .eu domains
- Increase timeouts for .eu domain lookups in TCP and native WHOIS
- Improve domain scheduler to prevent overwriting valid data with zero-value dates
- Enhance site UI with subdomain indicators in domain tables
- Add filtering capabilities to the calendar view
- Implement drag-and-drop reordering for systems table
- Add new debug and test utilities for WHOIS and date parsing logic
2026-05-08 11:07:34 +02:00
59 changed files with 5667 additions and 1417 deletions
+1
View File
@@ -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
BIN
View File
Binary file not shown.
+100
View File
@@ -71,6 +71,33 @@ type Domain struct {
AbuseEmail string `json:"abuse_email" db:"abuse_email"`
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
+62
View File
@@ -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())
}
+275
View File
@@ -0,0 +1,275 @@
package detect
import (
"net/http"
"strings"
)
// ProviderInfo holds detected provider name
type ProviderInfo struct {
Name string
}
// DetectDNSProvider detects the DNS provider from NS records
func DetectDNSProvider(nsRecords []string) string {
for _, ns := range nsRecords {
nsLower := strings.ToLower(ns)
switch {
case strings.Contains(nsLower, "cloudflare"):
return "Cloudflare"
case strings.Contains(nsLower, "awsdns"):
return "Amazon Route 53"
case strings.Contains(nsLower, "googledomains") || strings.Contains(nsLower, "google.com"):
return "Google Domains"
case strings.Contains(nsLower, "namecheap") || strings.Contains(nsLower, "namecheaphosting"):
return "Namecheap"
case strings.Contains(nsLower, "godaddy"):
return "GoDaddy"
case strings.Contains(nsLower, "domaincontrol"):
return "GoDaddy"
case strings.Contains(nsLower, "nsone.net"):
return "NS1"
case strings.Contains(nsLower, "digitalocean"):
return "DigitalOcean"
case strings.Contains(nsLower, "linode"):
return "Linode"
case strings.Contains(nsLower, "vultr"):
return "Vultr"
case strings.Contains(nsLower, "he.net"):
return "Hurricane Electric"
case strings.Contains(nsLower, "dyn.com") || strings.Contains(nsLower, "dynect"):
return "Dyn (Oracle)"
case strings.Contains(nsLower, "ultradns"):
return "UltraDNS"
case strings.Contains(nsLower, "dnsimple"):
return "DNSimple"
case strings.Contains(nsLower, "hover"):
return "Hover"
case strings.Contains(nsLower, "register.com"):
return "Register.com"
case strings.Contains(nsLower, "enom"):
return "eNom"
case strings.Contains(nsLower, "worldnic"):
return "Network Solutions"
case strings.Contains(nsLower, "zoneedit"):
return "ZoneEdit"
case strings.Contains(nsLower, "easydns"):
return "EasyDNS"
case strings.Contains(nsLower, "gandi"):
return "Gandi"
case strings.Contains(nsLower, "ovh"):
return "OVH"
case strings.Contains(nsLower, "hetzner"):
return "Hetzner"
case strings.Contains(nsLower, "azure-dns"):
return "Microsoft Azure"
}
}
return ""
}
// DetectEmailProvider detects the email provider from MX records
func DetectEmailProvider(mxRecords []string) string {
for _, mx := range mxRecords {
mxLower := strings.ToLower(mx)
// Extract just the hostname part if it has priority prefix
host := mxLower
if idx := strings.Index(mxLower, " "); idx > 0 {
host = strings.TrimSpace(mxLower[idx+1:])
}
host = strings.TrimSuffix(host, ".")
switch {
case strings.Contains(host, "google") || strings.Contains(host, "gmail"):
return "Google Workspace"
case strings.Contains(host, "outlook") || strings.Contains(host, "microsoft") || strings.Contains(host, "protection.outlook"):
return "Microsoft 365"
case strings.Contains(host, "purelymail"):
return "Purelymail"
case strings.Contains(host, "zoho"):
return "Zoho Mail"
case strings.Contains(host, "protonmail") || strings.Contains(host, "pm.me"):
return "ProtonMail"
case strings.Contains(host, "fastmail"):
return "Fastmail"
case strings.Contains(host, "tutanota"):
return "Tutanota"
case strings.Contains(host, "mxroute"):
return "MXroute"
case strings.Contains(host, "namecheap"):
return "Namecheap"
case strings.Contains(host, "icloud") || strings.Contains(host, "me.com"):
return "iCloud Mail"
case strings.Contains(host, "yahoo"):
return "Yahoo"
case strings.Contains(host, "qq.com"):
return "QQ Mail"
case strings.Contains(host, "mail.ru"):
return "Mail.ru"
case strings.Contains(host, "yandex"):
return "Yandex"
case strings.Contains(host, "hover"):
return "Hover"
case strings.Contains(host, "godaddy") || strings.Contains(host, "domaincontrol"):
return "GoDaddy"
case strings.Contains(host, "pobox"):
return "Pobox"
case strings.Contains(host, "runbox"):
return "Runbox"
case strings.Contains(host, "posteo"):
return "Posteo"
case strings.Contains(host, "mailbox.org"):
return "Mailbox.org"
case strings.Contains(host, "forwardemail"):
return "Forward Email"
case strings.Contains(host, "improvmx"):
return "ImprovMX"
case strings.Contains(host, "cloudflare"):
return "Cloudflare Email Routing"
case strings.Contains(host, "amazonaws") || strings.Contains(host, "aws"):
return "Amazon SES"
case strings.Contains(host, "sendgrid") || strings.Contains(host, "twilio"):
return "SendGrid"
case strings.Contains(host, "mailgun"):
return "Mailgun"
case strings.Contains(host, "postmark"):
return "Postmark"
}
}
return ""
}
// DetectHostingProvider detects the hosting provider from HTTP headers
func DetectHostingProvider(headers http.Header) string {
server := strings.ToLower(headers.Get("Server"))
poweredBy := strings.ToLower(headers.Get("X-Powered-By"))
cfRay := headers.Get("CF-Ray")
vercelCache := headers.Get("X-Vercel-Cache")
vercelID := headers.Get("X-Vercel-Id")
netlifyID := headers.Get("X-NF-Request-Id")
githubRequest := headers.Get("X-GitHub-Request-Id")
switch {
case vercelCache != "" || vercelID != "":
return "Vercel"
case netlifyID != "":
return "Netlify"
case githubRequest != "":
return "GitHub Pages"
case cfRay != "":
return "Cloudflare"
case strings.Contains(server, "cloudflare"):
return "Cloudflare"
case strings.Contains(server, "nginx") && vercelCache != "":
return "Vercel"
case strings.Contains(server, "awselb") || strings.Contains(server, "elb"):
return "AWS"
case strings.Contains(server, "amazon"):
return "AWS"
case strings.Contains(server, "microsoft-iis"):
return "Microsoft Azure"
case strings.Contains(server, "google") || strings.Contains(server, "gws"):
return "Google Cloud"
case strings.Contains(server, "heroku"):
return "Heroku"
case strings.Contains(server, "digitalocean"):
return "DigitalOcean"
case strings.Contains(server, "linode"):
return "Linode"
case strings.Contains(server, "ovh"):
return "OVH"
case strings.Contains(server, "hetzner"):
return "Hetzner"
case strings.Contains(server, "fastly"):
return "Fastly"
case strings.Contains(server, "bunnycdn"):
return "BunnyCDN"
case strings.Contains(server, "keycdn"):
return "KeyCDN"
case strings.Contains(server, "stackpath"):
return "StackPath"
case strings.Contains(server, "sucuri"):
return "Sucuri"
case strings.Contains(poweredBy, "next.js") || strings.Contains(poweredBy, "nextjs"):
return "Vercel"
case strings.Contains(poweredBy, "php"):
return "PHP"
case strings.Contains(server, "apache"):
return "Apache"
case strings.Contains(server, "nginx"):
return "nginx"
case strings.Contains(server, "caddy"):
return "Caddy"
case strings.Contains(server, "lighttpd"):
return "Lighttpd"
case strings.Contains(server, "litespeed"):
return "LiteSpeed"
case strings.Contains(server, "openresty"):
return "OpenResty"
case strings.Contains(server, "jetty"):
return "Jetty"
case strings.Contains(server, "tomcat"):
return "Tomcat"
case strings.Contains(server, "iis"):
return "IIS"
}
return ""
}
// DetectCertificateAuthority detects the CA from an issuer string
func DetectCertificateAuthority(issuer string) string {
issuerLower := strings.ToLower(issuer)
switch {
case strings.Contains(issuerLower, "let's encrypt"):
return "Let's Encrypt"
case strings.Contains(issuerLower, "digicert"):
return "DigiCert"
case strings.Contains(issuerLower, "sectigo") || strings.Contains(issuerLower, "comodoca"):
return "Sectigo"
case strings.Contains(issuerLower, "globalsign"):
return "GlobalSign"
case strings.Contains(issuerLower, "geotrust"):
return "GeoTrust"
case strings.Contains(issuerLower, "thawte"):
return "Thawte"
case strings.Contains(issuerLower, "rapidssl"):
return "RapidSSL"
case strings.Contains(issuerLower, "symantec"):
return "Symantec"
case strings.Contains(issuerLower, "entrust"):
return "Entrust"
case strings.Contains(issuerLower, "certum"):
return "Certum"
case strings.Contains(issuerLower, "go daddy") || strings.Contains(issuerLower, "godaddy"):
return "GoDaddy"
case strings.Contains(issuerLower, "amazon"):
return "Amazon"
case strings.Contains(issuerLower, "google") && strings.Contains(issuerLower, "trust"):
return "Google Trust Services"
case strings.Contains(issuerLower, "cloudflare"):
return "Cloudflare"
case strings.Contains(issuerLower, "zero ssl") || strings.Contains(issuerLower, "zerossl"):
return "ZeroSSL"
case strings.Contains(issuerLower, "ssl.com"):
return "SSL.com"
case strings.Contains(issuerLower, "buypass"):
return "Buypass"
case strings.Contains(issuerLower, "harica"):
return "HARICA"
case strings.Contains(issuerLower, " Actalis "):
return "Actalis"
case strings.Contains(issuerLower, "swisssign"):
return "SwissSign"
case strings.Contains(issuerLower, "telekom"):
return "Telekom"
case strings.Contains(issuerLower, "trustwave"):
return "Trustwave"
case strings.Contains(issuerLower, "identrust"):
return "IdenTrust"
case strings.Contains(issuerLower, "usertrust"):
return "UserTrust"
case strings.Contains(issuerLower, "isrg") || strings.Contains(issuerLower, "internet security research"):
return "Let's Encrypt"
}
return issuer
}
+79 -5
View File
@@ -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
}
+58 -5
View File
@@ -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
+75 -9
View File
@@ -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")
+29 -18
View File
@@ -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&apos;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>
)
}
+349 -191
View File
@@ -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>
+123
View File
@@ -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"
+32
View File
@@ -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) {
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+16 -8
View File
@@ -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
+160
View File
@@ -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")
}
}
+94
View File
@@ -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'")
}
+112
View File
@@ -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")
}
}
+163
View File
@@ -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")
}
}
+112
View File
@@ -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()
}
}
+119
View File
@@ -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()
}
}
+23
View File
@@ -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');