mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
feat(site): enhance monitoring, domain, and system tracking
Build Docker images / Hub (push) Failing after 5m57s
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:
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user