feat(site): overhaul domain and monitor detail views
Build Docker images / Hub (push) Failing after 1m21s

Refactor the domain and monitor detail pages to use a new modular
component architecture. This includes:

- Implementing `domain-info-sections.tsx` and `monitor-info-sections.tsx`
  to provide structured, card-based information layouts.
- Enhancing domain details with new sections for DNS, SSL, SEO, and
  hosting information.
- Improving monitor details with enhanced uptime visualizations and
  response time statistics.
- Updating the PageSpeed check API to support an optional API key from
  environment variables.
- Cleaning up UI components and improving code formatting in the
  monitors table.
- Updating localization files to support new UI strings.
This commit is contained in:
Tomas Dvorak
2026-05-18 18:27:12 +02:00
parent fe5c7eaa95
commit 18046aee71
37 changed files with 1906 additions and 1384 deletions
+1
View File
@@ -14,6 +14,7 @@ MAX_STATUS_PAGES=10
# Optional Features # Optional Features
PAGESPEED_ENABLED=true PAGESPEED_ENABLED=true
# PAGESPEED_API_KEY=your_google_api_key_here
SUBDOMAIN_DISCOVERY=true SUBDOMAIN_DISCOVERY=true
STATUS_PAGES_ENABLED=true STATUS_PAGES_ENABLED=true
BADGES_ENABLED=true BADGES_ENABLED=true
+4 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/henrygd/beszel/internal/entities/monitor" "github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/hub/pagespeed" "github.com/henrygd/beszel/internal/hub/pagespeed"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -641,10 +642,11 @@ func (h *APIHandler) runPageSpeedCheck(e *core.RequestEvent) error {
return e.BadRequestError("strategy must be 'mobile' or 'desktop'", nil) return e.BadRequestError("strategy must be 'mobile' or 'desktop'", nil)
} }
checker := pagespeed.NewChecker("") apiKey, _ := utils.GetEnv("PAGESPEED_API_KEY")
checker := pagespeed.NewChecker(apiKey)
metrics, err := checker.CheckURL(url, strategy) metrics, err := checker.CheckURL(url, strategy)
if err != nil { if err != nil {
return e.InternalServerError("PageSpeed check failed", err) return e.BadRequestError("PageSpeed check failed: "+err.Error(), err)
} }
vitals := pagespeed.GetCoreWebVitalsStatus(metrics) vitals := pagespeed.GetCoreWebVitalsStatus(metrics)
@@ -22,13 +22,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { memo, useMemo, useState } from "react" import { memo, useMemo, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -40,20 +34,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
Table, import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { import {
deleteMonitor, deleteMonitor,
@@ -193,10 +175,7 @@ function MonitorCard({
<Edit3Icon className="mr-2 h-4 w-4" /> <Edit3Icon className="mr-2 h-4 w-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => deleteMutation.mutate(monitor.id)} className="text-destructive">
onClick={() => deleteMutation.mutate(monitor.id)}
className="text-destructive"
>
<Trash2Icon className="mr-2 h-4 w-4" /> <Trash2Icon className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@@ -219,13 +198,13 @@ function MonitorCard({
{displayOptions.showUptimePercentage && ( {displayOptions.showUptimePercentage && (
<UptimePill uptime={monitor.uptime_stats?.uptime_24h ?? 100} label="24h" /> <UptimePill uptime={monitor.uptime_stats?.uptime_24h ?? 100} label="24h" />
)} )}
{displayOptions.showUptimePercentage && monitor.uptime_stats?.uptime_7d !== undefined && monitor.uptime_stats.uptime_7d !== monitor.uptime_stats?.uptime_24h && ( {displayOptions.showUptimePercentage &&
<UptimePill uptime={monitor.uptime_stats.uptime_7d} label="7d" /> monitor.uptime_stats?.uptime_7d !== undefined &&
)} monitor.uptime_stats.uptime_7d !== monitor.uptime_stats?.uptime_24h && (
<UptimePill uptime={monitor.uptime_stats.uptime_7d} label="7d" />
)}
</div> </div>
{displayOptions.showHeartbeatDots && ( {displayOptions.showHeartbeatDots && <UptimeDots heartbeats={monitor.recent_heartbeats} />}
<UptimeDots heartbeats={monitor.recent_heartbeats} />
)}
</div> </div>
)} )}
@@ -266,12 +245,7 @@ function MonitorCard({
onClick={() => checkMutation.mutate(monitor.id)} onClick={() => checkMutation.mutate(monitor.id)}
disabled={checkMutation.isPending} disabled={checkMutation.isPending}
> >
<RefreshCwIcon <RefreshCwIcon className={cn("h-4 w-4 mr-1", checkMutation.isPending && "animate-spin")} />
className={cn(
"h-4 w-4 mr-1",
checkMutation.isPending && "animate-spin"
)}
/>
Check Check
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -291,9 +265,13 @@ function MonitorCard({
disabled={pauseMutation.isPending} disabled={pauseMutation.isPending}
> >
{monitor.status === "paused" ? ( {monitor.status === "paused" ? (
<><PlayIcon className="h-4 w-4 mr-1" /> Resume</> <>
<PlayIcon className="h-4 w-4 mr-1" /> Resume
</>
) : ( ) : (
<><PauseIcon className="h-4 w-4 mr-1" /> Pause</> <>
<PauseIcon className="h-4 w-4 mr-1" /> Pause
</>
)} )}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -307,28 +285,42 @@ function MonitorCard({
) )
} }
// Uptime pill badge component - big and visible // Uptime pill badge component - styled like domainstack.io status badges
function UptimePill({ uptime, label = "24h" }: { uptime: number; label?: string }) { function UptimePill({ uptime, label = "24h" }: { uptime: number; label?: string }) {
let colorClass = "bg-green-500/15 text-green-600 border-green-500/30" let colorClass = "bg-green-500/10 text-green-700 border-green-500/20 dark:text-green-400"
let icon = <CheckCircleIcon className="h-3.5 w-3.5" /> let icon = <CheckCircleIcon className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
let ringClass = "ring-green-500/20"
if (uptime < 99.9) { if (uptime < 99.9) {
colorClass = "bg-green-500/15 text-green-600 border-green-500/30" colorClass = "bg-green-500/10 text-green-700 border-green-500/20 dark:text-green-400"
ringClass = "ring-green-500/20"
} }
if (uptime < 95) { if (uptime < 95) {
colorClass = "bg-yellow-500/15 text-yellow-600 border-yellow-500/30" colorClass = "bg-yellow-500/10 text-yellow-700 border-yellow-500/20 dark:text-yellow-400"
icon = <AlertTriangle className="h-3.5 w-3.5" /> icon = <AlertTriangle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400" />
ringClass = "ring-yellow-500/20"
} }
if (uptime < 90) { if (uptime < 90) {
colorClass = "bg-red-500/15 text-red-600 border-red-500/30" colorClass = "bg-red-500/10 text-red-700 border-red-500/20 dark:text-red-400"
icon = <XCircle className="h-3.5 w-3.5" /> icon = <XCircle className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
ringClass = "ring-red-500/20"
} }
return ( return (
<div className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border-2 ${colorClass}`}> <div
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-semibold shadow-sm",
"transition-all hover:scale-105",
colorClass,
ringClass,
"ring-1"
)}
>
{icon} {icon}
<span className="text-sm font-bold">{formatUptime(uptime)}</span> <span>{formatUptime(uptime)}</span>
<span className="text-[10px] font-medium uppercase opacity-70">{label}</span> <span className="text-[10px] font-medium uppercase opacity-60 border-l border-current/20 pl-1.5 ml-0.5">
{label}
</span>
</div> </div>
) )
} }
@@ -339,17 +331,11 @@ function UptimeBar({ stats }: { stats?: Record<string, number> }) {
const uptime7d = stats?.uptime_7d ?? 100 const uptime7d = stats?.uptime_7d ?? 100
const uptime30d = stats?.uptime_30d ?? 100 const uptime30d = stats?.uptime_30d ?? 100
let color = "bg-green-500"
if (uptime24h < 95) color = "bg-yellow-500"
if (uptime24h < 90) color = "bg-red-500"
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UptimePill uptime={uptime24h} label="24h" /> <UptimePill uptime={uptime24h} label="24h" />
{uptime7d !== 100 && uptime7d !== uptime24h && ( {uptime7d !== 100 && uptime7d !== uptime24h && <UptimePill uptime={uptime7d} label="7d" />}
<UptimePill uptime={uptime7d} label="7d" />
)}
{uptime30d !== 100 && uptime30d !== uptime24h && uptime30d !== uptime7d && ( {uptime30d !== 100 && uptime30d !== uptime24h && uptime30d !== uptime7d && (
<UptimePill uptime={uptime30d} label="30d" /> <UptimePill uptime={uptime30d} label="30d" />
)} )}
@@ -358,39 +344,52 @@ function UptimeBar({ stats }: { stats?: Record<string, number> }) {
) )
} }
// Mini uptime dots visualization // Mini uptime dots visualization - styled as a clean status bar
function UptimeDots({ heartbeats }: { heartbeats?: Array<{ status: string; time: string }> }) { function UptimeDots({ heartbeats }: { heartbeats?: Array<{ status: string; time: string }> }) {
if (!heartbeats || heartbeats.length === 0) { const totalSlots = 16
return ( const recent = heartbeats?.slice(-totalSlots) || []
<div className="flex gap-0.5"> const emptySlots = totalSlots - recent.length
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-3 w-2 rounded-sm bg-muted" /> const getStatusColor = (status: string) => {
))} switch (status) {
</div> case "up":
) return "bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.4)]"
case "down":
return "bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.4)]"
case "paused":
return "bg-gray-400"
default:
return "bg-yellow-500"
}
} }
// Take last 12 heartbeats
const recent = heartbeats.slice(-12)
return ( return (
<div className="flex gap-0.5"> <TooltipProvider>
{recent.map((hb, i) => ( <div className="flex items-center gap-1">
<div <div className="flex gap-[3px] p-1 rounded-md bg-muted/50">
key={i} {recent.map((hb, i) => (
className={cn( <Tooltip key={i}>
"h-3 w-2 rounded-sm transition-colors", <TooltipTrigger asChild>
hb.status === "up" ? "bg-green-500" : <div
hb.status === "down" ? "bg-red-500" : className={cn(
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500" "h-4 w-[6px] rounded-full transition-all cursor-pointer hover:scale-125",
)} getStatusColor(hb.status)
title={`${hb.status} at ${new Date(hb.time).toLocaleString()}`} )}
/> />
))} </TooltipTrigger>
{recent.length < 12 && Array.from({ length: 12 - recent.length }).map((_, i) => ( <TooltipContent side="bottom" className="text-xs">
<div key={`empty-${i}`} className="h-3 w-2 rounded-sm bg-muted" /> <p className="capitalize font-medium">{hb.status}</p>
))} <p className="text-muted-foreground">{new Date(hb.time).toLocaleString()}</p>
</div> </TooltipContent>
</Tooltip>
))}
{emptySlots > 0 &&
Array.from({ length: emptySlots }).map((_, i) => (
<div key={`empty-${i}`} className="h-4 w-[6px] rounded-full bg-muted" />
))}
</div>
</div>
</TooltipProvider>
) )
} }
@@ -466,9 +465,7 @@ function MonitorRow({
</TableCell> </TableCell>
<TableCell> <TableCell>
{monitor.last_check ? ( {monitor.last_check ? (
<div className="text-sm"> <div className="text-sm">{formatPing(monitor.uptime_stats?.last_ping || 0)}</div>
{formatPing(monitor.uptime_stats?.last_ping || 0)}
</div>
) : ( ) : (
<span className="text-sm text-muted-foreground">-</span> <span className="text-sm text-muted-foreground">-</span>
)} )}
@@ -505,12 +502,7 @@ function MonitorRow({
onClick={() => checkMutation.mutate(monitor.id)} onClick={() => checkMutation.mutate(monitor.id)}
disabled={checkMutation.isPending} disabled={checkMutation.isPending}
> >
<RefreshCwIcon <RefreshCwIcon className={cn("h-4 w-4", checkMutation.isPending && "animate-spin")} />
className={cn(
"h-4 w-4",
checkMutation.isPending && "animate-spin"
)}
/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -529,11 +521,7 @@ function MonitorRow({
onClick={() => pauseMutation.mutate(monitor.id)} onClick={() => pauseMutation.mutate(monitor.id)}
disabled={pauseMutation.isPending} disabled={pauseMutation.isPending}
> >
{monitor.status === "paused" ? ( {monitor.status === "paused" ? <PlayIcon className="h-4 w-4" /> : <PauseIcon className="h-4 w-4" />}
<PlayIcon className="h-4 w-4" />
) : (
<PauseIcon className="h-4 w-4" />
)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -553,10 +541,7 @@ function MonitorRow({
<Edit3Icon className="mr-2 h-4 w-4" /> <Edit3Icon className="mr-2 h-4 w-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(monitor.id)}>
className="text-destructive"
onClick={() => deleteMutation.mutate(monitor.id)}
>
<Trash2Icon className="mr-2 h-4 w-4" /> <Trash2Icon className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@@ -593,10 +578,11 @@ export default memo(function MonitorsTable() {
window.innerWidth < 1024 ? "grid" : "table" window.innerWidth < 1024 ? "grid" : "table"
) )
const [displayOptions, setDisplayOptions] = useBrowserStorage<DisplayOptions>( const [displayOptions, setDisplayOptions] = useBrowserStorage<DisplayOptions>("monitorsDisplayOptions", {
"monitorsDisplayOptions", showUptimePills: true,
{ showUptimePills: true, showUptimePercentage: true, showHeartbeatDots: true } showUptimePercentage: true,
) showHeartbeatDots: true,
})
const { data: monitors = [], isLoading } = useQuery({ const { data: monitors = [], isLoading } = useQuery({
queryKey: ["monitors"], queryKey: ["monitors"],
@@ -673,8 +659,7 @@ export default memo(function MonitorsTable() {
{stats.down > 0 && ( {stats.down > 0 && (
<> <>
{" "} {" "}
{stats.down}{" "} {stats.down} <ArrowDownIcon className="inline h-3 w-3 text-red-500" />
<ArrowDownIcon className="inline h-3 w-3 text-red-500" />
</> </>
)} )}
{stats.paused > 0 && ( {stats.paused > 0 && (
@@ -939,10 +924,7 @@ export default memo(function MonitorsTable() {
</CardContent> </CardContent>
{/* Add Monitor Dialog */} {/* Add Monitor Dialog */}
<AddMonitorDialog <AddMonitorDialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
/>
{/* Edit Monitor Dialog */} {/* Edit Monitor Dialog */}
{editingMonitor && ( {editingMonitor && (
@@ -0,0 +1,875 @@
import type { Domain } from "@/lib/domains"
import { formatDate, formatDays } from "@/lib/domains"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import {
Globe,
Shield,
Server,
MapPin,
FileText,
Building2,
User,
Eye,
EyeOff,
Mail,
Network,
Search,
Code2,
AlertTriangle,
CheckCircle2,
Clock,
ExternalLink,
Info,
} from "lucide-react"
// --- Reusable section wrapper inspired by domainstack.io ---
function SectionCard({
title,
description,
icon: Icon,
children,
accent = "slate",
}: {
title: string
description?: string
icon: React.ElementType
children: React.ReactNode
accent?: "blue" | "green" | "orange" | "purple" | "slate" | "red" | "yellow"
}) {
const accentBorder = {
blue: "border-t-blue-500/40",
green: "border-t-green-500/40",
orange: "border-t-orange-500/40",
purple: "border-t-purple-500/40",
slate: "border-t-slate-500/30",
red: "border-t-red-500/40",
yellow: "border-t-yellow-500/40",
}
const accentIcon = {
blue: "text-blue-500",
green: "text-green-500",
orange: "text-orange-500",
purple: "text-purple-500",
slate: "text-slate-500",
red: "text-red-500",
yellow: "text-yellow-500",
}
return (
<Card className={cn("overflow-hidden rounded-xl border-t-2", accentBorder[accent])}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2.5">
<Icon className={cn("h-5 w-5", accentIcon[accent])} />
<div>
<CardTitle className="text-base">{title}</CardTitle>
{description && <CardDescription className="text-xs">{description}</CardDescription>}
</div>
</div>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
function KV({
label,
value,
suffix,
leading,
}: {
label: string
value?: string | null
suffix?: React.ReactNode
leading?: React.ReactNode
}) {
if (!value && value !== "0") return null
return (
<div className="flex h-14 min-w-0 items-center justify-between gap-3 rounded-lg border bg-background/60 px-3 py-2">
<div className="flex min-w-0 flex-col">
<div className="text-[10px] leading-none tracking-wider uppercase text-foreground/60">{label}</div>
<div className="inline-flex min-w-0 items-center gap-1.5 text-[13px] text-foreground/90 mt-1">
{leading ? <span className="shrink-0">{leading}</span> : null}
<span className="truncate">{value}</span>
{suffix ? <span className="shrink-0">{suffix}</span> : null}
</div>
</div>
</div>
)
}
function KVGrid({ children, cols = 2 }: { children: React.ReactNode; cols?: 1 | 2 | 3 | 4 }) {
const colClass =
cols === 4
? "lg:grid-cols-4 md:grid-cols-2"
: cols === 3
? "sm:grid-cols-3"
: cols === 1
? "grid-cols-1"
: "sm:grid-cols-2"
return <div className={cn("grid grid-cols-1 gap-2", colClass)}>{children}</div>
}
function DnsGroup({
type,
records,
ttl,
}: {
type: string
records: Array<{ value: string; ttl?: string | number }>
ttl?: string | number
}) {
if (!records?.length) return null
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="font-mono text-[10px] px-1.5 py-0.5">
{type}
</Badge>
<span className="text-xs text-muted-foreground">
{records.length} record{records.length !== 1 ? "s" : ""}
</span>
</div>
<div className="space-y-1">
{records.map((rec, i) => (
<div key={i} className="flex items-center gap-2 rounded-md border bg-background/40 px-2.5 py-1.5 text-sm">
<code className="text-xs font-mono text-foreground/80 truncate flex-1">{rec.value}</code>
{(rec.ttl || ttl) && (
<span className="text-[10px] text-muted-foreground shrink-0">TTL {rec.ttl || ttl}</span>
)}
</div>
))}
</div>
</div>
)
}
function MapEmbed({ lat, lon, title }: { lat: number; lon: number; title?: string }) {
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.8}%2C${lat - 0.8}%2C${lon + 0.8}%2C${lat + 0.8}&layer=mapnik&marker=${lat}%2C${lon}`
return (
<div className="relative h-[200px] w-full overflow-hidden rounded-lg border mt-2">
<iframe title={title || "Location map"} src={mapUrl} className="h-full w-full border-0" loading="lazy" />
<a
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=12/${lat}/${lon}`}
target="_blank"
rel="noopener noreferrer"
className="absolute bottom-2 right-2 rounded-md bg-white/90 px-2 py-1 text-[10px] font-medium text-foreground shadow-sm hover:bg-white border"
>
Open Map
</a>
</div>
)
}
function StatusDot({ status }: { status: string }) {
const colors: Record<string, string> = {
up: "bg-green-500",
down: "bg-red-500",
paused: "bg-gray-400",
active: "bg-green-500",
expiring: "bg-yellow-500",
expired: "bg-red-500",
unknown: "bg-gray-400",
}
return <div className={cn("h-2.5 w-2.5 rounded-full", colors[status] || "bg-yellow-500")} />
}
function CertificateCard({
cert,
index,
total,
}: {
cert: NonNullable<Domain["certificates"]> extends Array<infer T> ? T : never
index: number
total: number
}) {
const label = index === 0 ? "Leaf" : index === total - 1 ? "Root" : "Intermediate"
return (
<div className="rounded-lg border p-3 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={index === 0 ? "default" : "secondary"} className="text-[10px]">
{label}
</Badge>
{cert.ca_provider && (
<Badge variant="outline" className="text-[10px]">
{cert.ca_provider}
</Badge>
)}
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground text-xs">Subject:</span>{" "}
<span className="font-medium">{cert.subject}</span>
</p>
<p>
<span className="text-muted-foreground text-xs">Issuer:</span> {cert.issuer}
</p>
</div>
{cert.alt_names && cert.alt_names.length > 0 && (
<div>
<p className="text-[10px] text-muted-foreground mb-1">SANs ({cert.alt_names.length})</p>
<div className="flex flex-wrap gap-1">
{cert.alt_names.slice(0, 6).map((name, j) => (
<code key={j} className="text-[10px] bg-muted px-1.5 py-0.5 rounded">
{name}
</code>
))}
{cert.alt_names.length > 6 && (
<span className="text-[10px] text-muted-foreground">+{cert.alt_names.length - 6}</span>
)}
</div>
</div>
)}
<div className="text-[10px] text-muted-foreground pt-1 border-t">
{cert.valid_from} {cert.valid_to}
</div>
</div>
)
}
// --- Main exported sections ---
export function RegistrationSection({ domain }: { domain: Domain }) {
return (
<SectionCard title="Registration" description="Registrar and registrant details" icon={Building2} accent="blue">
<KVGrid>
<KV
label="Registrar"
value={domain.registrar_name || "Unknown"}
leading={<Building2 className="h-3.5 w-3.5 text-muted-foreground" />}
/>
<KV
label="Registrant"
value={
domain.privacy_enabled
? "Hidden (Privacy Protected)"
: domain.registrant_name || domain.registrant_org || "Unknown"
}
leading={
domain.privacy_enabled ? (
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<User className="h-3.5 w-3.5 text-muted-foreground" />
)
}
suffix={
domain.privacy_enabled !== undefined && (
<Badge variant={domain.privacy_enabled ? "default" : "outline"} className="text-[10px]">
{domain.privacy_enabled ? "Privacy On" : "Privacy Off"}
</Badge>
)
}
/>
<KV label="Created" value={formatDate(domain.creation_date) || "Unknown"} />
<KV
label="Expires"
value={formatDate(domain.expiry_date) || "Unknown"}
suffix={
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 ? (
<Badge
variant={
domain.days_until_expiry <= 7
? "destructive"
: domain.days_until_expiry <= 30
? "outline"
: "secondary"
}
className="text-[10px]"
>
{formatDays(domain.days_until_expiry)}
</Badge>
) : null
}
/>
{domain.registrar_id && <KV label="Registrar IANA ID" value={domain.registrar_id} />}
{domain.whois_server && <KV label="WHOIS Server" value={domain.whois_server} />}
</KVGrid>
{domain.whois_status && (
<div className="mt-3 pt-3 border-t">
<p className="text-[10px] uppercase tracking-wider text-foreground/60 mb-2">EPP Status Codes</p>
<div className="flex flex-wrap gap-1.5">
{domain.whois_status.split(", ").map((s, i) => (
<Badge key={i} variant="secondary" className="text-[10px]">
{s}
</Badge>
))}
</div>
</div>
)}
</SectionCard>
)
}
export function HostingSection({ domain }: { domain: Domain }) {
const location = [domain.host_city, domain.host_region, domain.host_country].filter(Boolean).join(", ") || null
const hasCoords = domain.host_lat !== undefined && domain.host_lon !== undefined
return (
<SectionCard title="Hosting & Email" description="Providers and IP geolocation" icon={Server} accent="green">
<KVGrid>
{domain.dns_provider && (
<KV
label="DNS"
value={domain.dns_provider}
leading={<Network className="h-3.5 w-3.5 text-muted-foreground" />}
/>
)}
{domain.hosting_provider && (
<KV
label="Hosting"
value={domain.hosting_provider}
leading={<Server className="h-3.5 w-3.5 text-muted-foreground" />}
/>
)}
{domain.email_provider && (
<KV
label="Email"
value={domain.email_provider}
leading={<Mail className="h-3.5 w-3.5 text-muted-foreground" />}
/>
)}
{domain.ca_provider && (
<KV
label="Certificate Authority"
value={domain.ca_provider}
leading={<Shield className="h-3.5 w-3.5 text-muted-foreground" />}
/>
)}
{location && (
<KV
label="Location"
value={location}
leading={
<span className="text-sm">
{domain.host_country_code ? (
<span title={domain.host_country_code}>
{String.fromCodePoint(
...domain.host_country_code
.toUpperCase()
.split("")
.map((c) => 127397 + c.charCodeAt(0))
)}
</span>
) : (
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
)}
</span>
}
/>
)}
</KVGrid>
{hasCoords && domain.host_lat && domain.host_lon && (
<MapEmbed lat={domain.host_lat} lon={domain.host_lon} title={`Map for ${domain.domain_name}`} />
)}
{/* IP Addresses */}
<div className="mt-3 pt-3 border-t">
<p className="text-[10px] uppercase tracking-wider text-foreground/60 mb-2">IP Addresses</p>
<div className="space-y-1">
{domain.ipv4_addresses?.map((ip) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] font-mono">
IPv4
</Badge>
<code className="text-sm font-mono">{ip}</code>
</div>
))}
{domain.ipv6_addresses?.map((ip) => (
<div key={ip} className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] font-mono">
IPv6
</Badge>
<code className="text-sm font-mono break-all">{ip}</code>
</div>
))}
{!domain.ipv4_addresses?.length && !domain.ipv6_addresses?.length && (
<p className="text-sm text-muted-foreground">No IP addresses found</p>
)}
</div>
</div>
</SectionCard>
)
}
export function DnsSection({ domain }: { domain: Domain }) {
const aRecords =
domain.dns_a_records?.map((v) => ({ value: v })) || domain.ipv4_addresses?.map((v) => ({ value: v })) || []
const aaaaRecords =
domain.dns_aaaa_records?.map((v) => ({ value: v })) || domain.ipv6_addresses?.map((v) => ({ value: v })) || []
const mxRecords =
domain.dns_mx_records?.map((v) => ({ value: v })) || domain.mx_records?.map((v) => ({ value: v })) || []
const nsRecords =
domain.dns_ns_records?.map((v) => ({ value: v })) || domain.name_servers?.map((v) => ({ value: v })) || []
const txtRecords =
domain.dns_txt_records?.map((v) => ({ value: v })) || domain.txt_records?.map((v) => ({ value: v })) || []
if (
!aRecords.length &&
!aaaaRecords.length &&
!mxRecords.length &&
!nsRecords.length &&
!txtRecords.length &&
!domain.cname_record &&
!domain.srv_records?.length
) {
return (
<SectionCard title="DNS Records" description="A, AAAA, MX, CNAME, TXT, NS" icon={Network} accent="orange">
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No DNS records available</p>
</div>
</SectionCard>
)
}
return (
<SectionCard title="DNS Records" description="A, AAAA, MX, CNAME, TXT, NS" icon={Network} accent="orange">
<div className="space-y-4">
<DnsGroup type="A" records={aRecords} />
<DnsGroup type="AAAA" records={aaaaRecords} />
{domain.cname_record && <DnsGroup type="CNAME" records={[{ value: domain.cname_record }]} />}
<DnsGroup type="MX" records={mxRecords} />
<DnsGroup type="TXT" records={txtRecords} />
<DnsGroup type="NS" records={nsRecords} />
{domain.srv_records && domain.srv_records.length > 0 && (
<DnsGroup type="SRV" records={domain.srv_records.map((v) => ({ value: v }))} />
)}
{domain.dnssec && (
<div className="flex items-center gap-2 pt-2 border-t">
<span className="text-xs text-muted-foreground">DNSSEC</span>
<Badge variant={domain.dnssec === "signed" ? "default" : "secondary"} className="text-[10px]">
{domain.dnssec}
</Badge>
</div>
)}
</div>
</SectionCard>
)
}
export function SslSection({ domain }: { domain: Domain }) {
if (!domain.ssl_valid_to) {
return (
<SectionCard title="SSL Certificates" description="Issuer and validity" icon={Shield} accent="purple">
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No SSL certificate information available</p>
</div>
</SectionCard>
)
}
return (
<SectionCard title="SSL Certificates" description="Issuer and validity" icon={Shield} accent="purple">
<div className="space-y-3">
<KVGrid>
<KV
label="Status"
value={domain.ssl_days_until && domain.ssl_days_until > 0 ? "Valid" : "Expired"}
leading={<StatusDot status={domain.ssl_days_until && domain.ssl_days_until > 0 ? "up" : "down"} />}
suffix={
domain.ssl_days_until !== undefined && (
<Badge
variant={
domain.ssl_days_until <= 7 ? "destructive" : domain.ssl_days_until <= 30 ? "outline" : "secondary"
}
className="text-[10px]"
>
{formatDays(domain.ssl_days_until)}
</Badge>
)
}
/>
<KV label="Subject" value={domain.ssl_subject || domain.domain_name} />
<KV label="Issuer" value={domain.ssl_issuer || "Unknown"} />
<KV label="Valid From" value={formatDate(domain.ssl_valid_from) || "Unknown"} />
<KV label="Valid To" value={formatDate(domain.ssl_valid_to) || "Unknown"} />
{domain.ssl_key_size && <KV label="Key Size" value={`${domain.ssl_key_size} bits`} />}
{domain.ssl_signature_algo && <KV label="Algorithm" value={domain.ssl_signature_algo} />}
</KVGrid>
{domain.certificates && domain.certificates.length > 0 && (
<div className="space-y-2 pt-2 border-t">
<p className="text-[10px] uppercase tracking-wider text-foreground/60">
Certificate Chain ({domain.certificates.length})
</p>
{domain.certificates.map((cert, i) => (
<CertificateCard key={i} cert={cert} index={i} total={domain.certificates?.length ?? 0} />
))}
</div>
)}
</div>
</SectionCard>
)
}
export function SeoSection({ domain }: { domain: Domain }) {
const seo = domain.seo_meta
if (!seo) {
return (
<SectionCard title="SEO & Social" description="Meta tags, previews, robots.txt" icon={Search} accent="slate">
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No SEO data available</p>
</div>
</SectionCard>
)
}
const metaTags = seo.general
const og = seo.openGraph
const twitter = seo.twitter
const robots = seo.robots
return (
<SectionCard title="SEO & Social" description="Meta tags, previews, robots.txt" icon={Search} accent="slate">
<div className="space-y-4">
{/* Meta Tags */}
{metaTags && (
<div>
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">Meta Tags</span>
{Object.values(metaTags).filter(Boolean).length > 0 && (
<Badge variant="secondary" className="text-[10px]">
{Object.values(metaTags).filter(Boolean).length}
</Badge>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{metaTags.title && <KV label="Title" value={metaTags.title} />}
{metaTags.description && <KV label="Description" value={metaTags.description} />}
{metaTags.canonical && <KV label="Canonical" value={metaTags.canonical} />}
{metaTags.robots && <KV label="Robots" value={metaTags.robots} />}
{metaTags.author && <KV label="Author" value={metaTags.author} />}
{metaTags.keywords && (
<div className="sm:col-span-2">
<KV
label="Keywords"
value={metaTags.keywords.substring(0, 120) + (metaTags.keywords.length > 120 ? "..." : "")}
/>
</div>
)}
</div>
</div>
)}
{/* Open Graph Preview */}
{og && (og.title || og.description) && (
<div className="pt-3 border-t">
<div className="flex items-center gap-2 mb-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">Open Graph</span>
</div>
<div className="rounded-lg border bg-background/40 p-3 space-y-1">
{og.images && og.images.length > 0 && (
<div className="mb-2">
<img
src={og.images[0]}
alt="OG preview"
className="max-h-32 rounded-md object-cover w-full"
loading="lazy"
onError={(e) => ((e.target as HTMLImageElement).style.display = "none")}
/>
</div>
)}
<p className="text-sm font-medium text-foreground/90">{og.title}</p>
<p className="text-xs text-muted-foreground line-clamp-2">{og.description}</p>
{og.url && (
<a
href={og.url}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-primary hover:underline truncate block"
>
{og.url}
</a>
)}
</div>
</div>
)}
{/* Twitter Card Preview */}
{twitter && (twitter.title || twitter.description) && (
<div className="pt-3 border-t">
<div className="flex items-center gap-2 mb-2">
<ExternalLink className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">Twitter/X Card</span>
{twitter.card && (
<Badge variant="outline" className="text-[10px]">
{twitter.card}
</Badge>
)}
</div>
<div className="rounded-lg border bg-background/40 p-3 space-y-1">
{twitter.image && (
<img
src={twitter.image}
alt="Twitter preview"
className="max-h-32 rounded-md object-cover w-full"
loading="lazy"
onError={(e) => ((e.target as HTMLImageElement).style.display = "none")}
/>
)}
<p className="text-sm font-medium text-foreground/90">{twitter.title}</p>
<p className="text-xs text-muted-foreground line-clamp-2">{twitter.description}</p>
</div>
</div>
)}
{/* robots.txt */}
{robots?.fetched && (
<div className="pt-3 border-t">
<div className="flex items-center gap-2 mb-2">
<Code2 className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">robots.txt</span>
</div>
{robots.sitemaps && robots.sitemaps.length > 0 && (
<div className="mb-2">
<p className="text-[10px] text-muted-foreground mb-1">Sitemaps</p>
<div className="flex flex-wrap gap-1">
{robots.sitemaps.map((s, i) => (
<a
key={i}
href={s}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-primary hover:underline truncate max-w-[300px]"
>
{s}
</a>
))}
</div>
</div>
)}
{robots.groups && robots.groups.length > 0 && (
<div className="space-y-2">
{robots.groups.map((group, i) => (
<div key={i} className="rounded-md bg-muted/50 p-2 text-xs space-y-1">
<p className="text-muted-foreground font-medium">User-agent: {group.userAgents.join(", ")}</p>
{group.rules.map((rule, j) => (
<div key={j} className="flex items-center gap-1.5">
{rule.type === "Allow" ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<AlertTriangle className="h-3 w-3 text-yellow-500" />
)}
<span className={rule.type === "Allow" ? "text-green-600" : "text-yellow-600"}>
{rule.type}: {rule.value}
</span>
</div>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
</SectionCard>
)
}
export function DomainTypeBadge({ type }: { type?: string }) {
if (!type) return null
const configs: Record<string, { color: string; icon: React.ElementType; label: string }> = {
expiry: { color: "bg-blue-500/10 text-blue-600 border-blue-500/20", icon: Clock, label: "Expiry Monitor" },
watchlist: { color: "bg-purple-500/10 text-purple-600 border-purple-500/20", icon: Eye, label: "Watchlist" },
portfolio: { color: "bg-green-500/10 text-green-600 border-green-500/20", icon: Globe, label: "Portfolio" },
}
const config = configs[type] || configs.expiry
const Icon = config.icon
return (
<Badge variant="outline" className={cn("gap-1 text-[10px]", config.color)}>
<Icon className="h-3 w-3" />
{config.label}
</Badge>
)
}
export function ValuationSection({ domain }: { domain: Domain }) {
const hasData = (domain.purchase_price ?? 0) > 0 || (domain.current_value ?? 0) > 0 || (domain.renewal_cost ?? 0) > 0
if (!hasData) return null
return (
<SectionCard
title="Valuation & Costs"
description="Financial information and renewal settings"
icon={FileText}
accent="yellow"
>
<KVGrid>
{(domain.purchase_price ?? 0) > 0 && <KV label="Purchase Price" value={`$${domain.purchase_price}`} />}
{(domain.current_value ?? 0) > 0 && <KV label="Current Value" value={`$${domain.current_value}`} />}
{(domain.renewal_cost ?? 0) > 0 && <KV label="Renewal Cost" value={`$${domain.renewal_cost}`} />}
<KV
label="Auto-renew"
value={domain.auto_renew ? "Enabled" : "Disabled"}
leading={
domain.auto_renew ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
) : (
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
)
}
/>
</KVGrid>
</SectionCard>
)
}
export function DomainExpiryOverview({ domain }: { domain: Domain }) {
return (
<div className="grid sm:grid-cols-2 gap-4">
{/* Domain Expiry */}
<Card
className={cn(
"overflow-hidden",
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 7
? "border-red-500/30"
: domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? "border-yellow-500/30"
: ""
)}
>
<div
className={cn(
"h-1",
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 7
? "bg-red-500"
: domain.days_until_expiry !== undefined &&
domain.days_until_expiry >= 0 &&
domain.days_until_expiry <= 30
? "bg-yellow-500"
: "bg-green-500"
)}
/>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div
className={cn(
"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={cn(
"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={cn(
"text-xl 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 data"
: "N/A"}
</div>
</div>
</CardContent>
</Card>
{/* SSL Expiry */}
<Card
className={cn(
"overflow-hidden",
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
? "border-red-500/30"
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
? "border-yellow-500/30"
: ""
)}
>
<div
className={cn(
"h-1",
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 7
? "bg-red-500"
: domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 30
? "bg-yellow-500"
: "bg-green-500"
)}
/>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div
className={cn(
"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={cn(
"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>
<div
className={cn(
"text-xl 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>
</CardContent>
</Card>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,403 @@
import { useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Globe, Shield, Server, MapPin, FileText, Info, AlertTriangle } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/domains"
import type { Monitor, Heartbeat } from "@/lib/monitors"
// --- Styled components inspired by domainstack.io ---
function InfoSection({
title,
icon: Icon,
children,
accent = "slate",
}: {
title: string
icon: React.ElementType
children: React.ReactNode
accent?: "blue" | "green" | "orange" | "purple" | "slate"
}) {
const accentColors = {
blue: "border-blue-500/10 bg-blue-500/5",
green: "border-green-500/10 bg-green-500/5",
orange: "border-orange-500/10 bg-orange-500/5",
purple: "border-purple-500/10 bg-purple-500/5",
slate: "border-border bg-background/60",
}
return (
<Card className={cn("relative overflow-hidden rounded-xl border", accentColors[accent])}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-base">{title}</CardTitle>
</div>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
function KV({
label,
value,
suffix,
leading,
}: {
label: string
value: string
suffix?: React.ReactNode
leading?: React.ReactNode
}) {
return (
<div className="flex h-14 min-w-0 items-center justify-between gap-3 rounded-lg border bg-background/60 px-3 py-2">
<div className="flex min-w-0 flex-col">
<div className="text-[10px] leading-none tracking-wider uppercase text-foreground/70">{label}</div>
<div className="inline-flex min-w-0 items-center gap-1.5 text-[13px] text-foreground/95 mt-1">
{leading ? <span className="shrink-0">{leading}</span> : null}
<span className="truncate">{value}</span>
{suffix ? <span className="shrink-0">{suffix}</span> : null}
</div>
</div>
</div>
)
}
function KVGrid({ children, cols = 2 }: { children: React.ReactNode; cols?: 1 | 2 | 3 }) {
const colClass = cols === 3 ? "sm:grid-cols-3" : cols === 1 ? "grid-cols-1" : "sm:grid-cols-2"
return <div className={cn("grid grid-cols-1 gap-2", colClass)}>{children}</div>
}
// --- Data fetching hooks ---
interface DnsRecord {
type: string
value: string
ttl?: number
}
interface DomainInfo {
hostname: string | null
rootDomain: string | null
dnsRecords: DnsRecord[]
geo: { city?: string; region?: string; country?: string; lat?: number; lon?: number } | null
ssl: { valid: boolean; expiry?: string; daysLeft?: number } | null
seo: {
title?: string
description?: string
canonical?: string
robots?: string
generator?: string
} | null
loading: boolean
}
function useDomainInfo(monitor: Monitor | undefined, heartbeats: Heartbeat[] | undefined) {
const [info, setInfo] = useState<DomainInfo>({
hostname: null,
rootDomain: null,
dnsRecords: [],
geo: null,
ssl: null,
seo: null,
loading: true,
})
const hostname = useMemo(() => {
if (!monitor) return null
if (monitor.hostname) return monitor.hostname.toLowerCase()
if (monitor.url) {
try {
const url = new URL(monitor.url.startsWith("http") ? monitor.url : `https://${monitor.url}`)
return url.hostname.toLowerCase()
} catch {
return monitor.url.toLowerCase()
}
}
return null
}, [monitor])
const rootDomain = useMemo(() => {
if (!hostname) return null
const clean = hostname.replace(/^www\./, "")
const parts = clean.split(".")
if (parts.length <= 2) return clean
const specialTLDs = ["co.uk", "com.au", "co.jp", "com.br", "co.nz", "co.za", "co.in", "com.cn"]
const lastTwo = parts.slice(-2).join(".")
const lastThree = parts.slice(-3).join(".")
if (specialTLDs.includes(lastThree)) return lastThree
return lastTwo
}, [hostname])
// SSL from latest heartbeat
useEffect(() => {
if (!heartbeats?.length) {
setInfo((prev) => ({ ...prev, ssl: null }))
return
}
const latest = heartbeats[0]
if (latest.cert_expiry) {
const expiry = new Date(latest.cert_expiry * 1000)
const daysLeft = Math.ceil((expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
setInfo((prev) => ({
...prev,
ssl: { valid: latest.cert_valid ?? true, expiry: expiry.toISOString(), daysLeft },
}))
}
}, [heartbeats])
// Fetch DNS, geo, SEO
useEffect(() => {
if (!hostname) {
setInfo((prev) => ({ ...prev, loading: false }))
return
}
let cancelled = false
async function fetchData() {
// DNS via Cloudflare DoH
const dnsPromise = fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`, {
headers: { Accept: "application/dns-json" },
})
.then((r) => r.json())
.then((data) => {
const records: DnsRecord[] = []
if (data.Answer) {
for (const ans of data.Answer) {
records.push({ type: "A", value: ans.data, ttl: ans.TTL })
}
}
return records
})
.catch(() => [] as DnsRecord[])
// Geolocation via ipapi.co (free, no key needed for basic)
const geoPromise = fetch(`https://ipapi.co/${encodeURIComponent(hostname)}/json/`, {
headers: { Accept: "application/json" },
})
.then((r) => {
if (!r.ok) throw new Error("geo failed")
return r.json()
})
.then((data) => ({
city: data.city,
region: data.region,
country: data.country_name,
lat: data.latitude,
lon: data.longitude,
}))
.catch(() => null)
// SEO meta tags
const seoPromise = monitor?.url
? fetch(monitor.url, { method: "GET", mode: "cors" })
.then((r) => r.text())
.then((html) => {
const parser = new DOMParser()
const doc = parser.parseFromString(html, "text/html")
const getMeta = (name: string) =>
doc.querySelector(`meta[name="${name}"]`)?.getAttribute("content") ||
doc.querySelector(`meta[property="og:${name}"]`)?.getAttribute("content") ||
undefined
return {
title: doc.querySelector("title")?.textContent || undefined,
description: getMeta("description"),
canonical: doc.querySelector('link[rel="canonical"]')?.getAttribute("href") || undefined,
robots: getMeta("robots"),
generator: getMeta("generator"),
}
})
.catch(() => null)
: Promise.resolve(null)
const [dnsRecords, geo, seo] = await Promise.all([dnsPromise, geoPromise, seoPromise])
if (!cancelled) {
setInfo((prev) => ({
...prev,
hostname,
rootDomain,
dnsRecords,
geo,
seo,
loading: false,
}))
}
}
fetchData()
return () => {
cancelled = true
}
}, [hostname, monitor?.url, rootDomain])
return { ...info, hostname, rootDomain }
}
// --- Map component ---
function MapEmbed({ lat, lon, hostname }: { lat: number; lon: number; hostname?: string | null }) {
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.5}%2C${lat - 0.5}%2C${lon + 0.5}%2C${lat + 0.5}&layer=mapnik&marker=${lat}%2C${lon}`
return (
<div className="relative h-[220px] w-full overflow-hidden rounded-lg border">
<iframe
title={`Map for ${hostname || "location"}`}
src={mapUrl}
className="h-full w-full border-0"
loading="lazy"
/>
<a
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=12/${lat}/${lon}`}
target="_blank"
rel="noopener noreferrer"
className="absolute bottom-2 right-2 rounded bg-white/90 px-2 py-1 text-[10px] font-medium text-foreground shadow hover:bg-white"
>
View Larger Map
</a>
</div>
)
}
// --- Main composite component ---
export function MonitorInfoSections({
monitor,
heartbeats,
}: {
monitor: Monitor | undefined
heartbeats: Heartbeat[] | undefined
}) {
const { hostname, rootDomain, dnsRecords, geo, ssl, seo, loading } = useDomainInfo(monitor, heartbeats)
if (!monitor) return null
const showSeo = seo && (seo.title || seo.description)
return (
<div className="grid gap-4">
{/* Registration / Domain Overview */}
<InfoSection title="Domain Overview" icon={Globe} accent="blue">
<KVGrid>
<KV
label="Hostname"
value={hostname || "N/A"}
leading={<Globe className="h-3.5 w-3.5 text-muted-foreground" />}
/>
<KV
label="Root Domain"
value={rootDomain || "N/A"}
leading={<Server className="h-3.5 w-3.5 text-muted-foreground" />}
/>
<KV label="Type" value={monitor.type.toUpperCase()} />
<KV label="Created" value={formatDate(monitor.created)} />
</KVGrid>
</InfoSection>
<div className="grid sm:grid-cols-2 gap-4">
{/* Hosting & Geolocation */}
<InfoSection title="Hosting & Location" icon={MapPin} accent="green">
{geo ? (
<div className="space-y-3">
<KVGrid cols={1}>
<KV
label="Location"
value={[geo.city, geo.region, geo.country].filter(Boolean).join(", ") || "Unknown"}
leading={<MapPin className="h-3.5 w-3.5 text-muted-foreground" />}
/>
</KVGrid>
{geo.lat && geo.lon ? <MapEmbed lat={geo.lat} lon={geo.lon} hostname={hostname} /> : null}
</div>
) : loading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
Looking up location...
</div>
) : (
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-sm text-muted-foreground">No geolocation data available.</div>
</div>
)}
</InfoSection>
{/* SSL Certificate */}
<InfoSection title="SSL Certificate" icon={Shield} accent="purple">
{ssl ? (
<div className="space-y-2">
<KVGrid cols={1}>
<KV
label="Status"
value={ssl.valid ? "Valid" : "Invalid"}
leading={
<div className={cn("h-2.5 w-2.5 rounded-full", ssl.valid ? "bg-green-500" : "bg-red-500")} />
}
/>
<KV label="Expires" value={ssl.expiry ? formatDate(ssl.expiry) : "Unknown"} />
{ssl.daysLeft !== undefined && (
<KV
label="Days Left"
value={`${ssl.daysLeft} days`}
suffix={
ssl.daysLeft <= 7 ? (
<Badge variant="destructive" className="text-[10px]">
Expiring Soon
</Badge>
) : ssl.daysLeft <= 30 ? (
<Badge variant="outline" className="text-[10px] border-yellow-500/50 text-yellow-600">
Warning
</Badge>
) : null
}
/>
)}
</KVGrid>
</div>
) : monitor.type === "https" || monitor.url?.startsWith("https") ? (
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-sm text-muted-foreground">No SSL data yet. It will appear after the next check.</div>
</div>
) : (
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-sm text-muted-foreground">SSL not applicable for this monitor type.</div>
</div>
)}
</InfoSection>
</div>
{/* DNS Records */}
{dnsRecords.length > 0 && (
<InfoSection title="DNS Records" icon={Server} accent="orange">
<div className="space-y-2">
{dnsRecords.map((rec, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border bg-background/60 px-3 py-2">
<Badge variant="outline" className="shrink-0 font-mono text-[10px]">
{rec.type}
</Badge>
<span className="text-[13px] text-foreground/90 truncate">{rec.value}</span>
{rec.ttl && <span className="ml-auto text-[10px] text-muted-foreground">TTL {rec.ttl}</span>}
</div>
))}
</div>
</InfoSection>
)}
{/* SEO & Meta */}
{showSeo && (
<InfoSection title="SEO & Social" icon={FileText} accent="slate">
<KVGrid>
{seo?.title && <KV label="Title" value={seo.title} />}
{seo?.description && <KV label="Description" value={seo.description} />}
{seo?.canonical && <KV label="Canonical" value={seo.canonical} />}
{seo?.robots && <KV label="Robots" value={seo.robots} />}
{seo?.generator && <KV label="Generator" value={seo.generator} />}
</KVGrid>
</InfoSection>
)}
</div>
)
}
+228 -205
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -35,6 +36,9 @@ import {
Zap, Zap,
Gauge, Gauge,
Smartphone, Smartphone,
Lock,
Eye,
LayoutDashboard,
type LucideIcon, type LucideIcon,
} from "lucide-react" } from "lucide-react"
import { import {
@@ -62,21 +66,11 @@ import {
getStatusPageUrl, getStatusPageUrl,
removeMonitorFromStatusPage, removeMonitorFromStatusPage,
} from "@/lib/statuspages" } from "@/lib/statuspages"
import { import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, LineChart } from "recharts"
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Area,
Cell,
ComposedChart,
Legend,
} from "recharts"
import { Link, navigate } from "@/components/router" import { Link, navigate } from "@/components/router"
import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog" import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { MonitorInfoSections } from "./monitor-info-sections"
type HeartbeatRow = Heartbeat type HeartbeatRow = Heartbeat
@@ -105,9 +99,13 @@ function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] })
key={i} key={i}
className={cn( className={cn(
"flex-1 rounded-sm transition-all hover:opacity-80 cursor-pointer", "flex-1 rounded-sm transition-all hover:opacity-80 cursor-pointer",
hb.status === "up" ? "bg-green-500" : hb.status === "up"
hb.status === "down" ? "bg-red-500" : ? "bg-green-500"
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500" : hb.status === "down"
? "bg-red-500"
: hb.status === "paused"
? "bg-gray-400"
: "bg-yellow-500"
)} )}
title={`${hb.status} • ${formatPing(hb.ping)} • ${formatDate(hb.time || "")}`} title={`${hb.status} • ${formatPing(hb.ping)} • ${formatDate(hb.time || "")}`}
/> />
@@ -118,11 +116,11 @@ function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] })
<span> <span>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-green-500" /> <span className="w-2 h-2 rounded-full bg-green-500" />
{recent.filter(h => h.status === "up").length} up {recent.filter((h) => h.status === "up").length} up
</span> </span>
<span className="inline-flex items-center gap-1 ml-3"> <span className="inline-flex items-center gap-1 ml-3">
<span className="w-2 h-2 rounded-full bg-red-500" /> <span className="w-2 h-2 rounded-full bg-red-500" />
{recent.filter(h => h.status === "down").length} down {recent.filter((h) => h.status === "down").length} down
</span> </span>
</span> </span>
</div> </div>
@@ -134,7 +132,7 @@ function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] })
function ResponseTimeStats({ heartbeats }: { heartbeats?: HeartbeatRow[] }) { function ResponseTimeStats({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
const stats = useMemo(() => { const stats = useMemo(() => {
if (!heartbeats?.length) return null if (!heartbeats?.length) return null
const pings = heartbeats.filter(h => h.ping && h.ping > 0).map(h => h.ping) const pings = heartbeats.filter((h) => h.ping && h.ping > 0).map((h) => h.ping)
if (!pings.length) return null if (!pings.length) return null
const sorted = [...pings].sort((a, b) => a - b) const sorted = [...pings].sort((a, b) => a - b)
@@ -177,17 +175,23 @@ function ResponseTimeStats({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
function getVitalColor(status: string): string { function getVitalColor(status: string): string {
switch (status) { switch (status) {
case "good": return "text-green-500" case "good":
case "needs-improvement": return "text-yellow-500" return "text-green-500"
default: return "text-red-500" case "needs-improvement":
return "text-yellow-500"
default:
return "text-red-500"
} }
} }
function getVitalBg(status: string): string { function getVitalBg(status: string): string {
switch (status) { switch (status) {
case "good": return "bg-green-500/10 border-green-500/20" case "good":
case "needs-improvement": return "bg-yellow-500/10 border-yellow-500/20" return "bg-green-500/10 border-green-500/20"
default: return "bg-red-500/10 border-red-500/20" case "needs-improvement":
return "bg-yellow-500/10 border-yellow-500/20"
default:
return "bg-red-500/10 border-red-500/20"
} }
} }
@@ -199,10 +203,27 @@ function ScoreRing({ score, label }: { score: number; label: string }) {
return ( return (
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<div className="relative w-12 h-12"> <div className="relative w-12 h-12">
<svg className="w-12 h-12 -rotate-90" viewBox="0 0 44 44"> <svg
className="w-12 h-12 -rotate-90"
viewBox="0 0 44 44"
role="img"
aria-label={`Score ${Math.round(score)} for ${label}`}
>
<title>
Score {Math.round(score)} for {label}
</title>
<circle cx="22" cy="22" r="18" fill="none" stroke="currentColor" strokeWidth="4" className="text-muted/30" /> <circle cx="22" cy="22" r="18" fill="none" stroke="currentColor" strokeWidth="4" className="text-muted/30" />
<circle cx="22" cy="22" r="18" fill="none" strokeWidth="4" strokeLinecap="round" <circle
className={bg} strokeDasharray={circumference} strokeDashoffset={offset} /> cx="22"
cy="22"
r="18"
fill="none"
strokeWidth="4"
strokeLinecap="round"
className={bg}
strokeDasharray={circumference}
strokeDashoffset={offset}
/>
</svg> </svg>
<span className={cn("absolute inset-0 flex items-center justify-center text-xs font-bold", color)}> <span className={cn("absolute inset-0 flex items-center justify-center text-xs font-bold", color)}>
{Math.round(score)} {Math.round(score)}
@@ -218,7 +239,12 @@ function VitalCard({ label, value, status, detail }: { label: string; value: str
<div className={cn("p-3 rounded-lg border", getVitalBg(status))}> <div className={cn("p-3 rounded-lg border", getVitalBg(status))}>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground">{label}</span> <span className="text-xs text-muted-foreground">{label}</span>
<div className={cn("w-2 h-2 rounded-full", status === "good" ? "bg-green-500" : status === "needs-improvement" ? "bg-yellow-500" : "bg-red-500")} /> <div
className={cn(
"w-2 h-2 rounded-full",
status === "good" ? "bg-green-500" : status === "needs-improvement" ? "bg-yellow-500" : "bg-red-500"
)}
/>
</div> </div>
<div className={cn("text-lg font-bold", getVitalColor(status))}>{value}</div> <div className={cn("text-lg font-bold", getVitalColor(status))}>{value}</div>
<div className="text-[10px] text-muted-foreground">{detail}</div> <div className="text-[10px] text-muted-foreground">{detail}</div>
@@ -232,11 +258,14 @@ function formatMs(ms: number): string {
} }
function CoreWebVitalsCard({ monitorId, url }: { monitorId: string; url?: string }) { function CoreWebVitalsCard({ monitorId, url }: { monitorId: string; url?: string }) {
if (!url) return null
const [strategy, setStrategy] = useState<"mobile" | "desktop">("mobile") const [strategy, setStrategy] = useState<"mobile" | "desktop">("mobile")
const { toast } = useToast() const { toast } = useToast()
const { data, isPending: isPageSpeedLoading, mutate } = useMutation({ const {
data,
isPending: isPageSpeedLoading,
mutate,
} = useMutation({
mutationFn: () => runPageSpeedCheck(monitorId, strategy), mutationFn: () => runPageSpeedCheck(monitorId, strategy),
onSuccess: () => { onSuccess: () => {
toast({ title: "Lighthouse check complete" }) toast({ title: "Lighthouse check complete" })
@@ -246,6 +275,8 @@ function CoreWebVitalsCard({ monitorId, url }: { monitorId: string; url?: string
}, },
}) })
if (!url) return null
return ( return (
<Card> <Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
@@ -255,24 +286,32 @@ function CoreWebVitalsCard({ monitorId, url }: { monitorId: string; url?: string
Core Web Vitals Core Web Vitals
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{data ? `Checked ${new Date(data.checkedAt).toLocaleString()}` : "Run a Lighthouse check to get performance metrics"} {data
? `Checked ${new Date(data.checkedAt).toLocaleString()}`
: "Run a Lighthouse check to get performance metrics"}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex rounded-lg border overflow-hidden"> <div className="flex rounded-lg border overflow-hidden">
<button <button
onClick={() => setStrategy("mobile")} onClick={() => setStrategy("mobile")}
className={cn("px-3 py-1.5 text-xs font-medium transition-colors", className={cn(
strategy === "mobile" ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80")} "px-3 py-1.5 text-xs font-medium transition-colors",
strategy === "mobile" ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80"
)}
> >
<Smartphone className="h-3 w-3 inline mr-1" />Mobile <Smartphone className="h-3 w-3 inline mr-1" />
Mobile
</button> </button>
<button <button
onClick={() => setStrategy("desktop")} onClick={() => setStrategy("desktop")}
className={cn("px-3 py-1.5 text-xs font-medium transition-colors", className={cn(
strategy === "desktop" ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80")} "px-3 py-1.5 text-xs font-medium transition-colors",
strategy === "desktop" ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80"
)}
> >
<Gauge className="h-3 w-3 inline mr-1" />Desktop <Gauge className="h-3 w-3 inline mr-1" />
Desktop
</button> </button>
</div> </div>
<Button size="sm" onClick={() => mutate()} disabled={isPageSpeedLoading}> <Button size="sm" onClick={() => mutate()} disabled={isPageSpeedLoading}>
@@ -294,12 +333,42 @@ function CoreWebVitalsCard({ monitorId, url }: { monitorId: string; url?: string
{/* Core Web Vitals */} {/* Core Web Vitals */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<VitalCard label="LCP" value={formatMs(data.lcp)} status={data.vitals.lcp || "poor"} detail="Largest Contentful Paint" /> <VitalCard
<VitalCard label="FID" value={formatMs(data.tbt)} status={data.vitals.fid || "poor"} detail="Total Blocking Time (proxy)" /> label="LCP"
<VitalCard label="CLS" value={data.cls.toFixed(3)} status={data.vitals.cls || "poor"} detail="Cumulative Layout Shift" /> value={formatMs(data.lcp)}
<VitalCard label="FCP" value={formatMs(data.fcp)} status={data.vitals.fcp || "poor"} detail="First Contentful Paint" /> status={data.vitals.lcp || "poor"}
<VitalCard label="TTFB" value={formatMs(data.ttfb)} status={data.vitals.ttfb || "poor"} detail="Time to First Byte" /> detail="Largest Contentful Paint"
<VitalCard label="TTI" value={formatMs(data.tti)} status={data.vitals.tti || "poor"} detail="Time to Interactive" /> />
<VitalCard
label="FID"
value={formatMs(data.tbt)}
status={data.vitals.fid || "poor"}
detail="Total Blocking Time (proxy)"
/>
<VitalCard
label="CLS"
value={data.cls.toFixed(3)}
status={data.vitals.cls || "poor"}
detail="Cumulative Layout Shift"
/>
<VitalCard
label="FCP"
value={formatMs(data.fcp)}
status={data.vitals.fcp || "poor"}
detail="First Contentful Paint"
/>
<VitalCard
label="TTFB"
value={formatMs(data.ttfb)}
status={data.vitals.ttfb || "poor"}
detail="Time to First Byte"
/>
<VitalCard
label="TTI"
value={formatMs(data.tti)}
status={data.vitals.tti || "poor"}
detail="Time to Interactive"
/>
</div> </div>
</div> </div>
) : ( ) : (
@@ -377,6 +446,15 @@ function StatCard({
) )
} }
function MonitorFaviconImage({ monitor, iconColor }: { monitor: Monitor; iconColor: string }) {
const [error, setError] = useState(false)
const faviconUrl = getMonitorFaviconUrl(monitor)
if (!faviconUrl || error) {
return <Globe className={cn("h-6 w-6", iconColor)} />
}
return <img src={faviconUrl} alt="" className="h-6 w-6 object-contain" onError={() => setError(true)} />
}
export default memo(function MonitorDetail({ id }: { id: string }) { export default memo(function MonitorDetail({ id }: { id: string }) {
const { toast } = useToast() const { toast } = useToast()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -438,6 +516,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
const [isCreateStatusPageOpen, setIsCreateStatusPageOpen] = useState(false) const [isCreateStatusPageOpen, setIsCreateStatusPageOpen] = useState(false)
const [statusPageName, setStatusPageName] = useState("") const [statusPageName, setStatusPageName] = useState("")
const [statusPageSlug, setStatusPageSlug] = useState("") const [statusPageSlug, setStatusPageSlug] = useState("")
const [statusPagePublic, setStatusPagePublic] = useState(true)
const { data: statusPages } = useQuery({ const { data: statusPages } = useQuery({
queryKey: ["status-pages"], queryKey: ["status-pages"],
@@ -474,19 +553,29 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
}) })
const createStatusPageMutation = useMutation({ const createStatusPageMutation = useMutation({
mutationFn: () => mutationFn: async () => {
createStatusPage({ const page = await createStatusPage({
name: statusPageName || `${monitor?.name} Status`, name: statusPageName || `${monitor?.name} Status`,
slug: statusPageSlug || monitor?.name?.toLowerCase().replace(/\s+/g, "-") || "status", slug: statusPageSlug || monitor?.name?.toLowerCase().replace(/\s+/g, "-") || "status",
title: statusPageName || `${monitor?.name} Status Page`, title: statusPageName || `${monitor?.name} Status Page`,
public: true, public: statusPagePublic,
}), })
onSuccess: () => { // Auto-link this monitor to the newly created status page
await addMonitorToStatusPage(page.id, { monitor: id })
return page
},
onSuccess: (page) => {
queryClient.invalidateQueries({ queryKey: ["status-pages"] }) queryClient.invalidateQueries({ queryKey: ["status-pages"] })
toast({ title: "Status page created" }) queryClient.invalidateQueries({ queryKey: ["monitor-status-page-links", id] })
toast({ title: "Status page created and monitor linked" })
setIsCreateStatusPageOpen(false) setIsCreateStatusPageOpen(false)
setStatusPageName("") setStatusPageName("")
setStatusPageSlug("") setStatusPageSlug("")
setStatusPagePublic(true)
// Open private pages in new tab since user is authenticated
if (!page.public) {
window.open(getStatusPageUrl(page.slug), "_blank", "noopener,noreferrer")
}
}, },
}) })
@@ -561,18 +650,20 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
const isPaused = monitor.status === "paused" const isPaused = monitor.status === "paused"
const isPending = monitor.status === "pending" const isPending = monitor.status === "pending"
const headerIconColor = isUp ? "text-green-500" : isPaused ? "text-gray-500" : isPending ? "text-yellow-500" : "text-red-500" const headerIconColor = isUp
const headerBgColor = isUp ? "bg-green-500/10" : isPaused ? "bg-gray-500/10" : isPending ? "bg-yellow-500/10" : "bg-red-500/10" ? "text-green-500"
: isPaused
// Favicon component ? "text-gray-500"
function MonitorFaviconImage({ monitor }: { monitor: Monitor }) { : isPending
const [error, setError] = useState(false) ? "text-yellow-500"
const faviconUrl = getMonitorFaviconUrl(monitor) : "text-red-500"
if (!faviconUrl || error) { const headerBgColor = isUp
return <Globe className={cn("h-6 w-6", headerIconColor)} /> ? "bg-green-500/10"
} : isPaused
return <img src={faviconUrl} alt="" className="h-6 w-6 object-contain" onError={() => setError(true)} /> ? "bg-gray-500/10"
} : isPending
? "bg-yellow-500/10"
: "bg-red-500/10"
return ( return (
<div className="grid gap-4 mb-14"> <div className="grid gap-4 mb-14">
@@ -581,13 +672,8 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div <div className={cn("h-12 w-12 rounded-full flex items-center justify-center", headerBgColor)}>
className={cn( <MonitorFaviconImage monitor={monitor} iconColor={headerIconColor} />
"h-12 w-12 rounded-full flex items-center justify-center",
headerBgColor
)}
>
<MonitorFaviconImage monitor={monitor} />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold">{monitor.name}</h1> <h1 className="text-2xl font-bold">{monitor.name}</h1>
@@ -697,13 +783,16 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
{/* Core Web Vitals */} {/* Core Web Vitals */}
<CoreWebVitalsCard monitorId={id} url={monitor.url} /> <CoreWebVitalsCard monitorId={id} url={monitor.url} />
{/* Combined Uptime & Response Chart */} {/* Domain Info Sections */}
<MonitorInfoSections monitor={monitor} heartbeats={heartbeats} />
{/* Response Time Chart */}
<Card> <Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<CardTitle>Uptime & Response Time</CardTitle> <CardTitle>Response Time</CardTitle>
<CardDescription> <CardDescription>
<Trans>Status and response time over the selected period</Trans> <Trans>Response time over the selected period</Trans>
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -723,45 +812,36 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<div className="h-[300px]"> <div className="h-[300px]">
{chartData.length > 0 ? ( {chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData}> <LineChart data={chartData}>
<defs> <defs>
<linearGradient id="colorResponse" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="colorResponse" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} /> <stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} /> <stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} /> <CartesianGrid vertical={false} strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 12 }} /> <XAxis dataKey="time" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
<YAxis yAxisId="left" tick={{ fontSize: 12 }} unit="ms" /> <YAxis tick={{ fontSize: 11 }} tickLine={false} axisLine={false} unit="ms" />
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
domain={[0, 1]}
tickFormatter={(v) => (v === 1 ? "Up" : "Down")}
/>
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
backgroundColor: "hsl(var(--card))", backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))", border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}} }}
formatter={(value: number) => [`${value}ms`, "Response Time"]}
/> />
<Legend /> <Line
<Area
yAxisId="left"
type="monotone" type="monotone"
dataKey="responseTime" dataKey="responseTime"
stroke="#3b82f6" stroke="hsl(var(--chart-1))"
strokeWidth={1.5}
fillOpacity={1} fillOpacity={1}
fill="url(#colorResponse)" fill="url(#colorResponse)"
name="Response Time (ms)" name="Response Time"
isAnimationActive={false}
dot={false}
/> />
<Bar yAxisId="right" dataKey="status" barSize={4} name="Status"> </LineChart>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.status === 1 ? "#22c55e" : "#ef4444"} />
))}
</Bar>
</ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground"> <div className="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
@@ -783,7 +863,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} /> <RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
Run First Check Run First Check
</Button> </Button>
)} )}
</div> </div>
)} )}
</div> </div>
@@ -823,12 +903,20 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Status Page</CardTitle> <div className="flex items-center justify-between">
<CardDescription>Link this monitor to public status pages</CardDescription> <div>
<CardTitle>Status Page</CardTitle>
<CardDescription>Create or link to status pages</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => setIsCreateStatusPageOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create New
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{statusPages && statusPages.length > 0 ? ( {statusPages && statusPages.length > 0 ? (
<div className="space-y-3"> <div className="space-y-2">
{statusPages.map((page) => { {statusPages.map((page) => {
const isLinked = linkedStatusPageMonitors?.some((link) => link.status_page_id === page.id) || false const isLinked = linkedStatusPageMonitors?.some((link) => link.status_page_id === page.id) || false
const linkInfo = linkedStatusPageMonitors?.find((link) => link.status_page_id === page.id) const linkInfo = linkedStatusPageMonitors?.find((link) => link.status_page_id === page.id)
@@ -836,15 +924,25 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
return ( return (
<div <div
key={page.id} key={page.id}
className={`flex items-center justify-between p-3 rounded-lg border ${ className={cn(
isLinked ? 'bg-primary/5 border-primary/20' : 'bg-muted/30' "flex items-center justify-between p-3 rounded-lg border transition-colors",
}`} isLinked ? "bg-primary/5 border-primary/20" : "bg-muted/30 border-border"
)}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LayoutDashboard className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-sm truncate">{page.name}</span> <span className="font-medium text-sm truncate">{page.name}</span>
{page.public && ( {page.public ? (
<Globe className="h-3 w-3 text-muted-foreground flex-shrink-0" /> <Badge variant="outline" className="text-[10px] gap-1">
<Eye className="h-2.5 w-2.5" />
Public
</Badge>
) : (
<Badge variant="outline" className="text-[10px] gap-1">
<Lock className="h-2.5 w-2.5" />
Private
</Badge>
)} )}
</div> </div>
{isLinked && linkInfo && ( {isLinked && linkInfo && (
@@ -853,30 +951,24 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
{linkInfo.group && ` • Group: ${linkInfo.group}`} {linkInfo.group && ` • Group: ${linkInfo.group}`}
</p> </p>
)} )}
{!isLinked && page.public && ( {!isLinked && (
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{page.monitor_count} monitor{page.monitor_count !== 1 ? 's' : ''} linked {page.monitor_count} monitor{page.monitor_count !== 1 ? "s" : ""} linked
</p> </p>
)} )}
</div> </div>
<div className="flex items-center gap-2 ml-2"> <div className="flex items-center gap-2 ml-2">
{isLinked && page.public && ( <Button
<Button variant="ghost"
variant="ghost" size="icon"
size="icon" className="h-8 w-8"
className="h-8 w-8" asChild
asChild title={page.public ? "View public status page" : "View private status page"}
> >
<a <a href={getStatusPageUrl(page.slug)} target="_blank" rel="noopener noreferrer">
href={getStatusPageUrl(page.slug)} <ExternalLink className="h-4 w-4" />
target="_blank" </a>
rel="noopener noreferrer" </Button>
title="View public status page"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
<Button <Button
variant={isLinked ? "default" : "outline"} variant={isLinked ? "default" : "outline"}
size="sm" size="sm"
@@ -905,100 +997,22 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
) : ( ) : (
<div className="text-center py-4"> <div className="text-center py-4">
<p className="text-sm text-muted-foreground">No status pages yet.</p> <p className="text-sm text-muted-foreground">No status pages yet.</p>
<p className="text-xs text-muted-foreground mt-1">Create one to share your service status publicly.</p> <p className="text-xs text-muted-foreground mt-1">
Create one to share your service status or keep it private for internal use.
</p>
</div> </div>
)} )}
<Button variant="outline" size="sm" className="w-full" onClick={() => setIsCreateStatusPageOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Status Page
</Button>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Card>
<CardHeader>
<CardTitle>Check History</CardTitle>
<CardDescription>Timeline of the last 50 monitor checks</CardDescription>
</CardHeader>
<CardContent>
{heartbeats?.length ? (
<div className="space-y-1">
{heartbeats.slice(0, 50).map((hb: Heartbeat, i: number) => {
const date = new Date(hb.time || "")
const showDate = i === 0 || (
new Date(heartbeats[i - 1].time || "").toDateString() !== date.toDateString()
)
return (
<div key={hb.id}>
{showDate && (
<div className="text-xs text-muted-foreground font-medium py-1.5 border-b border-border/50 mt-2 first:mt-0">
{date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}
</div>
)}
<div className="flex items-center gap-3 py-1.5 px-2 rounded-md hover:bg-muted/50 transition-colors">
<div className={cn(
"w-2 h-2 rounded-full flex-shrink-0",
hb.status === "up" ? "bg-green-500" :
hb.status === "down" ? "bg-red-500" :
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"text-xs font-medium",
hb.status === "up" ? "text-green-600" :
hb.status === "down" ? "text-red-600" : "text-muted-foreground"
)}>
{hb.status}
</span>
<span className="text-xs text-muted-foreground">
{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
{hb.msg && hb.msg !== "-" && (
<p className="text-[11px] text-muted-foreground truncate">{hb.msg}</p>
)}
</div>
<div className="text-xs font-mono text-muted-foreground flex-shrink-0">
{formatPing(hb.ping)}
</div>
</div>
</div>
)
})}
</div>
) : (
<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."}
</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>
)}
</CardContent>
</Card>
{/* Create Status Page Dialog */} {/* Create Status Page Dialog */}
{isCreateStatusPageOpen && ( {isCreateStatusPageOpen && (
<AlertDialog open={isCreateStatusPageOpen} onOpenChange={setIsCreateStatusPageOpen}> <AlertDialog open={isCreateStatusPageOpen} onOpenChange={setIsCreateStatusPageOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Create Status Page</AlertDialogTitle> <AlertDialogTitle>Create Status Page</AlertDialogTitle>
<AlertDialogDescription>Create a public status page for this monitor.</AlertDialogDescription> <AlertDialogDescription>Create a status page and optionally link this monitor.</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
@@ -1019,6 +1033,15 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
placeholder={monitor.name?.toLowerCase().replace(/\s+/g, "-")} placeholder={monitor.name?.toLowerCase().replace(/\s+/g, "-")}
/> />
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="sp-public" className="text-sm font-medium">
Public Status Page
</Label>
<p className="text-xs text-muted-foreground">Anyone can view this page without authentication.</p>
</div>
<Switch id="sp-public" checked={statusPagePublic} onCheckedChange={setStatusPagePublic} />
</div>
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => setIsCreateStatusPageOpen(false)}>Cancel</AlertDialogCancel> <AlertDialogCancel onClick={() => setIsCreateStatusPageOpen(false)}>Cancel</AlertDialogCancel>
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "تم حلها"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "الحالة" msgstr "الحالة"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Решен"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Статус" msgstr "Статус"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Vyřešeno"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Stav" msgstr "Stav"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Løst"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Gelöst"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1825,6 +1825,10 @@ msgstr "Resolved"
msgid "Response" msgid "Response"
msgstr "Response" msgstr "Response"
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr "Response time over the selected period"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "Response Times" #~ msgstr "Response Times"
@@ -2036,8 +2040,8 @@ msgid "Status"
msgstr "Status" msgstr "Status"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "Status and response time over the selected period" #~ msgstr "Status and response time over the selected period"
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Resuelto"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Estado" msgstr "Estado"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "حل شده"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "وضعیت" msgstr "وضعیت"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Résolu"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Statut" msgstr "Statut"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "נפתר"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "סטטוס" msgstr "סטטוס"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Razrješeno"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Megoldva"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Állapot" msgstr "Állapot"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Diselesaikan"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Risolto"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Stato" msgstr "Stato"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "解決済み"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "ステータス" msgstr "ステータス"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "해결됨"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "상태" msgstr "상태"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Opgelost"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Løst"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Rozwiązany"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Resolvido"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Estado" msgstr "Estado"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Завершено"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Статус" msgstr "Статус"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Rešeno"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Stanje" msgstr "Stanje"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Решено"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Статус" msgstr "Статус"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Löst"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Çözüldü"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Durum" msgstr "Durum"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Вирішено"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Статус" msgstr "Статус"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "Đã giải quyết"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "Trạng thái" msgstr "Trạng thái"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "已解决"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "状态" msgstr "状态"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "已解決"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "狀態" msgstr "狀態"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"
+6 -2
View File
@@ -1830,6 +1830,10 @@ msgstr "已解決"
msgid "Response" msgid "Response"
msgstr "" msgstr ""
#: src/components/routes/monitor.tsx
msgid "Response time over the selected period"
msgstr ""
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
#~ msgid "Response Times" #~ msgid "Response Times"
#~ msgstr "" #~ msgstr ""
@@ -2041,8 +2045,8 @@ msgid "Status"
msgstr "狀態" msgstr "狀態"
#: src/components/routes/monitor.tsx #: src/components/routes/monitor.tsx
msgid "Status and response time over the selected period" #~ msgid "Status and response time over the selected period"
msgstr "" #~ msgstr ""
#: src/components/routes/status-pages.tsx #: src/components/routes/status-pages.tsx
msgid "Status Page Manager" msgid "Status Page Manager"