mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
feat(site): overhaul domain and monitor detail views
Build Docker images / Hub (push) Failing after 1m21s
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:
@@ -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
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
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" />
|
<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>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex gap-[3px] p-1 rounded-md bg-muted/50">
|
||||||
{recent.map((hb, i) => (
|
{recent.map((hb, i) => (
|
||||||
|
<Tooltip key={i}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
key={i}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-3 w-2 rounded-sm transition-colors",
|
"h-4 w-[6px] rounded-full transition-all cursor-pointer hover:scale-125",
|
||||||
hb.status === "up" ? "bg-green-500" :
|
getStatusColor(hb.status)
|
||||||
hb.status === "down" ? "bg-red-500" :
|
|
||||||
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
|
|
||||||
)}
|
)}
|
||||||
title={`${hb.status} at ${new Date(hb.time).toLocaleString()}`}
|
|
||||||
/>
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
<p className="capitalize font-medium">{hb.status}</p>
|
||||||
|
<p className="text-muted-foreground">{new Date(hb.time).toLocaleString()}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{recent.length < 12 && Array.from({ length: 12 - recent.length }).map((_, i) => (
|
{emptySlots > 0 &&
|
||||||
<div key={`empty-${i}`} className="h-3 w-2 rounded-sm bg-muted" />
|
Array.from({ length: emptySlots }).map((_, i) => (
|
||||||
|
<div key={`empty-${i}`} className="h-4 w-[6px] rounded-full bg-muted" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
@@ -823,12 +903,20 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<CardTitle>Status Page</CardTitle>
|
<CardTitle>Status Page</CardTitle>
|
||||||
<CardDescription>Link this monitor to public status pages</CardDescription>
|
<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)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="View public status page"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant={isLinked ? "default" : "outline"}
|
variant={isLinked ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -905,92 +997,14 @@ 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">
|
||||||
</div>
|
Create one to share your service status or keep it private for internal use.
|
||||||
)}
|
|
||||||
<Button variant="outline" size="sm" className="w-full" onClick={() => setIsCreateStatusPageOpen(true)}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Create Status Page
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</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>
|
</p>
|
||||||
{isPending && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => checkMutation.mutate()}
|
|
||||||
disabled={checkMutation.isPending}
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
|
|
||||||
Run First Check
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Status Page Dialog */}
|
{/* Create Status Page Dialog */}
|
||||||
{isCreateStatusPageOpen && (
|
{isCreateStatusPageOpen && (
|
||||||
@@ -998,7 +1012,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user