feat(site): enhance monitoring, domain, and system tracking
Build Docker images / Hub (push) Failing after 5m57s

- Improve domain lookup by adding CNAME and SRV record support
- Enhance domain status logic to include expiry and DNS resolution verification
- Update monitoring API to perform synchronous initial checks for immediate status updates
- Refactor site UI:
    - Add tag filtering to domains and monitors tables
    - Improve calendar view with better visual indicators for today and events
    - Update monitor detail view with improved status badges and pending states
    - Simplify home page layout by removing redundant card wrappers
- Update localization files for numerous languages to support new UI elements
- Add `cleanEndpointsConfig` to hub to safely reuse Docker network settings during container updates
This commit is contained in:
Tomas Dvorak
2026-05-02 15:38:41 +02:00
parent c7e2c88604
commit 21657abe38
48 changed files with 3215 additions and 583 deletions
@@ -131,6 +131,9 @@ export function CalendarView() {
<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>
@@ -161,37 +164,55 @@ export function CalendarView() {
key={index}
className={`
min-h-[48px] sm:min-h-[72px] lg:min-h-[96px]
border rounded sm:rounded-lg p-0.5 sm:p-1.5 lg:p-2
border rounded-md sm:rounded-lg p-0.5 sm:p-1.5 lg:p-2
transition-all duration-150
${day.day === 0 ? "bg-muted/10 border-transparent" : "bg-card hover:bg-muted/30 hover:shadow-sm"}
${isToday(day.day) ? "ring-2 ring-primary ring-offset-1" : ""}
${day.day === 0 ? "bg-muted/5 border-transparent" : "bg-card hover:bg-muted/30 hover:shadow-sm"}
${isToday(day.day) ? "ring-2 ring-primary/70 ring-offset-1 bg-primary/5" : ""}
`}
>
{day.day > 0 && (
<>
<div className={`
font-semibold text-[11px] sm:text-xs lg:text-sm mb-0.5 sm:mb-1
${isToday(day.day) ? "text-primary" : ""}
text-[11px] sm:text-xs lg:text-sm mb-0.5 sm:mb-1
${isToday(day.day)
? "flex items-center justify-center"
: "font-medium text-muted-foreground"
}
`}>
{day.day}
{isToday(day.day) ? (
<span className="inline-flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-[10px] sm:text-xs font-bold">
{day.day}
</span>
) : (
day.day
)}
</div>
<div className="space-y-px sm:space-y-0.5">
<div className="space-y-0.5 sm:space-y-1">
{day.events.slice(0, 2).map((event, idx) => (
<Link
key={event.id}
href={event.link || "/calendar"}
className="
text-[9px] sm:text-[10px] lg:text-xs px-0.5 sm:px-1 py-px sm:py-0.5 rounded
flex items-center gap-0.5 sm:gap-1
hover:brightness-110 transition-all
text-[9px] sm:text-[10px] lg:text-xs px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full
flex items-center gap-1 sm:gap-1.5
hover:scale-[1.02] transition-all shadow-sm
"
style={{ backgroundColor: `${event.color}20`, color: event.color }}
style={{
backgroundColor: `${event.color}18`,
color: event.color,
border: `1px solid ${event.color}30`,
}}
title={event.title}
>
{getEventIcon(event.type)}
<span className="truncate hidden lg:inline">{event.title}</span>
<span className="truncate hidden sm:inline max-w-[70px] lg:max-w-[110px] font-medium">{event.title}</span>
{idx === 1 && day.events.length > 2 && (
<span className="text-[8px] sm:text-[9px]">+{day.events.length - 2}</span>
<span
className="text-[8px] sm:text-[9px] shrink-0 font-bold px-1 rounded-full"
style={{ backgroundColor: `${event.color}30`, color: event.color }}
>
+{day.events.length - 2}
</span>
)}
</Link>
))}
@@ -60,17 +60,17 @@ import {
AlertTriangle,
CheckCircle2,
Clock,
Settings2Icon,
FilterIcon,
LayoutGridIcon,
LayoutListIcon,
Tag,
} 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"
type StatusFilter = "all" | "active" | "expiring" | "expired" | "unknown" | "paused"
export default function DomainsTable() {
const { t } = useLingui()
@@ -81,6 +81,7 @@ export default function DomainsTable() {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [filter, setFilter] = useState("")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [tagFilter, setTagFilter] = useState<string>("all")
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
"domainsViewMode",
@@ -98,16 +99,29 @@ export default function DomainsTable() {
return domains.filter((d) => d.status === statusFilter)
}, [domains, statusFilter])
// Then filter by search text
// Then filter by search text and tags
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])
let result = statusFilteredDomains
if (filter) {
const f = filter.toLowerCase()
result = result.filter(
(d) =>
d.domain_name.toLowerCase().includes(f) ||
(d.registrar_name || "").toLowerCase().includes(f)
)
}
if (tagFilter !== "all") {
result = result.filter((d) => d.tags?.includes(tagFilter))
}
return result
}, [statusFilteredDomains, filter, tagFilter])
// Extract all unique tags
const allTags = useMemo(() => {
const tagSet = new Set<string>()
domains.forEach((d) => d.tags?.forEach((tag) => tagSet.add(tag)))
return Array.from(tagSet).sort()
}, [domains])
const statusCounts = useMemo(() => {
const total = domains.length
@@ -115,7 +129,8 @@ export default function DomainsTable() {
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 }
const paused = domains.filter((d) => d.status === "paused").length
return { total, active, expiring, expired, unknown, paused }
}, [domains])
const deleteMutation = useMutation({
@@ -205,6 +220,13 @@ export default function DomainsTable() {
<AlertTriangle className="inline h-3 w-3 text-red-500" />
</>
)}
{statusCounts.paused > 0 && (
<>
{" "}
{statusCounts.paused}{" "}
<Clock className="inline h-3 w-3 text-gray-400" />
</>
)}
/ {statusCounts.total})
</span>
</CardDescription>
@@ -215,6 +237,31 @@ export default function DomainsTable() {
</Button>
</div>
{/* Quick status filters */}
<div className="flex flex-wrap gap-1.5">
{[
{ key: "all", label: `All ${statusCounts.total}`, color: "bg-primary" },
{ key: "active", label: `Active ${statusCounts.active}`, color: "bg-green-500" },
{ key: "expiring", label: `Expiring ${statusCounts.expiring}`, color: "bg-yellow-500" },
{ key: "expired", label: `Expired ${statusCounts.expired}`, color: "bg-red-500" },
{ key: "unknown", label: `Unknown ${statusCounts.unknown}`, color: "bg-gray-400" },
{ key: "paused", label: `Paused ${statusCounts.paused}`, color: "bg-gray-400" },
].map((s) => (
<Button
key={s.key}
variant={statusFilter === s.key ? "default" : "outline"}
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => setStatusFilter(s.key as StatusFilter)}
disabled={s.key !== "all" && parseInt(s.label.split(" ")[1]) === 0}
>
<span className={`h-2 w-2 rounded-full ${s.color}`} />
{s.label.split(" ")[0]}
<span className="text-[10px] opacity-70">{s.label.split(" ")[1]}</span>
</Button>
))}
</div>
{/* Filter row */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
@@ -225,11 +272,33 @@ export default function DomainsTable() {
className="w-full"
/>
</div>
{allTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Tag className="me-1.5 size-4 opacity-80" />
{tagFilter === "all" ? t`Tags` : tagFilter}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={tagFilter} onValueChange={setTagFilter}>
<DropdownMenuRadioItem value="all">
<Trans>All Tags</Trans>
</DropdownMenuRadioItem>
{allTags.map((tag) => (
<DropdownMenuRadioItem key={tag} value={tag}>
{tag}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Settings2Icon className="me-1.5 size-4 opacity-80" />
<Trans>View</Trans>
<Button variant="outline" size="sm">
<FilterIcon className="me-1.5 size-4 opacity-80" />
<Trans>Options</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
@@ -271,6 +340,11 @@ export default function DomainsTable() {
<DropdownMenuRadioItem value="unknown">
<Trans>Unknown ({statusCounts.unknown})</Trans>
</DropdownMenuRadioItem>
{statusCounts.paused > 0 && (
<DropdownMenuRadioItem value="paused">
<Trans>Paused ({statusCounts.paused})</Trans>
</DropdownMenuRadioItem>
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -304,6 +378,7 @@ export default function DomainsTable() {
<TableHead>Days Left</TableHead>
<TableHead>Registrar</TableHead>
<TableHead>SSL Expiry</TableHead>
<TableHead>Tags</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -361,6 +436,19 @@ export default function DomainsTable() {
"N/A"
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{domain.tags?.map((tag: string) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium"
>
<Tag className="h-3 w-3" />
{tag}
</span>
))}
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -447,6 +535,20 @@ export default function DomainsTable() {
</Badge>
</div>
{domain.tags && domain.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{domain.tags.map((tag: string) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium"
>
<Tag className="h-3 w-3" />
{tag}
</span>
))}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<div className="text-xs text-muted-foreground">Days Left</div>
@@ -1,13 +1,10 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
ArrowDownIcon,
ArrowUpDownIcon,
ArrowUpIcon,
CheckCircleIcon,
Edit3Icon,
EyeIcon,
FilterIcon,
GlobeIcon,
LayoutGridIcon,
@@ -17,6 +14,7 @@ import {
PlusIcon,
RefreshCwIcon,
Settings2Icon,
TagIcon,
Trash2Icon,
XCircleIcon,
} from "lucide-react"
@@ -31,7 +29,6 @@ import {
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
@@ -65,6 +62,7 @@ import {
resumeMonitor,
type Monitor,
type MonitorStatus,
type MonitorType,
formatUptime,
formatPing,
} from "@/lib/monitors"
@@ -201,6 +199,20 @@ function MonitorCard({
</div>
</div>
{monitor.tags && monitor.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{monitor.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium"
>
<TagIcon className="h-3 w-3" />
{tag}
</span>
))}
</div>
)}
<div className="flex items-center gap-2 pt-2 border-t">
<TooltipProvider>
<Tooltip>
@@ -356,6 +368,19 @@ function MonitorRow({
<TableCell>
<UptimeBar stats={monitor.uptime_stats} />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{monitor.tags?.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium"
>
<TagIcon className="h-3 w-3" />
{tag}
</span>
))}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<TooltipProvider>
@@ -433,12 +458,15 @@ function MonitorRow({
type ViewMode = "table" | "grid"
type StatusFilter = "all" | MonitorStatus
type TypeFilter = "all" | MonitorType
// Main component
export default memo(function MonitorsTable() {
const { t, i18n } = useLingui()
const { t } = useLingui()
const [filter, setFilter] = useState("")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [tagFilter, setTagFilter] = useState<string>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [editingMonitor, setEditingMonitor] = useState<Monitor | null>(null)
@@ -453,23 +481,46 @@ export default memo(function MonitorsTable() {
refetchInterval: 30000,
})
// Extract all unique types
const allTypes = useMemo(() => {
const typeSet = new Set<MonitorType>()
monitors.forEach((m) => typeSet.add(m.type))
return Array.from(typeSet).sort()
}, [monitors])
// Filter by status first
const statusFilteredMonitors = useMemo(() => {
if (statusFilter === "all") return monitors
return monitors.filter((m) => m.status === statusFilter)
}, [monitors, statusFilter])
// Then filter by search text
// Then filter by search text and type
const filteredMonitors = useMemo(() => {
if (!filter) return statusFilteredMonitors
const f = filter.toLowerCase()
return statusFilteredMonitors.filter(
(m) =>
m.name.toLowerCase().includes(f) ||
(m.url || "").toLowerCase().includes(f) ||
(m.hostname || "").toLowerCase().includes(f)
)
}, [statusFilteredMonitors, filter])
let result = statusFilteredMonitors
if (filter) {
const f = filter.toLowerCase()
result = result.filter(
(m) =>
m.name.toLowerCase().includes(f) ||
(m.url || "").toLowerCase().includes(f) ||
(m.hostname || "").toLowerCase().includes(f)
)
}
if (tagFilter !== "all") {
result = result.filter((m) => m.tags?.includes(tagFilter))
}
if (typeFilter !== "all") {
result = result.filter((m) => m.type === typeFilter)
}
return result
}, [statusFilteredMonitors, filter, tagFilter, typeFilter])
// Extract all unique tags
const allTags = useMemo(() => {
const tagSet = new Set<string>()
monitors.forEach((m) => m.tags?.forEach((tag) => tagSet.add(tag)))
return Array.from(tagSet).sort()
}, [monitors])
const stats = useMemo(() => {
const total = monitors.length
@@ -490,7 +541,7 @@ export default memo(function MonitorsTable() {
<div className="flex-1">
<CardTitle className="text-xl mb-2 flex items-center gap-2">
<GlobeIcon className="h-5 w-5 text-primary" />
<Trans>Website & Service Monitoring</Trans>
<Trans>Status</Trans>
</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Trans>Monitor websites, APIs, and services</Trans>
@@ -519,6 +570,29 @@ export default memo(function MonitorsTable() {
</Button>
</div>
{/* Quick status filters */}
<div className="flex flex-wrap gap-1.5">
{[
{ key: "all", label: `All ${stats.total}`, color: "bg-primary" },
{ key: "up", label: `Up ${stats.up}`, color: "bg-green-500" },
{ key: "down", label: `Down ${stats.down}`, color: "bg-red-500" },
{ key: "paused", label: `Paused ${stats.paused}`, color: "bg-gray-400" },
].map((s) => (
<Button
key={s.key}
variant={statusFilter === s.key ? "default" : "outline"}
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => setStatusFilter(s.key as StatusFilter)}
disabled={s.key !== "all" && parseInt(s.label.split(" ")[1]) === 0}
>
<span className={`h-2 w-2 rounded-full ${s.color}`} />
{s.label.split(" ")[0]}
<span className="text-[10px] opacity-70">{s.label.split(" ")[1]}</span>
</Button>
))}
</div>
{/* Filter row */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
@@ -529,11 +603,55 @@ export default memo(function MonitorsTable() {
className="w-full"
/>
</div>
{allTypes.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<GlobeIcon className="me-1.5 size-4 opacity-80" />
{typeFilter === "all" ? t`Type` : getMonitorTypeLabel(typeFilter)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={typeFilter} onValueChange={(v) => setTypeFilter(v as TypeFilter)}>
<DropdownMenuRadioItem value="all">
<Trans>All Types</Trans>
</DropdownMenuRadioItem>
{allTypes.map((type) => (
<DropdownMenuRadioItem key={type} value={type}>
{getMonitorTypeLabel(type)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{allTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<TagIcon className="me-1.5 size-4 opacity-80" />
{tagFilter === "all" ? t`Tags` : tagFilter}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={tagFilter} onValueChange={setTagFilter}>
<DropdownMenuRadioItem value="all">
<Trans>All Tags</Trans>
</DropdownMenuRadioItem>
{allTags.map((tag) => (
<DropdownMenuRadioItem key={tag} value={tag}>
{tag}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Button variant="outline" size="sm">
<Settings2Icon className="me-1.5 size-4 opacity-80" />
<Trans>View</Trans>
<Trans>Options</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
@@ -595,7 +713,7 @@ export default memo(function MonitorsTable() {
</div>
) : filteredMonitors.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{filter || statusFilter !== "all" ? (
{filter || statusFilter !== "all" || tagFilter !== "all" || typeFilter !== "all" ? (
<Trans>No monitors match your filters.</Trans>
) : (
<div>
@@ -628,6 +746,9 @@ export default memo(function MonitorsTable() {
<TableHead>
<Trans>Uptime (24h)</Trans>
</TableHead>
<TableHead>
<Trans>Tags</Trans>
</TableHead>
<TableHead className="text-right">
<Trans>Actions</Trans>
</TableHead>
+314 -114
View File
@@ -44,7 +44,6 @@ import {
formatDate,
formatDays,
} from "@/lib/domains"
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from "recharts"
import { Link, navigate } from "@/components/router"
import { DomainDialog } from "@/components/domains-table/domain-dialog"
@@ -259,77 +258,146 @@ export default memo(function DomainDetail({ id }: { id: string }) {
/>
</div>
{/* Expiry Comparison Chart */}
<Card>
<CardHeader>
<CardTitle>Expiry Overview</CardTitle>
<CardDescription>Days remaining until domain and SSL certificate expiration</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[
...(typeof domain.days_until_expiry === "number" && domain.days_until_expiry >= 0
? [{ name: "Domain Expiry", days: domain.days_until_expiry }]
: []),
...(typeof domain.ssl_days_until === "number" && domain.ssl_days_until >= 0
? [{ name: "SSL Expiry", days: domain.ssl_days_until }]
: []),
]}
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis type="number" tick={{ fontSize: 12 }} />
<YAxis dataKey="name" type="category" tick={{ fontSize: 12 }} width={100} />
<Tooltip
formatter={(value: number) => [`${value} days`, "Remaining"]}
contentStyle={{ backgroundColor: "hsl(var(--card))", border: "1px solid hsl(var(--border))" }}
/>
<Bar dataKey="days" radius={[0, 4, 4, 0]}>
{[{ days: domain.days_until_expiry ?? 0 }, { days: domain.ssl_days_until ?? 0 }].map(
(entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.days <= 14 ? "#ef4444" : entry.days <= 30 ? "#f59e0b" : "#22c55e"}
/>
)
)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Expiry Overview - Clean visual cards */}
<div className="grid sm:grid-cols-2 gap-4">
{/* Domain Expiry Card */}
<Card className={`${
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 7
? "border-red-500/40"
: domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? "border-yellow-500/40"
: ""
}`}>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl ${
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 7
? "bg-red-500/10"
: domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? "bg-yellow-500/10"
: "bg-green-500/10"
}`}>
<Globe className={`h-5 w-5 ${
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 7
? "text-red-500"
: domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? "text-yellow-500"
: "text-green-500"
}`} />
</div>
<div>
<p className="text-sm text-muted-foreground">Domain Expires</p>
<p className="font-semibold">{formatDate(domain.expiry_date) || "N/A"}</p>
</div>
</div>
<div className={`text-2xl font-bold ${
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 7
? "text-red-500"
: domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? "text-yellow-500"
: "text-green-500"
}`}>
{typeof domain.days_until_expiry === "number" && domain.days_until_expiry >= 0
? formatDays(domain.days_until_expiry)
: domain.days_until_expiry === -1
? "No expiry data"
: "N/A"
}
</div>
</div>
{typeof domain.days_until_expiry === "number" && domain.days_until_expiry >= 0 && (() => {
const d = domain.days_until_expiry
return (
<div className="flex gap-1 mt-2">
{Array.from({ length: Math.min(12, Math.ceil(d / 30)) }).map((_, i) => (
<div
key={i}
className={`flex-1 h-1.5 rounded-full ${
d <= 7 ? "bg-red-500"
: d <= 30 ? "bg-yellow-500"
: "bg-green-500"
}`}
/>
))}
{d > 360 && (
<span className="text-[10px] text-muted-foreground ml-1">+</span>
)}
</div>
)
})()}
</CardContent>
</Card>
<div className="grid gap-4">
{/* Expiry Timeline Chart */}
<Card>
<CardHeader>
<CardTitle>Change Timeline</CardTitle>
<CardDescription>Recent detected domain, DNS, SSL, and registrar changes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{history?.slice(0, 8).map((event) => (
<div key={event.id} className="flex items-start gap-3 rounded-md border p-3">
<Badge variant="outline" className="mt-0.5">
{event.change_type}
</Badge>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{event.field_name}</p>
<p className="text-xs text-muted-foreground break-words">
{event.old_value || "Unknown"} {"->"} {event.new_value || "Unknown"}
</p>
<p className="text-xs text-muted-foreground mt-1">{formatDate(event.created_at)}</p>
{/* SSL Expiry Card */}
<Card className={`${
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
? "border-red-500/40"
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
? "border-yellow-500/40"
: ""
}`}>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl ${
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
? "bg-red-500/10"
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
? "bg-yellow-500/10"
: "bg-green-500/10"
}`}>
<Shield className={`h-5 w-5 ${
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
? "text-red-500"
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
? "text-yellow-500"
: "text-green-500"
}`} />
</div>
<div>
<p className="text-sm text-muted-foreground">SSL Expires</p>
<p className="font-semibold">{formatDate(domain.ssl_valid_to) || "No SSL"}</p>
</div>
</div>
))}
{!history?.length && <p className="text-sm text-muted-foreground">No changes recorded yet.</p>}
</div>
</CardContent>
</Card>
<div className={`text-2xl font-bold ${
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
? "text-red-500"
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
? "text-yellow-500"
: "text-green-500"
}`}>
{typeof domain.ssl_days_until === "number" && domain.ssl_days_until >= 0
? formatDays(domain.ssl_days_until)
: "N/A"
}
</div>
</div>
{typeof domain.ssl_days_until === "number" && domain.ssl_days_until >= 0 && (() => {
const sslDaysUntil = domain.ssl_days_until;
return (
<div className="flex gap-1 mt-2">
{Array.from({ length: Math.min(12, Math.ceil(sslDaysUntil / 30)) }).map((_, i) => (
<div
key={i}
className={`flex-1 h-1.5 rounded-full ${
sslDaysUntil <= 7 ? "bg-red-500"
: sslDaysUntil <= 30 ? "bg-yellow-500"
: "bg-green-500"
}`}
/>
))}
{sslDaysUntil > 360 && (
<span className="text-[10px] text-muted-foreground ml-1">+</span>
)}
</div>
)
})()}
</CardContent>
</Card>
</div>
<div className="grid gap-4">
{/* Additional Info */}
<div className="grid sm:grid-cols-2 gap-4">
<Card>
@@ -355,29 +423,37 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Valuation</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Purchase Price</span>
<span className="font-medium">${domain.purchase_price || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Current Value</span>
<span className="font-medium">${domain.current_value || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Renewal Cost</span>
<span className="font-medium">${domain.renewal_cost || 0}</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>
{((domain.purchase_price ?? 0) > 0 || (domain.current_value ?? 0) > 0 || (domain.renewal_cost ?? 0) > 0) && (
<Card>
<CardHeader>
<CardTitle>Valuation</CardTitle>
</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>
</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 */}
@@ -397,9 +473,51 @@ export default memo(function DomainDetail({ id }: { id: string }) {
<Card>
<CardHeader>
<CardTitle>DNS Records</CardTitle>
<CardDescription>Name servers, mail exchangers, and text records</CardDescription>
<CardDescription>A, AAAA, name servers, mail exchangers, and text records</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* A Records (IPv4) */}
{(domain.ipv4_addresses?.length ?? 0) > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Server className="h-4 w-4" />
A Records (IPv4)
<Badge variant="secondary" className="ml-2">
{domain.ipv4_addresses?.length || 0}
</Badge>
</h4>
<div className="space-y-1">
{domain.ipv4_addresses?.map((ip: string, i: number) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">A</Badge>
<code className="text-sm font-mono">{ip}</code>
</div>
))}
</div>
</div>
)}
{/* AAAA Records (IPv6) */}
{(domain.ipv6_addresses?.length ?? 0) > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Server className="h-4 w-4" />
AAAA Records (IPv6)
<Badge variant="secondary" className="ml-2">
{domain.ipv6_addresses?.length || 0}
</Badge>
</h4>
<div className="space-y-1">
{domain.ipv6_addresses?.map((ip: string, i: number) => (
<div key={i} className="flex items-center gap-2">
<Badge variant="outline">AAAA</Badge>
<code className="text-sm font-mono break-all">{ip}</code>
</div>
))}
</div>
</div>
)}
{/* Nameservers */}
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
@@ -441,6 +559,20 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</div>
)}
{/* CNAME Record */}
{domain.cname_record && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Globe className="h-4 w-4" />
CNAME Record
</h4>
<div className="flex items-center gap-2">
<Badge variant="outline">CNAME</Badge>
<code className="text-sm">{domain.cname_record}</code>
</div>
</div>
)}
{/* TXT Records */}
{domain.txt_records && domain.txt_records.length > 0 && (
<div>
@@ -462,6 +594,27 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</div>
)}
{/* SRV Records */}
{domain.srv_records && domain.srv_records.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Server className="h-4 w-4" />
SRV Records
<Badge variant="secondary" className="ml-2">
{domain.srv_records.length}
</Badge>
</h4>
<div className="space-y-1">
{domain.srv_records?.map((srv: string, i: number) => (
<div key={i} className="flex items-start gap-2">
<Badge variant="outline">SRV</Badge>
<code className="text-sm break-all">{srv}</code>
</div>
))}
</div>
</div>
)}
{/* DNSSEC */}
{domain.dnssec && (
<div>
@@ -667,15 +820,15 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</div>
)}
{/* Domain Status */}
{domain.status && domain.status !== "unknown" && (
{/* WHOIS Domain Status (EPP status codes) */}
{domain.whois_status && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Shield className="h-4 w-4" />
Domain Status
EPP Status Codes
</h4>
<div className="flex flex-wrap gap-2">
{domain.status.split(", ").map((status: string, i: number) => (
{domain.whois_status.split(", ").map((status: string, i: number) => (
<Badge key={i} variant="secondary">
{status}
</Badge>
@@ -683,6 +836,17 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</div>
</div>
)}
{/* WHOIS Server */}
{domain.whois_server && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Server className="h-4 w-4" />
WHOIS Server
</h4>
<code className="text-sm">{domain.whois_server}</code>
</div>
)}
</CardContent>
</Card>
</div>
@@ -690,26 +854,62 @@ export default memo(function DomainDetail({ id }: { id: string }) {
<Card>
<CardHeader>
<CardTitle>Change History</CardTitle>
<CardDescription>Historical changes to domain information</CardDescription>
<CardDescription>Timeline of detected domain, DNS, SSL, and registrar changes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{history?.map((item: DomainHistory) => (
<div key={item.id} className="flex items-start gap-4 pb-4 border-b last:border-0">
<div className="p-2 bg-muted rounded-lg">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="font-medium">{item.change_type}</p>
<p className="text-sm text-muted-foreground">{item.change_description}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(item.created_at || item.created).toLocaleString()}
</p>
</div>
</div>
))}
{!history?.length && <p className="text-muted-foreground text-center py-8">No history available</p>}
</div>
{history?.length ? (
<div className="relative space-y-0">
{/* Timeline line */}
<div className="absolute left-[15px] top-2 bottom-2 w-px bg-border" />
{history.map((item: DomainHistory) => {
const typeConfig: Record<string, { color: string; icon: string }> = {
expiry: { color: "bg-yellow-500", icon: "📅" },
ssl: { color: "bg-purple-500", icon: "🔒" },
dns: { color: "bg-blue-500", icon: "🌐" },
registrar: { color: "bg-orange-500", icon: "🏢" },
ip: { color: "bg-cyan-500", icon: "💻" },
host: { color: "bg-teal-500", icon: "📍" },
status: { color: "bg-green-500", icon: "✅" },
}
const config = typeConfig[item.change_type] || { color: "bg-gray-500", icon: "📋" }
return (
<div key={item.id} className="relative flex items-start gap-3 pb-4 last:pb-0">
{/* Timeline dot */}
<div className={`relative z-10 mt-1 h-[30px] w-[30px] shrink-0 rounded-full ${config.color}/10 flex items-center justify-center border-2 border-background`}>
<div className={`h-2.5 w-2.5 rounded-full ${config.color}`} />
</div>
{/* Content */}
<div className="min-w-0 flex-1 rounded-lg border p-3">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{item.change_type}
</Badge>
<span className="text-xs text-muted-foreground">
{formatDate(item.created_at)}
</span>
</div>
<p className="text-sm font-medium">{item.field_name}</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-1">
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px] break-all max-w-[200px] truncate">
{item.old_value || "—"}
</code>
<span className="shrink-0"></span>
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px] break-all max-w-[200px] truncate">
{item.new_value || "—"}
</code>
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8">
<FileText className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No changes recorded yet.</p>
<p className="text-xs text-muted-foreground mt-1">Changes will appear here when domain data is updated.</p>
</div>
)}
</CardContent>
</Card>
<DomainDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} domain={domain} isEdit />
+15 -60
View File
@@ -1,4 +1,4 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { useLingui } from "@lingui/react/macro"
import { getPagePath } from "@nanostores/router"
import { memo, Suspense, useEffect, useMemo } from "react"
import { Link, $router } from "@/components/router"
@@ -7,8 +7,8 @@ import MonitorsTable from "@/components/monitors-table/monitors-table"
import DomainsTable from "@/components/domains-table/domains-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Globe, AlertTriangle, Calendar, Server, Activity } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Globe, AlertTriangle, Calendar } from "lucide-react"
export default memo(() => {
const { t } = useLingui()
@@ -24,65 +24,20 @@ export default memo(() => {
{/* Active Alerts */}
<ActiveAlerts />
{/* System Monitoring Section */}
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-4 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Server className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg"><Trans>System Monitoring</Trans></CardTitle>
<CardDescription><Trans>Track system resources, containers, and health</Trans></CardDescription>
</div>
</div>
</CardHeader>
<div className="pt-1">
<Suspense>
<SystemsTable />
</Suspense>
</div>
</Card>
{/* System Monitoring */}
<Suspense>
<SystemsTable />
</Suspense>
{/* Website & Service Monitoring Section */}
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-4 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg"><Trans>Website & Service Monitoring</Trans></CardTitle>
<CardDescription><Trans>Monitor websites, APIs, and services</Trans></CardDescription>
</div>
</div>
</CardHeader>
<div className="pt-1">
<Suspense>
<MonitorsTable />
</Suspense>
</div>
</Card>
{/* Status Monitoring */}
<Suspense>
<MonitorsTable />
</Suspense>
{/* Domain Monitoring Section */}
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-4 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Globe className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg"><Trans>Domain Monitoring</Trans></CardTitle>
<CardDescription><Trans>Track domain expiry dates and DNS status</Trans></CardDescription>
</div>
</div>
</CardHeader>
<div className="pt-1">
<Suspense>
<DomainsTable />
</Suspense>
</div>
</Card>
{/* Domain Monitoring */}
<Suspense>
<DomainsTable />
</Suspense>
{/* Quick Actions */}
<section className="grid grid-cols-1 md:grid-cols-3 gap-4">
+60 -45
View File
@@ -318,6 +318,10 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
const isUp = monitor.status === "up"
const isPaused = monitor.status === "paused"
const isPending = monitor.status === "pending"
const headerIconColor = isUp ? "text-green-500" : isPaused ? "text-gray-500" : isPending ? "text-yellow-500" : "text-red-500"
const headerBgColor = isUp ? "bg-green-500/10" : isPaused ? "bg-gray-500/10" : isPending ? "bg-yellow-500/10" : "bg-red-500/10"
return (
<div className="grid gap-4 mb-14">
@@ -329,32 +333,35 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<div
className={cn(
"h-12 w-12 rounded-full flex items-center justify-center",
isUp ? "bg-green-500/10" : isPaused ? "bg-gray-500/10" : "bg-red-500/10"
headerBgColor
)}
>
<Globe
className={cn("h-6 w-6", isUp ? "text-green-500" : isPaused ? "text-gray-500" : "text-red-500")}
/>
<Globe className={cn("h-6 w-6", headerIconColor)} />
</div>
<div>
<h1 className="text-2xl font-bold">{monitor.name}</h1>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-2 mt-1 flex-wrap">
<StatusBadge status={monitor.status} />
<Badge variant="secondary">{getMonitorTypeLabel(monitor.type)}</Badge>
{monitor.interval && <Badge variant="outline">{monitor.interval}s interval</Badge>}
{isPending && (
<Badge variant="outline" className="text-yellow-600 border-yellow-500/30">
Waiting for first check
</Badge>
)}
</div>
{monitor.url && <p className="text-sm text-muted-foreground mt-1">{monitor.url}</p>}
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
variant={isPending ? "default" : "outline"}
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending || isPaused}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
<Trans>Check Now</Trans>
{isPending ? "Run First Check" : "Check Now"}
</Button>
{monitor.url && (
<Button variant="outline" size="sm" asChild>
@@ -414,36 +421,6 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
/>
</div>
{/* Pending / No Data State */}
{monitor.status === "pending" && !heartbeats?.length && (
<Card className="border-yellow-500/20 bg-yellow-50/5 dark:bg-yellow-950/10">
<CardContent className="p-6">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-500/10 rounded-lg">
<Clock className="h-5 w-5 text-yellow-500" />
</div>
<div>
<p className="font-medium">Initial check pending</p>
<p className="text-sm text-muted-foreground">
This monitor has not been checked yet. Click "Check Now" to run the first check.
</p>
</div>
</div>
<Button
variant="default"
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
<Trans>Check Now</Trans>
</Button>
</div>
</CardContent>
</Card>
)}
{/* Combined Uptime & Response Chart */}
<Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
@@ -511,8 +488,26 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
<Trans>No data available for selected time range</Trans>
<div className="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
<div className="p-3 bg-muted/50 rounded-full">
<Activity className="h-6 w-6 opacity-50" />
</div>
<p className="text-sm">
{isPending
? "No check data yet. Run a check to see the chart."
: "No data available for selected time range"}
</p>
{isPending && (
<Button
variant="outline"
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
Run First Check
</Button>
)}
</div>
)}
</div>
@@ -672,12 +667,32 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</TableRow>
))}
{!heartbeats?.length && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
No check history available
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell colSpan={4}>
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<div className="p-2 bg-muted/50 rounded-full">
<Clock className="h-5 w-5 opacity-50" />
</div>
<p className="text-sm">
{isPending
? "No checks have been run yet."
: "No check history available for the selected period."}
</p>
{isPending && (
<Button
variant="outline"
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
Run First Check
</Button>
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>