mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
feat(hub,site): enhance domain intelligence and monitor performance
Build Docker images / Hub (push) Failing after 1m35s
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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user