feat(hub): improve WHOIS lookup reliability and enhance site UI
Build Docker images / Hub (push) Failing after 52s

Implement enhanced WHOIS lookup strategies, specifically targeting .eu
domains through EURid web scraping and alternative services to
improve data accuracy for expiry dates.

- Add EURid web scraping and alternative WHOIS service support for .eu domains
- Increase timeouts for .eu domain lookups in TCP and native WHOIS
- Improve domain scheduler to prevent overwriting valid data with zero-value dates
- Enhance site UI with subdomain indicators in domain tables
- Add filtering capabilities to the calendar view
- Implement drag-and-drop reordering for systems table
- Add new debug and test utilities for WHOIS and date parsing logic
This commit is contained in:
Tomas Dvorak
2026-05-08 11:07:34 +02:00
parent 1af18872d5
commit b6f40af67f
15 changed files with 1934 additions and 195 deletions
+3 -3
View File
@@ -151,13 +151,13 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
oldRecord := record.Fresh()
// Update record (only overwrite if new data is present to preserve valid data on partial lookups)
if newData.ExpiryDate != nil {
if newData.ExpiryDate != nil && newData.ExpiryDate.After(time.Time{}) {
record.Set("expiry_date", *newData.ExpiryDate)
}
if newData.CreationDate != nil {
if newData.CreationDate != nil && newData.CreationDate.After(time.Time{}) {
record.Set("creation_date", *newData.CreationDate)
}
if newData.UpdatedDate != nil {
if newData.UpdatedDate != nil && newData.UpdatedDate.After(time.Time{}) {
record.Set("updated_date", *newData.UpdatedDate)
}
if newData.RegistrarName != "" {
+453 -9
View File
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os/exec"
@@ -79,32 +80,55 @@ func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*d
func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
var lastErr error
// Try RDAP first (modern replacement for WHOIS)
// Try RDAP first
data, err := s.tryRDAP(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if err != nil {
lastErr = err
}
// Try pure-Go TCP WHOIS (works in containers without whois binary)
// Try TCP WHOIS (this should work for .eu domains)
data, err = s.tryTCPWHOIS(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if lastErr == nil {
if err != nil {
lastErr = err
}
// Try native whois command
// Try native whois command (often works when TCP fails)
data, err = s.tryNativeWHOIS(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if lastErr == nil {
if err != nil {
lastErr = err
}
// Try WhoisXML API if key is configured
// Try EURid web scraping for .eu domains to get expiry dates
parts := strings.Split(domainName, ".")
if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" {
data, err = s.tryEURidWebScraping(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if err != nil {
lastErr = err
}
// Try alternative WHOIS services for .eu domains
data, err = s.tryAlternativeWHOIS(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if err != nil {
lastErr = err
}
}
// Try WhoisXML API if key is configured (this can provide expiry dates for .eu domains)
if s.whoisXMLAPIKey != "" {
data, err = s.tryWhoisXML(ctx, domainName)
if err == nil && data != nil {
@@ -251,8 +275,15 @@ func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (
return nil, fmt.Errorf("whois command not found")
}
// Use longer timeout for .eu domains
timeout := 10 * time.Second
parts := strings.Split(domainName, ".")
if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" {
timeout = 20 * time.Second
}
// Execute whois with timeout
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "whois", domainName)
@@ -312,7 +343,13 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do
addr := net.JoinHostPort(server, "43")
dialer := &net.Dialer{Timeout: 10 * time.Second}
// Use longer timeout for .eu domains as they can be slow
timeout := 10 * time.Second
if tld == "eu" {
timeout = 20 * time.Second
}
dialer := &net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("tcp whois dial failed: %w", err)
@@ -326,7 +363,7 @@ func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*do
}
// Read response with deadline
if err := conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return nil, err
}
@@ -414,11 +451,417 @@ func (s *LookupService) tryWhoisXML(ctx context.Context, domainName string) (*do
}, nil
}
// parseEUWHOIS parses .eu domain WHOIS output which has a unique format
func (s *LookupService) parseEUWHOIS(output, domainName string) (*domain.WHOISData, error) {
lines := strings.Split(output, "\n")
var registrarName, organization string
var statuses []string
// Parse the .eu specific format
currentSection := ""
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip comments and empty lines
if line == "" || strings.HasPrefix(line, "%") {
continue
}
// Track sections
if strings.HasPrefix(line, "Registrant:") {
currentSection = "registrant"
continue
}
if strings.HasPrefix(line, "Technical:") {
currentSection = "technical"
continue
}
if strings.HasPrefix(line, "Registrar:") {
currentSection = "registrar"
continue
}
if strings.HasPrefix(line, "Name servers:") {
currentSection = "nameservers"
continue
}
// Parse based on current section
if idx := strings.Index(line, ":"); idx > 0 {
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
switch currentSection {
case "registrar":
if strings.TrimSpace(key) == "Name" {
registrarName = value
}
if strings.TrimSpace(key) == "Website" {
// Could extract website URL if needed
}
case "technical":
if strings.TrimSpace(key) == "Organisation" {
organization = value
}
}
}
}
// For .eu domains, we often don't get expiry dates, so we'll return what we have
return &domain.WHOISData{
DomainName: domainName,
Status: statuses,
DNSSEC: "", // .eu WHOIS doesn't provide DNSSEC info
Dates: domain.WHOISDates{
ExpiryDate: nil, // .eu domains don't show expiry in TCP WHOIS
CreationDate: nil,
UpdatedDate: nil,
},
Registrar: domain.WHOISRegistrar{
Name: registrarName,
ID: "",
URL: "",
},
Registrant: domain.WHOISContact{
Name: "NOT DISCLOSED",
Organization: organization,
},
}, nil
}
// tryEURidWebScraping attempts to scrape EURid's web WHOIS for .eu domains
func (s *LookupService) tryEURidWebScraping(ctx context.Context, domainName string) (*domain.WHOISData, error) {
// Try multiple EURid endpoints
endpoints := []string{
fmt.Sprintf("https://whois.eurid.eu/en/?q=%s", domainName),
fmt.Sprintf("https://whois.eurid.eu/en/search?q=%s", domainName),
fmt.Sprintf("https://www.eurid.eu/en/whois/?domain=%s", domainName),
}
for _, url := range endpoints {
data, err := s.tryEURidEndpoint(ctx, url, domainName)
if err == nil && data != nil {
return data, nil
}
}
return nil, fmt.Errorf("all EURid web scraping attempts failed for %s", domainName)
}
// tryEURidEndpoint attempts to scrape a specific EURid endpoint
func (s *LookupService) tryEURidEndpoint(ctx context.Context, url, domainName string) (*domain.WHOISData, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Use more realistic browser headers
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Sec-Ch-Ua", "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"")
req.Header.Set("Sec-Ch-Ua-Mobile", "?0")
req.Header.Set("Sec-Ch-Ua-Platform", "\"Windows\"")
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "none")
req.Header.Set("Sec-Fetch-User", "?1")
req.Header.Set("Upgrade-Insecure-Requests", "1")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch EURid web page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("EURid web page returned status %d", resp.StatusCode)
}
// Read the HTML response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read EURid response: %w", err)
}
// Parse the HTML to extract expiry date
return s.parseEURidWebHTML(string(body), domainName)
}
// tryAlternativeWHOIS tries alternative WHOIS services for .eu domains
func (s *LookupService) tryAlternativeWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
// Try multiple alternative WHOIS services
services := []struct {
name string
url string
}{
{"whois.com", fmt.Sprintf("https://www.whois.com/whois/%s", domainName)},
{"who.is", fmt.Sprintf("https://who.is/whois/%s", domainName)},
{"ip2location.com", fmt.Sprintf("https://www.ip2location.com/whois/%s", domainName)},
}
for _, service := range services {
data, err := s.tryAlternativeWHOISService(ctx, service.name, service.url, domainName)
if err == nil && data != nil {
return data, nil
}
}
return nil, fmt.Errorf("all alternative WHOIS services failed for %s", domainName)
}
// tryAlternativeWHOISService attempts to fetch WHOIS data from an alternative service
func (s *LookupService) tryAlternativeWHOISService(ctx context.Context, serviceName, url, domainName string) (*domain.WHOISData, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request for %s: %w", serviceName, err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch %s: %w", serviceName, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%s returned status %d", serviceName, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read %s response: %w", serviceName, err)
}
return s.parseAlternativeWHOISHTML(string(body), domainName, serviceName)
}
// parseAlternativeWHOISHTML parses HTML from alternative WHOIS services
func (s *LookupService) parseAlternativeWHOISHTML(html, domainName, serviceName string) (*domain.WHOISData, error) {
var expiryDate, registrarName, status string
// Look for expiry date patterns (common across WHOIS services)
expiryPatterns := []string{
`Expiry Date:\s*</[^>]*>\s*([^<\n]+)`,
`Expiry Date:</[^>]*>\s*([^<\n]+)`,
`Expires on:\s*</[^>]*>\s*([^<\n]+)`,
`Expires:\s*</[^>]*>\s*([^<\n]+)`,
`"expiry":"([^"]+)"`,
`"expires":"([^"]+)"`,
`data-expiry="([^"]+)"`,
`\d{4}-\d{2}-\d{2}`, // ISO date pattern
`\d{2}/\d{2}/\d{4}`, // DD/MM/YYYY pattern
}
for _, pattern := range expiryPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
expiryDate = strings.TrimSpace(matches[1])
break
}
}
// Look for registrar name
registrarPatterns := []string{
`Registrar:\s*</[^>]*>\s*([^<\n]+)`,
`Registrar:</[^>]*>\s*([^<\n]+)`,
`Registered through:\s*</[^>]*>\s*([^<\n]+)`,
`"registrar":"([^"]+)"`,
`data-registrar="([^"]+)"`,
}
for _, pattern := range registrarPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
registrarName = strings.TrimSpace(matches[1])
break
}
}
// Look for status
statusPatterns := []string{
`Status:\s*</[^>]*>\s*([^<\n]+)`,
`Status:</[^>]*>\s*([^<\n]+)`,
`"status":"([^"]+)"`,
`data-status="([^"]+)"`,
}
for _, pattern := range statusPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
status = strings.TrimSpace(matches[1])
break
}
}
// Parse expiry date if found
var parsedExpiry *time.Time
if expiryDate != "" {
// Try different date formats
dateFormats := []string{
"2006-01-02", // ISO
"02/01/2006", // DD/MM/YYYY
"01/02/2006", // MM/DD/YYYY
"2006-01-02T15:04:05Z", // ISO with time
}
for _, format := range dateFormats {
if parsed, err := time.Parse(format, expiryDate); err == nil {
parsedExpiry = &parsed
break
}
}
}
// Create WHOIS data structure
var statuses []string
if status != "" {
statuses = []string{status}
}
return &domain.WHOISData{
DomainName: domainName,
Status: statuses,
DNSSEC: "",
Dates: domain.WHOISDates{
ExpiryDate: parsedExpiry,
CreationDate: nil,
UpdatedDate: nil,
},
Registrar: domain.WHOISRegistrar{
Name: registrarName,
ID: "",
URL: "",
},
Registrant: domain.WHOISContact{
Name: "NOT DISCLOSED",
Organization: "",
},
}, nil
}
// parseEURidWebHTML parses EURid's web WHOIS HTML to extract domain information
func (s *LookupService) parseEURidWebHTML(html, domainName string) (*domain.WHOISData, error) {
// This is a simplified HTML parser - in production, you'd want to use a proper HTML parser
// For now, we'll use regex to find key information
var expiryDate, registrarName, status string
// Look for expiry date patterns in EURid's HTML
expiryPatterns := []string{
`Expiry date:\s*</strong>\s*(\d{2}/\d{2}/\d{4})`,
`Expiry date:</strong>\s*(\d{2}/\d{2}/\d{4})`,
`Expiry date</strong>\s*(\d{2}/\d{2}/\d{4})`,
`"expiryDate":"([^"]+)"`,
`data-expiry="([^"]+)"`,
}
for _, pattern := range expiryPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
expiryDate = matches[1]
break
}
}
// Look for registrar name
registrarPatterns := []string{
`Registrar:\s*</strong>\s*([^<\n]+)`,
`Registrar:</strong>\s*([^<\n]+)`,
`Registrar</strong>\s*([^<\n]+)`,
`"registrar":"([^"]+)"`,
`data-registrar="([^"]+)"`,
}
for _, pattern := range registrarPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
registrarName = strings.TrimSpace(matches[1])
break
}
}
// Look for status
statusPatterns := []string{
`Status:\s*</strong>\s*([^<\n]+)`,
`Status:</strong>\s*([^<\n]+)`,
`Status</strong>\s*([^<\n]+)`,
`"status":"([^"]+)"`,
`data-status="([^"]+)"`,
}
for _, pattern := range statusPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
status = strings.TrimSpace(matches[1])
break
}
}
// Parse expiry date if found
var parsedExpiry *time.Time
if expiryDate != "" {
// Try DD/MM/YYYY format first
if parsed, err := time.Parse("02/01/2006", expiryDate); err == nil {
parsedExpiry = &parsed
} else if parsed, err := time.Parse("2006-01-02", expiryDate); err == nil {
parsedExpiry = &parsed
}
}
// Create WHOIS data structure
var statuses []string
if status != "" {
statuses = []string{status}
}
return &domain.WHOISData{
DomainName: domainName,
Status: statuses,
DNSSEC: "",
Dates: domain.WHOISDates{
ExpiryDate: parsedExpiry,
CreationDate: nil,
UpdatedDate: nil,
},
Registrar: domain.WHOISRegistrar{
Name: registrarName,
ID: "",
URL: "",
},
Registrant: domain.WHOISContact{
Name: "NOT DISCLOSED",
Organization: "",
},
}, nil
}
// parseWHOISOutput parses the raw WHOIS text output
func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHOISData, error) {
lines := strings.Split(output, "\n")
data := make(map[string]string)
// Special handling for .eu domains which have a different format
parts := strings.Split(domainName, ".")
isEUDomain := len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu"
if isEUDomain {
return s.parseEUWHOIS(output, domainName)
}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "%") {
@@ -465,6 +908,7 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
)
// Extract registrar - try multiple field names used by different WHOIS servers
// .eu domains use "Name:" under Registrar section
registrarName := data["registrar"]
if registrarName == "" {
registrarName = data["registrar_name"]
+5 -1
View File
@@ -293,8 +293,12 @@ func (h *APIHandler) createMonitor(e *core.RequestEvent) error {
h.scheduler.AddMonitor(record)
// Run initial check synchronously so the monitor shows real status immediately
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
result, err := h.scheduler.RunManualCheck(record.Id)
if err != nil {
log.Printf("[monitor-api] Initial check failed for %s: %v", record.Id, err)
// Note: The monitor will remain in pending status and the scheduler will retry
} else {
log.Printf("[monitor-api] Initial check completed for %s: status=%s, ping=%v", record.Id, result.Status, result.Ping)
}
// Re-fetch the updated record to get the new status
@@ -5,11 +5,25 @@ import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Link } from "@/components/router"
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield } from "lucide-react"
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield, Filter, X } from "lucide-react"
import { getCalendarEvents, type CalendarEvent } from "@/lib/incidents"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function CalendarView() {
const [currentDate, setCurrentDate] = useState(new Date())
const [eventFilters, setEventFilters] = useState({
domain_expiry: true,
ssl_expiry: true,
incident: true,
})
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
@@ -46,20 +60,22 @@ export function CalendarView() {
// Days of month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
const dayEvents = events?.filter((e) => e.date === dateStr) || []
const dayEvents = events?.filter((e) =>
e.date === dateStr && eventFilters[e.type as keyof typeof eventFilters]
) || []
d.push({ day, events: dayEvents })
}
return d
}, [year, month, daysInMonth, firstDayOfMonth, events])
}, [year, month, daysInMonth, firstDayOfMonth, events, eventFilters])
const upcomingEvents = useMemo(() => {
const today = toDateString(new Date())
return (events || [])
.filter((event) => event.date >= today)
.filter((event) => event.date >= today && eventFilters[event.type as keyof typeof eventFilters])
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 8)
}, [events])
}, [events, eventFilters])
const prevMonth = () => {
setCurrentDate(new Date(year, month - 1, 1))
@@ -123,6 +139,8 @@ export function CalendarView() {
return (
<Card className="w-full">
<CardHeader className="pb-4">
<div className="flex flex-col gap-4">
{/* Title Row */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle className="flex items-center gap-2 text-lg sm:text-xl">
<div className="p-2 bg-primary/10 rounded-lg">
@@ -145,6 +163,94 @@ export function CalendarView() {
</Button>
</div>
</div>
{/* Filter Controls Row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Show:</span>
<div className="flex flex-wrap gap-1">
<Button
variant={eventFilters.domain_expiry ? "default" : "outline"}
size="sm"
onClick={() => setEventFilters(prev => ({ ...prev, domain_expiry: !prev.domain_expiry }))}
className="h-7 text-xs gap-1"
>
<Globe className="h-3 w-3" />
Domain
</Button>
<Button
variant={eventFilters.ssl_expiry ? "default" : "outline"}
size="sm"
onClick={() => setEventFilters(prev => ({ ...prev, ssl_expiry: !prev.ssl_expiry }))}
className="h-7 text-xs gap-1"
>
<Shield className="h-3 w-3" />
SSL
</Button>
<Button
variant={eventFilters.incident ? "default" : "outline"}
size="sm"
onClick={() => setEventFilters(prev => ({ ...prev, incident: !prev.incident }))}
className="h-7 text-xs gap-1"
>
<AlertCircle className="h-3 w-3" />
Incidents
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-xs">
<Filter className="h-3 w-3 mr-1" />
Quick Filters
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Event Types</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={eventFilters.domain_expiry}
onCheckedChange={(checked) => setEventFilters(prev => ({ ...prev, domain_expiry: checked }))}
>
<div className="flex items-center gap-2">
<Globe className="h-3 w-3" />
Domain Expiry
</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={eventFilters.ssl_expiry}
onCheckedChange={(checked) => setEventFilters(prev => ({ ...prev, ssl_expiry: checked }))}
>
<div className="flex items-center gap-2">
<Shield className="h-3 w-3" />
SSL Expiry
</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={eventFilters.incident}
onCheckedChange={(checked) => setEventFilters(prev => ({ ...prev, incident: checked }))}
>
<div className="flex items-center gap-2">
<AlertCircle className="h-3 w-3" />
Incidents
</div>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setEventFilters({ domain_expiry: true, ssl_expiry: true, incident: true })}>
Show All
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEventFilters({ domain_expiry: true, ssl_expiry: false, incident: false })}>
Domain Only
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEventFilters({ domain_expiry: false, ssl_expiry: true, incident: false })}>
SSL Only
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Day headers */}
@@ -41,16 +41,15 @@ import {
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import {
getDomains,
deleteDomain,
refreshDomain,
getStatusBadgeColor,
getStatusLabel,
getDomainSubdomains,
formatDate,
type Domain,
type Subdomain,
} from "@/lib/domains"
import {
MoreHorizontal,
@@ -67,7 +66,7 @@ import {
} from "lucide-react"
import { DomainDialog } from "./domain-dialog"
import { Link } from "@/components/router"
import { useBrowserStorage } from "@/lib/utils"
import { cn, useBrowserStorage } from "@/lib/utils"
type ViewMode = "table" | "grid"
type StatusFilter = "all" | "active" | "expiring" | "expired" | "unknown" | "paused"
@@ -103,6 +102,41 @@ function DaysLeftBadge({ days, label = "days" }: { days: number | undefined; lab
)
}
// Subdomain indicator component
function SubdomainIndicator({ domainId }: { domainId: string }) {
const { data: subdomains, isLoading } = useQuery({
queryKey: ["domain-subdomains", domainId],
queryFn: () => getDomainSubdomains(domainId),
enabled: !!domainId,
staleTime: 5 * 60 * 1000, // 5 minutes
})
if (isLoading || !subdomains || subdomains.length === 0) {
return null
}
const activeCount = subdomains.filter(s => s.status === "active").length
const totalCount = subdomains.length
const hasIssues = subdomains.some(s => s.status === "error")
return (
<div className="flex items-center gap-1">
<div className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border",
hasIssues
? "bg-orange-500/15 text-orange-600 border-orange-500/30"
: "bg-blue-500/15 text-blue-600 border-blue-500/30"
)}>
<Globe className="h-3 w-3" />
<span>{activeCount}/{totalCount}</span>
</div>
{hasIssues && (
<AlertTriangle className="h-3 w-3 text-orange-500" />
)}
</div>
)
}
export default function DomainsTable() {
const { t } = useLingui()
const { toast } = useToast()
@@ -203,17 +237,33 @@ export default function DomainsTable() {
refreshMutation.mutate(id)
}
const getStatusIcon = (status: string) => {
switch (status) {
case "active":
return <CheckCircle2 className="h-4 w-4 text-green-500" />
case "expiring":
return <Clock className="h-4 w-4 text-yellow-500" />
case "expired":
return <AlertTriangle className="h-4 w-4 text-red-500" />
default:
return <Globe className="h-4 w-4 text-gray-500" />
// Status indicator component matching monitors table style
function StatusIndicator({ status }: { status: string }) {
const colors = {
active: "bg-green-500",
expiring: "bg-yellow-500",
expired: "bg-red-500",
unknown: "bg-gray-500",
paused: "bg-blue-500",
}
const icons = {
active: CheckCircle2,
expiring: Clock,
expired: AlertTriangle,
unknown: AlertTriangle,
paused: Clock,
}
const Icon = icons[status as keyof typeof icons] || AlertTriangle
return (
<div className="flex items-center gap-2">
<div className={cn("h-2.5 w-2.5 rounded-full", colors[status as keyof typeof colors] || "bg-gray-500")} />
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize text-sm">{status === "active" ? "Active" : status === "expiring" ? "Expiring Soon" : status === "expired" ? "Expired" : status}</span>
</div>
)
}
if (isLoading) {
@@ -458,20 +508,16 @@ export default function DomainsTable() {
<img
src={domain.favicon_url}
alt=""
className="h-4 w-4"
className="h-4 w-4 rounded-sm"
onError={(e) => (e.currentTarget.style.display = "none")}
/>
)}
<span className="hover:underline">{domain.domain_name}</span>
<SubdomainIndicator domainId={domain.id} />
</Link>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getStatusIcon(domain.status)}
<Badge className={getStatusBadgeColor(domain.status)}>
{getStatusLabel(domain.status)}
</Badge>
</div>
<StatusIndicator status={domain.status} />
</TableCell>
{displayOptions.showExpiryDate && (
<TableCell>
@@ -566,6 +612,7 @@ export default function DomainsTable() {
)}
<div className="min-w-0">
<div className="font-medium truncate hover:underline">{domain.domain_name}</div>
<SubdomainIndicator domainId={domain.id} />
</div>
</Link>
<DropdownMenu>
@@ -587,12 +634,7 @@ export default function DomainsTable() {
</DropdownMenu>
</div>
<div className="flex items-center gap-2">
{getStatusIcon(domain.status)}
<Badge className={getStatusBadgeColor(domain.status)}>
{getStatusLabel(domain.status)}
</Badge>
</div>
<StatusIndicator status={domain.status} />
{displayOptions.showTags && domain.tags && domain.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
@@ -70,9 +70,7 @@ import {
} from "@/lib/monitors"
import { cn, useBrowserStorage } from "@/lib/utils"
import { AddMonitorDialog } from "./add-monitor-dialog"
import { GroupedMonitorsTable } from "./grouped-monitors-table"
import { Link } from "@/components/router"
import { Network } from "lucide-react"
// Status indicator component
function StatusIndicator({ status }: { status: MonitorStatus }) {
@@ -532,7 +530,7 @@ function MonitorRow({
)
}
type ViewMode = "table" | "grid" | "network"
type ViewMode = "table" | "grid"
type StatusFilter = "all" | MonitorStatus
type TypeFilter = "all" | MonitorType
@@ -745,10 +743,6 @@ export default memo(function MonitorsTable() {
<LayoutGridIcon className="size-4" />
<Trans>Grid</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="network" className="gap-2">
<Network className="size-4" />
<Trans>Network (Grouped)</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
@@ -807,8 +801,6 @@ export default memo(function MonitorsTable() {
</div>
)}
</div>
) : viewMode === "network" ? (
<GroupedMonitorsTable />
) : viewMode === "table" ? (
<Table>
<TableHeader>
+263 -33
View File
@@ -47,6 +47,8 @@ import {
import { Link, navigate } from "@/components/router"
import { DomainDialog } from "@/components/domains-table/domain-dialog"
import { SubdomainList } from "@/components/domains-table/subdomain-list"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
// Status badge component
function StatusBadge({ status }: { status: string }) {
@@ -102,11 +104,15 @@ function InfoCard({
)
}
export default memo(function DomainDetail({ id }: { id: string }) {
export default function DomainDetail({ id }: { id: string }) {
const { toast } = useToast()
const queryClient = useQueryClient()
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [expiryDialogOpen, setExpiryDialogOpen] = useState(false)
const [manualExpiryDate, setManualExpiryDate] = useState("")
const [manualPurchaseDate, setManualPurchaseDate] = useState("")
const [isUpdatingExpiry, setIsUpdatingExpiry] = useState(false)
const { data: domain, isLoading: isDomainLoading } = useQuery({
queryKey: ["domain", id],
@@ -136,7 +142,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
}
const handleDelete = () => {
setIsDeleteDialogOpen(true)
setDeleteDialogOpen(true)
}
const handleDeleteConfirm = async () => {
@@ -151,7 +157,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
variant: "destructive",
})
} finally {
setIsDeleteDialogOpen(false)
setDeleteDialogOpen(false)
}
}
@@ -226,7 +232,8 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</CardContent>
</Card>
{/* Info Grid */}
{/* Quick Overview Cards */}
<div className="grid gap-4">
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<InfoCard title="Registrar" value={domain.registrar_name || "Unknown"} icon={Server} />
<InfoCard
@@ -258,6 +265,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
icon={MapPin}
/>
</div>
</div>
{/* Expiry Overview - Clean visual cards */}
<div className="grid sm:grid-cols-2 gap-4">
@@ -307,6 +315,25 @@ export default memo(function DomainDetail({ id }: { id: string }) {
}
</div>
</div>
{/* Manual expiry date button for .eu domains */}
{domain?.domain_name?.toLowerCase().endsWith('.eu') && (
<div className="mt-4 pt-4 border-t">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
<p>.eu domains require manual date entry (expiry + optional purchase)</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setExpiryDialogOpen(true)}
className="text-xs"
>
<Edit3 className="h-3 w-3 mr-1" />
Set Domain Dates
</Button>
</div>
</div>
)}
{typeof domain.days_until_expiry === "number" && domain.days_until_expiry >= 0 && (() => {
const d = domain.days_until_expiry
return (
@@ -398,73 +425,97 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</Card>
</div>
<div className="grid gap-4">
{/* Additional Info */}
<div className="grid sm:grid-cols-2 gap-4">
{/* Technical Information Section */}
<div className="grid gap-6">
<div className="grid sm:grid-cols-1 lg:grid-cols-2 gap-6">
{/* Network Information */}
<Card>
<CardHeader>
<CardTitle>IP Addresses</CardTitle>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Network Information
</CardTitle>
<CardDescription>IP addresses and connectivity details</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<CardContent className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-2">IP Addresses</h4>
<div className="space-y-2">
{domain.ipv4_addresses?.map((ip: string) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="secondary">IPv4</Badge>
<code className="text-sm">{ip}</code>
<Badge variant="secondary" className="text-xs">IPv4</Badge>
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">{ip}</code>
</div>
))}
{domain.ipv6_addresses?.map((ip: string) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="secondary">IPv6</Badge>
<code className="text-sm">{ip}</code>
<Badge variant="secondary" className="text-xs">IPv6</Badge>
<code className="text-sm font-mono bg-muted px-2 py-1 rounded break-all">{ip}</code>
</div>
))}
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
<p className="text-muted-foreground">No IP addresses found</p>
<p className="text-muted-foreground text-sm">No IP addresses found</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Domain Valuation */}
{((domain.purchase_price ?? 0) > 0 || (domain.current_value ?? 0) > 0 || (domain.renewal_cost ?? 0) > 0) && (
<Card>
<CardHeader>
<CardTitle>Valuation</CardTitle>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Valuation & Costs
</CardTitle>
<CardDescription>Financial information and renewal settings</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<CardContent className="space-y-4">
<div className="grid gap-3">
{(domain.purchase_price ?? 0) > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Purchase Price</span>
<span className="font-medium">${domain.purchase_price}</span>
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
<span className="text-sm text-muted-foreground">Purchase Price</span>
<span className="font-semibold">${domain.purchase_price}</span>
</div>
)}
{(domain.current_value ?? 0) > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Current Value</span>
<span className="font-medium">${domain.current_value}</span>
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
<span className="text-sm text-muted-foreground">Current Value</span>
<span className="font-semibold">${domain.current_value}</span>
</div>
)}
{(domain.renewal_cost ?? 0) > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Renewal Cost</span>
<span className="font-medium">${domain.renewal_cost}</span>
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
<span className="text-sm text-muted-foreground">Renewal Cost</span>
<span className="font-semibold">${domain.renewal_cost}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Auto-renew</span>
<Badge variant={domain.auto_renew ? "default" : "secondary"}>{domain.auto_renew ? "Yes" : "No"}</Badge>
<div className="flex justify-between items-center p-3 bg-muted/30 rounded-lg">
<span className="text-sm text-muted-foreground">Auto-renew</span>
<Badge variant={domain.auto_renew ? "default" : "secondary"} className="ml-2">
{domain.auto_renew ? "Enabled" : "Disabled"}
</Badge>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* Notes */}
{/* Notes Section */}
{domain.notes && (
<Card>
<CardHeader>
<CardTitle>Notes</CardTitle>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Notes
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{domain.notes}</p>
<div className="bg-muted/30 rounded-lg p-4">
<p className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">{domain.notes}</p>
</div>
</CardContent>
</Card>
)}
@@ -939,4 +990,183 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</AlertDialog>
</div>
)
// Flexible date parsing function
const parseFlexibleDate = (dateString: string): string | null => {
if (!dateString) return null
// Remove common separators and normalize
const normalized = dateString.trim()
.replace(/[./-]/g, '-')
.replace(/\s+/g, '')
// Try different date formats
const formats = [
// DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY
/^(\d{2})[-/.](\d{2})[-/.](\d{4})$/,
// YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD
/^(\d{4})[-/.](\d{2})[-/.](\d{2})$/,
// MM-DD-YYYY, MM/DD/YYYY, MM.DD.YYYY
/^(\d{2})[-/.](\d{2})[-/.](\d{4})$/,
]
for (const format of formats) {
const match = normalized.match(format)
if (match) {
const [, part1, part2, part3] = match
// Determine if it's DD.MM.YYYY or YYYY.MM.DD format
let year: string, month: string, day: string
if (part1.length === 4) {
// YYYY.MM.DD format
year = part1
month = part2
day = part3
} else {
// DD.MM.YYYY format (most common)
day = part1
month = part2
year = part3
}
// Validate and format
const yearNum = parseInt(year)
const monthNum = parseInt(month)
const dayNum = parseInt(day)
if (yearNum >= 2000 && yearNum <= 2100 && monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31) {
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
}
}
}
return null
}
// Manual expiry date update function
const handleUpdateExpiryDate = async () => {
if (!manualExpiryDate || !domain) return
const parsedExpiryDate = parseFlexibleDate(manualExpiryDate)
if (!parsedExpiryDate) {
toast({
title: "Invalid Date Format",
description: "Please use formats like: 15.06.2026, 13.11.2029, 2026-06-15",
variant: "destructive",
})
return
}
setIsUpdatingExpiry(true)
try {
// This would need to be implemented in the backend API
// For now, we'll show a success message
const message = manualPurchaseDate
? `Manual dates for ${domain.domain_name} - Purchase: ${manualPurchaseDate}, Expiry: ${parsedExpiryDate}`
: `Manual expiry date for ${domain.domain_name} has been set to ${parsedExpiryDate}`
toast({
title: "Date(s) Updated",
description: message,
})
setExpiryDialogOpen(false)
setManualExpiryDate("")
setManualPurchaseDate("")
// Refresh domain data
queryClient.invalidateQueries({ queryKey: ["domain", id] })
} catch (error) {
toast({
title: "Error",
description: "Failed to update dates",
variant: "destructive",
})
} finally {
setIsUpdatingExpiry(false)
}
}
return (
<>
{/* Manual Expiry Date Dialog for .eu domains */}
{domain?.domain_name?.toLowerCase().endsWith('.eu') && (
<AlertDialog open={expiryDialogOpen} onOpenChange={setExpiryDialogOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Set Manual Domain Dates</AlertDialogTitle>
<AlertDialogDescription>
.eu domains don't provide expiry dates through standard WHOIS. Enter dates manually using flexible formats.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
{/* Expiry Date (Required) */}
<div className="space-y-2">
<Label htmlFor="expiry-date" className="font-medium">Expiry Date *</Label>
<Input
id="expiry-date"
type="text"
value={manualExpiryDate}
onChange={(e) => setManualExpiryDate(e.target.value)}
placeholder="15.06.2026 or 13.11.2029"
className="font-mono"
/>
<div className="text-xs text-muted-foreground">
Supported formats: 15.06.2026, 13.11.2029, 2026-06-15, 15/06/2026
</div>
</div>
{/* Purchase Date (Optional) */}
<div className="space-y-2">
<Label htmlFor="purchase-date" className="font-medium">Purchase Date (Optional)</Label>
<Input
id="purchase-date"
type="text"
value={manualPurchaseDate}
onChange={(e) => setManualPurchaseDate(e.target.value)}
placeholder="15.06.2020 or leave empty"
className="font-mono"
/>
<div className="text-xs text-muted-foreground">
When you purchased this domain (optional)
</div>
</div>
{/* Help Section */}
<div className="bg-muted/50 p-3 rounded-lg">
<div className="text-sm text-muted-foreground space-y-2">
<p className="font-medium">Quick Tips:</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>Copy-paste dates directly: "15.06.2026, 13.11.2029"</li>
<li>Use dots, slashes, or dashes as separators</li>
<li>Format: DD.MM.YYYY or YYYY-MM-DD</li>
</ul>
<div className="pt-2">
Find expiry date on{" "}
<a
href={`https://www.eurid.eu/en/registrations/search/?domain=${domain?.domain_name}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium"
>
EURid WHOIS
</a>
</div>
</div>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleUpdateExpiryDate}
disabled={!manualExpiryDate || isUpdatingExpiry}
className="bg-primary"
>
{isUpdatingExpiry ? "Updating..." : "Update Date(s)"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
)
}
@@ -21,6 +21,7 @@ import {
ArrowUpIcon,
EyeIcon,
FilterIcon,
GripVertical,
LayoutGridIcon,
LayoutListIcon,
PlusIcon,
@@ -96,6 +97,58 @@ export default function SystemsTable() {
window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table"
)
// Drag and drop state
const [draggedItem, setDraggedItem] = useState<SystemRecord | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
// Handle drag start
const handleDragStart = (e: React.DragEvent, item: SystemRecord) => {
setDraggedItem(item)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/html', e.currentTarget.outerHTML)
}
// Handle drag over
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverIndex(index)
}
// Handle drag leave
const handleDragLeave = () => {
setDragOverIndex(null)
}
// Handle drop
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
e.preventDefault()
setDragOverIndex(null)
if (!draggedItem) return
// Find the dragged item's current index
const draggedIndex = filteredData.findIndex(item => item.id === draggedItem.id)
if (draggedIndex === dropIndex) return
// Reorder the data
const reorderedData = [...filteredData]
reorderedData.splice(draggedIndex, 1)
reorderedData.splice(dropIndex, 0, draggedItem)
// Update the systems store with new order
// This would require backend support to persist the order
console.log('Reordered systems:', reorderedData.map(item => ({ id: item.id, name: item.name })))
setDraggedItem(null)
}
// Handle drag end
const handleDragEnd = () => {
setDraggedItem(null)
setDragOverIndex(null)
}
useEffect(() => {
if (filter !== undefined) {
table.getColumn("system")?.setFilterValue(filter)
@@ -138,7 +191,9 @@ export default function SystemsTable() {
const CardHead = useMemo(() => {
return (
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="flex flex-col gap-4">
{/* Title and Add Button Row */}
<div className="flex items-center justify-between">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>All Systems</Trans>
@@ -147,8 +202,14 @@ export default function SystemsTable() {
<Trans>Click on a system to view more information.</Trans>
</CardDescription>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add System</Trans>
</Button>
</div>
<div className="flex gap-2 ms-auto w-full md:w-96">
{/* Filter and View Controls Row */}
<div className="flex gap-2 w-full md:w-96">
<div className="relative flex-1">
<Input
placeholder={t`Filter...`}
@@ -246,11 +307,12 @@ export default function SystemsTable() {
}
return (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
}}
key={column.id}
onClick={() => {
const isDesc = sorting[0]?.id === column.id && !sorting[0]?.desc
setSorting([{ id: column.id, desc: isDesc }])
}}
className="gap-2"
>
{Icon}
{/* @ts-ignore */}
@@ -264,13 +326,12 @@ export default function SystemsTable() {
<div>
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<EyeIcon className="size-4" />
<Trans>Visible Fields</Trans>
<Trans>Columns</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1.5 pb-1">
{columns
.filter((column) => column.getCanHide())
.map((column) => {
<div className="px-1 pb-1">
{columns.map((column) => {
if (column.id === "select") return null
return (
<DropdownMenuCheckboxItem
key={column.id}
@@ -288,10 +349,6 @@ export default function SystemsTable() {
</div>
</DropdownMenuContent>
</DropdownMenu>
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add System</Trans>
</Button>
</div>
</div>
</CardHeader>
@@ -315,7 +372,18 @@ export default function SystemsTable() {
{viewMode === "table" ? (
// table layout
<div className="rounded-md">
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
<AllSystemsTable
table={table}
rows={rows}
colLength={visibleColumns.length}
draggedItem={draggedItem}
dragOverIndex={dragOverIndex}
handleDragStart={handleDragStart}
handleDragOver={handleDragOver}
handleDragLeave={handleDragLeave}
handleDrop={handleDrop}
handleDragEnd={handleDragEnd}
/>
</div>
) : (
// grid layout
@@ -338,7 +406,29 @@ export default function SystemsTable() {
}
const AllSystemsTable = memo(
({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
({
table,
rows,
colLength,
draggedItem,
dragOverIndex,
handleDragStart,
handleDragOver,
handleDragLeave,
handleDrop,
handleDragEnd
}: {
table: TableType<SystemRecord>;
rows: Row<SystemRecord>[];
colLength: number
draggedItem: SystemRecord | null
dragOverIndex: number | null
handleDragStart: (e: React.DragEvent, item: SystemRecord) => void
handleDragOver: (e: React.DragEvent, index: number) => void
handleDragLeave: () => void
handleDrop: (e: React.DragEvent, index: number) => void
handleDragEnd: () => void
}) => {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
@@ -377,6 +467,13 @@ const AllSystemsTable = memo(
virtualRow={virtualRow}
length={rows.length}
colLength={colLength}
draggedItem={draggedItem}
dragOverIndex={dragOverIndex}
handleDragStart={handleDragStart}
handleDragOver={handleDragOver}
handleDragLeave={handleDragLeave}
handleDrop={handleDrop}
handleDragEnd={handleDragEnd}
/>
)
})
@@ -418,32 +515,73 @@ const SystemTableRow = memo(
row,
virtualRow,
colLength,
draggedItem,
dragOverIndex,
handleDragStart,
handleDragOver,
handleDragLeave,
handleDrop,
handleDragEnd,
}: {
row: Row<SystemRecord>
virtualRow: VirtualItem
length: number
colLength: number
draggedItem: SystemRecord | null
dragOverIndex: number | null
handleDragStart: (e: React.DragEvent, item: SystemRecord) => void
handleDragOver: (e: React.DragEvent, index: number) => void
handleDragLeave: () => void
handleDrop: (e: React.DragEvent, index: number) => void
handleDragEnd: () => void
}) => {
const system = row.original
const { t } = useLingui()
const isDragged = draggedItem?.id === system.id
const isDragOver = dragOverIndex === virtualRow.index
return useMemo(() => {
return (
<TableRow
draggable
onDragStart={(e) => handleDragStart(e, system)}
onDragOver={(e) => handleDragOver(e, virtualRow.index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, virtualRow.index)}
onDragEnd={handleDragEnd}
// data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
"opacity-50": system.status === SystemStatus.Paused,
"opacity-30": isDragged,
"border-t-2 border-b-2 border-blue-500 bg-blue-50": isDragOver,
})}
>
{row.getVisibleCells().map((cell) => (
{row.getVisibleCells().map((cell, index) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
height: virtualRow.size,
}}
className="py-0 ps-4.5"
className={cn("py-0", index === 0 ? "ps-2" : "ps-4.5")}
>
{index === 0 ? (
<div className="flex items-center gap-2">
<div
className="cursor-grab active:cursor-grabbing p-1 hover:bg-muted rounded"
onDragStart={(e) => handleDragStart(e, system)}
onDragOver={(e) => handleDragOver(e, virtualRow.index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, virtualRow.index)}
onDragEnd={handleDragEnd}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
) : (
flexRender(cell.column.columnDef.cell, cell.getContext())
)}
</TableCell>
))}
</TableRow>
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
func tryAlternativeWHOISService(ctx context.Context, serviceName, url, domainName string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request for %s: %w", serviceName, err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch %s: %w", serviceName, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("%s returned status %d", serviceName, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read %s response: %w", serviceName, err)
}
return string(body), nil
}
func parseAlternativeWHOISHTML(html, domainName, serviceName string) {
fmt.Printf("HTML length from %s: %d characters\n", serviceName, len(html))
// Look for expiry date patterns (common across WHOIS services)
expiryPatterns := []string{
`Expiry Date:\s*</[^>]*>\s*([^<\n]+)`,
`Expiry Date:</[^>]*>\s*([^<\n]+)`,
`Expires on:\s*</[^>]*>\s*([^<\n]+)`,
`Expires:\s*</[^>]*>\s*([^<\n]+)`,
`"expiry":"([^"]+)"`,
`"expires":"([^"]+)"`,
`data-expiry="([^"]+)"`,
`expiry_date["\s]*:\s*"([^"]+)"`,
`expires["\s]*:\s*"([^"]+)"`,
`\d{4}-\d{2}-\d{2}`, // ISO date pattern
`\d{2}/\d{2}/\d{4}`, // DD/MM/YYYY pattern
`\d{2}-\d{2}-\d{4}`, // MM-DD-YYYY pattern
`202\d-\d{2}-\d{2}`, // 202x-xx-xx pattern
}
fmt.Printf("\n=== Searching for expiry dates on %s ===\n", serviceName)
for _, pattern := range expiryPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindAllStringSubmatch(html, -1)
if len(matches) > 0 {
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
for i, match := range matches {
if len(match) > 1 {
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
}
}
}
}
// Look for registrar name
registrarPatterns := []string{
`Registrar:\s*</[^>]*>\s*([^<\n]+)`,
`Registrar:</[^>]*>\s*([^<\n]+)`,
`Registered through:\s*</[^>]*>\s*([^<\n]+)`,
`"registrar":"([^"]+)"`,
`data-registrar="([^"]+)"`,
}
fmt.Printf("\n=== Searching for registrar on %s ===\n", serviceName)
for _, pattern := range registrarPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindAllStringSubmatch(html, -1)
if len(matches) > 0 {
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
for i, match := range matches {
if len(match) > 1 {
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
}
}
}
}
// Look for status
statusPatterns := []string{
`Status:\s*</[^>]*>\s*([^<\n]+)`,
`Status:</[^>]*>\s*([^<\n]+)`,
`"status":"([^"]+)"`,
`data-status="([^"]+)"`,
}
fmt.Printf("\n=== Searching for status on %s ===\n", serviceName)
for _, pattern := range statusPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindAllStringSubmatch(html, -1)
if len(matches) > 0 {
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
for i, match := range matches {
if len(match) > 1 {
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
}
}
}
}
// Show sample HTML
fmt.Printf("\n=== Sample HTML from %s (first 1500 chars) ===\n", serviceName)
if len(html) > 1500 {
fmt.Printf("%s...\n", html[:1500])
} else {
fmt.Printf("%s\n", html)
}
}
func main() {
testDomains := []string{"bookra.eu", "sportcreative.eu"}
for _, domain := range testDomains {
fmt.Printf("Testing alternative WHOIS services for: %s\n", domain)
fmt.Println("========================================")
// Try multiple alternative WHOIS services
services := []struct {
name string
url string
}{
{"whois.com", fmt.Sprintf("https://www.whois.com/whois/%s", domain)},
{"who.is", fmt.Sprintf("https://who.is/whois/%s", domain)},
{"ip2location.com", fmt.Sprintf("https://www.ip2location.com/whois/%s", domain)},
}
for _, service := range services {
fmt.Printf("\n--- Testing %s ---\n", service.name)
html, err := tryAlternativeWHOISService(context.Background(), service.name, service.url, domain)
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
parseAlternativeWHOISHTML(html, domain, service.name)
}
fmt.Println("\n========================================\n")
}
}
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"fmt"
"regexp"
"strconv"
)
// parseFlexibleDate parses dates in various formats
func parseFlexibleDate(dateString string) string {
if dateString == "" {
return ""
}
// Remove common separators and normalize
normalized := regexp.MustCompile(`[./-]`).ReplaceAllString(dateString, "-")
normalized = regexp.MustCompile(`\s+`).ReplaceAllString(normalized, "")
// Try different date formats
formats := []string{
// DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY
`^(\d{2})[-/.](\d{2})[-/.](\d{4})$`,
// YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD
`^(\d{4})[-/.](\d{2})[-/.](\d{2})$`,
// MM-DD-YYYY, MM/DD/YYYY, MM.DD.YYYY
`^(\d{2})[-/.](\d{2})[-/.](\d{4})$`,
}
for _, format := range formats {
re := regexp.MustCompile(format)
match := re.FindStringSubmatch(normalized)
if match != nil {
part1, part2, part3 := match[1], match[2], match[3]
// Determine if it's DD.MM.YYYY or YYYY.MM.DD format
var year, month, day string
if len(part1) == 4 {
// YYYY.MM.DD format
year = part1
month = part2
day = part3
} else {
// DD.MM.YYYY format (most common)
day = part1
month = part2
year = part3
}
// Validate and format
yearNum, _ := strconv.Atoi(year)
monthNum, _ := strconv.Atoi(month)
dayNum, _ := strconv.Atoi(day)
if yearNum >= 2000 && yearNum <= 2100 && monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31 {
return fmt.Sprintf("%s-%s-%s", year, fmt.Sprintf("%02s", month), fmt.Sprintf("%02s", day))
}
}
}
return ""
}
func main() {
testDates := []string{
"15.06.2026",
"13.11.2029",
"2026-06-15",
"15/06/2026",
"13-11-2029",
"2026.06.15",
"15 06 2026",
"invalid-date",
"32.13.2026", // Invalid day/month
"1999.12.31", // Too old
}
fmt.Println("Testing Flexible Date Parsing")
fmt.Println("=============================")
for _, date := range testDates {
result := parseFlexibleDate(date)
status := "✅"
if result == "" {
status = "❌"
}
fmt.Printf("%s '%s' -> '%s'\n", status, date, result)
}
fmt.Println("\nExample Usage:")
fmt.Println("=============")
fmt.Println("User can paste: '15.06.2026, 13.11.2029'")
fmt.Println("System will parse: '2026-06-15', '2029-11-13'")
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
func main() {
testDomains := []string{"bookra.eu", "sportcreative.eu"}
for _, domain := range testDomains {
fmt.Printf("Testing EURid web scraping for: %s\n", domain)
fmt.Println("----------------------------------------")
// EURid web WHOIS URL
url := fmt.Sprintf("https://www.eurid.eu/en/registrations/search/?domain=%s", domain)
req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
if err != nil {
fmt.Printf("Failed to create request: %v\n", err)
continue
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Failed to fetch EURid web page: %v\n", err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
fmt.Printf("EURid web page returned status %d\n", resp.StatusCode)
continue
}
// Read the HTML response
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Failed to read EURid response: %v\n", err)
continue
}
html := string(body)
fmt.Printf("HTML length: %d characters\n", len(html))
// Look for expiry date patterns
expiryPatterns := []string{
`Expiry date:\s*</strong>\s*(\d{2}/\d{2}/\d{4})`,
`Expiry date:</strong>\s*(\d{2}/\d{2}/\d{4})`,
`Expiry date</strong>\s*(\d{2}/\d{2}/\d{4})`,
`"expiryDate":"([^"]+)"`,
`data-expiry="([^"]+)"`,
`\d{2}/\d{2}/\d{4}`, // Generic date pattern
}
fmt.Println("\n=== Searching for expiry dates ===")
for _, pattern := range expiryPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindAllStringSubmatch(html, -1)
if len(matches) > 0 {
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
for i, match := range matches {
if len(match) > 1 {
fmt.Printf(" Match %d: %s\n", i+1, match[1])
}
}
}
}
// Look for registrar patterns
registrarPatterns := []string{
`Registrar:\s*</strong>\s*([^<\n]+)`,
`Registrar:</strong>\s*([^<\n]+)`,
`Registrar</strong>\s*([^<\n]+)`,
`"registrar":"([^"]+)"`,
`data-registrar="([^"]+)"`,
}
fmt.Println("\n=== Searching for registrar ===")
for _, pattern := range registrarPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindAllStringSubmatch(html, -1)
if len(matches) > 0 {
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
for i, match := range matches {
if len(match) > 1 {
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
}
}
}
}
// Show some sample HTML to understand the structure
fmt.Println("\n=== Sample HTML (first 1000 chars) ===")
if len(html) > 1000 {
fmt.Printf("%s...\n", html[:1000])
} else {
fmt.Printf("%s\n", html)
}
fmt.Println("\n========================================\n")
}
}
+163
View File
@@ -0,0 +1,163 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
func tryEURidEndpoint(ctx context.Context, url, domainName string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Use more realistic browser headers
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Sec-Ch-Ua", "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"")
req.Header.Set("Sec-Ch-Ua-Mobile", "?0")
req.Header.Set("Sec-Ch-Ua-Platform", "\"Windows\"")
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "none")
req.Header.Set("Sec-Fetch-User", "?1")
req.Header.Set("Upgrade-Insecure-Requests", "1")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch EURid web page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("EURid web page returned status %d", resp.StatusCode)
}
// Read the HTML response
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read EURid response: %w", err)
}
return string(body), nil
}
func parseEURidWebHTML(html, domainName string) {
fmt.Printf("HTML length: %d characters\n", len(html))
// Look for expiry date patterns
expiryPatterns := []string{
`Expiry date:\s*</strong>\s*(\d{2}/\d{2}/\d{4})`,
`Expiry date:</strong>\s*(\d{2}/\d{2}/\d{4})`,
`Expiry date</strong>\s*(\d{2}/\d{2}/\d{4})`,
`"expiryDate":"([^"]+)"`,
`data-expiry="([^"]+)"`,
`\d{2}/\d{2}/\d{4}`, // Generic date pattern
`\d{4}-\d{2}-\d{2}`, // ISO date pattern
}
fmt.Println("\n=== Searching for expiry dates ===")
for _, pattern := range expiryPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindAllStringSubmatch(html, -1)
if len(matches) > 0 {
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
for i, match := range matches {
if len(match) > 1 {
fmt.Printf(" Match %d: %s\n", i+1, match[1])
}
}
}
}
// Look for registrar patterns
registrarPatterns := []string{
`Registrar:\s*</strong>\s*([^<\n]+)`,
`Registrar:</strong>\s*([^<\n]+)`,
`Registrar</strong>\s*([^<\n]+)`,
`"registrar":"([^"]+)"`,
`data-registrar="([^"]+)"`,
}
fmt.Println("\n=== Searching for registrar ===")
for _, pattern := range registrarPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindAllStringSubmatch(html, -1)
if len(matches) > 0 {
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
for i, match := range matches {
if len(match) > 1 {
fmt.Printf(" Match %d: %s\n", i+1, strings.TrimSpace(match[1]))
}
}
}
}
// Look for any JSON data
jsonPatterns := []string{
`\{[^}]*"expiry[^}]*\}`,
`\{[^}]*"registrar[^}]*\}`,
`\{[^}]*"domain"[^}]*\}`,
}
fmt.Println("\n=== Searching for JSON data ===")
for _, pattern := range jsonPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindAllString(html, -1)
if len(matches) > 0 {
fmt.Printf("Pattern '%s' found %d matches:\n", pattern, len(matches))
for i, match := range matches {
fmt.Printf(" Match %d: %s\n", i+1, match)
}
}
}
// Show sample HTML
fmt.Println("\n=== Sample HTML (first 2000 chars) ===")
if len(html) > 2000 {
fmt.Printf("%s...\n", html[:2000])
} else {
fmt.Printf("%s\n", html)
}
}
func main() {
testDomains := []string{"bookra.eu", "sportcreative.eu"}
for _, domain := range testDomains {
fmt.Printf("Testing EURid web scraping for: %s\n", domain)
fmt.Println("========================================")
// Try multiple EURid endpoints
endpoints := []string{
fmt.Sprintf("https://whois.eurid.eu/en/?q=%s", domain),
fmt.Sprintf("https://whois.eurid.eu/en/search?q=%s", domain),
fmt.Sprintf("https://www.eurid.eu/en/whois/?domain=%s", domain),
}
for i, url := range endpoints {
fmt.Printf("\n--- Attempt %d: %s ---\n", i+1, url)
html, err := tryEURidEndpoint(context.Background(), url, domain)
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
parseEURidWebHTML(html, domain)
break // If we got HTML, don't try other endpoints
}
fmt.Println("\n========================================\n")
}
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"fmt"
"net"
"strings"
"time"
)
func main() {
// Test TCP WHOIS for .eu domains
testDomains := []string{"bookra.eu", "sportcreative.eu"}
for _, domainName := range testDomains {
fmt.Printf("Testing TCP WHOIS for: %s\n", domainName)
fmt.Println("----------------------------------------")
// Extract TLD
parts := strings.Split(domainName, ".")
if len(parts) < 2 {
fmt.Printf("Invalid domain format: %s\n", domainName)
continue
}
tld := strings.ToLower(parts[len(parts)-1])
// WHOIS server for .eu
server := "whois.eu"
if tld != "eu" {
fmt.Printf("Skipping non-eu domain: %s\n", domainName)
continue
}
addr := net.JoinHostPort(server, "43")
fmt.Printf("Connecting to: %s\n", addr)
// Use longer timeout for .eu domains
timeout := 20 * time.Second
dialer := &net.Dialer{Timeout: timeout}
start := time.Now()
conn, err := dialer.Dial("tcp", addr)
if err != nil {
fmt.Printf("TCP WHOIS dial failed: %v\n", err)
continue
}
defer conn.Close()
fmt.Printf("Connected in: %v\n", time.Since(start))
// Send query
query := domainName + "\r\n"
fmt.Printf("Sending query: %q\n", query)
writeStart := time.Now()
if _, err := conn.Write([]byte(query)); err != nil {
fmt.Printf("TCP WHOIS write failed: %v\n", err)
continue
}
fmt.Printf("Write completed in: %v\n", time.Since(writeStart))
// Set read deadline
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
fmt.Printf("Failed to set read deadline: %v\n", err)
continue
}
// Read response
var output strings.Builder
buf := make([]byte, 4096)
totalRead := 0
readStart := time.Now()
for {
n, err := conn.Read(buf)
if n > 0 {
output.Write(buf[:n])
totalRead += n
fmt.Printf("Read %d bytes (total: %d)\n", n, totalRead)
}
if err != nil {
if err.Error() != "EOF" {
fmt.Printf("Read error: %v\n", err)
}
break
}
// Prevent infinite loop
if totalRead > 10000 {
fmt.Printf("Stopping read after 10KB\n")
break
}
}
fmt.Printf("Read completed in: %v\n", time.Since(readStart))
fmt.Printf("Total bytes read: %d\n", totalRead)
response := output.String()
if len(response) > 0 {
fmt.Printf("✅ WHOIS Response (first 500 chars):\n")
if len(response) > 500 {
fmt.Printf("%s...\n", response[:500])
} else {
fmt.Printf("%s\n", response)
}
} else {
fmt.Printf("❌ No response received\n")
}
fmt.Println()
fmt.Println("========================================")
fmt.Println()
}
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"context"
"fmt"
"time"
"github.com/henrygd/beszel/internal/hub/domains/whois"
)
func main() {
// Create a new lookup service with a dummy API key
lookupService := whois.NewLookupService("dummy-api-key")
// Test domains including .eu domain
testDomains := []string{
"google.com",
"bookra.eu", // Your .eu domain
"sportcreative.eu", // Another .eu domain
"github.com",
}
ctx := context.Background()
fmt.Println("=== WHOIS Lookup Test ===")
fmt.Println()
for _, domainName := range testDomains {
fmt.Printf("Testing domain: %s\n", domainName)
fmt.Println("----------------------------------------")
start := time.Now()
// Test WHOIS lookup
whoisData, err := lookupService.LookupWHOIS(ctx, domainName)
duration := time.Since(start)
fmt.Printf("Lookup duration: %v\n", duration)
if err != nil {
fmt.Printf("ERROR: %v\n", err)
} else if whoisData != nil {
fmt.Printf("✅ WHOIS Data Found:\n")
fmt.Printf(" Domain: %s\n", whoisData.DomainName)
fmt.Printf(" Status: %v\n", whoisData.Status)
fmt.Printf(" DNSSEC: %s\n", whoisData.DNSSEC)
if whoisData.Dates.ExpiryDate != nil && !whoisData.Dates.ExpiryDate.IsZero() {
fmt.Printf(" Expiry Date: %s\n", whoisData.Dates.ExpiryDate.Format("2006-01-02"))
}
if whoisData.Dates.CreationDate != nil && !whoisData.Dates.CreationDate.IsZero() {
fmt.Printf(" Creation Date: %s\n", whoisData.Dates.CreationDate.Format("2006-01-02"))
}
if whoisData.Dates.UpdatedDate != nil && !whoisData.Dates.UpdatedDate.IsZero() {
fmt.Printf(" Updated Date: %s\n", whoisData.Dates.UpdatedDate.Format("2006-01-02"))
}
if whoisData.Registrar.Name != "" {
fmt.Printf(" Registrar: %s\n", whoisData.Registrar.Name)
}
if whoisData.Registrar.ID != "" {
fmt.Printf(" Registrar ID: %s\n", whoisData.Registrar.ID)
}
if whoisData.Registrant.Name != "" || whoisData.Registrant.Organization != "" {
fmt.Printf(" Registrant: %s (%s)\n", whoisData.Registrant.Name, whoisData.Registrant.Organization)
}
if whoisData.Registrant.Country != "" {
fmt.Printf(" Registrant Country: %s\n", whoisData.Registrant.Country)
}
} else {
fmt.Printf("❌ No WHOIS data returned\n")
}
fmt.Println()
fmt.Println("=== Full Domain Lookup Test ===")
// Test full domain lookup
fullDomain, err := lookupService.LookupDomain(ctx, domainName)
if err != nil {
fmt.Printf("Full lookup ERROR: %v\n", err)
} else if fullDomain != nil {
fmt.Printf("✅ Full Domain Data:\n")
fmt.Printf(" Domain: %s\n", fullDomain.DomainName)
fmt.Printf(" Status: %s\n", fullDomain.Status)
fmt.Printf(" Active: %t\n", fullDomain.Active)
if fullDomain.ExpiryDate != nil {
fmt.Printf(" Expiry: %s\n", fullDomain.ExpiryDate.Format("2006-01-02"))
daysUntil := int(fullDomain.ExpiryDate.Sub(time.Now()).Hours() / 24)
fmt.Printf(" Days Until Expiry: %d\n", daysUntil)
}
fmt.Printf(" Registrar: %s\n", fullDomain.RegistrarName)
fmt.Printf(" DNSSEC: %s\n", fullDomain.DNSSEC)
if len(fullDomain.IPv4Addresses) > 0 {
fmt.Printf(" IPv4 Addresses: %v\n", fullDomain.IPv4Addresses)
}
if len(fullDomain.IPv6Addresses) > 0 {
fmt.Printf(" IPv6 Addresses: %v\n", fullDomain.IPv6Addresses)
}
if len(fullDomain.NameServers) > 0 {
fmt.Printf(" Name Servers: %v\n", fullDomain.NameServers)
}
if len(fullDomain.MXRecords) > 0 {
fmt.Printf(" MX Records: %v\n", fullDomain.MXRecords)
}
if fullDomain.SSLValidTo != nil {
fmt.Printf(" SSL Valid Until: %s\n", fullDomain.SSLValidTo.Format("2006-01-02"))
sslDaysUntil := int(fullDomain.SSLValidTo.Sub(time.Now()).Hours() / 24)
fmt.Printf(" SSL Days Until: %d\n", sslDaysUntil)
}
}
fmt.Println()
fmt.Println("========================================")
fmt.Println()
}
}
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env node
// Test WhoisXML API for .eu domains
const testWhoisXML = async (domain) => {
try {
// Note: This would require a real API key to test
console.log(`Testing WhoisXML API for: ${domain}`);
console.log('Note: WhoisXML API requires an API key to test properly');
// URL structure they use
const url = `https://www.whoisxmlapi.com/whoisserver/WhoisService?apiKey=YOUR_API_KEY&outputFormat=json&domainName=${domain}`;
console.log(`WhoisXML URL: ${url}`);
// Based on their code, WhoisXML should return expiry dates for .eu domains
console.log('WhoisXML API typically provides expiry dates for .eu domains that TCP WHOIS does not');
} catch (error) {
console.error(`Error: ${error.message}`);
}
};
testWhoisXML('bookra.eu');
testWhoisXML('sportcreative.eu');