feat(hub,site): enhance domain intelligence and monitor performance
Build Docker images / Hub (push) Failing after 1m35s

Implement comprehensive domain data collection including provider detection (DNS, hosting, email, CA), HTTP headers, TLS certificate chains, and SEO metadata. Added PageSpeed Insights integration for monitors to track Core Web Vitals.

- **hub**:
  - Add provider detection logic for DNS, email, and hosting.
  - Expand `Domain` entity to include SEO, headers, certificates, and enhanced registration details.
  - Implement automated collection of TLD, WHOIS raw data, and host country codes.
  - Update scheduler to track changes in providers and security settings (privacy/transfer lock).
  - Add PageSpeed check endpoint to monitor API.
- **site**:
  - Update domain table and detail views to display new intelligence (providers, headers, SEO).
  - Implement PageSpeed metrics visualization with Core Web Vitals status indicators.
  - Add display options for provider information in the domain list.
- **db**:
  - Add migration for new domain collection fields.
This commit is contained in:
Tomas Dvorak
2026-05-14 13:33:03 +02:00
parent 0dd7db8a82
commit fe5c7eaa95
16 changed files with 1712 additions and 146 deletions
@@ -440,6 +440,18 @@ export function DomainDialog({ open, onOpenChange, domain, isEdit = false }: Dom
{lookupData.host_country && (
<p className="text-sm">Location: {lookupData.host_country}</p>
)}
{lookupData.dns_provider && (
<p className="text-sm">DNS: {lookupData.dns_provider}</p>
)}
{lookupData.hosting_provider && (
<p className="text-sm">Hosting: {lookupData.hosting_provider}</p>
)}
{lookupData.email_provider && (
<p className="text-sm">Email: {lookupData.email_provider}</p>
)}
{lookupData.ca_provider && (
<p className="text-sm">CA: {lookupData.ca_provider}</p>
)}
</div>
{/* Manual expiry fallback when WHOIS doesn't return expiry */}
@@ -49,7 +49,6 @@ import {
getDomainSubdomains,
formatDate,
type Domain,
type Subdomain,
} from "@/lib/domains"
import {
MoreHorizontal,
@@ -76,6 +75,7 @@ type DisplayOptions = {
showRegistrar: boolean
showExpiryDate: boolean
showTags: boolean
showProviders: boolean
}
// Days left badge component - big and visible
@@ -155,7 +155,7 @@ export default function DomainsTable() {
const [displayOptions, setDisplayOptions] = useBrowserStorage<DisplayOptions>(
"domainsDisplayOptions",
{ showSSL: true, showRegistrar: true, showExpiryDate: true, showTags: true }
{ showSSL: true, showRegistrar: true, showExpiryDate: true, showTags: true, showProviders: false }
)
const { data: domains = [], isLoading } = useQuery({
@@ -463,6 +463,12 @@ function StatusIndicator({ status }: { status: string }) {
>
Tags
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={displayOptions.showProviders}
onCheckedChange={(checked: boolean) => setDisplayOptions({ ...displayOptions, showProviders: checked })}
>
Providers
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -496,6 +502,7 @@ function StatusIndicator({ status }: { status: string }) {
{displayOptions.showRegistrar && <TableHead>Registrar</TableHead>}
{displayOptions.showSSL && <TableHead>SSL Expiry</TableHead>}
{displayOptions.showTags && <TableHead>Tags</TableHead>}
{displayOptions.showProviders && <TableHead>Providers</TableHead>}
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -554,6 +561,16 @@ function StatusIndicator({ status }: { status: string }) {
</div>
</TableCell>
)}
{displayOptions.showProviders && (
<TableCell>
<div className="flex flex-col gap-0.5 text-xs">
{domain.dns_provider && <span className="text-muted-foreground">DNS: <span className="text-foreground">{domain.dns_provider}</span></span>}
{domain.hosting_provider && <span className="text-muted-foreground">Host: <span className="text-foreground">{domain.hosting_provider}</span></span>}
{domain.email_provider && <span className="text-muted-foreground">Email: <span className="text-foreground">{domain.email_provider}</span></span>}
{domain.ca_provider && <span className="text-muted-foreground">CA: <span className="text-foreground">{domain.ca_provider}</span></span>}
</div>
</TableCell>
)}
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -659,6 +676,14 @@ function StatusIndicator({ status }: { status: string }) {
<span className="text-xs text-muted-foreground truncate max-w-[120px]">{domain.registrar_name || "Unknown"}</span>
)}
</div>
{displayOptions.showProviders && (
<div className="flex flex-wrap gap-1 text-[10px]">
{domain.dns_provider && <span className="text-muted-foreground">DNS: <span className="text-foreground">{domain.dns_provider}</span></span>}
{domain.hosting_provider && <span className="text-muted-foreground">Host: <span className="text-foreground">{domain.hosting_provider}</span></span>}
{domain.email_provider && <span className="text-muted-foreground">Email: <span className="text-foreground">{domain.email_provider}</span></span>}
{domain.ca_provider && <span className="text-muted-foreground">CA: <span className="text-foreground">{domain.ca_provider}</span></span>}
</div>
)}
<div className="flex gap-2">
<DaysLeftBadge days={domain.days_until_expiry} />
{displayOptions.showSSL && domain.ssl_valid_to && (
+275 -5
View File
@@ -1,4 +1,4 @@
import { memo, useState } from "react"
import { useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Trans } from "@lingui/react/macro"
import { useToast } from "@/components/ui/use-toast"
@@ -33,6 +33,14 @@ import {
User,
Mail,
Building,
Key,
Eye,
EyeOff,
Network,
Code2,
Search,
Lock,
Unlock,
type LucideIcon,
} from "lucide-react"
import {
@@ -108,7 +116,6 @@ export default function DomainDetail({ id }: { id: string }) {
const { toast } = useToast()
const queryClient = useQueryClient()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [expiryDialogOpen, setExpiryDialogOpen] = useState(false)
const [manualExpiryDate, setManualExpiryDate] = useState("")
@@ -266,6 +273,35 @@ export default function DomainDetail({ id }: { id: string }) {
icon={MapPin}
/>
</div>
{/* Provider badges row */}
{(domain.dns_provider || domain.hosting_provider || domain.email_provider || domain.ca_provider) && (
<div className="flex flex-wrap gap-2 mt-2">
{domain.dns_provider && (
<Badge variant="secondary" className="text-xs gap-1">
<Network className="h-3 w-3" />
DNS: {domain.dns_provider}
</Badge>
)}
{domain.hosting_provider && (
<Badge variant="secondary" className="text-xs gap-1">
<Server className="h-3 w-3" />
Hosting: {domain.hosting_provider}
</Badge>
)}
{domain.email_provider && (
<Badge variant="secondary" className="text-xs gap-1">
<Mail className="h-3 w-3" />
Email: {domain.email_provider}
</Badge>
)}
{domain.ca_provider && (
<Badge variant="secondary" className="text-xs gap-1">
<Shield className="h-3 w-3" />
CA: {domain.ca_provider}
</Badge>
)}
</div>
)}
</div>
{/* Expiry Overview - Clean visual cards */}
@@ -679,6 +715,163 @@ export default function DomainDetail({ id }: { id: string }) {
</Card>
</div>
{/* HTTP Headers */}
{domain.headers && domain.headers.length > 0 && (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code2 className="h-5 w-5" />
HTTP Headers
</CardTitle>
<CardDescription>Response headers from the server</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1 max-h-80 overflow-y-auto">
{domain.headers.map((h, i) => (
<div key={i} className="flex items-start gap-2 text-sm py-1 border-b last:border-0">
<code className="text-xs text-muted-foreground shrink-0 w-32 truncate">{h.name}</code>
<code className="text-xs break-all">{h.value}</code>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)}
{/* SEO Metadata */}
{domain.seo_meta && (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
SEO Metadata
</CardTitle>
<CardDescription>Search engine optimization data</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* General Meta */}
{domain.seo_meta.general && (
<div className="space-y-2">
<h4 className="text-sm font-medium">General Meta Tags</h4>
<div className="space-y-1 text-sm">
{domain.seo_meta.general.title && (
<p><span className="text-muted-foreground">Title:</span> {domain.seo_meta.general.title}</p>
)}
{domain.seo_meta.general.description && (
<p><span className="text-muted-foreground">Description:</span> {domain.seo_meta.general.description}</p>
)}
{domain.seo_meta.general.canonical && (
<p><span className="text-muted-foreground">Canonical:</span> <a href={domain.seo_meta.general.canonical} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{domain.seo_meta.general.canonical}</a></p>
)}
{domain.seo_meta.general.robots && (
<p><span className="text-muted-foreground">Robots:</span> {domain.seo_meta.general.robots}</p>
)}
{domain.seo_meta.general.author && (
<p><span className="text-muted-foreground">Author:</span> {domain.seo_meta.general.author}</p>
)}
{domain.seo_meta.general.keywords && (
<p><span className="text-muted-foreground">Keywords:</span> {domain.seo_meta.general.keywords}</p>
)}
</div>
</div>
)}
{/* Open Graph */}
{domain.seo_meta.openGraph && (domain.seo_meta.openGraph.title || domain.seo_meta.openGraph.description) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Globe className="h-4 w-4" />
Open Graph
</h4>
<div className="space-y-1 text-sm">
{domain.seo_meta.openGraph.title && (
<p><span className="text-muted-foreground">Title:</span> {domain.seo_meta.openGraph.title}</p>
)}
{domain.seo_meta.openGraph.description && (
<p><span className="text-muted-foreground">Description:</span> {domain.seo_meta.openGraph.description}</p>
)}
{domain.seo_meta.openGraph.type && (
<p><span className="text-muted-foreground">Type:</span> {domain.seo_meta.openGraph.type}</p>
)}
{domain.seo_meta.openGraph.url && (
<p><span className="text-muted-foreground">URL:</span> <a href={domain.seo_meta.openGraph.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{domain.seo_meta.openGraph.url}</a></p>
)}
{domain.seo_meta.openGraph.images && domain.seo_meta.openGraph.images.length > 0 && (
<div>
<p className="text-muted-foreground">Images:</p>
<div className="flex flex-wrap gap-2 mt-1">
{domain.seo_meta.openGraph.images.map((img, i) => (
<a key={i} href={img} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline truncate max-w-[300px]">{img}</a>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Twitter Cards */}
{domain.seo_meta.twitter && (domain.seo_meta.twitter.title || domain.seo_meta.twitter.description) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Mail className="h-4 w-4" />
Twitter/X Cards
</h4>
<div className="space-y-1 text-sm">
{domain.seo_meta.twitter.title && (
<p><span className="text-muted-foreground">Title:</span> {domain.seo_meta.twitter.title}</p>
)}
{domain.seo_meta.twitter.description && (
<p><span className="text-muted-foreground">Description:</span> {domain.seo_meta.twitter.description}</p>
)}
{domain.seo_meta.twitter.card && (
<p><span className="text-muted-foreground">Card:</span> {domain.seo_meta.twitter.card}</p>
)}
</div>
</div>
)}
{/* Robots.txt */}
{domain.seo_meta.robots && domain.seo_meta.robots.fetched && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
robots.txt
</h4>
{domain.seo_meta.robots.sitemaps && domain.seo_meta.robots.sitemaps.length > 0 && (
<div className="mb-2">
<p className="text-xs text-muted-foreground">Sitemaps:</p>
<div className="flex flex-wrap gap-1 mt-1">
{domain.seo_meta.robots.sitemaps.map((sitemap, i) => (
<a key={i} href={sitemap} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">{sitemap}</a>
))}
</div>
</div>
)}
{domain.seo_meta.robots.groups && domain.seo_meta.robots.groups.length > 0 && (
<div className="space-y-2">
{domain.seo_meta.robots.groups.map((group, i) => (
<div key={i} className="rounded bg-muted p-2 text-xs">
<p className="text-muted-foreground">User-agent: {group.userAgents.join(", ")}</p>
{group.rules.map((rule, j) => (
<p key={j} className={rule.type === "Disallow" ? "text-red-500" : "text-green-500"}>
{rule.type}: {rule.value}
</p>
))}
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
)}
<div className="grid gap-4">
<Card>
<CardHeader>
@@ -743,6 +936,47 @@ export default function DomainDetail({ id }: { id: string }) {
</div>
)}
</div>
{/* Certificate Chain */}
{domain.certificates && domain.certificates.length > 0 && (
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-3">Certificate Chain ({domain.certificates.length})</h4>
<div className="space-y-3">
{domain.certificates.map((cert, i) => (
<div key={i} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center gap-2">
<Badge variant={i === 0 ? "default" : "secondary"} className="text-[10px]">
{i === 0 ? "Leaf" : i === domain.certificates!.length - 1 ? "Root" : "Intermediate"}
</Badge>
{cert.ca_provider && (
<Badge variant="outline" className="text-[10px]">{cert.ca_provider}</Badge>
)}
</div>
<div className="text-sm">
<p><span className="text-muted-foreground">Subject:</span> {cert.subject}</p>
<p><span className="text-muted-foreground">Issuer:</span> {cert.issuer}</p>
</div>
{cert.alt_names && cert.alt_names.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">Subject Alternative Names ({cert.alt_names.length})</p>
<div className="flex flex-wrap gap-1">
{cert.alt_names.slice(0, 8).map((name, j) => (
<code key={j} className="text-[10px] bg-muted px-1.5 py-0.5 rounded">{name}</code>
))}
{cert.alt_names.length > 8 && (
<span className="text-[10px] text-muted-foreground">+{cert.alt_names.length - 8} more</span>
)}
</div>
</div>
)}
<div className="text-xs text-muted-foreground">
Valid: {formatDate(cert.valid_from)} {formatDate(cert.valid_to)}
</div>
</div>
))}
</div>
</div>
)}
</>
) : (
<div className="text-center py-8">
@@ -787,13 +1021,13 @@ export default function DomainDetail({ id }: { id: string }) {
</div>
</div>
{/* Important Dates */}
{/* Important Dates & TLD */}
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" />
Important Dates
</h4>
<div className="grid sm:grid-cols-3 gap-4">
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">Registration</p>
<p className="font-medium">{formatDate(domain.creation_date)}</p>
@@ -806,9 +1040,45 @@ export default function DomainDetail({ id }: { id: string }) {
<p className="text-sm text-muted-foreground">Expires</p>
<p className="font-medium">{formatDate(domain.expiry_date)}</p>
</div>
{domain.tld && (
<div>
<p className="text-sm text-muted-foreground">TLD</p>
<p className="font-medium">.{domain.tld}</p>
</div>
)}
</div>
</div>
{/* Privacy & Security */}
{(domain.privacy_enabled !== undefined || domain.transfer_lock !== undefined || domain.host_country_code) && (
<div className="space-y-2 pt-4 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<Key className="h-4 w-4" />
Privacy & Security
</h4>
<div className="flex flex-wrap gap-2">
{domain.privacy_enabled !== undefined && (
<Badge variant={domain.privacy_enabled ? "default" : "secondary"} className="gap-1">
{domain.privacy_enabled ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{domain.privacy_enabled ? "Privacy Protected" : "Privacy Visible"}
</Badge>
)}
{domain.transfer_lock !== undefined && (
<Badge variant={domain.transfer_lock ? "default" : "secondary"} className="gap-1">
{domain.transfer_lock ? <Lock className="h-3 w-3" /> : <Unlock className="h-3 w-3" />}
{domain.transfer_lock ? "Transfer Locked" : "Transfer Unlocked"}
</Badge>
)}
{domain.host_country_code && (
<Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" />
{domain.host_country_code}
</Badge>
)}
</div>
</div>
)}
{/* Registrant Contact */}
{(domain.registrant_name || domain.registrant_org) && (
<div className="space-y-2 pt-4 border-t">
@@ -970,7 +1240,7 @@ export default function DomainDetail({ id }: { id: string }) {
</Card>
<DomainDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} domain={domain} isEdit />
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Domain</AlertDialogTitle>
+206 -83
View File
@@ -17,7 +17,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import {
Globe,
Clock,
@@ -33,6 +32,9 @@ import {
TrendingUp,
TrendingDown,
Plus,
Zap,
Gauge,
Smartphone,
type LucideIcon,
} from "lucide-react"
import {
@@ -49,6 +51,7 @@ import {
getMonitorFaviconUrl,
formatUptime,
formatPing,
runPageSpeedCheck,
} from "@/lib/monitors"
import { formatDate } from "@/lib/domains"
import {
@@ -75,7 +78,7 @@ import { Link, navigate } from "@/components/router"
import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog"
import { cn } from "@/lib/utils"
type HeartbeatRow = Heartbeat & { timestamp?: string }
type HeartbeatRow = Heartbeat
// Uptime Bar Component - Visual timeline of recent checks
function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
@@ -106,7 +109,7 @@ function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] })
hb.status === "down" ? "bg-red-500" :
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
)}
title={`${hb.status} • ${formatPing(hb.ping)} • ${formatDate(hb.time || hb.timestamp || "")}`}
title={`${hb.status} • ${formatPing(hb.ping)} • ${formatDate(hb.time || "")}`}
/>
))}
</div>
@@ -172,40 +175,142 @@ function ResponseTimeStats({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
)
}
// Core Web Vitals placeholder component
function CoreWebVitalsCard({ url }: { url?: string }) {
function getVitalColor(status: string): string {
switch (status) {
case "good": return "text-green-500"
case "needs-improvement": return "text-yellow-500"
default: return "text-red-500"
}
}
function getVitalBg(status: string): string {
switch (status) {
case "good": return "bg-green-500/10 border-green-500/20"
case "needs-improvement": return "bg-yellow-500/10 border-yellow-500/20"
default: return "bg-red-500/10 border-red-500/20"
}
}
function ScoreRing({ score, label }: { score: number; label: string }) {
const color = score >= 90 ? "text-green-500" : score >= 70 ? "text-yellow-500" : "text-red-500"
const bg = score >= 90 ? "stroke-green-500" : score >= 70 ? "stroke-yellow-500" : "stroke-red-500"
const circumference = 2 * Math.PI * 18
const offset = circumference - (score / 100) * circumference
return (
<div className="flex flex-col items-center gap-1">
<div className="relative w-12 h-12">
<svg className="w-12 h-12 -rotate-90" viewBox="0 0 44 44">
<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"
className={bg} strokeDasharray={circumference} strokeDashoffset={offset} />
</svg>
<span className={cn("absolute inset-0 flex items-center justify-center text-xs font-bold", color)}>
{Math.round(score)}
</span>
</div>
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</span>
</div>
)
}
function VitalCard({ label, value, status, detail }: { label: string; value: string; status: string; detail: string }) {
return (
<div className={cn("p-3 rounded-lg border", getVitalBg(status))}>
<div className="flex items-center justify-between mb-1">
<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>
<div className={cn("text-lg font-bold", getVitalColor(status))}>{value}</div>
<div className="text-[10px] text-muted-foreground">{detail}</div>
</div>
)
}
function formatMs(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function CoreWebVitalsCard({ monitorId, url }: { monitorId: string; url?: string }) {
if (!url) return null
const [strategy, setStrategy] = useState<"mobile" | "desktop">("mobile")
const { toast } = useToast()
const { data, isPending: isPageSpeedLoading, mutate } = useMutation({
mutationFn: () => runPageSpeedCheck(monitorId, strategy),
onSuccess: () => {
toast({ title: "Lighthouse check complete" })
},
onError: (err: Error) => {
toast({ title: "Check failed", description: err.message, variant: "destructive" })
},
})
return (
<Card>
<CardHeader>
<CardTitle>Core Web Vitals</CardTitle>
<CardDescription>Lighthouse performance metrics (coming soon)</CardDescription>
</CardHeader>
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-yellow-500" />
Core Web Vitals
</CardTitle>
<CardDescription>
{data ? `Checked ${new Date(data.checkedAt).toLocaleString()}` : "Run a Lighthouse check to get performance metrics"}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border overflow-hidden">
<button
onClick={() => setStrategy("mobile")}
className={cn("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
</button>
<button
onClick={() => setStrategy("desktop")}
className={cn("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
</button>
</div>
<Button size="sm" onClick={() => mutate()} disabled={isPageSpeedLoading}>
<RefreshCw className={cn("mr-2 h-4 w-4", isPageSpeedLoading && "animate-spin")} />
{isPageSpeedLoading ? "Running..." : "Run Check"}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1">LCP</div>
<div className="text-2xl font-bold text-yellow-500">-</div>
<div className="text-xs text-muted-foreground mt-1">Largest Contentful Paint</div>
{data ? (
<div className="space-y-4">
{/* Lighthouse Scores */}
<div className="flex items-center gap-4 justify-center sm:justify-start">
<ScoreRing score={data.performance} label="Perf" />
<ScoreRing score={data.accessibility} label="A11y" />
<ScoreRing score={data.bestPractices} label="BP" />
<ScoreRing score={data.seo} label="SEO" />
</div>
{/* Core Web Vitals */}
<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 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 className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1">FID</div>
<div className="text-2xl font-bold text-green-500">-</div>
<div className="text-xs text-muted-foreground mt-1">First Input Delay</div>
) : (
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<div className="p-3 bg-muted/50 rounded-full">
<Gauge className="h-6 w-6 opacity-50" />
</div>
<p className="text-sm">No Lighthouse data yet. Click "Run Check" to analyze performance.</p>
<p className="text-xs text-muted-foreground">Powered by Google PageSpeed Insights</p>
</div>
<div className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1">CLS</div>
<div className="text-2xl font-bold text-green-500">-</div>
<div className="text-xs text-muted-foreground mt-1">Cumulative Layout Shift</div>
</div>
</div>
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div className="flex items-center gap-2 text-sm text-blue-600">
<Activity className="h-4 w-4" />
<span>Core Web Vitals monitoring requires additional configuration</span>
</div>
</div>
)}
</CardContent>
</Card>
)
@@ -400,7 +505,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
}
const cutoff = now - (ranges[timeRange] || ranges["24h"])
return heartbeats.filter((h: HeartbeatRow) => {
const t = new Date(h.time || h.timestamp || "").getTime()
const t = new Date(h.time || "").getTime()
return t >= cutoff
})
}, [heartbeats, timeRange])
@@ -412,7 +517,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
.slice()
.reverse()
.map((h: HeartbeatRow) => ({
time: new Date(h.time || h.timestamp || "").toLocaleTimeString(),
time: new Date(h.time || "").toLocaleTimeString(),
responseTime: h.ping || 0,
status: h.status === "up" ? 1 : 0,
}))
@@ -590,7 +695,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</div>
{/* Core Web Vitals */}
<CoreWebVitalsCard url={monitor.url} />
<CoreWebVitalsCard monitorId={id} url={monitor.url} />
{/* Combined Uptime & Response Chart */}
<Card>
@@ -813,59 +918,77 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<Card>
<CardHeader>
<CardTitle>Recent Checks</CardTitle>
<CardDescription>Last 50 monitor checks</CardDescription>
<CardTitle>Check History</CardTitle>
<CardDescription>Timeline of the last 50 monitor checks</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Status</TableHead>
<TableHead>Response Time</TableHead>
<TableHead>Message</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{heartbeats?.slice(0, 50).map((hb: HeartbeatRow) => (
<TableRow key={hb.id}>
<TableCell>{formatDate(hb.time || hb.timestamp)}</TableCell>
<TableCell>
<Badge variant={hb.status === "up" ? "default" : "destructive"}>{hb.status}</Badge>
</TableCell>
<TableCell>{formatPing(hb.ping)}</TableCell>
<TableCell className="max-w-xs truncate">{hb.msg || "-"}</TableCell>
</TableRow>
))}
{!heartbeats?.length && (
<TableRow>
<TableCell colSpan={4}>
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<div className="p-2 bg-muted/50 rounded-full">
<Clock className="h-5 w-5 opacity-50" />
</div>
<p className="text-sm">
{isPending
? "No checks have been run yet."
: "No check history available for the selected period."}
</p>
{isPending && (
<Button
variant="outline"
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
Run First Check
</Button>
{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>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<div className="p-2 bg-muted/50 rounded-full">
<Clock className="h-5 w-5 opacity-50" />
</div>
<p className="text-sm">
{isPending ? "No checks have been run yet." : "No check history available."}
</p>
{isPending && (
<Button
variant="outline"
size="sm"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
>
<RefreshCw className={cn("mr-2 h-4 w-4", checkMutation.isPending && "animate-spin")} />
Run First Check
</Button>
)}
</div>
)}
</CardContent>
</Card>
+120
View File
@@ -121,6 +121,66 @@ export interface Domain {
dns_spf_records?: string[]
dns_dkim_records?: string[]
dns_dmarc_records?: string[]
// Provider Detection
dns_provider?: string
hosting_provider?: string
email_provider?: string
ca_provider?: string
// HTTP Headers
headers?: { name: string; value: string }[]
// Certificate Chain
certificates?: {
issuer: string
subject: string
alt_names: string[]
valid_from: string
valid_to: string
ca_provider: string
}[]
// SEO Metadata
seo_meta?: {
openGraph: {
url: string
type: string
title: string
images: string[]
description: string
}
twitter: {
title: string
description: string
image: string
card: string
}
general: {
title: string
author: string
robots: string
keywords: string
canonical: string
description: string
}
robots: {
fetched: boolean
groups: {
userAgents: string[]
rules: { type: string; value: string }[]
}[]
sitemaps: string[]
}
}
// Raw WHOIS & Registration Details
whois_raw?: string
privacy_enabled?: boolean
transfer_lock?: boolean
tld?: string
domain_statuses?: string[]
host_country_code?: string
}
export interface DomainHistory {
@@ -204,6 +264,66 @@ export interface DomainLookupResult {
host_isp?: string
favicon_url?: string
last_checked?: string
// Provider Detection
dns_provider?: string
hosting_provider?: string
email_provider?: string
ca_provider?: string
// HTTP Headers
headers?: { name: string; value: string }[]
// Certificate Chain
certificates?: {
issuer: string
subject: string
alt_names: string[]
valid_from: string
valid_to: string
ca_provider: string
}[]
// SEO Metadata
seo_meta?: {
openGraph: {
url: string
type: string
title: string
images: string[]
description: string
}
twitter: {
title: string
description: string
image: string
card: string
}
general: {
title: string
author: string
robots: string
keywords: string
canonical: string
description: string
}
robots: {
fetched: boolean
groups: {
userAgents: string[]
rules: { type: string; value: string }[]
}[]
sitemaps: string[]
}
}
// Raw WHOIS & Registration Details
whois_raw?: string
privacy_enabled?: boolean
transfer_lock?: boolean
tld?: string
domain_statuses?: string[]
host_country_code?: string
}
const API_BASE = "/api/beszel/domains"
+25
View File
@@ -197,6 +197,25 @@ export interface CheckResult {
time?: string
}
export interface PageSpeedMetrics {
performance: number
accessibility: number
bestPractices: number
seo: number
pwa: number
fcp: number
lcp: number
ttfb: number
cls: number
tbt: number
speedIndex: number
tti: number
strategy: string
checkedAt: string
url: string
vitals: Record<string, string>
}
// API Functions
export async function listMonitors(): Promise<Monitor[]> {
const response = await pb.send<{ monitors: Monitor[] }>("/api/beszel/monitors", {})
@@ -261,6 +280,12 @@ export function getMonitorHeartbeats(id: string): Promise<{ heartbeats: Heartbea
return pb.send(`/api/beszel/monitors/${id}/heartbeats`, {})
}
export function runPageSpeedCheck(id: string, strategy: string = "mobile"): Promise<PageSpeedMetrics> {
return pb.send(`/api/beszel/monitors/${id}/pagespeed?strategy=${strategy}`, {
method: "POST",
})
}
// Helper functions
export function getMonitorTypeLabel(type: MonitorType): string {
const labels: Record<MonitorType, string> = {