Initial commit: Beszel fork with Domain Locker integration

This commit is contained in:
Tomas Dvorak
2026-04-21 15:39:43 +02:00
commit 363d708e91
440 changed files with 160889 additions and 0 deletions
@@ -0,0 +1,661 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { useToast } from "@/components/ui/use-toast"
import {
createMonitor,
updateMonitor,
type Monitor,
type MonitorType,
type CreateMonitorRequest,
type UpdateMonitorRequest,
} from "@/lib/monitors"
const MONITOR_TYPES: { value: MonitorType; label: string }[] = [
{ value: "http", label: "HTTP" },
{ value: "https", label: "HTTPS" },
{ value: "tcp", label: "TCP Port" },
{ value: "ping", label: "Ping" },
{ value: "dns", label: "DNS" },
{ value: "keyword", label: "HTTP Keyword" },
{ value: "json-query", label: "HTTP JSON" },
{ value: "docker", label: "Docker Container" },
]
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"]
const DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "NS", "TXT", "SRV"]
interface AddMonitorDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
monitor?: Monitor | null
isEdit?: boolean
}
export function AddMonitorDialog({
open,
onOpenChange,
monitor,
isEdit = false,
}: AddMonitorDialogProps) {
const { t } = useLingui()
const { toast } = useToast()
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState("basic")
// Form state
const [name, setName] = useState("")
const [type, setType] = useState<MonitorType>("https")
const [url, setUrl] = useState("")
const [hostname, setHostname] = useState("")
const [port, setPort] = useState<number | "">("")
const [method, setMethod] = useState("GET")
const [headers, setHeaders] = useState("")
const [body, setBody] = useState("")
const [interval, setInterval] = useState(60)
const [timeout, setTimeout] = useState(30)
const [retries, setRetries] = useState(1)
const [keyword, setKeyword] = useState("")
const [jsonQuery, setJsonQuery] = useState("")
const [expectedValue, setExpectedValue] = useState("")
const [invertKeyword, setInvertKeyword] = useState(false)
const [dnsResolveServer, setDnsResolveServer] = useState("")
const [dnsResolverMode, setDnsResolverMode] = useState("A")
const [description, setDescription] = useState("")
const [ignoreTLSError, setIgnoreTLSError] = useState(false)
const [certExpiryNotification, setCertExpiryNotification] = useState(false)
const [certExpiryDays, setCertExpiryDays] = useState(14)
// Reset form when dialog opens/closes
useEffect(() => {
if (open) {
if (isEdit && monitor) {
// Populate form for editing
setName(monitor.name)
setType(monitor.type)
setUrl(monitor.url || "")
setHostname(monitor.hostname || "")
setPort(monitor.port || "")
setMethod(monitor.method || "GET")
setHeaders("") // Parse from JSON if needed
setBody("")
setInterval(monitor.interval || 60)
setTimeout(monitor.timeout || 30)
setRetries(monitor.retries || 1)
setKeyword(monitor.keyword || "")
setJsonQuery(monitor.json_query || "")
setExpectedValue(monitor.expected_value || "")
setInvertKeyword(monitor.invert_keyword || false)
setDnsResolveServer(monitor.dns_resolve_server || "")
setDnsResolverMode(monitor.dns_resolver_mode || "A")
setDescription(monitor.description || "")
setIgnoreTLSError(monitor.ignore_tls_error || false)
setCertExpiryNotification(monitor.cert_expiry_notification || false)
setCertExpiryDays(monitor.cert_expiry_days || 14)
} else {
// Reset to defaults for new monitor
setName("")
setType("https")
setUrl("")
setHostname("")
setPort("")
setMethod("GET")
setHeaders("")
setBody("")
setInterval(60)
setTimeout(30)
setRetries(1)
setKeyword("")
setJsonQuery("")
setExpectedValue("")
setInvertKeyword(false)
setDnsResolveServer("")
setDnsResolverMode("A")
setDescription("")
setIgnoreTLSError(false)
setCertExpiryNotification(false)
setCertExpiryDays(14)
}
setActiveTab("basic")
}
}, [open, isEdit, monitor])
const createMutation = useMutation({
mutationFn: createMonitor,
onSuccess: () => {
toast({ title: t`Monitor created successfully` })
queryClient.invalidateQueries({ queryKey: ["monitors"] })
onOpenChange(false)
},
onError: (error) => {
toast({
title: t`Failed to create monitor`,
description: error instanceof Error ? error.message : "Unknown error",
variant: "destructive",
})
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateMonitorRequest }) =>
updateMonitor(id, data),
onSuccess: () => {
toast({ title: t`Monitor updated successfully` })
queryClient.invalidateQueries({ queryKey: ["monitors"] })
onOpenChange(false)
},
onError: (error) => {
toast({
title: t`Failed to update monitor`,
description: error instanceof Error ? error.message : "Unknown error",
variant: "destructive",
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
toast({ title: t`Name is required`, variant: "destructive" })
return
}
if (isEdit && monitor) {
const data: UpdateMonitorRequest = {
name: name.trim(),
url: url.trim() || undefined,
hostname: hostname.trim() || undefined,
port: port ? Number(port) : undefined,
method: ["http", "https", "keyword", "json-query"].includes(type)
? method
: undefined,
headers: headers.trim() || undefined,
body: body.trim() || undefined,
interval,
timeout,
retries,
keyword: type === "keyword" ? keyword.trim() : undefined,
json_query: type === "json-query" ? jsonQuery.trim() : undefined,
expected_value: type === "json-query" ? expectedValue.trim() : undefined,
invert_keyword: type === "keyword" ? invertKeyword : undefined,
dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined,
dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined,
description: description.trim() || undefined,
ignore_tls_error:
type === "https" || type === "keyword" || type === "json-query"
? ignoreTLSError
: undefined,
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
cert_expiry_days: type === "https" ? certExpiryDays : undefined,
}
updateMutation.mutate({ id: monitor.id, data })
} else {
const data: CreateMonitorRequest = {
name: name.trim(),
type,
url: url.trim() || undefined,
hostname: hostname.trim() || undefined,
port: port ? Number(port) : undefined,
method: ["http", "https", "keyword", "json-query"].includes(type)
? method
: undefined,
headers: headers.trim() || undefined,
body: body.trim() || undefined,
interval,
timeout,
retries,
keyword: type === "keyword" ? keyword.trim() : undefined,
json_query: type === "json-query" ? jsonQuery.trim() : undefined,
expected_value: type === "json-query" ? expectedValue.trim() : undefined,
invert_keyword: type === "keyword" ? invertKeyword : undefined,
dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined,
dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined,
description: description.trim() || undefined,
ignore_tls_error:
type === "https" || type === "keyword" || type === "json-query"
? ignoreTLSError
: undefined,
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
cert_expiry_days: type === "https" ? certExpiryDays : undefined,
}
createMutation.mutate(data)
}
}
const needsUrl = ["http", "https", "keyword", "json-query"].includes(type)
const needsHostname = ["tcp", "ping", "dns"].includes(type)
const needsPort = type === "tcp"
const needsHttpOptions = ["http", "https", "keyword", "json-query"].includes(type)
const needsKeyword = type === "keyword"
const needsJsonQuery = type === "json-query"
const needsDnsOptions = type === "dns"
const needsTlsOptions = type === "https"
const isPending = createMutation.isPending || updateMutation.isPending
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? <Trans>Edit Monitor</Trans> : <Trans>Add Monitor</Trans>}
</DialogTitle>
<DialogDescription>
<Trans>
Configure a monitor to track website or service availability.
</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="basic">
<Trans>Basic</Trans>
</TabsTrigger>
<TabsTrigger value="advanced">
<Trans>Advanced</Trans>
</TabsTrigger>
<TabsTrigger value="notifications">
<Trans>Notifications</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4 mt-4">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">
<Trans>Monitor Name</Trans> *
</Label>
<Input
id="name"
placeholder={t`e.g., My Website`}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="type">
<Trans>Monitor Type</Trans> *
</Label>
<Select
value={type}
onValueChange={(v) => setType(v as MonitorType)}
>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONITOR_TYPES.map((mt) => (
<SelectItem key={mt.value} value={mt.value}>
{mt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{needsUrl && (
<div className="grid gap-2">
<Label htmlFor="url">
<Trans>URL</Trans> *
</Label>
<Input
id="url"
placeholder={t`https://example.com`}
value={url}
onChange={(e) => setUrl(e.target.value)}
required
/>
</div>
)}
{needsHostname && (
<div className="grid gap-2">
<Label htmlFor="hostname">
<Trans>Hostname</Trans> *
</Label>
<Input
id="hostname"
placeholder={t`example.com`}
value={hostname}
onChange={(e) => setHostname(e.target.value)}
required
/>
</div>
)}
{needsPort && (
<div className="grid gap-2">
<Label htmlFor="port">
<Trans>Port</Trans> *
</Label>
<Input
id="port"
type="number"
placeholder={t`443`}
value={port}
onChange={(e) =>
setPort(
e.target.value ? Number(e.target.value) : ""
)
}
required
/>
</div>
)}
{needsHttpOptions && (
<div className="grid gap-2">
<Label htmlFor="method">
<Trans>HTTP Method</Trans>
</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger id="method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{needsKeyword && (
<div className="grid gap-2">
<Label htmlFor="keyword">
<Trans>Keyword to Search</Trans> *
</Label>
<Input
id="keyword"
placeholder={t`Success`}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
required
/>
<div className="flex items-center gap-2 mt-2">
<Switch
id="invertKeyword"
checked={invertKeyword}
onCheckedChange={setInvertKeyword}
/>
<Label htmlFor="invertKeyword">
<Trans>Invert match (alert if keyword found)</Trans>
</Label>
</div>
</div>
)}
{needsJsonQuery && (
<div className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="jsonQuery">
<Trans>JSON Path</Trans> *
</Label>
<Input
id="jsonQuery"
placeholder={t`data.status`}
value={jsonQuery}
onChange={(e) => setJsonQuery(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="expectedValue">
<Trans>Expected Value</Trans>
</Label>
<Input
id="expectedValue"
placeholder={t`active`}
value={expectedValue}
onChange={(e) => setExpectedValue(e.target.value)}
/>
</div>
</div>
)}
{needsDnsOptions && (
<div className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="dnsResolverMode">
<Trans>Record Type</Trans>
</Label>
<Select
value={dnsResolverMode}
onValueChange={setDnsResolverMode}
>
<SelectTrigger id="dnsResolverMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DNS_RECORD_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="dnsResolveServer">
<Trans>DNS Server (optional)</Trans>
</Label>
<Input
id="dnsResolveServer"
placeholder={t`8.8.8.8`}
value={dnsResolveServer}
onChange={(e) => setDnsResolveServer(e.target.value)}
/>
</div>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="description">
<Trans>Description</Trans>
</Label>
<Textarea
id="description"
placeholder={t`Optional description for this monitor`}
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
</div>
</TabsContent>
<TabsContent value="advanced" className="space-y-4 mt-4">
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="interval">
<Trans>Interval (seconds)</Trans>
</Label>
<Input
id="interval"
type="number"
min={20}
max={86400}
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="timeout">
<Trans>Timeout (seconds)</Trans>
</Label>
<Input
id="timeout"
type="number"
min={1}
max={300}
value={timeout}
onChange={(e) => setTimeout(Number(e.target.value))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="retries">
<Trans>Retries</Trans>
</Label>
<Input
id="retries"
type="number"
min={0}
max={10}
value={retries}
onChange={(e) => setRetries(Number(e.target.value))}
/>
</div>
</div>
{needsHttpOptions && (
<div className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="headers">
<Trans>Headers (JSON)</Trans>
</Label>
<Textarea
id="headers"
placeholder={`{\n "Authorization": "Bearer token"\n}`}
value={headers}
onChange={(e) => setHeaders(e.target.value)}
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="body">
<Trans>Body</Trans>
</Label>
<Textarea
id="body"
placeholder={t`Request body for POST/PUT requests`}
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
/>
</div>
</div>
)}
{needsTlsOptions && (
<div className="space-y-4 border rounded-lg p-4">
<div className="flex items-center gap-2">
<Switch
id="ignoreTLSError"
checked={ignoreTLSError}
onCheckedChange={setIgnoreTLSError}
/>
<Label htmlFor="ignoreTLSError">
<Trans>Ignore TLS/SSL errors</Trans>
</Label>
</div>
</div>
)}
</TabsContent>
<TabsContent value="notifications" className="space-y-4 mt-4">
{needsTlsOptions && (
<div className="space-y-4 border rounded-lg p-4">
<div className="flex items-center gap-2">
<Switch
id="certExpiryNotification"
checked={certExpiryNotification}
onCheckedChange={setCertExpiryNotification}
/>
<Label htmlFor="certExpiryNotification">
<Trans>Notify when certificate expires</Trans>
</Label>
</div>
{certExpiryNotification && (
<div className="grid gap-2 mt-2">
<Label htmlFor="certExpiryDays">
<Trans>Days before expiry to notify</Trans>
</Label>
<Input
id="certExpiryDays"
type="number"
min={1}
max={90}
value={certExpiryDays}
onChange={(e) =>
setCertExpiryDays(Number(e.target.value))
}
/>
</div>
)}
</div>
)}
{!needsTlsOptions && (
<p className="text-muted-foreground text-sm">
<Trans>
Certificate expiry notifications are only available
for HTTPS monitors.
</Trans>
</p>
)}
<div className="border rounded-lg p-4">
<p className="text-sm text-muted-foreground">
<Trans>
General notification settings will be configured in
the Notifications tab.
</Trans>
</p>
</div>
</TabsContent>
</Tabs>
<DialogFooter className="mt-6">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<Trans>Saving...</Trans>
) : isEdit ? (
<Trans>Update Monitor</Trans>
) : (
<Trans>Create Monitor</Trans>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,428 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
ArrowDownIcon,
ArrowUpIcon,
CheckCircleIcon,
Edit3Icon,
GlobeIcon,
PauseIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
Trash2Icon,
XCircleIcon,
} from "lucide-react"
import { memo, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Table,
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 {
deleteMonitor,
getMonitorTypeLabel,
listMonitors,
manualCheck,
pauseMonitor,
resumeMonitor,
type Monitor,
type MonitorStatus,
formatUptime,
formatPing,
} from "@/lib/monitors"
import { cn } from "@/lib/utils"
import { AddMonitorDialog } from "./add-monitor-dialog"
import { Link } from "@/components/router"
// Status indicator component
function StatusIndicator({ status }: { status: MonitorStatus }) {
const colors = {
up: "bg-green-500",
down: "bg-red-500",
pending: "bg-yellow-400",
paused: "bg-gray-400",
maintenance: "bg-blue-500",
}
const icons = {
up: CheckCircleIcon,
down: XCircleIcon,
pending: RefreshCwIcon,
paused: PauseIcon,
maintenance: RefreshCwIcon,
}
const Icon = icons[status] || RefreshCwIcon
return (
<div className="flex items-center gap-2">
<div className={cn("h-2.5 w-2.5 rounded-full", colors[status])} />
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize text-sm">{status}</span>
</div>
)
}
// Uptime bar component
function UptimeBar({ stats }: { stats?: Record<string, number> }) {
const uptime24h = stats?.uptime_24h ?? 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>
<span className="text-xs text-muted-foreground w-14">
{formatUptime(uptime24h)}
</span>
</div>
)
}
// Monitor row component
function MonitorRow({
monitor,
onEdit,
}: {
monitor: Monitor
onEdit: (m: Monitor) => void
}) {
const { toast } = useToast()
const queryClient = useQueryClient()
const checkMutation = useMutation({
mutationFn: manualCheck,
onSuccess: (result) => {
toast({
title: `Check complete`,
description: `${monitor.name} is ${result.status} (${formatPing(result.ping)})`,
})
queryClient.invalidateQueries({ queryKey: ["monitors"] })
},
onError: () => {
toast({
title: "Check failed",
variant: "destructive",
})
},
})
const pauseMutation = useMutation({
mutationFn: monitor.status === "paused" ? resumeMonitor : pauseMonitor,
onSuccess: () => {
toast({
title: monitor.status === "paused" ? "Monitor resumed" : "Monitor paused",
})
queryClient.invalidateQueries({ queryKey: ["monitors"] })
},
})
const deleteMutation = useMutation({
mutationFn: deleteMonitor,
onSuccess: () => {
toast({ title: "Monitor deleted" })
queryClient.invalidateQueries({ queryKey: ["monitors"] })
},
})
return (
<TableRow>
<TableCell>
<Link href={`/monitor/${monitor.id}`} className="flex items-center gap-3 cursor-pointer">
<GlobeIcon className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium hover:underline">{monitor.name}</div>
<div className="text-xs text-muted-foreground">
{monitor.url || monitor.hostname}
{monitor.port ? `:${monitor.port}` : ""}
</div>
</div>
</Link>
</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
{getMonitorTypeLabel(monitor.type)}
</span>
</TableCell>
<TableCell>
<StatusIndicator status={monitor.status} />
</TableCell>
<TableCell>
{monitor.last_check ? (
<div className="text-sm">
{formatPing(monitor.uptime_stats?.last_ping || 0)}
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<UptimeBar stats={monitor.uptime_stats} />
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => checkMutation.mutate(monitor.id)}
disabled={checkMutation.isPending}
>
<RefreshCwIcon
className={cn(
"h-4 w-4",
checkMutation.isPending && "animate-spin"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Check now</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => pauseMutation.mutate(monitor.id)}
disabled={pauseMutation.isPending}
>
{monitor.status === "paused" ? (
<PlayIcon className="h-4 w-4" />
) : (
<PauseIcon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{monitor.status === "paused" ? "Resume" : "Pause"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit3Icon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(monitor)}>
<Edit3Icon className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteMutation.mutate(monitor.id)}
>
<Trash2Icon className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
)
}
// Main component
export default memo(function MonitorsTable() {
const { t } = useLingui()
const [filter, setFilter] = useState("")
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [editingMonitor, setEditingMonitor] = useState<Monitor | null>(null)
const { data: monitors = [], isLoading } = useQuery({
queryKey: ["monitors"],
queryFn: listMonitors,
refetchInterval: 30000, // Refresh every 30 seconds
})
const filteredMonitors = useMemo(() => {
if (!filter) return monitors
const f = filter.toLowerCase()
return monitors.filter(
(m) =>
m.name.toLowerCase().includes(f) ||
(m.url || "").toLowerCase().includes(f) ||
(m.hostname || "").toLowerCase().includes(f)
)
}, [monitors, filter])
const stats = useMemo(() => {
const total = monitors.length
const up = monitors.filter((m) => m.status === "up").length
const down = monitors.filter((m) => m.status === "down").length
const paused = monitors.filter((m) => m.status === "paused").length
return { total, up, down, paused }
}, [monitors])
return (
<Card>
<CardHeader className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<CardTitle className="text-xl">
<Trans>Website & Service Monitoring</Trans>
</CardTitle>
<CardDescription>
<Trans>Monitor websites, APIs, and services</Trans>
<span className="ml-2 text-xs">
({stats.up} <ArrowUpIcon className="inline h-3 w-3 text-green-500" />
{stats.down > 0 && (
<>
{" "}
{stats.down}{" "}
<ArrowDownIcon className="inline h-3 w-3 text-red-500" />
</>
)}
{stats.paused > 0 && (
<>
{" "}
{stats.paused} <PauseIcon className="inline h-3 w-3 text-gray-400" />
</>
)}
/ {stats.total})
</span>
</CardDescription>
</div>
<div className="flex gap-2">
<Input
placeholder={t`Search monitors...`}
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full sm:w-64"
/>
<Button onClick={() => setIsAddDialogOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add</Trans>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">
<Trans>Loading...</Trans>
</div>
) : filteredMonitors.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{filter ? (
<Trans>No monitors match your search.</Trans>
) : (
<div>
<p className="mb-4">
<Trans>No monitors configured yet.</Trans>
</p>
<Button onClick={() => setIsAddDialogOpen(true)} variant="outline">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add your first monitor</Trans>
</Button>
</div>
)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Trans>Name</Trans>
</TableHead>
<TableHead>
<Trans>Type</Trans>
</TableHead>
<TableHead>
<Trans>Status</Trans>
</TableHead>
<TableHead>
<Trans>Response</Trans>
</TableHead>
<TableHead>
<Trans>Uptime (24h)</Trans>
</TableHead>
<TableHead className="text-right">
<Trans>Actions</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredMonitors.map((monitor) => (
<MonitorRow
key={monitor.id}
monitor={monitor}
onEdit={setEditingMonitor}
/>
))}
</TableBody>
</Table>
)}
</CardContent>
{/* Add Monitor Dialog */}
<AddMonitorDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
/>
{/* Edit Monitor Dialog */}
{editingMonitor && (
<AddMonitorDialog
open={!!editingMonitor}
onOpenChange={(open) => !open && setEditingMonitor(null)}
monitor={editingMonitor}
isEdit
/>
)}
</Card>
)
})