Add public monitoring features and CI updates

- Add status pages, incidents, badges, maintenance, bulk ops, and metrics
- Add Docker packaging, env example, and frontend routes
- Refresh GitHub workflows and project metadata
This commit is contained in:
Tomas Dvorak
2026-04-27 11:10:18 +02:00
parent 363d708e91
commit 8011d487f1
101 changed files with 16126 additions and 2028 deletions
@@ -1,9 +1,27 @@
"use client"
import { useState } from "react"
import { useState, useMemo } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useToast } from "@/components/ui/use-toast"
import { Trans, useLingui } from "@lingui/react/macro"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card"
import {
Table,
TableBody,
@@ -16,9 +34,14 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import {
getDomains,
deleteDomain,
@@ -27,37 +50,80 @@ import {
getStatusLabel,
formatDate,
formatDays,
cleanDomain,
type Domain,
} from "@/lib/domains"
import { MoreHorizontal, Plus, RefreshCw, Globe, AlertTriangle, CheckCircle2, Clock } from "lucide-react"
import {
MoreHorizontal,
Plus,
RefreshCw,
Globe,
AlertTriangle,
CheckCircle2,
Clock,
Settings2Icon,
FilterIcon,
LayoutGridIcon,
LayoutListIcon,
} from "lucide-react"
import { DomainDialog } from "./domain-dialog"
import { Link } from "@/components/router"
import { useBrowserStorage } from "@/lib/utils"
type ViewMode = "table" | "grid"
type StatusFilter = "all" | "active" | "expiring" | "expired" | "unknown" | "watchlist"
export default function DomainsTable() {
const { t } = useLingui()
const { toast } = useToast()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const [editingDomain, setEditingDomain] = useState<Domain | null>(null)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [filter, setFilter] = useState("")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
"domainsViewMode",
window.innerWidth < 1024 ? "grid" : "table"
)
const { data: domains, isLoading } = useQuery({
const { data: domains = [], isLoading } = useQuery({
queryKey: ["domains"],
queryFn: getDomains,
})
// Filter by status first
const statusFilteredDomains = useMemo(() => {
if (statusFilter === "all") return domains
return domains.filter((d) => d.status === statusFilter)
}, [domains, statusFilter])
// Then filter by search text
const filteredDomains = useMemo(() => {
if (!filter) return statusFilteredDomains
const f = filter.toLowerCase()
return statusFilteredDomains.filter(
(d) =>
d.domain_name.toLowerCase().includes(f) ||
(d.registrar_name || "").toLowerCase().includes(f)
)
}, [statusFilteredDomains, filter])
const statusCounts = useMemo(() => {
const total = domains.length
const active = domains.filter((d) => d.status === "active").length
const expiring = domains.filter((d) => d.status === "expiring").length
const expired = domains.filter((d) => d.status === "expired").length
const unknown = domains.filter((d) => d.status === "unknown").length
return { total, active, expiring, expired, unknown }
}, [domains])
const deleteMutation = useMutation({
mutationFn: deleteDomain,
onSuccess: () => {
toast({ title: "Domain deleted successfully" })
queryClient.invalidateQueries({ queryKey: ["domains"] })
},
onError: (error: Error) => {
toast({
title: "Failed to delete domain",
description: error.message,
variant: "destructive",
})
},
})
const refreshMutation = useMutation({
@@ -66,13 +132,6 @@ export default function DomainsTable() {
toast({ title: "Domain refresh started" })
queryClient.invalidateQueries({ queryKey: ["domains"] })
},
onError: (error: Error) => {
toast({
title: "Failed to refresh domain",
description: error.message,
variant: "destructive",
})
},
})
const handleEdit = (domain: Domain) => {
@@ -86,9 +145,7 @@ export default function DomainsTable() {
}
const handleDelete = (id: string) => {
if (confirm("Are you sure you want to delete this domain?")) {
deleteMutation.mutate(id)
}
setDeleteConfirmId(id)
}
const handleRefresh = (id: string) => {
@@ -109,136 +166,319 @@ export default function DomainsTable() {
}
if (isLoading) {
return <div className="p-4">Loading...</div>
return (
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardContent className="p-0">
<div className="p-8 text-center text-muted-foreground">Loading...</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Domain Expiry Monitoring</h2>
<Button onClick={handleAdd}>
<Plus className="mr-2 h-4 w-4" />
Add Domain
</Button>
</div>
<>
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 pb-5">
<div className="flex flex-col gap-4">
{/* Title row */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-xl mb-2 flex items-center gap-2">
<Globe className="h-5 w-5 text-primary" />
<Trans>Domain Monitoring</Trans>
</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Trans>Track domain expiry dates and watch domains for purchase</Trans>
<span className="text-xs text-muted-foreground">
({statusCounts.active} <CheckCircle2 className="inline h-3 w-3 text-green-500" />
{statusCounts.expiring > 0 && (
<>
{" "}
{statusCounts.expiring}{" "}
<Clock className="inline h-3 w-3 text-yellow-500" />
</>
)}
{statusCounts.expired > 0 && (
<>
{" "}
{statusCounts.expired}{" "}
<AlertTriangle className="inline h-3 w-3 text-red-500" />
</>
)}
/ {statusCounts.total})
</span>
</CardDescription>
</div>
<Button onClick={handleAdd} className="shrink-0">
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Domain</Trans>
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Domain</TableHead>
<TableHead>Status</TableHead>
<TableHead>Expiry</TableHead>
<TableHead>Days Left</TableHead>
<TableHead>Registrar</TableHead>
<TableHead>SSL Expiry</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{domains?.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No domains tracked. Add domains to monitor their expiry dates.
</TableCell>
</TableRow>
) : (
domains?.map((domain) => (
<TableRow key={domain.id}>
<TableCell className="font-medium">
<Link href={`/domain/${domain.id}`} className="flex items-center gap-2 cursor-pointer">
{domain.favicon_url && (
<img
src={domain.favicon_url}
alt=""
className="h-4 w-4"
onError={(e) => (e.currentTarget.style.display = "none")}
></img>
{/* Filter row */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Input
placeholder={t`Filter domains...`}
onChange={(e) => setFilter(e.target.value)}
value={filter}
className="w-full"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Settings2Icon className="me-1.5 size-4 opacity-80" />
<Trans>View</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
{/* Layout */}
<DropdownMenuLabel className="flex items-center gap-2">
<LayoutGridIcon className="size-4" />
<Trans>Layout</Trans>
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<DropdownMenuRadioItem value="table" className="gap-2">
<LayoutListIcon className="size-4" />
<Trans>Table</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="grid" className="gap-2">
<LayoutGridIcon className="size-4" />
<Trans>Grid</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{/* Status Filter */}
<DropdownMenuLabel className="flex items-center gap-2">
<FilterIcon className="size-4" />
<Trans>Status</Trans>
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<DropdownMenuRadioItem value="all">
<Trans>All ({statusCounts.total})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="active">
<Trans>Active ({statusCounts.active})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="expiring">
<Trans>Expiring ({statusCounts.expiring})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="expired">
<Trans>Expired ({statusCounts.expired})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="unknown">
<Trans>Unknown ({statusCounts.unknown})</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{filteredDomains.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{filter || statusFilter !== "all" ? (
"No domains match your filters."
) : (
<div>
<p className="mb-4">No domains tracked. Add domains to monitor their expiry dates or track domains you want to buy.</p>
<Button onClick={handleAdd} variant="outline">
<Plus className="mr-2 h-4 w-4" />
Add your first domain
</Button>
</div>
)}
</div>
) : viewMode === "table" ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Domain</TableHead>
<TableHead>Status</TableHead>
<TableHead>Expiry</TableHead>
<TableHead>Days Left</TableHead>
<TableHead>Registrar</TableHead>
<TableHead>SSL Expiry</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDomains.map((domain) => (
<TableRow key={domain.id}>
<TableCell className="font-medium">
<Link href={`/domain/${domain.id}`} className="flex items-center gap-2 cursor-pointer">
{domain.favicon_url && (
<img
src={domain.favicon_url}
alt=""
className="h-4 w-4"
onError={(e) => (e.currentTarget.style.display = "none")}
/>
)}
<span className="hover:underline">{domain.domain_name}</span>
</Link>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getStatusIcon(domain.status)}
<Badge className={getStatusBadgeColor(domain.status)}>
{getStatusLabel(domain.status)}
</Badge>
</div>
</TableCell>
<TableCell>
{domain.expiry_date ? formatDate(domain.expiry_date) : "Unknown"}
</TableCell>
<TableCell>
<span className={
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? domain.days_until_expiry <= 7
? "text-red-600 font-semibold"
: "text-yellow-600"
: ""
}>
{formatDays(domain.days_until_expiry)}
</span>
</TableCell>
<TableCell>{domain.registrar_name || "Unknown"}</TableCell>
<TableCell>
{domain.ssl_valid_to ? (
<span
className={
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14
? "text-red-600"
: ""
}
>
{formatDays(domain.ssl_days_until)}
</span>
) : (
"N/A"
)}
<span className="hover:underline">{domain.domain_name}</span>
</Link>
</TableCell>
<TableCell>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(domain)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleRefresh(domain.id)}
disabled={refreshMutation.isPending}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href={`https://${domain.domain_name}`}
target="_blank"
rel="noopener noreferrer"
>
<Globe className="mr-2 h-4 w-4" />
Visit
</a>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(domain.id)}
className="text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{filteredDomains.map((domain) => (
<div key={domain.id} className="rounded-lg border bg-card p-4 space-y-3 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<Link href={`/domain/${domain.id}`} className="flex items-center gap-3 cursor-pointer min-w-0">
{domain.favicon_url && (
<img
src={domain.favicon_url}
alt=""
className="h-5 w-5 shrink-0"
onError={(e) => (e.currentTarget.style.display = "none")}
/>
)}
<div className="min-w-0">
<div className="font-medium truncate hover:underline">{domain.domain_name}</div>
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(domain)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRefresh(domain.id)} disabled={refreshMutation.isPending}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(domain.id)} className="text-destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-2">
{getStatusIcon(domain.status)}
<Badge className={getStatusBadgeColor(domain.status)}>
{getStatusLabel(domain.status)}
</Badge>
</div>
</TableCell>
<TableCell>
{domain.expiry_date ? formatDate(domain.expiry_date) : "Unknown"}
</TableCell>
<TableCell>
<span className={
domain.days_until_expiry !== undefined && domain.days_until_expiry <= 30
? domain.days_until_expiry <= 7
? "text-red-600 font-semibold"
: "text-yellow-600"
: ""
}>
{formatDays(domain.days_until_expiry)}
</span>
</TableCell>
<TableCell>{domain.registrar_name || "Unknown"}</TableCell>
<TableCell>
{domain.ssl_valid_to ? (
<span
className={
domain.ssl_days_until !== undefined && domain.ssl_days_until <= 14
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<div className="text-xs text-muted-foreground">Days Left</div>
<span className={
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? domain.days_until_expiry <= 7
? "text-red-600 font-semibold"
: "text-yellow-600"
: ""
}>
{formatDays(domain.days_until_expiry)}
</span>
</div>
<div>
<div className="text-xs text-muted-foreground">SSL</div>
<span
className={
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14
? "text-red-600"
: ""
}
>
{formatDays(domain.ssl_days_until)}
</span>
) : (
"N/A"
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(domain)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleRefresh(domain.id)}
disabled={refreshMutation.isPending}
}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href={`https://${domain.domain_name}`}
target="_blank"
rel="noopener noreferrer"
>
<Globe className="mr-2 h-4 w-4" />
Visit
</a>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(domain.id)}
className="text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
{formatDays(domain.ssl_days_until)}
</span>
</div>
</div>
</div>
))}
</div>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<DomainDialog
open={dialogOpen}
@@ -246,6 +486,34 @@ export default function DomainsTable() {
domain={editingDomain}
isEdit={!!editingDomain}
/>
</div>
<AlertDialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Delete Domain</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>Are you sure you want to delete this domain? This action cannot be undone.</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteConfirmId) {
deleteMutation.mutate(deleteConfirmId)
setDeleteConfirmId(null)
}
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<Trans>Delete</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}