mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-07-29 15:23:48 +00:00
feat(site): implement subdomain discovery and enhanced monitoring dashboard
This commit introduces a comprehensive subdomain discovery system and significantly upgrades the monitoring and domain management user interfaces.
Key changes include:
- **Subdomain Discovery**: Added a new service in the hub that performs advanced subdomain discovery using DNS brute forcing, Certificate Transparency (CT) log searches, pattern enumeration, and HTTP probing.
- **Enhanced Domain Management**:
- Added API endpoints for retrieving, discovering, and deleting subdomains.
- Implemented a new `SubdomainList` component in the UI to manage discovered subdomains.
- Improved WHOIS lookup robustness by supporting a wider range of registry field variations.
- **Advanced Monitoring UI**:
- Introduced `GroupedMonitorsTable` to organize monitors by root domain and their respective subdomains.
- Added visual uptime timelines (heartbeat dots) and response time statistics (Avg, Min, Max, P95, P99) to the monitor detail view.
- Implemented "Uptime Pills" for high-visibility status indicators in the monitors table.
- **Status Page Management**: Replaced the static status pages table with a full `StatusPageManager` capable of managing status pages and incidents.
- **Refactoring & Cleanup**:
- Cleaned up `.gitignore` and removed unused reference submodules.
- Improved domain extraction and grouping logic in the frontend.
- Enhanced the `SystemsTable` with better sorting and layout.
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Collapsible } from "@/components/ui/collapsible"
|
||||
import {
|
||||
Globe,
|
||||
Server,
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Minus,
|
||||
ExternalLink,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
listMonitors,
|
||||
groupMonitorsByDomain,
|
||||
getMonitorStatusColor,
|
||||
formatUptime,
|
||||
type Monitor,
|
||||
type GroupedMonitors,
|
||||
} from "@/lib/monitors"
|
||||
import { Link } from "@/components/router"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface GroupedMonitorsTableProps {
|
||||
view?: "grid" | "list"
|
||||
}
|
||||
|
||||
function DomainGroupHeader({
|
||||
domain,
|
||||
group,
|
||||
monitorCount,
|
||||
}: {
|
||||
domain: string
|
||||
group: GroupedMonitors
|
||||
monitorCount: number
|
||||
}) {
|
||||
// Calculate aggregate status for the domain
|
||||
const allMonitors = [...group.monitors, ...Array.from(group.subdomains.values()).flat()]
|
||||
const upCount = allMonitors.filter((m) => m.status === "up").length
|
||||
const downCount = allMonitors.filter((m) => m.status === "down").length
|
||||
const pausedCount = allMonitors.filter((m) => m.status === "paused").length
|
||||
|
||||
const statusColor =
|
||||
downCount > 0 ? "bg-red-500" : upCount > 0 ? "bg-green-500" : pausedCount > 0 ? "bg-yellow-500" : "bg-gray-400"
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className={cn("h-3 w-3 rounded-full", statusColor)} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-semibold">{domain}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{monitorCount} monitor{monitorCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{upCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
{upCount}
|
||||
</span>
|
||||
)}
|
||||
{downCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-600">
|
||||
<ArrowDownRight className="h-3.5 w-3.5" />
|
||||
{downCount}
|
||||
</span>
|
||||
)}
|
||||
{pausedCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-yellow-600">
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
{pausedCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonitorCard({ monitor }: { monitor: Monitor }) {
|
||||
const uptime24h = monitor.uptime_stats?.["24h"] ?? 100
|
||||
const statusColor = getMonitorStatusColor(monitor.status)
|
||||
|
||||
return (
|
||||
<Link href={`/monitor/${monitor.id}`}>
|
||||
<div className="group relative rounded-lg border p-3 hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn("mt-1 h-2.5 w-2.5 rounded-full", statusColor)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{monitor.name}</span>
|
||||
{monitor.active === false && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<Activity className="h-3 w-3" />
|
||||
<span>24h: {formatUptime(uptime24h)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function SubdomainSection({
|
||||
subdomain,
|
||||
monitors,
|
||||
}: {
|
||||
subdomain: string
|
||||
monitors: Monitor[]
|
||||
}) {
|
||||
const downCount = monitors.filter((m) => m.status === "down").length
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center gap-2 py-1.5">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{subdomain}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1">
|
||||
{monitors.length}
|
||||
</Badge>
|
||||
{downCount > 0 && (
|
||||
<Badge variant="destructive" className="text-[10px] h-5 px-1">
|
||||
{downCount} down
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title={`${subdomain} (${monitors.length})`}
|
||||
icon={<Server className="h-4 w-4" />}
|
||||
defaultOpen={true}
|
||||
className="ml-4 border-l-2 border-muted rounded-none border-t-0 border-r-0 border-b-0"
|
||||
>
|
||||
<div className="pl-6 py-1 space-y-1">
|
||||
{monitors.map((monitor) => (
|
||||
<MonitorCard key={monitor.id} monitor={monitor} />
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function DomainGroup({ domain, group }: { domain: string; group: GroupedMonitors }) {
|
||||
const monitorCount = group.monitors.length + Array.from(group.subdomains.values()).flat().length
|
||||
|
||||
const content = (
|
||||
<CardContent className="pt-0 pb-4 px-4">
|
||||
{/* Root domain monitors */}
|
||||
{group.monitors.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-2 py-1.5 text-sm text-muted-foreground">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span>Root Domain</span>
|
||||
</div>
|
||||
<div className="pl-6 space-y-1">
|
||||
{group.monitors.map((monitor) => (
|
||||
<MonitorCard key={monitor.id} monitor={monitor} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subdomain sections */}
|
||||
{Array.from(group.subdomains.entries()).map(([subdomain, monitors]) => (
|
||||
<SubdomainSection key={subdomain} subdomain={subdomain} monitors={monitors} />
|
||||
))}
|
||||
</CardContent>
|
||||
)
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title={domain}
|
||||
icon={<Globe className="h-4 w-4" />}
|
||||
defaultOpen={true}
|
||||
description={<DomainGroupHeader domain={domain} group={group} monitorCount={monitorCount} />}
|
||||
>
|
||||
{content}
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export function GroupedMonitorsTable() {
|
||||
const { data: monitors, isLoading } = useQuery({
|
||||
queryKey: ["monitors"],
|
||||
queryFn: listMonitors,
|
||||
})
|
||||
|
||||
const groupedMonitors = useMemo(() => {
|
||||
if (!monitors) return new Map()
|
||||
return groupMonitorsByDomain(monitors)
|
||||
}, [monitors])
|
||||
|
||||
const ungroupedMonitors = useMemo(() => {
|
||||
if (!monitors) return []
|
||||
return monitors.filter((m) => !m.url && !m.hostname)
|
||||
}, [monitors])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-32 bg-muted rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const domainGroups = Array.from(groupedMonitors.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Domain groups */}
|
||||
{domainGroups.map(([domain, group]) => (
|
||||
<DomainGroup key={domain} domain={domain} group={group} />
|
||||
))}
|
||||
|
||||
{/* Ungrouped monitors (no URL/hostname) */}
|
||||
{ungroupedMonitors.length > 0 && (
|
||||
<Collapsible
|
||||
title="Other Monitors"
|
||||
icon={<Server className="h-4 w-4" />}
|
||||
defaultOpen={true}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{ungroupedMonitors.map((monitor) => (
|
||||
<MonitorCard key={monitor.id} monitor={monitor} />
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{domainGroups.length === 0 && ungroupedMonitors.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-lg font-medium">No monitors yet</p>
|
||||
<p className="text-muted-foreground">Create your first monitor to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CheckCircleIcon,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
Settings2Icon,
|
||||
TagIcon,
|
||||
Trash2Icon,
|
||||
XCircle,
|
||||
XCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useMemo, useState } from "react"
|
||||
@@ -68,7 +70,9 @@ import {
|
||||
} from "@/lib/monitors"
|
||||
import { cn, useBrowserStorage } from "@/lib/utils"
|
||||
import { AddMonitorDialog } from "./add-monitor-dialog"
|
||||
import { GroupedMonitorsTable } from "./grouped-monitors-table"
|
||||
import { Link } from "@/components/router"
|
||||
import { Network } from "lucide-react"
|
||||
|
||||
// Status indicator component
|
||||
function StatusIndicator({ status }: { status: MonitorStatus }) {
|
||||
@@ -176,14 +180,26 @@ function MonitorCard({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">Type</div>
|
||||
<div className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
||||
{getMonitorTypeLabel(monitor.type)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
||||
{/* Uptime - Prominent pill display */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<UptimePill uptime={monitor.uptime_stats?.uptime_24h ?? 100} label="24h" />
|
||||
{monitor.uptime_stats?.uptime_7d !== undefined && monitor.uptime_stats.uptime_7d !== monitor.uptime_stats?.uptime_24h && (
|
||||
<UptimePill uptime={monitor.uptime_stats.uptime_7d} label="7d" />
|
||||
)}
|
||||
</div>
|
||||
<UptimeDots heartbeats={monitor.recent_heartbeats} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="text-xs text-muted-foreground">Response</div>
|
||||
<div>
|
||||
{monitor.last_check ? (
|
||||
@@ -193,10 +209,6 @@ function MonitorCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<div className="text-xs text-muted-foreground">Uptime (24h)</div>
|
||||
<UptimeBar stats={monitor.uptime_stats} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{monitor.tags && monitor.tags.length > 0 && (
|
||||
@@ -265,25 +277,89 @@ function MonitorCard({
|
||||
)
|
||||
}
|
||||
|
||||
// Uptime bar component
|
||||
// Uptime pill badge component - big and visible
|
||||
function UptimePill({ uptime, label = "24h" }: { uptime: number; label?: string }) {
|
||||
let colorClass = "bg-green-500/15 text-green-600 border-green-500/30"
|
||||
let icon = <CheckCircleIcon className="h-3.5 w-3.5" />
|
||||
|
||||
if (uptime < 99.9) {
|
||||
colorClass = "bg-green-500/15 text-green-600 border-green-500/30"
|
||||
}
|
||||
if (uptime < 95) {
|
||||
colorClass = "bg-yellow-500/15 text-yellow-600 border-yellow-500/30"
|
||||
icon = <AlertTriangle className="h-3.5 w-3.5" />
|
||||
}
|
||||
if (uptime < 90) {
|
||||
colorClass = "bg-red-500/15 text-red-600 border-red-500/30"
|
||||
icon = <XCircle className="h-3.5 w-3.5" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border-2 ${colorClass}`}>
|
||||
{icon}
|
||||
<span className="text-sm font-bold">{formatUptime(uptime)}</span>
|
||||
<span className="text-[10px] font-medium uppercase opacity-70">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Uptime bar component with pill style
|
||||
function UptimeBar({ stats }: { stats?: Record<string, number> }) {
|
||||
const uptime24h = stats?.uptime_24h ?? 100
|
||||
const uptime7d = stats?.uptime_7d ?? 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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all", color)}
|
||||
style={{ width: `${uptime24h}%` }}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<UptimePill uptime={uptime24h} label="24h" />
|
||||
{uptime7d !== 100 && uptime7d !== uptime24h && (
|
||||
<UptimePill uptime={uptime7d} label="7d" />
|
||||
)}
|
||||
{uptime30d !== 100 && uptime30d !== uptime24h && uptime30d !== uptime7d && (
|
||||
<UptimePill uptime={uptime30d} label="30d" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-14">
|
||||
{formatUptime(uptime24h)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mini uptime dots visualization
|
||||
function UptimeDots({ heartbeats }: { heartbeats?: Array<{ status: string; time: string }> }) {
|
||||
if (!heartbeats || heartbeats.length === 0) {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="h-3 w-2 rounded-sm bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Take last 12 heartbeats
|
||||
const recent = heartbeats.slice(-12)
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{recent.map((hb, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-3 w-2 rounded-sm transition-colors",
|
||||
hb.status === "up" ? "bg-green-500" :
|
||||
hb.status === "down" ? "bg-red-500" :
|
||||
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
|
||||
)}
|
||||
title={`${hb.status} at ${new Date(hb.time).toLocaleString()}`}
|
||||
/>
|
||||
))}
|
||||
{recent.length < 12 && Array.from({ length: 12 - recent.length }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-3 w-2 rounded-sm bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -456,7 +532,7 @@ function MonitorRow({
|
||||
)
|
||||
}
|
||||
|
||||
type ViewMode = "table" | "grid"
|
||||
type ViewMode = "table" | "grid" | "network"
|
||||
type StatusFilter = "all" | MonitorStatus
|
||||
type TypeFilter = "all" | MonitorType
|
||||
|
||||
@@ -669,6 +745,10 @@ export default memo(function MonitorsTable() {
|
||||
<LayoutGridIcon className="size-4" />
|
||||
<Trans>Grid</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="network" className="gap-2">
|
||||
<Network className="size-4" />
|
||||
<Trans>Network (Grouped)</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -727,6 +807,8 @@ export default memo(function MonitorsTable() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "network" ? (
|
||||
<GroupedMonitorsTable />
|
||||
) : viewMode === "table" ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
Reference in New Issue
Block a user