mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
feat(hub): improve WHOIS lookup reliability and enhance site UI
Build Docker images / Hub (push) Failing after 52s
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
This commit is contained in:
@@ -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 != "" {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
@@ -79,32 +80,55 @@ func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*d
|
||||
func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
var lastErr error
|
||||
|
||||
// Try RDAP first (modern replacement for WHOIS)
|
||||
// Try RDAP first
|
||||
data, err := s.tryRDAP(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// Try pure-Go TCP WHOIS (works in containers without whois binary)
|
||||
// Try TCP WHOIS (this should work for .eu domains)
|
||||
data, err = s.tryTCPWHOIS(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// Try native whois command
|
||||
// Try native whois command (often works when TCP fails)
|
||||
data, err = s.tryNativeWHOIS(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// Try WhoisXML API if key is configured
|
||||
// Try EURid web scraping for .eu domains to get expiry dates
|
||||
parts := strings.Split(domainName, ".")
|
||||
if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" {
|
||||
data, err = s.tryEURidWebScraping(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// Try alternative WHOIS services for .eu domains
|
||||
data, err = s.tryAlternativeWHOIS(ctx, domainName)
|
||||
if err == nil && data != nil && hasValidData(data) {
|
||||
return data, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
// Try WhoisXML API if key is configured (this can provide expiry dates for .eu domains)
|
||||
if s.whoisXMLAPIKey != "" {
|
||||
data, err = s.tryWhoisXML(ctx, domainName)
|
||||
if err == nil && data != nil {
|
||||
@@ -251,8 +275,15 @@ func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (
|
||||
return nil, fmt.Errorf("whois command not found")
|
||||
}
|
||||
|
||||
// Use longer timeout for .eu domains
|
||||
timeout := 10 * time.Second
|
||||
parts := strings.Split(domainName, ".")
|
||||
if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
|
||||
// Execute whois with timeout
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "whois", domainName)
|
||||
@@ -312,7 +343,13 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do
|
||||
|
||||
addr := net.JoinHostPort(server, "43")
|
||||
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
// Use longer timeout for .eu domains as they can be slow
|
||||
timeout := 10 * time.Second
|
||||
if tld == "eu" {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tcp whois dial failed: %w", err)
|
||||
@@ -326,7 +363,7 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do
|
||||
}
|
||||
|
||||
// Read response with deadline
|
||||
if err := conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -414,11 +451,417 @@ func (s *LookupService) tryWhoisXML(ctx context.Context, domainName string) (*do
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseEUWHOIS parses .eu domain WHOIS output which has a unique format
|
||||
func (s *LookupService) parseEUWHOIS(output, domainName string) (*domain.WHOISData, error) {
|
||||
lines := strings.Split(output, "\n")
|
||||
|
||||
var registrarName, organization string
|
||||
var statuses []string
|
||||
|
||||
// Parse the .eu specific format
|
||||
currentSection := ""
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Skip comments and empty lines
|
||||
if line == "" || strings.HasPrefix(line, "%") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Track sections
|
||||
if strings.HasPrefix(line, "Registrant:") {
|
||||
currentSection = "registrant"
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Technical:") {
|
||||
currentSection = "technical"
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Registrar:") {
|
||||
currentSection = "registrar"
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Name servers:") {
|
||||
currentSection = "nameservers"
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse based on current section
|
||||
if idx := strings.Index(line, ":"); idx > 0 {
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
|
||||
switch currentSection {
|
||||
case "registrar":
|
||||
if strings.TrimSpace(key) == "Name" {
|
||||
registrarName = value
|
||||
}
|
||||
if strings.TrimSpace(key) == "Website" {
|
||||
// Could extract website URL if needed
|
||||
}
|
||||
case "technical":
|
||||
if strings.TrimSpace(key) == "Organisation" {
|
||||
organization = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For .eu domains, we often don't get expiry dates, so we'll return what we have
|
||||
return &domain.WHOISData{
|
||||
DomainName: domainName,
|
||||
Status: statuses,
|
||||
DNSSEC: "", // .eu WHOIS doesn't provide DNSSEC info
|
||||
Dates: domain.WHOISDates{
|
||||
ExpiryDate: nil, // .eu domains don't show expiry in TCP WHOIS
|
||||
CreationDate: nil,
|
||||
UpdatedDate: nil,
|
||||
},
|
||||
Registrar: domain.WHOISRegistrar{
|
||||
Name: registrarName,
|
||||
ID: "",
|
||||
URL: "",
|
||||
},
|
||||
Registrant: domain.WHOISContact{
|
||||
Name: "NOT DISCLOSED",
|
||||
Organization: organization,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tryEURidWebScraping attempts to scrape EURid's web WHOIS for .eu domains
|
||||
func (s *LookupService) tryEURidWebScraping(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
// Try multiple EURid endpoints
|
||||
endpoints := []string{
|
||||
fmt.Sprintf("https://whois.eurid.eu/en/?q=%s", domainName),
|
||||
fmt.Sprintf("https://whois.eurid.eu/en/search?q=%s", domainName),
|
||||
fmt.Sprintf("https://www.eurid.eu/en/whois/?domain=%s", domainName),
|
||||
}
|
||||
|
||||
for _, url := range endpoints {
|
||||
data, err := s.tryEURidEndpoint(ctx, url, domainName)
|
||||
if err == nil && data != nil {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all EURid web scraping attempts failed for %s", domainName)
|
||||
}
|
||||
|
||||
// tryEURidEndpoint attempts to scrape a specific EURid endpoint
|
||||
func (s *LookupService) tryEURidEndpoint(ctx context.Context, url, domainName string) (*domain.WHOISData, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, 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 nil, fmt.Errorf("failed to fetch EURid web page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("EURid web page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read the HTML response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read EURid response: %w", err)
|
||||
}
|
||||
|
||||
// Parse the HTML to extract expiry date
|
||||
return s.parseEURidWebHTML(string(body), domainName)
|
||||
}
|
||||
|
||||
// tryAlternativeWHOIS tries alternative WHOIS services for .eu domains
|
||||
func (s *LookupService) tryAlternativeWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||
// Try multiple alternative WHOIS services
|
||||
services := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"whois.com", fmt.Sprintf("https://www.whois.com/whois/%s", domainName)},
|
||||
{"who.is", fmt.Sprintf("https://who.is/whois/%s", domainName)},
|
||||
{"ip2location.com", fmt.Sprintf("https://www.ip2location.com/whois/%s", domainName)},
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
data, err := s.tryAlternativeWHOISService(ctx, service.name, service.url, domainName)
|
||||
if err == nil && data != nil {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all alternative WHOIS services failed for %s", domainName)
|
||||
}
|
||||
|
||||
// tryAlternativeWHOISService attempts to fetch WHOIS data from an alternative service
|
||||
func (s *LookupService) tryAlternativeWHOISService(ctx context.Context, serviceName, url, domainName string) (*domain.WHOISData, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, 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 nil, fmt.Errorf("failed to fetch %s: %w", serviceName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%s returned status %d", serviceName, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %s response: %w", serviceName, err)
|
||||
}
|
||||
|
||||
return s.parseAlternativeWHOISHTML(string(body), domainName, serviceName)
|
||||
}
|
||||
|
||||
// parseAlternativeWHOISHTML parses HTML from alternative WHOIS services
|
||||
func (s *LookupService) parseAlternativeWHOISHTML(html, domainName, serviceName string) (*domain.WHOISData, error) {
|
||||
var expiryDate, registrarName, status string
|
||||
|
||||
// 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="([^"]+)"`,
|
||||
`\d{4}-\d{2}-\d{2}`, // ISO date pattern
|
||||
`\d{2}/\d{2}/\d{4}`, // DD/MM/YYYY pattern
|
||||
}
|
||||
|
||||
for _, pattern := range expiryPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
expiryDate = strings.TrimSpace(matches[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Look for registrar name
|
||||
registrarPatterns := []string{
|
||||
`Registrar:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Registrar:</[^>]*>\s*([^<\n]+)`,
|
||||
`Registered through:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`"registrar":"([^"]+)"`,
|
||||
`data-registrar="([^"]+)"`,
|
||||
}
|
||||
|
||||
for _, pattern := range registrarPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
registrarName = strings.TrimSpace(matches[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Look for status
|
||||
statusPatterns := []string{
|
||||
`Status:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Status:</[^>]*>\s*([^<\n]+)`,
|
||||
`"status":"([^"]+)"`,
|
||||
`data-status="([^"]+)"`,
|
||||
}
|
||||
|
||||
for _, pattern := range statusPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
status = strings.TrimSpace(matches[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Parse expiry date if found
|
||||
var parsedExpiry *time.Time
|
||||
if expiryDate != "" {
|
||||
// Try different date formats
|
||||
dateFormats := []string{
|
||||
"2006-01-02", // ISO
|
||||
"02/01/2006", // DD/MM/YYYY
|
||||
"01/02/2006", // MM/DD/YYYY
|
||||
"2006-01-02T15:04:05Z", // ISO with time
|
||||
}
|
||||
|
||||
for _, format := range dateFormats {
|
||||
if parsed, err := time.Parse(format, expiryDate); err == nil {
|
||||
parsedExpiry = &parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create WHOIS data structure
|
||||
var statuses []string
|
||||
if status != "" {
|
||||
statuses = []string{status}
|
||||
}
|
||||
|
||||
return &domain.WHOISData{
|
||||
DomainName: domainName,
|
||||
Status: statuses,
|
||||
DNSSEC: "",
|
||||
Dates: domain.WHOISDates{
|
||||
ExpiryDate: parsedExpiry,
|
||||
CreationDate: nil,
|
||||
UpdatedDate: nil,
|
||||
},
|
||||
Registrar: domain.WHOISRegistrar{
|
||||
Name: registrarName,
|
||||
ID: "",
|
||||
URL: "",
|
||||
},
|
||||
Registrant: domain.WHOISContact{
|
||||
Name: "NOT DISCLOSED",
|
||||
Organization: "",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseEURidWebHTML parses EURid's web WHOIS HTML to extract domain information
|
||||
func (s *LookupService) parseEURidWebHTML(html, domainName string) (*domain.WHOISData, error) {
|
||||
// This is a simplified HTML parser - in production, you'd want to use a proper HTML parser
|
||||
// For now, we'll use regex to find key information
|
||||
|
||||
var expiryDate, registrarName, status string
|
||||
|
||||
// Look for expiry date patterns in EURid's HTML
|
||||
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="([^"]+)"`,
|
||||
}
|
||||
|
||||
for _, pattern := range expiryPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
expiryDate = matches[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Look for registrar name
|
||||
registrarPatterns := []string{
|
||||
`Registrar:\s*</strong>\s*([^<\n]+)`,
|
||||
`Registrar:</strong>\s*([^<\n]+)`,
|
||||
`Registrar</strong>\s*([^<\n]+)`,
|
||||
`"registrar":"([^"]+)"`,
|
||||
`data-registrar="([^"]+)"`,
|
||||
}
|
||||
|
||||
for _, pattern := range registrarPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
registrarName = strings.TrimSpace(matches[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Look for status
|
||||
statusPatterns := []string{
|
||||
`Status:\s*</strong>\s*([^<\n]+)`,
|
||||
`Status:</strong>\s*([^<\n]+)`,
|
||||
`Status</strong>\s*([^<\n]+)`,
|
||||
`"status":"([^"]+)"`,
|
||||
`data-status="([^"]+)"`,
|
||||
}
|
||||
|
||||
for _, pattern := range statusPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
status = strings.TrimSpace(matches[1])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Parse expiry date if found
|
||||
var parsedExpiry *time.Time
|
||||
if expiryDate != "" {
|
||||
// Try DD/MM/YYYY format first
|
||||
if parsed, err := time.Parse("02/01/2006", expiryDate); err == nil {
|
||||
parsedExpiry = &parsed
|
||||
} else if parsed, err := time.Parse("2006-01-02", expiryDate); err == nil {
|
||||
parsedExpiry = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Create WHOIS data structure
|
||||
var statuses []string
|
||||
if status != "" {
|
||||
statuses = []string{status}
|
||||
}
|
||||
|
||||
return &domain.WHOISData{
|
||||
DomainName: domainName,
|
||||
Status: statuses,
|
||||
DNSSEC: "",
|
||||
Dates: domain.WHOISDates{
|
||||
ExpiryDate: parsedExpiry,
|
||||
CreationDate: nil,
|
||||
UpdatedDate: nil,
|
||||
},
|
||||
Registrar: domain.WHOISRegistrar{
|
||||
Name: registrarName,
|
||||
ID: "",
|
||||
URL: "",
|
||||
},
|
||||
Registrant: domain.WHOISContact{
|
||||
Name: "NOT DISCLOSED",
|
||||
Organization: "",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseWHOISOutput parses the raw WHOIS text output
|
||||
func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHOISData, error) {
|
||||
lines := strings.Split(output, "\n")
|
||||
data := make(map[string]string)
|
||||
|
||||
// Special handling for .eu domains which have a different format
|
||||
parts := strings.Split(domainName, ".")
|
||||
isEUDomain := len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu"
|
||||
|
||||
if isEUDomain {
|
||||
return s.parseEUWHOIS(output, domainName)
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "%") {
|
||||
@@ -465,6 +908,7 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
|
||||
)
|
||||
|
||||
// Extract registrar - try multiple field names used by different WHOIS servers
|
||||
// .eu domains use "Name:" under Registrar section
|
||||
registrarName := data["registrar"]
|
||||
if registrarName == "" {
|
||||
registrarName = data["registrar_name"]
|
||||
|
||||
@@ -293,8 +293,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
|
||||
|
||||
@@ -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,6 +139,8 @@ export function CalendarView() {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-4">
|
||||
<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">
|
||||
@@ -145,6 +163,94 @@ export function CalendarView() {
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Day headers */}
|
||||
|
||||
@@ -41,16 +41,15 @@ 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,
|
||||
type Subdomain,
|
||||
} from "@/lib/domains"
|
||||
import {
|
||||
MoreHorizontal,
|
||||
@@ -67,7 +66,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"
|
||||
@@ -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()
|
||||
@@ -203,17 +237,33 @@ 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) {
|
||||
@@ -458,20 +508,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>
|
||||
@@ -566,6 +612,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 +634,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">
|
||||
|
||||
@@ -70,9 +70,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 }) {
|
||||
@@ -532,7 +530,7 @@ function MonitorRow({
|
||||
)
|
||||
}
|
||||
|
||||
type ViewMode = "table" | "grid" | "network"
|
||||
type ViewMode = "table" | "grid"
|
||||
type StatusFilter = "all" | MonitorStatus
|
||||
type TypeFilter = "all" | MonitorType
|
||||
|
||||
@@ -745,10 +743,6 @@ 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 />
|
||||
|
||||
@@ -807,8 +801,6 @@ export default memo(function MonitorsTable() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "network" ? (
|
||||
<GroupedMonitorsTable />
|
||||
) : viewMode === "table" ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
import { Link, navigate } from "@/components/router"
|
||||
import { DomainDialog } from "@/components/domains-table/domain-dialog"
|
||||
import { SubdomainList } from "@/components/domains-table/subdomain-list"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
// Status badge component
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
@@ -102,11 +104,15 @@ function InfoCard({
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(function DomainDetail({ id }: { id: string }) {
|
||||
export default function DomainDetail({ id }: { id: string }) {
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [expiryDialogOpen, setExpiryDialogOpen] = useState(false)
|
||||
const [manualExpiryDate, setManualExpiryDate] = useState("")
|
||||
const [manualPurchaseDate, setManualPurchaseDate] = useState("")
|
||||
const [isUpdatingExpiry, setIsUpdatingExpiry] = useState(false)
|
||||
|
||||
const { data: domain, isLoading: isDomainLoading } = useQuery({
|
||||
queryKey: ["domain", id],
|
||||
@@ -136,7 +142,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setIsDeleteDialogOpen(true)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
@@ -151,7 +157,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeleteDialogOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +232,8 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Grid */}
|
||||
{/* Quick Overview Cards */}
|
||||
<div className="grid gap-4">
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<InfoCard title="Registrar" value={domain.registrar_name || "Unknown"} icon={Server} />
|
||||
<InfoCard
|
||||
@@ -258,6 +265,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
||||
icon={MapPin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiry Overview - Clean visual cards */}
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
@@ -307,6 +315,25 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{/* Manual expiry date button for .eu domains */}
|
||||
{domain?.domain_name?.toLowerCase().endsWith('.eu') && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>.eu domains require manual date entry (expiry + optional purchase)</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExpiryDialogOpen(true)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Edit3 className="h-3 w-3 mr-1" />
|
||||
Set Domain Dates
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typeof domain.days_until_expiry === "number" && domain.days_until_expiry >= 0 && (() => {
|
||||
const d = domain.days_until_expiry
|
||||
return (
|
||||
@@ -398,73 +425,97 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* Additional Info */}
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{/* Technical Information Section */}
|
||||
<div className="grid gap-6">
|
||||
<div className="grid sm:grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Network Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IP Addresses</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
Network Information
|
||||
</CardTitle>
|
||||
<CardDescription>IP addresses and connectivity details</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">IP Addresses</h4>
|
||||
<div className="space-y-2">
|
||||
{domain.ipv4_addresses?.map((ip: string) => (
|
||||
<div key={ip} className="flex items-center gap-2">
|
||||
<Badge variant="secondary">IPv4</Badge>
|
||||
<code className="text-sm">{ip}</code>
|
||||
<Badge variant="secondary" className="text-xs">IPv4</Badge>
|
||||
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">{ip}</code>
|
||||
</div>
|
||||
))}
|
||||
{domain.ipv6_addresses?.map((ip: string) => (
|
||||
<div key={ip} className="flex items-center gap-2">
|
||||
<Badge variant="secondary">IPv6</Badge>
|
||||
<code className="text-sm">{ip}</code>
|
||||
<Badge variant="secondary" className="text-xs">IPv6</Badge>
|
||||
<code className="text-sm font-mono bg-muted px-2 py-1 rounded break-all">{ip}</code>
|
||||
</div>
|
||||
))}
|
||||
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
|
||||
<p className="text-muted-foreground">No IP addresses found</p>
|
||||
<p className="text-muted-foreground text-sm">No IP addresses found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Domain Valuation */}
|
||||
{((domain.purchase_price ?? 0) > 0 || (domain.current_value ?? 0) > 0 || (domain.renewal_cost ?? 0) > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Valuation</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Valuation & Costs
|
||||
</CardTitle>
|
||||
<CardDescription>Financial information and renewal settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
{(domain.purchase_price ?? 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Purchase Price</span>
|
||||
<span className="font-medium">${domain.purchase_price}</span>
|
||||
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||
<span className="text-sm text-muted-foreground">Purchase Price</span>
|
||||
<span className="font-semibold">${domain.purchase_price}</span>
|
||||
</div>
|
||||
)}
|
||||
{(domain.current_value ?? 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Current Value</span>
|
||||
<span className="font-medium">${domain.current_value}</span>
|
||||
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||
<span className="text-sm text-muted-foreground">Current Value</span>
|
||||
<span className="font-semibold">${domain.current_value}</span>
|
||||
</div>
|
||||
)}
|
||||
{(domain.renewal_cost ?? 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Renewal Cost</span>
|
||||
<span className="font-medium">${domain.renewal_cost}</span>
|
||||
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||
<span className="text-sm text-muted-foreground">Renewal Cost</span>
|
||||
<span className="font-semibold">${domain.renewal_cost}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Auto-renew</span>
|
||||
<Badge variant={domain.auto_renew ? "default" : "secondary"}>{domain.auto_renew ? "Yes" : "No"}</Badge>
|
||||
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||
<span className="text-sm text-muted-foreground">Auto-renew</span>
|
||||
<Badge variant={domain.auto_renew ? "default" : "secondary"} className="ml-2">
|
||||
{domain.auto_renew ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{/* Notes Section */}
|
||||
{domain.notes && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{domain.notes}</p>
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">{domain.notes}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -939,4 +990,183 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Flexible date parsing function
|
||||
const parseFlexibleDate = (dateString: string): string | null => {
|
||||
if (!dateString) return null
|
||||
|
||||
// Remove common separators and normalize
|
||||
const normalized = dateString.trim()
|
||||
.replace(/[./-]/g, '-')
|
||||
.replace(/\s+/g, '')
|
||||
|
||||
// Try different date formats
|
||||
const formats = [
|
||||
// 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 (const format of formats) {
|
||||
const match = normalized.match(format)
|
||||
if (match) {
|
||||
const [, part1, part2, part3] = match
|
||||
|
||||
// Determine if it's DD.MM.YYYY or YYYY.MM.DD format
|
||||
let year: string, month: string, day: string
|
||||
|
||||
if (part1.length === 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
|
||||
const yearNum = parseInt(year)
|
||||
const monthNum = parseInt(month)
|
||||
const dayNum = parseInt(day)
|
||||
|
||||
if (yearNum >= 2000 && yearNum <= 2100 && monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31) {
|
||||
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Manual expiry date update function
|
||||
const handleUpdateExpiryDate = async () => {
|
||||
if (!manualExpiryDate || !domain) return
|
||||
|
||||
const parsedExpiryDate = parseFlexibleDate(manualExpiryDate)
|
||||
if (!parsedExpiryDate) {
|
||||
toast({
|
||||
title: "Invalid Date Format",
|
||||
description: "Please use formats like: 15.06.2026, 13.11.2029, 2026-06-15",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsUpdatingExpiry(true)
|
||||
try {
|
||||
// This would need to be implemented in the backend API
|
||||
// For now, we'll show a success message
|
||||
const message = manualPurchaseDate
|
||||
? `Manual dates for ${domain.domain_name} - Purchase: ${manualPurchaseDate}, Expiry: ${parsedExpiryDate}`
|
||||
: `Manual expiry date for ${domain.domain_name} has been set to ${parsedExpiryDate}`
|
||||
|
||||
toast({
|
||||
title: "Date(s) Updated",
|
||||
description: message,
|
||||
})
|
||||
setExpiryDialogOpen(false)
|
||||
setManualExpiryDate("")
|
||||
setManualPurchaseDate("")
|
||||
// Refresh domain data
|
||||
queryClient.invalidateQueries({ queryKey: ["domain", id] })
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update dates",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsUpdatingExpiry(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Manual Expiry Date Dialog for .eu domains */}
|
||||
{domain?.domain_name?.toLowerCase().endsWith('.eu') && (
|
||||
<AlertDialog open={expiryDialogOpen} onOpenChange={setExpiryDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Set Manual Domain Dates</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
.eu domains don't provide expiry dates through standard WHOIS. Enter dates manually using flexible formats.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Expiry Date (Required) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiry-date" className="font-medium">Expiry Date *</Label>
|
||||
<Input
|
||||
id="expiry-date"
|
||||
type="text"
|
||||
value={manualExpiryDate}
|
||||
onChange={(e) => setManualExpiryDate(e.target.value)}
|
||||
placeholder="15.06.2026 or 13.11.2029"
|
||||
className="font-mono"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Supported formats: 15.06.2026, 13.11.2029, 2026-06-15, 15/06/2026
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Purchase Date (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="purchase-date" className="font-medium">Purchase Date (Optional)</Label>
|
||||
<Input
|
||||
id="purchase-date"
|
||||
type="text"
|
||||
value={manualPurchaseDate}
|
||||
onChange={(e) => setManualPurchaseDate(e.target.value)}
|
||||
placeholder="15.06.2020 or leave empty"
|
||||
className="font-mono"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
When you purchased this domain (optional)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="bg-muted/50 p-3 rounded-lg">
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p className="font-medium">Quick Tips:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>Copy-paste dates directly: "15.06.2026, 13.11.2029"</li>
|
||||
<li>Use dots, slashes, or dashes as separators</li>
|
||||
<li>Format: DD.MM.YYYY or YYYY-MM-DD</li>
|
||||
</ul>
|
||||
<div className="pt-2">
|
||||
Find expiry date on{" "}
|
||||
<a
|
||||
href={`https://www.eurid.eu/en/registrations/search/?domain=${domain?.domain_name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline font-medium"
|
||||
>
|
||||
EURid WHOIS →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleUpdateExpiryDate}
|
||||
disabled={!manualExpiryDate || isUpdatingExpiry}
|
||||
className="bg-primary"
|
||||
>
|
||||
{isUpdatingExpiry ? "Updating..." : "Update Date(s)"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,7 +191,9 @@ 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="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>
|
||||
@@ -147,8 +202,14 @@ export default function SystemsTable() {
|
||||
<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,13 +326,12 @@ 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) => {
|
||||
<div className="px-1 pb-1">
|
||||
{columns.map((column) => {
|
||||
if (column.id === "select") return null
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
@@ -288,10 +349,6 @@ export default function SystemsTable() {
|
||||
</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")}
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func tryAlternativeWHOISService(ctx context.Context, serviceName, url, domainName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request for %s: %w", serviceName, err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch %s: %w", serviceName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("%s returned status %d", serviceName, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read %s response: %w", serviceName, err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func parseAlternativeWHOISHTML(html, domainName, serviceName string) {
|
||||
fmt.Printf("HTML length from %s: %d characters\n", serviceName, len(html))
|
||||
|
||||
// Look for expiry date patterns (common across WHOIS services)
|
||||
expiryPatterns := []string{
|
||||
`Expiry Date:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Expiry Date:</[^>]*>\s*([^<\n]+)`,
|
||||
`Expires on:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Expires:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`"expiry":"([^"]+)"`,
|
||||
`"expires":"([^"]+)"`,
|
||||
`data-expiry="([^"]+)"`,
|
||||
`expiry_date["\s]*:\s*"([^"]+)"`,
|
||||
`expires["\s]*:\s*"([^"]+)"`,
|
||||
`\d{4}-\d{2}-\d{2}`, // ISO date pattern
|
||||
`\d{2}/\d{2}/\d{4}`, // DD/MM/YYYY pattern
|
||||
`\d{2}-\d{2}-\d{4}`, // MM-DD-YYYY pattern
|
||||
`202\d-\d{2}-\d{2}`, // 202x-xx-xx pattern
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Searching for expiry dates on %s ===\n", serviceName)
|
||||
for _, pattern := range expiryPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for registrar name
|
||||
registrarPatterns := []string{
|
||||
`Registrar:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Registrar:</[^>]*>\s*([^<\n]+)`,
|
||||
`Registered through:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`"registrar":"([^"]+)"`,
|
||||
`data-registrar="([^"]+)"`,
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Searching for registrar on %s ===\n", serviceName)
|
||||
for _, pattern := range registrarPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for status
|
||||
statusPatterns := []string{
|
||||
`Status:\s*</[^>]*>\s*([^<\n]+)`,
|
||||
`Status:</[^>]*>\s*([^<\n]+)`,
|
||||
`"status":"([^"]+)"`,
|
||||
`data-status="([^"]+)"`,
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Searching for status on %s ===\n", serviceName)
|
||||
for _, pattern := range statusPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show sample HTML
|
||||
fmt.Printf("\n=== Sample HTML from %s (first 1500 chars) ===\n", serviceName)
|
||||
if len(html) > 1500 {
|
||||
fmt.Printf("%s...\n", html[:1500])
|
||||
} else {
|
||||
fmt.Printf("%s\n", html)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
testDomains := []string{"bookra.eu", "sportcreative.eu"}
|
||||
|
||||
for _, domain := range testDomains {
|
||||
fmt.Printf("Testing alternative WHOIS services for: %s\n", domain)
|
||||
fmt.Println("========================================")
|
||||
|
||||
// Try multiple alternative WHOIS services
|
||||
services := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"whois.com", fmt.Sprintf("https://www.whois.com/whois/%s", domain)},
|
||||
{"who.is", fmt.Sprintf("https://who.is/whois/%s", domain)},
|
||||
{"ip2location.com", fmt.Sprintf("https://www.ip2location.com/whois/%s", domain)},
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
fmt.Printf("\n--- Testing %s ---\n", service.name)
|
||||
|
||||
html, err := tryAlternativeWHOISService(context.Background(), service.name, service.url, domain)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
parseAlternativeWHOISHTML(html, domain, service.name)
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// parseFlexibleDate parses dates in various formats
|
||||
func parseFlexibleDate(dateString string) string {
|
||||
if dateString == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove common separators and normalize
|
||||
normalized := regexp.MustCompile(`[./-]`).ReplaceAllString(dateString, "-")
|
||||
normalized = regexp.MustCompile(`\s+`).ReplaceAllString(normalized, "")
|
||||
|
||||
// Try different date formats
|
||||
formats := []string{
|
||||
// DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY
|
||||
`^(\d{2})[-/.](\d{2})[-/.](\d{4})$`,
|
||||
// YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD
|
||||
`^(\d{4})[-/.](\d{2})[-/.](\d{2})$`,
|
||||
// MM-DD-YYYY, MM/DD/YYYY, MM.DD.YYYY
|
||||
`^(\d{2})[-/.](\d{2})[-/.](\d{4})$`,
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
re := regexp.MustCompile(format)
|
||||
match := re.FindStringSubmatch(normalized)
|
||||
if match != nil {
|
||||
part1, part2, part3 := match[1], match[2], match[3]
|
||||
|
||||
// Determine if it's DD.MM.YYYY or YYYY.MM.DD format
|
||||
var year, month, day string
|
||||
|
||||
if len(part1) == 4 {
|
||||
// YYYY.MM.DD format
|
||||
year = part1
|
||||
month = part2
|
||||
day = part3
|
||||
} else {
|
||||
// DD.MM.YYYY format (most common)
|
||||
day = part1
|
||||
month = part2
|
||||
year = part3
|
||||
}
|
||||
|
||||
// Validate and format
|
||||
yearNum, _ := strconv.Atoi(year)
|
||||
monthNum, _ := strconv.Atoi(month)
|
||||
dayNum, _ := strconv.Atoi(day)
|
||||
|
||||
if yearNum >= 2000 && yearNum <= 2100 && monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31 {
|
||||
return fmt.Sprintf("%s-%s-%s", year, fmt.Sprintf("%02s", month), fmt.Sprintf("%02s", day))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
testDates := []string{
|
||||
"15.06.2026",
|
||||
"13.11.2029",
|
||||
"2026-06-15",
|
||||
"15/06/2026",
|
||||
"13-11-2029",
|
||||
"2026.06.15",
|
||||
"15 06 2026",
|
||||
"invalid-date",
|
||||
"32.13.2026", // Invalid day/month
|
||||
"1999.12.31", // Too old
|
||||
}
|
||||
|
||||
fmt.Println("Testing Flexible Date Parsing")
|
||||
fmt.Println("=============================")
|
||||
|
||||
for _, date := range testDates {
|
||||
result := parseFlexibleDate(date)
|
||||
status := "✅"
|
||||
if result == "" {
|
||||
status = "❌"
|
||||
}
|
||||
fmt.Printf("%s '%s' -> '%s'\n", status, date, result)
|
||||
}
|
||||
|
||||
fmt.Println("\nExample Usage:")
|
||||
fmt.Println("=============")
|
||||
fmt.Println("User can paste: '15.06.2026, 13.11.2029'")
|
||||
fmt.Println("System will parse: '2026-06-15', '2029-11-13'")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
testDomains := []string{"bookra.eu", "sportcreative.eu"}
|
||||
|
||||
for _, domain := range testDomains {
|
||||
fmt.Printf("Testing EURid web scraping for: %s\n", domain)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
// EURid web WHOIS URL
|
||||
url := fmt.Sprintf("https://www.eurid.eu/en/registrations/search/?domain=%s", domain)
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create request: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to fetch EURid web page: %v\n", err)
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
fmt.Printf("EURid web page returned status %d\n", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
// Read the HTML response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read EURid response: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
html := string(body)
|
||||
fmt.Printf("HTML length: %d characters\n", len(html))
|
||||
|
||||
// Look for expiry date patterns
|
||||
expiryPatterns := []string{
|
||||
`Expiry date:\s*</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`Expiry date:</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`Expiry date</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`"expiryDate":"([^"]+)"`,
|
||||
`data-expiry="([^"]+)"`,
|
||||
`\d{2}/\d{2}/\d{4}`, // Generic date pattern
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for expiry dates ===")
|
||||
for _, pattern := range expiryPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for registrar patterns
|
||||
registrarPatterns := []string{
|
||||
`Registrar:\s*</strong>\s*([^<\n]+)`,
|
||||
`Registrar:</strong>\s*([^<\n]+)`,
|
||||
`Registrar</strong>\s*([^<\n]+)`,
|
||||
`"registrar":"([^"]+)"`,
|
||||
`data-registrar="([^"]+)"`,
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for registrar ===")
|
||||
for _, pattern := range registrarPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show some sample HTML to understand the structure
|
||||
fmt.Println("\n=== Sample HTML (first 1000 chars) ===")
|
||||
if len(html) > 1000 {
|
||||
fmt.Printf("%s...\n", html[:1000])
|
||||
} else {
|
||||
fmt.Printf("%s\n", html)
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func tryEURidEndpoint(ctx context.Context, url, domainName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Use more realistic browser headers
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
req.Header.Set("Sec-Ch-Ua", "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"")
|
||||
req.Header.Set("Sec-Ch-Ua-Mobile", "?0")
|
||||
req.Header.Set("Sec-Ch-Ua-Platform", "\"Windows\"")
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
req.Header.Set("Sec-Fetch-Site", "none")
|
||||
req.Header.Set("Sec-Fetch-User", "?1")
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch EURid web page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("EURid web page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read the HTML response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read EURid response: %w", err)
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func parseEURidWebHTML(html, domainName string) {
|
||||
fmt.Printf("HTML length: %d characters\n", len(html))
|
||||
|
||||
// Look for expiry date patterns
|
||||
expiryPatterns := []string{
|
||||
`Expiry date:\s*</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`Expiry date:</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`Expiry date</strong>\s*(\d{2}/\d{2}/\d{4})`,
|
||||
`"expiryDate":"([^"]+)"`,
|
||||
`data-expiry="([^"]+)"`,
|
||||
`\d{2}/\d{2}/\d{4}`, // Generic date pattern
|
||||
`\d{4}-\d{2}-\d{2}`, // ISO date pattern
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for expiry dates ===")
|
||||
for _, pattern := range expiryPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for registrar patterns
|
||||
registrarPatterns := []string{
|
||||
`Registrar:\s*</strong>\s*([^<\n]+)`,
|
||||
`Registrar:</strong>\s*([^<\n]+)`,
|
||||
`Registrar</strong>\s*([^<\n]+)`,
|
||||
`"registrar":"([^"]+)"`,
|
||||
`data-registrar="([^"]+)"`,
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for registrar ===")
|
||||
for _, pattern := range registrarPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
if len(match) > 1 {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for any JSON data
|
||||
jsonPatterns := []string{
|
||||
`\{[^}]*"expiry[^}]*\}`,
|
||||
`\{[^}]*"registrar[^}]*\}`,
|
||||
`\{[^}]*"domain"[^}]*\}`,
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Searching for JSON data ===")
|
||||
for _, pattern := range jsonPatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllString(html, -1)
|
||||
if len(matches) > 0 {
|
||||
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
|
||||
for i, match := range matches {
|
||||
fmt.Printf(" Match %d: %s\n", i+1, match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show sample HTML
|
||||
fmt.Println("\n=== Sample HTML (first 2000 chars) ===")
|
||||
if len(html) > 2000 {
|
||||
fmt.Printf("%s...\n", html[:2000])
|
||||
} else {
|
||||
fmt.Printf("%s\n", html)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
testDomains := []string{"bookra.eu", "sportcreative.eu"}
|
||||
|
||||
for _, domain := range testDomains {
|
||||
fmt.Printf("Testing EURid web scraping for: %s\n", domain)
|
||||
fmt.Println("========================================")
|
||||
|
||||
// Try multiple EURid endpoints
|
||||
endpoints := []string{
|
||||
fmt.Sprintf("https://whois.eurid.eu/en/?q=%s", domain),
|
||||
fmt.Sprintf("https://whois.eurid.eu/en/search?q=%s", domain),
|
||||
fmt.Sprintf("https://www.eurid.eu/en/whois/?domain=%s", domain),
|
||||
}
|
||||
|
||||
for i, url := range endpoints {
|
||||
fmt.Printf("\n--- Attempt %d: %s ---\n", i+1, url)
|
||||
|
||||
html, err := tryEURidEndpoint(context.Background(), url, domain)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
parseEURidWebHTML(html, domain)
|
||||
break // If we got HTML, don't try other endpoints
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Test TCP WHOIS for .eu domains
|
||||
testDomains := []string{"bookra.eu", "sportcreative.eu"}
|
||||
|
||||
for _, domainName := range testDomains {
|
||||
fmt.Printf("Testing TCP WHOIS for: %s\n", domainName)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
// Extract TLD
|
||||
parts := strings.Split(domainName, ".")
|
||||
if len(parts) < 2 {
|
||||
fmt.Printf("Invalid domain format: %s\n", domainName)
|
||||
continue
|
||||
}
|
||||
tld := strings.ToLower(parts[len(parts)-1])
|
||||
|
||||
// WHOIS server for .eu
|
||||
server := "whois.eu"
|
||||
if tld != "eu" {
|
||||
fmt.Printf("Skipping non-eu domain: %s\n", domainName)
|
||||
continue
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(server, "43")
|
||||
fmt.Printf("Connecting to: %s\n", addr)
|
||||
|
||||
// Use longer timeout for .eu domains
|
||||
timeout := 20 * time.Second
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
|
||||
start := time.Now()
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
fmt.Printf("TCP WHOIS dial failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
fmt.Printf("Connected in: %v\n", time.Since(start))
|
||||
|
||||
// Send query
|
||||
query := domainName + "\r\n"
|
||||
fmt.Printf("Sending query: %q\n", query)
|
||||
|
||||
writeStart := time.Now()
|
||||
if _, err := conn.Write([]byte(query)); err != nil {
|
||||
fmt.Printf("TCP WHOIS write failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Write completed in: %v\n", time.Since(writeStart))
|
||||
|
||||
// Set read deadline
|
||||
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
fmt.Printf("Failed to set read deadline: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Read response
|
||||
var output strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
totalRead := 0
|
||||
|
||||
readStart := time.Now()
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
output.Write(buf[:n])
|
||||
totalRead += n
|
||||
fmt.Printf("Read %d bytes (total: %d)\n", n, totalRead)
|
||||
}
|
||||
if err != nil {
|
||||
if err.Error() != "EOF" {
|
||||
fmt.Printf("Read error: %v\n", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
// Prevent infinite loop
|
||||
if totalRead > 10000 {
|
||||
fmt.Printf("Stopping read after 10KB\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Read completed in: %v\n", time.Since(readStart))
|
||||
fmt.Printf("Total bytes read: %d\n", totalRead)
|
||||
|
||||
response := output.String()
|
||||
if len(response) > 0 {
|
||||
fmt.Printf("✅ WHOIS Response (first 500 chars):\n")
|
||||
if len(response) > 500 {
|
||||
fmt.Printf("%s...\n", response[:500])
|
||||
} else {
|
||||
fmt.Printf("%s\n", response)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ No response received\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/hub/domains/whois"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a new lookup service with a dummy API key
|
||||
lookupService := whois.NewLookupService("dummy-api-key")
|
||||
|
||||
// Test domains including .eu domain
|
||||
testDomains := []string{
|
||||
"google.com",
|
||||
"bookra.eu", // Your .eu domain
|
||||
"sportcreative.eu", // Another .eu domain
|
||||
"github.com",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
fmt.Println("=== WHOIS Lookup Test ===")
|
||||
fmt.Println()
|
||||
|
||||
for _, domainName := range testDomains {
|
||||
fmt.Printf("Testing domain: %s\n", domainName)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Test WHOIS lookup
|
||||
whoisData, err := lookupService.LookupWHOIS(ctx, domainName)
|
||||
|
||||
duration := time.Since(start)
|
||||
fmt.Printf("Lookup duration: %v\n", duration)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: %v\n", err)
|
||||
} else if whoisData != nil {
|
||||
fmt.Printf("✅ WHOIS Data Found:\n")
|
||||
fmt.Printf(" Domain: %s\n", whoisData.DomainName)
|
||||
fmt.Printf(" Status: %v\n", whoisData.Status)
|
||||
fmt.Printf(" DNSSEC: %s\n", whoisData.DNSSEC)
|
||||
|
||||
if whoisData.Dates.ExpiryDate != nil && !whoisData.Dates.ExpiryDate.IsZero() {
|
||||
fmt.Printf(" Expiry Date: %s\n", whoisData.Dates.ExpiryDate.Format("2006-01-02"))
|
||||
}
|
||||
if whoisData.Dates.CreationDate != nil && !whoisData.Dates.CreationDate.IsZero() {
|
||||
fmt.Printf(" Creation Date: %s\n", whoisData.Dates.CreationDate.Format("2006-01-02"))
|
||||
}
|
||||
if whoisData.Dates.UpdatedDate != nil && !whoisData.Dates.UpdatedDate.IsZero() {
|
||||
fmt.Printf(" Updated Date: %s\n", whoisData.Dates.UpdatedDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
if whoisData.Registrar.Name != "" {
|
||||
fmt.Printf(" Registrar: %s\n", whoisData.Registrar.Name)
|
||||
}
|
||||
if whoisData.Registrar.ID != "" {
|
||||
fmt.Printf(" Registrar ID: %s\n", whoisData.Registrar.ID)
|
||||
}
|
||||
|
||||
if whoisData.Registrant.Name != "" || whoisData.Registrant.Organization != "" {
|
||||
fmt.Printf(" Registrant: %s (%s)\n", whoisData.Registrant.Name, whoisData.Registrant.Organization)
|
||||
}
|
||||
if whoisData.Registrant.Country != "" {
|
||||
fmt.Printf(" Registrant Country: %s\n", whoisData.Registrant.Country)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ No WHOIS data returned\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("=== Full Domain Lookup Test ===")
|
||||
|
||||
// Test full domain lookup
|
||||
fullDomain, err := lookupService.LookupDomain(ctx, domainName)
|
||||
if err != nil {
|
||||
fmt.Printf("Full lookup ERROR: %v\n", err)
|
||||
} else if fullDomain != nil {
|
||||
fmt.Printf("✅ Full Domain Data:\n")
|
||||
fmt.Printf(" Domain: %s\n", fullDomain.DomainName)
|
||||
fmt.Printf(" Status: %s\n", fullDomain.Status)
|
||||
fmt.Printf(" Active: %t\n", fullDomain.Active)
|
||||
if fullDomain.ExpiryDate != nil {
|
||||
fmt.Printf(" Expiry: %s\n", fullDomain.ExpiryDate.Format("2006-01-02"))
|
||||
daysUntil := int(fullDomain.ExpiryDate.Sub(time.Now()).Hours() / 24)
|
||||
fmt.Printf(" Days Until Expiry: %d\n", daysUntil)
|
||||
}
|
||||
fmt.Printf(" Registrar: %s\n", fullDomain.RegistrarName)
|
||||
fmt.Printf(" DNSSEC: %s\n", fullDomain.DNSSEC)
|
||||
|
||||
if len(fullDomain.IPv4Addresses) > 0 {
|
||||
fmt.Printf(" IPv4 Addresses: %v\n", fullDomain.IPv4Addresses)
|
||||
}
|
||||
if len(fullDomain.IPv6Addresses) > 0 {
|
||||
fmt.Printf(" IPv6 Addresses: %v\n", fullDomain.IPv6Addresses)
|
||||
}
|
||||
if len(fullDomain.NameServers) > 0 {
|
||||
fmt.Printf(" Name Servers: %v\n", fullDomain.NameServers)
|
||||
}
|
||||
if len(fullDomain.MXRecords) > 0 {
|
||||
fmt.Printf(" MX Records: %v\n", fullDomain.MXRecords)
|
||||
}
|
||||
|
||||
if fullDomain.SSLValidTo != nil {
|
||||
fmt.Printf(" SSL Valid Until: %s\n", fullDomain.SSLValidTo.Format("2006-01-02"))
|
||||
sslDaysUntil := int(fullDomain.SSLValidTo.Sub(time.Now()).Hours() / 24)
|
||||
fmt.Printf(" SSL Days Until: %d\n", sslDaysUntil)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Test WhoisXML API for .eu domains
|
||||
const testWhoisXML = async (domain) => {
|
||||
try {
|
||||
// Note: This would require a real API key to test
|
||||
console.log(`Testing WhoisXML API for: ${domain}`);
|
||||
console.log('Note: WhoisXML API requires an API key to test properly');
|
||||
|
||||
// URL structure they use
|
||||
const url = `https://www.whoisxmlapi.com/whoisserver/WhoisService?apiKey=YOUR_API_KEY&outputFormat=json&domainName=${domain}`;
|
||||
console.log(`WhoisXML URL: ${url}`);
|
||||
|
||||
// Based on their code, WhoisXML should return expiry dates for .eu domains
|
||||
console.log('WhoisXML API typically provides expiry dates for .eu domains that TCP WHOIS does not');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
testWhoisXML('bookra.eu');
|
||||
testWhoisXML('sportcreative.eu');
|
||||
Reference in New Issue
Block a user