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
@@ -5,11 +5,25 @@ import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Link } from "@/components/router"
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield } from "lucide-react"
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield, Filter, X } from "lucide-react"
import { getCalendarEvents, type CalendarEvent } from "@/lib/incidents"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function CalendarView() {
const [currentDate, setCurrentDate] = useState(new Date())
const [eventFilters, setEventFilters] = useState({
domain_expiry: true,
ssl_expiry: true,
incident: true,
})
const year = currentDate.getFullYear()
const month = currentDate.getMonth()
@@ -46,20 +60,22 @@ export function CalendarView() {
// Days of month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
const dayEvents = events?.filter((e) => e.date === dateStr) || []
const dayEvents = events?.filter((e) =>
e.date === dateStr && eventFilters[e.type as keyof typeof eventFilters]
) || []
d.push({ day, events: dayEvents })
}
return d
}, [year, month, daysInMonth, firstDayOfMonth, events])
}, [year, month, daysInMonth, firstDayOfMonth, events, eventFilters])
const upcomingEvents = useMemo(() => {
const today = toDateString(new Date())
return (events || [])
.filter((event) => event.date >= today)
.filter((event) => event.date >= today && eventFilters[event.type as keyof typeof eventFilters])
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 8)
}, [events])
}, [events, eventFilters])
const prevMonth = () => {
setCurrentDate(new Date(year, month - 1, 1))
@@ -123,26 +139,116 @@ export function CalendarView() {
return (
<Card className="w-full">
<CardHeader className="pb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle className="flex items-center gap-2 text-lg sm:text-xl">
<div className="p-2 bg-primary/10 rounded-lg">
<CalendarIcon className="h-5 w-5 text-primary" />
<div className="flex flex-col gap-4">
{/* Title Row */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle className="flex items-center gap-2 text-lg sm:text-xl">
<div className="p-2 bg-primary/10 rounded-lg">
<CalendarIcon className="h-5 w-5 text-primary" />
</div>
<span>Calendar View</span>
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setCurrentDate(new Date())} className="h-8 text-xs">
Today
</Button>
<Button variant="outline" size="icon" onClick={prevMonth} className="h-8 w-8">
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="font-semibold min-w-[120px] sm:min-w-[160px] text-center text-sm sm:text-base px-2">
{monthNames[month]} {year}
</span>
<Button variant="outline" size="icon" onClick={nextMonth} className="h-8 w-8">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Filter Controls Row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Show:</span>
<div className="flex flex-wrap gap-1">
<Button
variant={eventFilters.domain_expiry ? "default" : "outline"}
size="sm"
onClick={() => setEventFilters(prev => ({ ...prev, domain_expiry: !prev.domain_expiry }))}
className="h-7 text-xs gap-1"
>
<Globe className="h-3 w-3" />
Domain
</Button>
<Button
variant={eventFilters.ssl_expiry ? "default" : "outline"}
size="sm"
onClick={() => setEventFilters(prev => ({ ...prev, ssl_expiry: !prev.ssl_expiry }))}
className="h-7 text-xs gap-1"
>
<Shield className="h-3 w-3" />
SSL
</Button>
<Button
variant={eventFilters.incident ? "default" : "outline"}
size="sm"
onClick={() => setEventFilters(prev => ({ ...prev, incident: !prev.incident }))}
className="h-7 text-xs gap-1"
>
<AlertCircle className="h-3 w-3" />
Incidents
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-xs">
<Filter className="h-3 w-3 mr-1" />
Quick Filters
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Event Types</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={eventFilters.domain_expiry}
onCheckedChange={(checked) => setEventFilters(prev => ({ ...prev, domain_expiry: checked }))}
>
<div className="flex items-center gap-2">
<Globe className="h-3 w-3" />
Domain Expiry
</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={eventFilters.ssl_expiry}
onCheckedChange={(checked) => setEventFilters(prev => ({ ...prev, ssl_expiry: checked }))}
>
<div className="flex items-center gap-2">
<Shield className="h-3 w-3" />
SSL Expiry
</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={eventFilters.incident}
onCheckedChange={(checked) => setEventFilters(prev => ({ ...prev, incident: checked }))}
>
<div className="flex items-center gap-2">
<AlertCircle className="h-3 w-3" />
Incidents
</div>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setEventFilters({ domain_expiry: true, ssl_expiry: true, incident: true })}>
Show All
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEventFilters({ domain_expiry: true, ssl_expiry: false, incident: false })}>
Domain Only
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEventFilters({ domain_expiry: false, ssl_expiry: true, incident: false })}>
SSL Only
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<span>Calendar View</span>
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setCurrentDate(new Date())} className="h-8 text-xs">
Today
</Button>
<Button variant="outline" size="icon" onClick={prevMonth} className="h-8 w-8">
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="font-semibold min-w-[120px] sm:min-w-[160px] text-center text-sm sm:text-base px-2">
{monthNames[month]} {year}
</span>
<Button variant="outline" size="icon" onClick={nextMonth} className="h-8 w-8">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
@@ -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,19 +237,35 @@ export default function DomainsTable() {
refreshMutation.mutate(id)
}
const getStatusIcon = (status: string) => {
switch (status) {
case "active":
return <CheckCircle2 className="h-4 w-4 text-green-500" />
case "expiring":
return <Clock className="h-4 w-4 text-yellow-500" />
case "expired":
return <AlertTriangle className="h-4 w-4 text-red-500" />
default:
return <Globe className="h-4 w-4 text-gray-500" />
}
// Status indicator component matching monitors table style
function StatusIndicator({ status }: { status: string }) {
const colors = {
active: "bg-green-500",
expiring: "bg-yellow-500",
expired: "bg-red-500",
unknown: "bg-gray-500",
paused: "bg-blue-500",
}
const icons = {
active: CheckCircle2,
expiring: Clock,
expired: AlertTriangle,
unknown: AlertTriangle,
paused: Clock,
}
const Icon = icons[status as keyof typeof icons] || AlertTriangle
return (
<div className="flex items-center gap-2">
<div className={cn("h-2.5 w-2.5 rounded-full", colors[status as keyof typeof colors] || "bg-gray-500")} />
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize text-sm">{status === "active" ? "Active" : status === "expiring" ? "Expiring Soon" : status === "expired" ? "Expired" : status}</span>
</div>
)
}
if (isLoading) {
return (
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
@@ -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>
+311 -81
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,37 +232,39 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</CardContent>
</Card>
{/* Info Grid */}
<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="Domain Expiry"
value={formatDate(domain.expiry_date)}
subtitle={formatDays(domain.days_until_expiry)}
icon={Calendar}
className={
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? "text-yellow-600"
: ""
}
/>
<InfoCard
title="SSL Expiry"
value={domain.ssl_valid_to ? formatDate(domain.ssl_valid_to) : "No SSL"}
subtitle={domain.ssl_valid_to ? formatDays(domain.ssl_days_until) : undefined}
icon={Shield}
className={
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14
? "text-red-600"
: ""
}
/>
<InfoCard
title="Location"
value={[domain.host_city, domain.host_region, domain.host_country].filter(Boolean).join(", ") || "Unknown"}
subtitle={domain.host_isp || domain.host_org}
icon={MapPin}
/>
{/* 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
title="Domain Expiry"
value={formatDate(domain.expiry_date)}
subtitle={formatDays(domain.days_until_expiry)}
icon={Calendar}
className={
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? "text-yellow-600"
: ""
}
/>
<InfoCard
title="SSL Expiry"
value={domain.ssl_valid_to ? formatDate(domain.ssl_valid_to) : "No SSL"}
subtitle={domain.ssl_valid_to ? formatDays(domain.ssl_days_until) : undefined}
icon={Shield}
className={
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14
? "text-red-600"
: ""
}
/>
<InfoCard
title="Location"
value={[domain.host_city, domain.host_region, domain.host_country].filter(Boolean).join(", ") || "Unknown"}
subtitle={domain.host_isp || domain.host_org}
icon={MapPin}
/>
</div>
</div>
{/* Expiry Overview - Clean visual cards */}
@@ -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">
{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>
<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" 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" 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 text-sm">No IP addresses found</p>
)}
</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>
</div>
))}
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
<p className="text-muted-foreground">No IP addresses found</p>
)}
</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">
{(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>
<CardContent className="space-y-4">
<div className="grid gap-3">
{(domain.purchase_price ?? 0) > 0 && (
<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 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 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 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>
)}
{(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>
)}
{(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>
)}
<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>
</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,17 +191,25 @@ export default function SystemsTable() {
const CardHead = useMemo(() => {
return (
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>All Systems</Trans>
</CardTitle>
<CardDescription className="flex">
<Trans>Click on a system to view more information.</Trans>
</CardDescription>
<div className="flex flex-col gap-4">
{/* Title and Add Button Row */}
<div className="flex items-center justify-between">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>All Systems</Trans>
</CardTitle>
<CardDescription className="flex">
<Trans>Click on a system to view more information.</Trans>
</CardDescription>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add System</Trans>
</Button>
</div>
<div className="flex gap-2 ms-auto w-full md:w-96">
{/* Filter and View Controls Row */}
<div className="flex gap-2 w-full md:w-96">
<div className="relative flex-1">
<Input
placeholder={t`Filter...`}
@@ -246,11 +307,12 @@ export default function SystemsTable() {
}
return (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
}}
key={column.id}
onClick={() => {
const isDesc = sorting[0]?.id === column.id && !sorting[0]?.desc
setSorting([{ id: column.id, desc: isDesc }])
}}
className="gap-2"
>
{Icon}
{/* @ts-ignore */}
@@ -264,34 +326,29 @@ export default function SystemsTable() {
<div>
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<EyeIcon className="size-4" />
<Trans>Visible Fields</Trans>
<Trans>Columns</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1.5 pb-1">
{columns
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
onSelect={(e) => e.preventDefault()}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuCheckboxItem>
)
})}
<div className="px-1 pb-1">
{columns.map((column) => {
if (column.id === "select") return null
return (
<DropdownMenuCheckboxItem
key={column.id}
onSelect={(e) => e.preventDefault()}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuCheckboxItem>
)
})}
</div>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add System</Trans>
</Button>
</div>
</div>
</CardHeader>
@@ -315,7 +372,18 @@ export default function SystemsTable() {
{viewMode === "table" ? (
// table layout
<div className="rounded-md">
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
<AllSystemsTable
table={table}
rows={rows}
colLength={visibleColumns.length}
draggedItem={draggedItem}
dragOverIndex={dragOverIndex}
handleDragStart={handleDragStart}
handleDragOver={handleDragOver}
handleDragLeave={handleDragLeave}
handleDrop={handleDrop}
handleDragEnd={handleDragEnd}
/>
</div>
) : (
// grid layout
@@ -338,7 +406,29 @@ export default function SystemsTable() {
}
const AllSystemsTable = memo(
({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
({
table,
rows,
colLength,
draggedItem,
dragOverIndex,
handleDragStart,
handleDragOver,
handleDragLeave,
handleDrop,
handleDragEnd
}: {
table: TableType<SystemRecord>;
rows: Row<SystemRecord>[];
colLength: number
draggedItem: SystemRecord | null
dragOverIndex: number | null
handleDragStart: (e: React.DragEvent, item: SystemRecord) => void
handleDragOver: (e: React.DragEvent, index: number) => void
handleDragLeave: () => void
handleDrop: (e: React.DragEvent, index: number) => void
handleDragEnd: () => void
}) => {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
@@ -377,6 +467,13 @@ const AllSystemsTable = memo(
virtualRow={virtualRow}
length={rows.length}
colLength={colLength}
draggedItem={draggedItem}
dragOverIndex={dragOverIndex}
handleDragStart={handleDragStart}
handleDragOver={handleDragOver}
handleDragLeave={handleDragLeave}
handleDrop={handleDrop}
handleDragEnd={handleDragEnd}
/>
)
})
@@ -418,32 +515,73 @@ const SystemTableRow = memo(
row,
virtualRow,
colLength,
draggedItem,
dragOverIndex,
handleDragStart,
handleDragOver,
handleDragLeave,
handleDrop,
handleDragEnd,
}: {
row: Row<SystemRecord>
virtualRow: VirtualItem
length: number
colLength: number
draggedItem: SystemRecord | null
dragOverIndex: number | null
handleDragStart: (e: React.DragEvent, item: SystemRecord) => void
handleDragOver: (e: React.DragEvent, index: number) => void
handleDragLeave: () => void
handleDrop: (e: React.DragEvent, index: number) => void
handleDragEnd: () => void
}) => {
const system = row.original
const { t } = useLingui()
const isDragged = draggedItem?.id === system.id
const isDragOver = dragOverIndex === virtualRow.index
return useMemo(() => {
return (
<TableRow
draggable
onDragStart={(e) => handleDragStart(e, system)}
onDragOver={(e) => handleDragOver(e, virtualRow.index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, virtualRow.index)}
onDragEnd={handleDragEnd}
// data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity relative safari:transform-3d", {
"opacity-50": system.status === SystemStatus.Paused,
"opacity-30": isDragged,
"border-t-2 border-b-2 border-blue-500 bg-blue-50": isDragOver,
})}
>
{row.getVisibleCells().map((cell) => (
{row.getVisibleCells().map((cell, index) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
height: virtualRow.size,
}}
className="py-0 ps-4.5"
className={cn("py-0", index === 0 ? "ps-2" : "ps-4.5")}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{index === 0 ? (
<div className="flex items-center gap-2">
<div
className="cursor-grab active:cursor-grabbing p-1 hover:bg-muted rounded"
onDragStart={(e) => handleDragStart(e, system)}
onDragOver={(e) => handleDragOver(e, virtualRow.index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, virtualRow.index)}
onDragEnd={handleDragEnd}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
) : (
flexRender(cell.column.columnDef.cell, cell.getContext())
)}
</TableCell>
))}
</TableRow>