mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-07-29 23:33:48 +00:00
Initial commit: Beszel fork with Domain Locker integration
This commit is contained in:
@@ -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>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user