mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-07-29 23:33:48 +00:00
Add public monitoring features and CI updates
- Add status pages, incidents, badges, maintenance, bulk ops, and metrics - Add Docker packaging, env example, and frontend routes - Refresh GitHub workflows and project metadata
This commit is contained in:
@@ -15,7 +15,9 @@ import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
@@ -32,15 +34,41 @@ import {
|
||||
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 MONITOR_TYPES: { value: MonitorType; label: string; group: string }[] = [
|
||||
// General
|
||||
{ value: "http", label: "HTTP", group: "General" },
|
||||
{ value: "https", label: "HTTPS", group: "General" },
|
||||
{ value: "keyword", label: "HTTP Keyword", group: "General" },
|
||||
{ value: "json-query", label: "HTTP JSON", group: "General" },
|
||||
{ value: "grpc-keyword", label: "gRPC Keyword", group: "General" },
|
||||
{ value: "real-browser", label: "Browser Engine (Beta)", group: "General" },
|
||||
{ value: "tcp", label: "TCP Port", group: "General" },
|
||||
{ value: "ping", label: "Ping", group: "General" },
|
||||
{ value: "dns", label: "DNS", group: "General" },
|
||||
{ value: "docker", label: "Docker Container", group: "General" },
|
||||
{ value: "push", label: "Push", group: "General" },
|
||||
{ value: "manual", label: "Manual", group: "General" },
|
||||
// Network / Protocol
|
||||
{ value: "mqtt", label: "MQTT", group: "Network / Protocol" },
|
||||
{ value: "rabbitmq", label: "RabbitMQ", group: "Network / Protocol" },
|
||||
{ value: "kafka-producer", label: "Kafka Producer", group: "Network / Protocol" },
|
||||
{ value: "smtp", label: "SMTP", group: "Network / Protocol" },
|
||||
{ value: "snmp", label: "SNMP", group: "Network / Protocol" },
|
||||
{ value: "websocket-upgrade", label: "WebSocket Upgrade", group: "Network / Protocol" },
|
||||
{ value: "sip-options", label: "SIP Options Ping", group: "Network / Protocol" },
|
||||
{ value: "tailscale-ping", label: "Tailscale Ping", group: "Network / Protocol" },
|
||||
{ value: "globalping", label: "Globalping", group: "Network / Protocol" },
|
||||
// Database
|
||||
{ value: "mysql", label: "MySQL / MariaDB", group: "Database" },
|
||||
{ value: "postgresql", label: "PostgreSQL", group: "Database" },
|
||||
{ value: "mongodb", label: "MongoDB", group: "Database" },
|
||||
{ value: "redis", label: "Redis", group: "Database" },
|
||||
{ value: "sqlserver", label: "Microsoft SQL Server", group: "Database" },
|
||||
{ value: "oracledb", label: "Oracle DB", group: "Database" },
|
||||
{ value: "radius", label: "RADIUS", group: "Database" },
|
||||
// Games
|
||||
{ value: "gamedig", label: "GameDig", group: "Game Server" },
|
||||
{ value: "steam", label: "Steam API", group: "Game Server" },
|
||||
]
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"]
|
||||
@@ -88,6 +116,27 @@ export function AddMonitorDialog({
|
||||
const [certExpiryNotification, setCertExpiryNotification] = useState(false)
|
||||
const [certExpiryDays, setCertExpiryDays] = useState(14)
|
||||
|
||||
// Database / network fields
|
||||
const [dbConnectionString, setDbConnectionString] = useState("")
|
||||
const [dbUsername, setDbUsername] = useState("")
|
||||
const [dbPassword, setDbPassword] = useState("")
|
||||
const [dbName, setDbName] = useState("")
|
||||
const [mqttTopic, setMqttTopic] = useState("")
|
||||
const [grpcKeyword, setGrpcKeyword] = useState("")
|
||||
|
||||
// Notification settings
|
||||
const [notifyOnDown, setNotifyOnDown] = useState(true)
|
||||
const [notifyOnRecover, setNotifyOnRecover] = useState(true)
|
||||
const [notifyOnResponseTime, setNotifyOnResponseTime] = useState(false)
|
||||
const [responseTimeThreshold, setResponseTimeThreshold] = useState(1000)
|
||||
const [notifyOnUptimeDrop, setNotifyOnUptimeDrop] = useState(false)
|
||||
const [uptimeThreshold, setUptimeThreshold] = useState(95)
|
||||
const [notifyRepeatedFailures, setNotifyRepeatedFailures] = useState(true)
|
||||
const [consecutiveFailures, setConsecutiveFailures] = useState(3)
|
||||
const [quietHoursStart, setQuietHoursStart] = useState("22:00")
|
||||
const [quietHoursEnd, setQuietHoursEnd] = useState("08:00")
|
||||
const [quietHoursEnabled, setQuietHoursEnabled] = useState(false)
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -114,6 +163,19 @@ export function AddMonitorDialog({
|
||||
setIgnoreTLSError(monitor.ignore_tls_error || false)
|
||||
setCertExpiryNotification(monitor.cert_expiry_notification || false)
|
||||
setCertExpiryDays(monitor.cert_expiry_days || 14)
|
||||
|
||||
// Load notification settings
|
||||
setNotifyOnDown(monitor.notify_on_down !== false)
|
||||
setNotifyOnRecover(monitor.notify_on_recover !== false)
|
||||
setNotifyOnResponseTime(monitor.notify_on_response_time || false)
|
||||
setResponseTimeThreshold(monitor.response_time_threshold || 1000)
|
||||
setNotifyOnUptimeDrop(monitor.notify_on_uptime_drop || false)
|
||||
setUptimeThreshold(monitor.uptime_threshold || 95)
|
||||
setNotifyRepeatedFailures(monitor.notify_repeated_failures !== false)
|
||||
setConsecutiveFailures(monitor.consecutive_failures || 3)
|
||||
setQuietHoursStart(monitor.quiet_hours_start || "22:00")
|
||||
setQuietHoursEnd(monitor.quiet_hours_end || "08:00")
|
||||
setQuietHoursEnabled(monitor.quiet_hours_enabled || false)
|
||||
} else {
|
||||
// Reset to defaults for new monitor
|
||||
setName("")
|
||||
@@ -137,6 +199,19 @@ export function AddMonitorDialog({
|
||||
setIgnoreTLSError(false)
|
||||
setCertExpiryNotification(false)
|
||||
setCertExpiryDays(14)
|
||||
|
||||
// Reset notification settings
|
||||
setNotifyOnDown(true)
|
||||
setNotifyOnRecover(true)
|
||||
setNotifyOnResponseTime(false)
|
||||
setResponseTimeThreshold(1000)
|
||||
setNotifyOnUptimeDrop(false)
|
||||
setUptimeThreshold(95)
|
||||
setNotifyRepeatedFailures(true)
|
||||
setConsecutiveFailures(3)
|
||||
setQuietHoursStart("22:00")
|
||||
setQuietHoursEnd("08:00")
|
||||
setQuietHoursEnabled(false)
|
||||
}
|
||||
setActiveTab("basic")
|
||||
}
|
||||
@@ -186,8 +261,8 @@ export function AddMonitorDialog({
|
||||
if (isEdit && monitor) {
|
||||
const data: UpdateMonitorRequest = {
|
||||
name: name.trim(),
|
||||
url: url.trim() || undefined,
|
||||
hostname: hostname.trim() || undefined,
|
||||
url: needsDbOptions ? dbConnectionString.trim() || undefined : url.trim() || undefined,
|
||||
hostname: needsHostname ? hostname.trim() || undefined : undefined,
|
||||
port: port ? Number(port) : undefined,
|
||||
method: ["http", "https", "keyword", "json-query"].includes(type)
|
||||
? method
|
||||
@@ -210,14 +285,32 @@ export function AddMonitorDialog({
|
||||
: undefined,
|
||||
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
|
||||
cert_expiry_days: type === "https" ? certExpiryDays : undefined,
|
||||
// Notification settings
|
||||
notify_on_down: notifyOnDown,
|
||||
notify_on_recover: notifyOnRecover,
|
||||
notify_on_response_time: notifyOnResponseTime,
|
||||
response_time_threshold: notifyOnResponseTime ? responseTimeThreshold : undefined,
|
||||
notify_on_uptime_drop: notifyOnUptimeDrop,
|
||||
uptime_threshold: notifyOnUptimeDrop ? uptimeThreshold : undefined,
|
||||
notify_repeated_failures: notifyRepeatedFailures,
|
||||
consecutive_failures: consecutiveFailures,
|
||||
quiet_hours_enabled: quietHoursEnabled,
|
||||
quiet_hours_start: quietHoursEnabled ? quietHoursStart : undefined,
|
||||
quiet_hours_end: quietHoursEnabled ? quietHoursEnd : undefined,
|
||||
// Database / network extra fields
|
||||
db_username: needsDbOptions ? dbUsername.trim() || undefined : undefined,
|
||||
db_password: needsDbOptions ? dbPassword.trim() || undefined : undefined,
|
||||
db_name: needsDbOptions ? dbName.trim() || undefined : undefined,
|
||||
mqtt_topic: needsMqttOptions ? mqttTopic.trim() || undefined : undefined,
|
||||
grpc_keyword: needsGrpcOptions ? grpcKeyword.trim() || undefined : undefined,
|
||||
}
|
||||
updateMutation.mutate({ id: monitor.id, data })
|
||||
} else {
|
||||
const data: CreateMonitorRequest = {
|
||||
name: name.trim(),
|
||||
type,
|
||||
url: url.trim() || undefined,
|
||||
hostname: hostname.trim() || undefined,
|
||||
url: needsDbOptions ? dbConnectionString.trim() || undefined : url.trim() || undefined,
|
||||
hostname: needsHostname ? hostname.trim() || undefined : undefined,
|
||||
port: port ? Number(port) : undefined,
|
||||
method: ["http", "https", "keyword", "json-query"].includes(type)
|
||||
? method
|
||||
@@ -240,19 +333,40 @@ export function AddMonitorDialog({
|
||||
: undefined,
|
||||
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
|
||||
cert_expiry_days: type === "https" ? certExpiryDays : undefined,
|
||||
// Notification settings
|
||||
notify_on_down: notifyOnDown,
|
||||
notify_on_recover: notifyOnRecover,
|
||||
notify_on_response_time: notifyOnResponseTime,
|
||||
response_time_threshold: notifyOnResponseTime ? responseTimeThreshold : undefined,
|
||||
notify_on_uptime_drop: notifyOnUptimeDrop,
|
||||
uptime_threshold: notifyOnUptimeDrop ? uptimeThreshold : undefined,
|
||||
notify_repeated_failures: notifyRepeatedFailures,
|
||||
consecutive_failures: consecutiveFailures,
|
||||
quiet_hours_enabled: quietHoursEnabled,
|
||||
quiet_hours_start: quietHoursEnabled ? quietHoursStart : undefined,
|
||||
quiet_hours_end: quietHoursEnabled ? quietHoursEnd : undefined,
|
||||
// Database / network extra fields
|
||||
db_username: needsDbOptions ? dbUsername.trim() || undefined : undefined,
|
||||
db_password: needsDbOptions ? dbPassword.trim() || undefined : undefined,
|
||||
db_name: needsDbOptions ? dbName.trim() || undefined : undefined,
|
||||
mqtt_topic: needsMqttOptions ? mqttTopic.trim() || undefined : undefined,
|
||||
grpc_keyword: needsGrpcOptions ? grpcKeyword.trim() || undefined : 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 needsUrl = ["http", "https", "keyword", "json-query", "grpc-keyword", "real-browser", "websocket-upgrade", "push"].includes(type)
|
||||
const needsHostname = ["tcp", "ping", "dns", "mqtt", "rabbitmq", "kafka-producer", "smtp", "snmp", "sip-options", "tailscale-ping", "globalping", "mysql", "postgresql", "mongodb", "redis", "sqlserver", "oracledb", "radius", "gamedig", "steam"].includes(type)
|
||||
const needsPort = ["tcp", "smtp", "mysql", "postgresql", "redis", "sqlserver", "oracledb", "radius", "mqtt", "rabbitmq", "kafka-producer", "gamedig", "steam", "snmp"].includes(type)
|
||||
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 needsDbOptions = ["mysql", "postgresql", "mongodb", "redis", "sqlserver", "oracledb", "radius"].includes(type)
|
||||
const needsMqttOptions = type === "mqtt"
|
||||
const needsGrpcOptions = type === "grpc-keyword"
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
@@ -296,6 +410,8 @@ export function AddMonitorDialog({
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -311,10 +427,15 @@ export function AddMonitorDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONITOR_TYPES.map((mt) => (
|
||||
<SelectItem key={mt.value} value={mt.value}>
|
||||
{mt.label}
|
||||
</SelectItem>
|
||||
{["General", "Network / Protocol", "Database", "Game Server"].map((group) => (
|
||||
<SelectGroup key={group}>
|
||||
<SelectLabel>{group}</SelectLabel>
|
||||
{MONITOR_TYPES.filter((mt) => mt.group === group).map((mt) => (
|
||||
<SelectItem key={mt.value} value={mt.value}>
|
||||
{mt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -479,6 +600,90 @@ export function AddMonitorDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsDbOptions && (
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<h4 className="font-medium text-sm">
|
||||
<Trans>Database Connection</Trans>
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dbConnectionString">
|
||||
<Trans>Host / Connection String</Trans> *
|
||||
</Label>
|
||||
<Input
|
||||
id="dbConnectionString"
|
||||
placeholder={t`localhost:3306`}
|
||||
value={dbConnectionString}
|
||||
onChange={(e) => setDbConnectionString(e.target.value)}
|
||||
required={needsDbOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dbUsername">
|
||||
<Trans>Username</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="dbUsername"
|
||||
placeholder={t`root`}
|
||||
value={dbUsername}
|
||||
onChange={(e) => setDbUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dbPassword">
|
||||
<Trans>Password</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="dbPassword"
|
||||
type="password"
|
||||
placeholder={t`password`}
|
||||
value={dbPassword}
|
||||
onChange={(e) => setDbPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dbName">
|
||||
<Trans>Database Name</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="dbName"
|
||||
placeholder={t`mydb`}
|
||||
value={dbName}
|
||||
onChange={(e) => setDbName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsMqttOptions && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mqttTopic">
|
||||
<Trans>MQTT Topic</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="mqttTopic"
|
||||
placeholder={t`sensor/temperature`}
|
||||
value={mqttTopic}
|
||||
onChange={(e) => setMqttTopic(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsGrpcOptions && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="grpcKeyword">
|
||||
<Trans>gRPC Keyword</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="grpcKeyword"
|
||||
placeholder={t`health`}
|
||||
value={grpcKeyword}
|
||||
onChange={(e) => setGrpcKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
<Trans>Description</Trans>
|
||||
@@ -583,54 +788,193 @@ export function AddMonitorDialog({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications" className="space-y-4 mt-4">
|
||||
{/* Status Change Notifications */}
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<h4 className="font-medium text-sm">Status Change Alerts</h4>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifyOnDown">Notify when monitor goes down</Label>
|
||||
<p className="text-xs text-muted-foreground">Send alert when service becomes unavailable</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifyOnDown"
|
||||
checked={notifyOnDown}
|
||||
onCheckedChange={setNotifyOnDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifyOnRecover">Notify when monitor recovers</Label>
|
||||
<p className="text-xs text-muted-foreground">Send alert when service comes back up</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifyOnRecover"
|
||||
checked={notifyOnRecover}
|
||||
onCheckedChange={setNotifyOnRecover}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifyRepeatedFailures">Repeated failures only</Label>
|
||||
<p className="text-xs text-muted-foreground">Only alert after multiple consecutive failures</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifyRepeatedFailures"
|
||||
checked={notifyRepeatedFailures}
|
||||
onCheckedChange={setNotifyRepeatedFailures}
|
||||
/>
|
||||
</div>
|
||||
{notifyRepeatedFailures && (
|
||||
<div className="grid gap-2 pl-4 border-l-2">
|
||||
<Label htmlFor="consecutiveFailures">Consecutive failures before alert</Label>
|
||||
<Input
|
||||
id="consecutiveFailures"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={consecutiveFailures}
|
||||
onChange={(e) => setConsecutiveFailures(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Alerts */}
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<h4 className="font-medium text-sm">Performance Alerts</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifyOnResponseTime">Response time threshold</Label>
|
||||
<p className="text-xs text-muted-foreground">Alert when response time exceeds limit</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifyOnResponseTime"
|
||||
checked={notifyOnResponseTime}
|
||||
onCheckedChange={setNotifyOnResponseTime}
|
||||
/>
|
||||
</div>
|
||||
{notifyOnResponseTime && (
|
||||
<div className="grid gap-2 pl-4 border-l-2">
|
||||
<Label htmlFor="responseTimeThreshold">Max response time (ms)</Label>
|
||||
<Input
|
||||
id="responseTimeThreshold"
|
||||
type="number"
|
||||
min={100}
|
||||
max={60000}
|
||||
step={100}
|
||||
value={responseTimeThreshold}
|
||||
onChange={(e) => setResponseTimeThreshold(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifyOnUptimeDrop">Uptime threshold</Label>
|
||||
<p className="text-xs text-muted-foreground">Alert when uptime percentage drops below</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifyOnUptimeDrop"
|
||||
checked={notifyOnUptimeDrop}
|
||||
onCheckedChange={setNotifyOnUptimeDrop}
|
||||
/>
|
||||
</div>
|
||||
{notifyOnUptimeDrop && (
|
||||
<div className="grid gap-2 pl-4 border-l-2">
|
||||
<Label htmlFor="uptimeThreshold">Minimum uptime (%)</Label>
|
||||
<Input
|
||||
id="uptimeThreshold"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={uptimeThreshold}
|
||||
onChange={(e) => setUptimeThreshold(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certificate Expiry */}
|
||||
{needsTlsOptions && (
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-sm">Certificate Alerts</h4>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="certExpiryNotification">Notify when certificate expires</Label>
|
||||
<p className="text-xs text-muted-foreground">Alert before SSL certificate expiry</p>
|
||||
</div>
|
||||
<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>
|
||||
<div className="grid gap-2 pl-4 border-l-2">
|
||||
<Label htmlFor="certExpiryDays">Days before expiry to notify</Label>
|
||||
<Input
|
||||
id="certExpiryDays"
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={certExpiryDays}
|
||||
onChange={(e) =>
|
||||
setCertExpiryDays(Number(e.target.value))
|
||||
}
|
||||
onChange={(e) => setCertExpiryDays(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</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>
|
||||
{/* Quiet Hours */}
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<h4 className="font-medium text-sm">Quiet Hours</h4>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="quietHoursEnabled">Enable quiet hours</Label>
|
||||
<p className="text-xs text-muted-foreground">Suppress notifications during specific hours</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="quietHoursEnabled"
|
||||
checked={quietHoursEnabled}
|
||||
onCheckedChange={setQuietHoursEnabled}
|
||||
/>
|
||||
</div>
|
||||
{quietHoursEnabled && (
|
||||
<div className="grid grid-cols-2 gap-4 pl-4 border-l-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="quietHoursStart">Start time</Label>
|
||||
<Input
|
||||
id="quietHoursStart"
|
||||
type="time"
|
||||
value={quietHoursStart}
|
||||
onChange={(e) => setQuietHoursStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="quietHoursEnd">End time</Label>
|
||||
<Input
|
||||
id="quietHoursEnd"
|
||||
type="time"
|
||||
value={quietHoursEnd}
|
||||
onChange={(e) => setQuietHoursEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -3,14 +3,20 @@ import { useStore } from "@nanostores/react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpIcon,
|
||||
CheckCircleIcon,
|
||||
Edit3Icon,
|
||||
EyeIcon,
|
||||
FilterIcon,
|
||||
GlobeIcon,
|
||||
LayoutGridIcon,
|
||||
LayoutListIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
Settings2Icon,
|
||||
Trash2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react"
|
||||
@@ -23,17 +29,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -64,7 +68,7 @@ import {
|
||||
formatUptime,
|
||||
formatPing,
|
||||
} from "@/lib/monitors"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn, useBrowserStorage } from "@/lib/utils"
|
||||
import { AddMonitorDialog } from "./add-monitor-dialog"
|
||||
import { Link } from "@/components/router"
|
||||
|
||||
@@ -97,6 +101,158 @@ function StatusIndicator({ status }: { status: MonitorStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Monitor Card component for grid view
|
||||
function MonitorCard({
|
||||
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"] })
|
||||
},
|
||||
})
|
||||
|
||||
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 (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<Link href={`/monitor/${monitor.id}`} className="flex items-center gap-3 cursor-pointer min-w-0">
|
||||
<div className="shrink-0">
|
||||
<StatusIndicator status={monitor.status} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate hover:underline">{monitor.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{monitor.url || monitor.hostname}
|
||||
{monitor.port ? `:${monitor.port}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
|
||||
<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
|
||||
onClick={() => deleteMutation.mutate(monitor.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
<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">
|
||||
<div className="text-xs text-muted-foreground">Response</div>
|
||||
<div>
|
||||
{monitor.last_check ? (
|
||||
formatPing(monitor.uptime_stats?.last_ping || 0)
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => checkMutation.mutate(monitor.id)}
|
||||
disabled={checkMutation.isPending}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"h-4 w-4 mr-1",
|
||||
checkMutation.isPending && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
Check
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Check now</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => pauseMutation.mutate(monitor.id)}
|
||||
disabled={pauseMutation.isPending}
|
||||
>
|
||||
{monitor.status === "paused" ? (
|
||||
<><PlayIcon className="h-4 w-4 mr-1" /> Resume</>
|
||||
) : (
|
||||
<><PauseIcon className="h-4 w-4 mr-1" /> Pause</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{monitor.status === "paused" ? "Resume" : "Pause"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Uptime bar component
|
||||
function UptimeBar({ stats }: { stats?: Record<string, number> }) {
|
||||
const uptime24h = stats?.uptime_24h ?? 100
|
||||
@@ -275,79 +431,161 @@ function MonitorRow({
|
||||
)
|
||||
}
|
||||
|
||||
type ViewMode = "table" | "grid"
|
||||
type StatusFilter = "all" | MonitorStatus
|
||||
|
||||
// Main component
|
||||
export default memo(function MonitorsTable() {
|
||||
const { t } = useLingui()
|
||||
const { t, i18n } = useLingui()
|
||||
const [filter, setFilter] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [editingMonitor, setEditingMonitor] = useState<Monitor | null>(null)
|
||||
|
||||
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
|
||||
"monitorsViewMode",
|
||||
window.innerWidth < 1024 ? "grid" : "table"
|
||||
)
|
||||
|
||||
const { data: monitors = [], isLoading } = useQuery({
|
||||
queryKey: ["monitors"],
|
||||
queryFn: listMonitors,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
// Filter by status first
|
||||
const statusFilteredMonitors = useMemo(() => {
|
||||
if (statusFilter === "all") return monitors
|
||||
return monitors.filter((m) => m.status === statusFilter)
|
||||
}, [monitors, statusFilter])
|
||||
|
||||
// Then filter by search text
|
||||
const filteredMonitors = useMemo(() => {
|
||||
if (!filter) return monitors
|
||||
if (!filter) return statusFilteredMonitors
|
||||
const f = filter.toLowerCase()
|
||||
return monitors.filter(
|
||||
return statusFilteredMonitors.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(f) ||
|
||||
(m.url || "").toLowerCase().includes(f) ||
|
||||
(m.hostname || "").toLowerCase().includes(f)
|
||||
)
|
||||
}, [monitors, filter])
|
||||
}, [statusFilteredMonitors, 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 }
|
||||
const pending = monitors.filter((m) => m.status === "pending").length
|
||||
const maintenance = monitors.filter((m) => m.status === "maintenance").length
|
||||
return { total, up, down, paused, pending, maintenance }
|
||||
}, [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)}>
|
||||
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
<CardHeader className="p-0 pb-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Title row */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl mb-2 flex items-center gap-2">
|
||||
<GlobeIcon className="h-5 w-5 text-primary" />
|
||||
<Trans>Website & Service Monitoring</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Trans>Monitor websites, APIs, and services</Trans>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({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>
|
||||
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add</Trans>
|
||||
<Trans>Add Monitor</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder={t`Filter monitors...`}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
value={filter}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Settings2Icon className="me-1.5 size-4 opacity-80" />
|
||||
<Trans>View</Trans>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-48">
|
||||
{/* Layout */}
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<LayoutGridIcon className="size-4" />
|
||||
<Trans>Layout</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
|
||||
<DropdownMenuRadioItem value="table" className="gap-2">
|
||||
<LayoutListIcon className="size-4" />
|
||||
<Trans>Table</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="grid" className="gap-2">
|
||||
<LayoutGridIcon className="size-4" />
|
||||
<Trans>Grid</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Status Filter */}
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<FilterIcon className="size-4" />
|
||||
<Trans>Status</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<DropdownMenuRadioItem value="all">
|
||||
<Trans>All ({stats.total})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="up">
|
||||
<Trans>Up ({stats.up})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="down">
|
||||
<Trans>Down ({stats.down})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="paused">
|
||||
<Trans>Paused ({stats.paused})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
{stats.pending > 0 && (
|
||||
<DropdownMenuRadioItem value="pending">
|
||||
<Trans>Pending ({stats.pending})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{stats.maintenance > 0 && (
|
||||
<DropdownMenuRadioItem value="maintenance">
|
||||
<Trans>Maintenance ({stats.maintenance})</Trans>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
@@ -357,8 +595,8 @@ export default memo(function MonitorsTable() {
|
||||
</div>
|
||||
) : filteredMonitors.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
{filter ? (
|
||||
<Trans>No monitors match your search.</Trans>
|
||||
{filter || statusFilter !== "all" ? (
|
||||
<Trans>No monitors match your filters.</Trans>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-4">
|
||||
@@ -371,7 +609,7 @@ export default memo(function MonitorsTable() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : viewMode === "table" ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -405,6 +643,16 @@ export default memo(function MonitorsTable() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredMonitors.map((monitor) => (
|
||||
<MonitorCard
|
||||
key={monitor.id}
|
||||
monitor={monitor}
|
||||
onEdit={setEditingMonitor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user