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()
|
oldRecord := record.Fresh()
|
||||||
|
|
||||||
// Update record (only overwrite if new data is present to preserve valid data on partial lookups)
|
// 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)
|
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)
|
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)
|
record.Set("updated_date", *newData.UpdatedDate)
|
||||||
}
|
}
|
||||||
if newData.RegistrarName != "" {
|
if newData.RegistrarName != "" {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"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) {
|
func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
// Try RDAP first (modern replacement for WHOIS)
|
// Try RDAP first
|
||||||
data, err := s.tryRDAP(ctx, domainName)
|
data, err := s.tryRDAP(ctx, domainName)
|
||||||
if err == nil && data != nil && hasValidData(data) {
|
if err == nil && data != nil && hasValidData(data) {
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
lastErr = err
|
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)
|
data, err = s.tryTCPWHOIS(ctx, domainName)
|
||||||
if err == nil && data != nil && hasValidData(data) {
|
if err == nil && data != nil && hasValidData(data) {
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
if lastErr == nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try native whois command
|
// Try native whois command (often works when TCP fails)
|
||||||
data, err = s.tryNativeWHOIS(ctx, domainName)
|
data, err = s.tryNativeWHOIS(ctx, domainName)
|
||||||
if err == nil && data != nil && hasValidData(data) {
|
if err == nil && data != nil && hasValidData(data) {
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
if lastErr == nil {
|
if err != nil {
|
||||||
lastErr = err
|
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 != "" {
|
if s.whoisXMLAPIKey != "" {
|
||||||
data, err = s.tryWhoisXML(ctx, domainName)
|
data, err = s.tryWhoisXML(ctx, domainName)
|
||||||
if err == nil && data != nil {
|
if err == nil && data != nil {
|
||||||
@@ -251,8 +275,15 @@ func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (
|
|||||||
return nil, fmt.Errorf("whois command not found")
|
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
|
// Execute whois with timeout
|
||||||
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(cmdCtx, "whois", domainName)
|
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")
|
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)
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tcp whois dial failed: %w", err)
|
return nil, fmt.Errorf("tcp whois dial failed: %w", err)
|
||||||
@@ -326,7 +363,7 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read response with deadline
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,11 +451,417 @@ func (s *LookupService) tryWhoisXML(ctx context.Context, domainName string) (*do
|
|||||||
}, nil
|
}, 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
|
// parseWHOISOutput parses the raw WHOIS text output
|
||||||
func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHOISData, error) {
|
func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHOISData, error) {
|
||||||
lines := strings.Split(output, "\n")
|
lines := strings.Split(output, "\n")
|
||||||
data := make(map[string]string)
|
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 {
|
for _, line := range lines {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(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
|
// Extract registrar - try multiple field names used by different WHOIS servers
|
||||||
|
// .eu domains use "Name:" under Registrar section
|
||||||
registrarName := data["registrar"]
|
registrarName := data["registrar"]
|
||||||
if registrarName == "" {
|
if registrarName == "" {
|
||||||
registrarName = data["registrar_name"]
|
registrarName = data["registrar_name"]
|
||||||
|
|||||||
@@ -293,8 +293,12 @@ func (h *APIHandler) createMonitor(e *core.RequestEvent) error {
|
|||||||
h.scheduler.AddMonitor(record)
|
h.scheduler.AddMonitor(record)
|
||||||
|
|
||||||
// Run initial check synchronously so the monitor shows real status immediately
|
// 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)
|
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
|
// 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Link } from "@/components/router"
|
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 { getCalendarEvents, type CalendarEvent } from "@/lib/incidents"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
export function CalendarView() {
|
export function CalendarView() {
|
||||||
const [currentDate, setCurrentDate] = useState(new Date())
|
const [currentDate, setCurrentDate] = useState(new Date())
|
||||||
|
const [eventFilters, setEventFilters] = useState({
|
||||||
|
domain_expiry: true,
|
||||||
|
ssl_expiry: true,
|
||||||
|
incident: true,
|
||||||
|
})
|
||||||
const year = currentDate.getFullYear()
|
const year = currentDate.getFullYear()
|
||||||
const month = currentDate.getMonth()
|
const month = currentDate.getMonth()
|
||||||
|
|
||||||
@@ -46,20 +60,22 @@ export function CalendarView() {
|
|||||||
// Days of month
|
// Days of month
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
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 })
|
d.push({ day, events: dayEvents })
|
||||||
}
|
}
|
||||||
|
|
||||||
return d
|
return d
|
||||||
}, [year, month, daysInMonth, firstDayOfMonth, events])
|
}, [year, month, daysInMonth, firstDayOfMonth, events, eventFilters])
|
||||||
|
|
||||||
const upcomingEvents = useMemo(() => {
|
const upcomingEvents = useMemo(() => {
|
||||||
const today = toDateString(new Date())
|
const today = toDateString(new Date())
|
||||||
return (events || [])
|
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))
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
}, [events])
|
}, [events, eventFilters])
|
||||||
|
|
||||||
const prevMonth = () => {
|
const prevMonth = () => {
|
||||||
setCurrentDate(new Date(year, month - 1, 1))
|
setCurrentDate(new Date(year, month - 1, 1))
|
||||||
@@ -123,6 +139,8 @@ export function CalendarView() {
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader className="pb-4">
|
<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">
|
<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">
|
<CardTitle className="flex items-center gap-2 text-lg sm:text-xl">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg">
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
@@ -145,6 +163,94 @@ export function CalendarView() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Day headers */}
|
{/* Day headers */}
|
||||||
|
|||||||
@@ -41,16 +41,15 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
getDomains,
|
getDomains,
|
||||||
deleteDomain,
|
deleteDomain,
|
||||||
refreshDomain,
|
refreshDomain,
|
||||||
getStatusBadgeColor,
|
getDomainSubdomains,
|
||||||
getStatusLabel,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
type Domain,
|
type Domain,
|
||||||
|
type Subdomain,
|
||||||
} from "@/lib/domains"
|
} from "@/lib/domains"
|
||||||
import {
|
import {
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
@@ -67,7 +66,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { DomainDialog } from "./domain-dialog"
|
import { DomainDialog } from "./domain-dialog"
|
||||||
import { Link } from "@/components/router"
|
import { Link } from "@/components/router"
|
||||||
import { useBrowserStorage } from "@/lib/utils"
|
import { cn, useBrowserStorage } from "@/lib/utils"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
type StatusFilter = "all" | "active" | "expiring" | "expired" | "unknown" | "paused"
|
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() {
|
export default function DomainsTable() {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
@@ -203,19 +237,35 @@ export default function DomainsTable() {
|
|||||||
refreshMutation.mutate(id)
|
refreshMutation.mutate(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
// Status indicator component matching monitors table style
|
||||||
switch (status) {
|
function StatusIndicator({ status }: { status: string }) {
|
||||||
case "active":
|
const colors = {
|
||||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
active: "bg-green-500",
|
||||||
case "expiring":
|
expiring: "bg-yellow-500",
|
||||||
return <Clock className="h-4 w-4 text-yellow-500" />
|
expired: "bg-red-500",
|
||||||
case "expired":
|
unknown: "bg-gray-500",
|
||||||
return <AlertTriangle className="h-4 w-4 text-red-500" />
|
paused: "bg-blue-500",
|
||||||
default:
|
|
||||||
return <Globe className="h-4 w-4 text-gray-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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||||
@@ -458,20 +508,16 @@ export default function DomainsTable() {
|
|||||||
<img
|
<img
|
||||||
src={domain.favicon_url}
|
src={domain.favicon_url}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-4 w-4"
|
className="h-4 w-4 rounded-sm"
|
||||||
onError={(e) => (e.currentTarget.style.display = "none")}
|
onError={(e) => (e.currentTarget.style.display = "none")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="hover:underline">{domain.domain_name}</span>
|
<span className="hover:underline">{domain.domain_name}</span>
|
||||||
|
<SubdomainIndicator domainId={domain.id} />
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<StatusIndicator status={domain.status} />
|
||||||
{getStatusIcon(domain.status)}
|
|
||||||
<Badge className={getStatusBadgeColor(domain.status)}>
|
|
||||||
{getStatusLabel(domain.status)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{displayOptions.showExpiryDate && (
|
{displayOptions.showExpiryDate && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -566,6 +612,7 @@ export default function DomainsTable() {
|
|||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-medium truncate hover:underline">{domain.domain_name}</div>
|
<div className="font-medium truncate hover:underline">{domain.domain_name}</div>
|
||||||
|
<SubdomainIndicator domainId={domain.id} />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -587,12 +634,7 @@ export default function DomainsTable() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<StatusIndicator status={domain.status} />
|
||||||
{getStatusIcon(domain.status)}
|
|
||||||
<Badge className={getStatusBadgeColor(domain.status)}>
|
|
||||||
{getStatusLabel(domain.status)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{displayOptions.showTags && domain.tags && domain.tags.length > 0 && (
|
{displayOptions.showTags && domain.tags && domain.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -70,9 +70,7 @@ import {
|
|||||||
} from "@/lib/monitors"
|
} from "@/lib/monitors"
|
||||||
import { cn, useBrowserStorage } from "@/lib/utils"
|
import { cn, useBrowserStorage } from "@/lib/utils"
|
||||||
import { AddMonitorDialog } from "./add-monitor-dialog"
|
import { AddMonitorDialog } from "./add-monitor-dialog"
|
||||||
import { GroupedMonitorsTable } from "./grouped-monitors-table"
|
|
||||||
import { Link } from "@/components/router"
|
import { Link } from "@/components/router"
|
||||||
import { Network } from "lucide-react"
|
|
||||||
|
|
||||||
// Status indicator component
|
// Status indicator component
|
||||||
function StatusIndicator({ status }: { status: MonitorStatus }) {
|
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 StatusFilter = "all" | MonitorStatus
|
||||||
type TypeFilter = "all" | MonitorType
|
type TypeFilter = "all" | MonitorType
|
||||||
|
|
||||||
@@ -745,10 +743,6 @@ export default memo(function MonitorsTable() {
|
|||||||
<LayoutGridIcon className="size-4" />
|
<LayoutGridIcon className="size-4" />
|
||||||
<Trans>Grid</Trans>
|
<Trans>Grid</Trans>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="network" className="gap-2">
|
|
||||||
<Network className="size-4" />
|
|
||||||
<Trans>Network (Grouped)</Trans>
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
@@ -807,8 +801,6 @@ export default memo(function MonitorsTable() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "network" ? (
|
|
||||||
<GroupedMonitorsTable />
|
|
||||||
) : viewMode === "table" ? (
|
) : viewMode === "table" ? (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import {
|
|||||||
import { Link, navigate } from "@/components/router"
|
import { Link, navigate } from "@/components/router"
|
||||||
import { DomainDialog } from "@/components/domains-table/domain-dialog"
|
import { DomainDialog } from "@/components/domains-table/domain-dialog"
|
||||||
import { SubdomainList } from "@/components/domains-table/subdomain-list"
|
import { SubdomainList } from "@/components/domains-table/subdomain-list"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
// Status badge component
|
// Status badge component
|
||||||
function StatusBadge({ status }: { status: string }) {
|
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 { toast } = useToast()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = 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({
|
const { data: domain, isLoading: isDomainLoading } = useQuery({
|
||||||
queryKey: ["domain", id],
|
queryKey: ["domain", id],
|
||||||
@@ -136,7 +142,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
setIsDeleteDialogOpen(true)
|
setDeleteDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
@@ -151,7 +157,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
|||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleteDialogOpen(false)
|
setDeleteDialogOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +232,8 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Info Grid */}
|
{/* Quick Overview Cards */}
|
||||||
|
<div className="grid gap-4">
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 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 title="Registrar" value={domain.registrar_name || "Unknown"} icon={Server} />
|
||||||
<InfoCard
|
<InfoCard
|
||||||
@@ -258,6 +265,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
|||||||
icon={MapPin}
|
icon={MapPin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Expiry Overview - Clean visual cards */}
|
{/* Expiry Overview - Clean visual cards */}
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
@@ -307,6 +315,25 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</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 && (() => {
|
{typeof domain.days_until_expiry === "number" && domain.days_until_expiry >= 0 && (() => {
|
||||||
const d = domain.days_until_expiry
|
const d = domain.days_until_expiry
|
||||||
return (
|
return (
|
||||||
@@ -398,73 +425,97 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
{/* Technical Information Section */}
|
||||||
{/* Additional Info */}
|
<div className="grid gap-6">
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Network Information */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>
|
</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) => (
|
{domain.ipv4_addresses?.map((ip: string) => (
|
||||||
<div key={ip} className="flex items-center gap-2">
|
<div key={ip} className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">IPv4</Badge>
|
<Badge variant="secondary" className="text-xs">IPv4</Badge>
|
||||||
<code className="text-sm">{ip}</code>
|
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">{ip}</code>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{domain.ipv6_addresses?.map((ip: string) => (
|
{domain.ipv6_addresses?.map((ip: string) => (
|
||||||
<div key={ip} className="flex items-center gap-2">
|
<div key={ip} className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">IPv6</Badge>
|
<Badge variant="secondary" className="text-xs">IPv6</Badge>
|
||||||
<code className="text-sm">{ip}</code>
|
<code className="text-sm font-mono bg-muted px-2 py-1 rounded break-all">{ip}</code>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
|
{!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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Domain Valuation */}
|
||||||
{((domain.purchase_price ?? 0) > 0 || (domain.current_value ?? 0) > 0 || (domain.renewal_cost ?? 0) > 0) && (
|
{((domain.purchase_price ?? 0) > 0 || (domain.current_value ?? 0) > 0 || (domain.renewal_cost ?? 0) > 0) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-3">
|
||||||
{(domain.purchase_price ?? 0) > 0 && (
|
{(domain.purchase_price ?? 0) > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||||
<span className="text-muted-foreground">Purchase Price</span>
|
<span className="text-sm text-muted-foreground">Purchase Price</span>
|
||||||
<span className="font-medium">${domain.purchase_price}</span>
|
<span className="font-semibold">${domain.purchase_price}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(domain.current_value ?? 0) > 0 && (
|
{(domain.current_value ?? 0) > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||||
<span className="text-muted-foreground">Current Value</span>
|
<span className="text-sm text-muted-foreground">Current Value</span>
|
||||||
<span className="font-medium">${domain.current_value}</span>
|
<span className="font-semibold">${domain.current_value}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(domain.renewal_cost ?? 0) > 0 && (
|
{(domain.renewal_cost ?? 0) > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||||
<span className="text-muted-foreground">Renewal Cost</span>
|
<span className="text-sm text-muted-foreground">Renewal Cost</span>
|
||||||
<span className="font-medium">${domain.renewal_cost}</span>
|
<span className="font-semibold">${domain.renewal_cost}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
|
||||||
<span className="text-muted-foreground">Auto-renew</span>
|
<span className="text-sm text-muted-foreground">Auto-renew</span>
|
||||||
<Badge variant={domain.auto_renew ? "default" : "secondary"}>{domain.auto_renew ? "Yes" : "No"}</Badge>
|
<Badge variant={domain.auto_renew ? "default" : "secondary"} className="ml-2">
|
||||||
|
{domain.auto_renew ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes Section */}
|
||||||
{domain.notes && (
|
{domain.notes && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notes</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Notes
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -939,4 +990,183 @@ export default memo(function DomainDetail({ id }: { id: string }) {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</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,
|
ArrowUpIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FilterIcon,
|
FilterIcon,
|
||||||
|
GripVertical,
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@@ -96,6 +97,58 @@ export default function SystemsTable() {
|
|||||||
window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table"
|
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(() => {
|
useEffect(() => {
|
||||||
if (filter !== undefined) {
|
if (filter !== undefined) {
|
||||||
table.getColumn("system")?.setFilterValue(filter)
|
table.getColumn("system")?.setFilterValue(filter)
|
||||||
@@ -138,7 +191,9 @@ export default function SystemsTable() {
|
|||||||
const CardHead = useMemo(() => {
|
const CardHead = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
<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">
|
<div className="px-2 sm:px-1">
|
||||||
<CardTitle className="mb-2">
|
<CardTitle className="mb-2">
|
||||||
<Trans>All Systems</Trans>
|
<Trans>All Systems</Trans>
|
||||||
@@ -147,8 +202,14 @@ export default function SystemsTable() {
|
|||||||
<Trans>Click on a system to view more information.</Trans>
|
<Trans>Click on a system to view more information.</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</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">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
@@ -246,11 +307,12 @@ export default function SystemsTable() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
|
|
||||||
}}
|
|
||||||
key={column.id}
|
key={column.id}
|
||||||
|
onClick={() => {
|
||||||
|
const isDesc = sorting[0]?.id === column.id && !sorting[0]?.desc
|
||||||
|
setSorting([{ id: column.id, desc: isDesc }])
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
>
|
>
|
||||||
{Icon}
|
{Icon}
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
@@ -264,13 +326,12 @@ export default function SystemsTable() {
|
|||||||
<div>
|
<div>
|
||||||
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
<EyeIcon className="size-4" />
|
<EyeIcon className="size-4" />
|
||||||
<Trans>Visible Fields</Trans>
|
<Trans>Columns</Trans>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="px-1.5 pb-1">
|
<div className="px-1 pb-1">
|
||||||
{columns
|
{columns.map((column) => {
|
||||||
.filter((column) => column.getCanHide())
|
if (column.id === "select") return null
|
||||||
.map((column) => {
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={column.id}
|
key={column.id}
|
||||||
@@ -288,10 +349,6 @@ export default function SystemsTable() {
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
|
|
||||||
<PlusIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Add System</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -315,7 +372,18 @@ export default function SystemsTable() {
|
|||||||
{viewMode === "table" ? (
|
{viewMode === "table" ? (
|
||||||
// table layout
|
// table layout
|
||||||
<div className="rounded-md">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// grid layout
|
// grid layout
|
||||||
@@ -338,7 +406,29 @@ export default function SystemsTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AllSystemsTable = memo(
|
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
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -377,6 +467,13 @@ const AllSystemsTable = memo(
|
|||||||
virtualRow={virtualRow}
|
virtualRow={virtualRow}
|
||||||
length={rows.length}
|
length={rows.length}
|
||||||
colLength={colLength}
|
colLength={colLength}
|
||||||
|
draggedItem={draggedItem}
|
||||||
|
dragOverIndex={dragOverIndex}
|
||||||
|
handleDragStart={handleDragStart}
|
||||||
|
handleDragOver={handleDragOver}
|
||||||
|
handleDragLeave={handleDragLeave}
|
||||||
|
handleDrop={handleDrop}
|
||||||
|
handleDragEnd={handleDragEnd}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -418,32 +515,73 @@ const SystemTableRow = memo(
|
|||||||
row,
|
row,
|
||||||
virtualRow,
|
virtualRow,
|
||||||
colLength,
|
colLength,
|
||||||
|
draggedItem,
|
||||||
|
dragOverIndex,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
handleDragEnd,
|
||||||
}: {
|
}: {
|
||||||
row: Row<SystemRecord>
|
row: Row<SystemRecord>
|
||||||
virtualRow: VirtualItem
|
virtualRow: VirtualItem
|
||||||
length: number
|
length: number
|
||||||
colLength: 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 system = row.original
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
const isDragged = draggedItem?.id === system.id
|
||||||
|
const isDragOver = dragOverIndex === virtualRow.index
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<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"}
|
// data-state={row.getIsSelected() && "selected"}
|
||||||
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
|
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
|
||||||
"opacity-50": system.status === SystemStatus.Paused,
|
"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
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
style={{
|
style={{
|
||||||
width: cell.column.getSize(),
|
width: cell.column.getSize(),
|
||||||
height: virtualRow.size,
|
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())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</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