feat: Add & Enhance Notification Channel and Alert Template to Add/Edit Domain Form

- Add multi-select for notification channels and an alert template field to the Add/Edit SSL Certificate dialog
This commit is contained in:
Tola Leng
2025-08-04 21:59:04 +07:00
parent f4cc96733c
commit 96bf456338
6 changed files with 273 additions and 110 deletions
@@ -1,24 +1,28 @@
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { toast } from "sonner";
import { Bell } from "lucide-react";
import { Bell, X } from "lucide-react";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { AddSSLCertificateDto } from "@/types/ssl.types";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { sslNotificationTemplateService, SslNotificationTemplate } from "@/services/sslNotificationTemplateService";
import { useLanguage } from "@/contexts/LanguageContext";
const formSchema = z.object({
domain: z.string().min(1, "Domain is required"),
warning_threshold: z.coerce.number().int().min(1).max(365),
expiry_threshold: z.coerce.number().int().min(1).max(30),
notification_channel: z.string().optional(), // Make it optional to allow empty string for "None"
notification_channels: z.array(z.string()).optional(),
alert_template: z.string().optional(),
check_interval: z.coerce.number().int().min(1).max(30).optional()
});
@@ -35,6 +39,7 @@ export const AddSSLCertificateForm = ({
}: AddSSLCertificateFormProps) => {
const { t } = useLanguage();
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [sslTemplates, setSslTemplates] = useState<SslNotificationTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
@@ -43,58 +48,78 @@ export const AddSSLCertificateForm = ({
domain: "",
warning_threshold: 30,
expiry_threshold: 7,
notification_channel: "none",
notification_channels: [],
alert_template: "none",
check_interval: 1
}
});
// Fetch notification channels when form loads
// Fetch notification channels and SSL templates when form loads
useEffect(() => {
const fetchNotificationChannels = async () => {
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch notification channels
const configs = await alertConfigService.getAlertConfigurations();
// console.log("Fetched notification channels:", configs);
// Only include enabled channels
const enabledConfigs = configs.filter(config => {
// Handle the possibility of enabled being a string
if (typeof config.enabled === 'string') {
return config.enabled === "true";
}
// Otherwise treat as boolean
return config.enabled === true;
});
setAlertConfigs(enabledConfigs);
// Fetch SSL notification templates
const templates = await sslNotificationTemplateService.getTemplates();
setSslTemplates(templates);
} catch (error) {
// console.error("Error fetching notification channels:", error);
toast.error(t('failedToLoadCertificates'));
} finally {
setIsLoading(false);
}
};
fetchNotificationChannels();
}, [form, t]);
fetchData();
}, [t]);
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
try {
// Convert the form values to the required DTO format with required properties
// Convert the form values to the required DTO format
const certData: AddSSLCertificateDto = {
domain: values.domain,
warning_threshold: values.warning_threshold,
expiry_threshold: values.expiry_threshold,
notification_channel: values.notification_channel === "none" ? "" : (values.notification_channel || ""), // Convert "none" to empty string
notification_channel: values.notification_channels && values.notification_channels.length > 0
? values.notification_channels.join(',')
: '',
notification_id: values.notification_channels && values.notification_channels.length > 0
? values.notification_channels.join(',')
: '',
template_id: values.alert_template && values.alert_template !== 'none' ? values.alert_template : '',
check_interval: values.check_interval
};
await onSubmit(certData);
form.reset();
} catch (error) {
// console.error("Error adding SSL certificate:", error);
toast.error(t('failedToAddCertificate'));
}
};
const handleAddNotificationChannel = (channelId: string) => {
const currentChannels = form.getValues("notification_channels") || [];
if (!currentChannels.includes(channelId)) {
form.setValue("notification_channels", [...currentChannels, channelId]);
}
};
const handleRemoveNotificationChannel = (channelId: string) => {
const currentChannels = form.getValues("notification_channels") || [];
form.setValue("notification_channels", currentChannels.filter(id => id !== channelId));
};
const selectedChannels = form.watch("notification_channels") || [];
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
@@ -167,31 +192,60 @@ export const AddSSLCertificateForm = ({
<FormField
control={form.control}
name="notification_channel"
name="notification_channels"
render={({ field }) => (
<FormItem>
<FormLabel>{t('notificationChannel')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "none"}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('chooseChannel')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">{t('none')}</SelectItem>
{alertConfigs.length > 0 ? (
alertConfigs.map((config) => (
<SelectItem key={config.id} value={config.id || ""}>
{config.notify_name} ({config.notification_type})
</SelectItem>
))
) : isLoading ? (
<SelectItem value="loading" disabled>{t('loadingChannels')}</SelectItem>
) : (
<SelectItem value="none-disabled" disabled>{t('noChannelsFound')}</SelectItem>
)}
</SelectContent>
</Select>
<FormLabel>{t('notificationChannel')} (Multi-select)</FormLabel>
<div className="space-y-3">
<Select
onValueChange={handleAddNotificationChannel}
value=""
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select channels to add..." />
</SelectTrigger>
</FormControl>
<SelectContent>
{alertConfigs.length > 0 ? (
alertConfigs
.filter(config => config.id && !selectedChannels.includes(config.id))
.map((config) => (
<SelectItem key={config.id} value={config.id || "unknown"}>
{config.notify_name} ({config.notification_type})
</SelectItem>
))
) : isLoading ? (
<SelectItem value="loading-placeholder" disabled>{t('loadingChannels')}</SelectItem>
) : (
<SelectItem value="no-channels-placeholder" disabled>{t('noChannelsFound')}</SelectItem>
)}
</SelectContent>
</Select>
{/* Display selected channels */}
{selectedChannels.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedChannels.map((channelId) => {
const channel = alertConfigs.find(config => config.id === channelId);
return (
<Badge key={channelId} variant="secondary" className="flex items-center gap-1">
{channel ? `${channel.notify_name} (${channel.notification_type})` : channelId}
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0 ml-1"
onClick={() => handleRemoveNotificationChannel(channelId)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
);
})}
</div>
)}
</div>
<FormDescription className="flex items-center gap-1">
<Bell className="h-4 w-4" />
{t('whereToSend')}
@@ -200,6 +254,41 @@ export const AddSSLCertificateForm = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="alert_template"
render={({ field }) => (
<FormItem>
<FormLabel>Alert Template</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "none"}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose an alert template (optional)" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{sslTemplates.length > 0 ? (
sslTemplates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))
) : isLoading ? (
<SelectItem value="loading-templates" disabled>Loading templates...</SelectItem>
) : (
<SelectItem value="no-templates-found" disabled>No templates found</SelectItem>
)}
</SelectContent>
</Select>
<FormDescription>
Template for SSL certificate alert messages
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>{t('cancel')}</Button>
@@ -1,4 +1,3 @@
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -7,17 +6,20 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Badge } from "@/components/ui/badge";
import { SSLCertificate } from "@/types/ssl.types";
import { Loader2, Bell } from "lucide-react";
import { Loader2, Bell, X } from "lucide-react";
import { toast } from "sonner";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { sslNotificationTemplateService, SslNotificationTemplate } from "@/services/sslNotificationTemplateService";
import { useLanguage } from "@/contexts/LanguageContext";
const formSchema = z.object({
domain: z.string().min(1, "Domain is required"),
warning_threshold: z.coerce.number().min(1, "Warning threshold must be at least 1 day"),
expiry_threshold: z.coerce.number().min(1, "Expiry threshold must be at least 1 day"),
notification_channel: z.string().min(1, "Notification channel is required"),
notification_channels: z.array(z.string()).default([]),
alert_template: z.string().optional(),
check_interval: z.coerce.number().int().min(1).max(30).optional(),
});
@@ -33,6 +35,7 @@ interface EditSSLCertificateFormProps {
export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPending }: EditSSLCertificateFormProps) => {
const { t } = useLanguage();
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [sslTemplates, setSslTemplates] = useState<SslNotificationTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
@@ -41,58 +44,81 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
domain: certificate.domain,
warning_threshold: certificate.warning_threshold,
expiry_threshold: certificate.expiry_threshold,
notification_channel: certificate.notification_channel,
notification_channels: certificate.notification_channel ? certificate.notification_channel.split(',') : [],
alert_template: certificate.alert_template || "none",
check_interval: certificate.check_interval || 1,
},
});
// Fetch notification channels when form loads
const notificationChannels = form.watch("notification_channels") || [];
// Fetch notification channels and SSL templates when form loads
useEffect(() => {
const fetchNotificationChannels = async () => {
const fetchData = async () => {
setIsLoading(true);
try {
// Fetch notification channels
const configs = await alertConfigService.getAlertConfigurations();
// console.log("Fetched notification channels:", configs);
// Only include enabled channels
const enabledConfigs = configs.filter(config => {
// Handle the possibility of enabled being a string
if (typeof config.enabled === 'string') {
return config.enabled === "true";
}
// Otherwise treat as boolean
return config.enabled === true;
});
setAlertConfigs(enabledConfigs);
// Fetch SSL notification templates
const templates = await sslNotificationTemplateService.getTemplates();
setSslTemplates(templates);
} catch (error) {
// console.error("Error fetching notification channels:", error);
toast.error(t('failedToLoadCertificates'));
} finally {
setIsLoading(false);
}
};
fetchNotificationChannels();
fetchData();
}, [t]);
const handleChannelAdd = (channelId: string) => {
const currentChannels = form.getValues("notification_channels") || [];
if (!currentChannels.includes(channelId)) {
form.setValue("notification_channels", [...currentChannels, channelId]);
}
};
const handleChannelRemove = (channelId: string) => {
const currentChannels = form.getValues("notification_channels") || [];
form.setValue("notification_channels", currentChannels.filter(id => id !== channelId));
};
const getSelectedChannelNames = () => {
return (notificationChannels || []).map(channelId => {
const config = alertConfigs.find(c => c.id === channelId);
return config ? `${config.notify_name} (${config.notification_type})` : channelId;
});
};
const handleSubmit = (data: FormValues) => {
// Merge the updated values with the original certificate
const updatedCertificate: SSLCertificate = {
...certificate,
...data,
// Save notification channels as comma-separated string for notification_channel field
notification_channel: data.notification_channels.length > 0 ? data.notification_channels.join(',') : '',
// Save notification channels as comma-separated string for notification_id field (for PocketBase)
notification_id: data.notification_channels.length > 0 ? data.notification_channels.join(',') : '',
// Save alert template as template_id field (for PocketBase) - handle "none" value
template_id: data.alert_template && data.alert_template !== 'none' ? data.alert_template : '',
// Ensure values are correctly typed as numbers
warning_threshold: Number(data.warning_threshold),
expiry_threshold: Number(data.expiry_threshold),
check_interval: data.check_interval ? Number(data.check_interval) : undefined
};
console.log("Submitting updated certificate:", updatedCertificate);
onSubmit(updatedCertificate);
};
// For debugging
console.log("Certificate data:", certificate);
console.log("Form default values:", form.getValues());
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
@@ -106,7 +132,7 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
<Input
{...field}
placeholder="example.com"
disabled={true} // Domain shouldn't be editable
disabled={true}
/>
</FormControl>
<FormDescription>
@@ -188,41 +214,52 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
<FormField
control={form.control}
name="notification_channel"
name="notification_channels"
render={({ field }) => (
<FormItem>
<FormLabel>{t('notificationChannel')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
disabled={isLoading}
>
<FormControl>
<FormDescription>
Select multiple notification channels for this SSL certificate
</FormDescription>
{/* Display selected channels as badges */}
{notificationChannels && notificationChannels.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{getSelectedChannelNames().map((channelName, index) => (
<Badge key={notificationChannels[index]} variant="secondary" className="flex items-center gap-1">
{channelName}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => handleChannelRemove(notificationChannels[index])}
/>
</Badge>
))}
</div>
)}
<FormControl>
<Select
onValueChange={handleChannelAdd}
disabled={isLoading}
value=""
>
<SelectTrigger>
<SelectValue placeholder={t('chooseChannel')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{alertConfigs.length > 0 ? (
alertConfigs.map((config) => (
<SelectItem key={config.id} value={config.id || ""}>
{config.notify_name} ({config.notification_type})
</SelectItem>
))
) : isLoading ? (
<SelectItem value="loading" disabled>{t('loadingChannels')}</SelectItem>
) : (
<>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
<SelectItem value="none">None</SelectItem>
</>
)}
</SelectContent>
</Select>
<SelectContent>
{alertConfigs
.filter(config => config.id && !notificationChannels?.includes(config.id))
.map((config) => (
<SelectItem key={config.id} value={config.id || "unknown"}>
{config.notify_name} ({config.notification_type})
</SelectItem>
))}
{alertConfigs.filter(config => config.id && !notificationChannels?.includes(config.id)).length === 0 && (
<SelectItem value="no-available-channels" disabled>No available channels</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription className="flex items-center gap-1">
<Bell className="h-4 w-4" />
{t('whereToSend')}
@@ -232,6 +269,45 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
)}
/>
<FormField
control={form.control}
name="alert_template"
render={({ field }) => (
<FormItem>
<FormLabel>Alert Template</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder={isLoading ? "Loading templates..." : "Select an alert template"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{sslTemplates?.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
{sslTemplates?.length === 0 && !isLoading && (
<SelectItem value="no-templates-available" disabled>No templates found</SelectItem>
)}
{isLoading && (
<SelectItem value="loading-templates-placeholder" disabled>Loading templates...</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Choose a template for SSL certificate alert messages
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
@@ -1,4 +1,3 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@@ -30,12 +29,9 @@ export const SSLDomainContent = () => {
queryKey: ['ssl-certificates'],
queryFn: async () => {
try {
// console.log("Fetching SSL certificates from SSLDomainContent...");
const result = await fetchSSLCertificates();
// console.log("Received SSL certificates:", result);
return result;
} catch (error) {
// console.error("Error fetching certificates:", error);
toast.error(t('failedToLoadCertificates'));
throw error;
}
@@ -53,30 +49,28 @@ export const SSLDomainContent = () => {
toast.success(t('sslCertificateAdded'));
},
onError: (error) => {
console.error("Error adding SSL certificate:", error);
toast.error(error instanceof Error ? error.message : t('failedToAddCertificate'));
}
});
// Edit certificate mutation - Updated to ensure thresholds are properly updated
// Edit certificate mutation - Updated to include notification_id and template_id
const editMutation = useMutation({
mutationFn: async (certificate: SSLCertificate) => {
// console.log("Updating certificate with data:", certificate);
// Create the update data object
// Create the update data object with new fields
const updateData = {
warning_threshold: Number(certificate.warning_threshold),
expiry_threshold: Number(certificate.expiry_threshold),
notification_channel: certificate.notification_channel,
notification_id: certificate.notification_id || '', // Multi notification channels
template_id: certificate.template_id || '', // Alert template ID
check_interval: certificate.check_interval,
};
// console.log("Update data to be sent:", updateData);
// Update certificate in the database using PocketBase directly
const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData);
// console.log("PocketBase update response:", updated);
// After updating the settings, refresh the certificate to ensure it's up to date
// This will also check if notification needs to be sent based on updated thresholds
const refreshedCert = await checkAndUpdateCertificate(certificate.id);
@@ -90,7 +84,6 @@ export const SSLDomainContent = () => {
toast.success(t('sslCertificateUpdated'));
},
onError: (error) => {
// console.error("Error updating SSL certificate:", error);
toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate'));
}
});
@@ -103,7 +96,6 @@ export const SSLDomainContent = () => {
toast.success(t('sslCertificateDeleted'));
},
onError: (error) => {
// console.error("Error deleting SSL certificate:", error);
toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate'));
}
});
@@ -117,7 +109,6 @@ export const SSLDomainContent = () => {
// Removed individual success toast notification
},
onError: (error) => {
// console.error("Error refreshing SSL certificate:", error);
setRefreshingId(null);
// Still refresh the data to show any partial information that was saved
@@ -142,7 +133,6 @@ export const SSLDomainContent = () => {
}
},
onError: (error) => {
// console.error("Error refreshing all certificates:", error);
toast.error(t('failedToCheckCertificate'));
setIsRefreshingAll(false);
@@ -167,7 +157,6 @@ export const SSLDomainContent = () => {
};
const handleUpdateCertificate = (certificate: SSLCertificate) => {
console.log("Handling certificate update with data:", certificate);
editMutation.mutate(certificate);
};
@@ -32,6 +32,8 @@ export const addSSLCertificate = async (
warning_threshold: Number(certificateData.warning_threshold) || 30,
expiry_threshold: Number(certificateData.expiry_threshold) || 7,
notification_channel: certificateData.notification_channel || "",
notification_id: certificateData.notification_id || "", // Multi notification channels
template_id: certificateData.template_id || "", // Alert template ID
check_interval: Number(certificateData.check_interval) || 1, // New field
check_at: currentTime, // Set to current time to trigger immediate check
};
@@ -41,7 +43,6 @@ export const addSSLCertificate = async (
return record as unknown as SSLCertificate;
} catch (error) {
console.error("Error adding SSL certificate:", error);
throw error;
}
};
@@ -70,7 +71,6 @@ export const checkAndUpdateCertificate = async (
// Return the current certificate data
return typedCertificate;
} catch (error) {
console.error("Error updating SSL certificate:", error);
throw error;
}
};
@@ -87,10 +87,8 @@ export const triggerImmediateCheck = async (certificateId: string): Promise<void
check_at: currentTime
});
console.log(`Triggered immediate check for certificate ${certificateId} at ${currentTime}`);
toast.success("SSL check scheduled - certificate will be checked shortly");
} catch (error) {
console.error("Error triggering immediate SSL check:", error);
toast.error("Failed to schedule SSL check");
throw error;
}
@@ -104,7 +102,6 @@ export const deleteSSLCertificate = async (id: string): Promise<boolean> => {
await pb.collection("ssl_certificates").delete(id);
return true;
} catch (error) {
console.error("Error deleting SSL certificate:", error);
throw error;
}
};
@@ -118,8 +115,6 @@ export const refreshAllCertificates = async (): Promise<{ success: number; faile
const response = await pb.collection("ssl_certificates").getList(1, 100);
const certificates = response.items as unknown as SSLCertificate[];
console.log(`Refreshing ${certificates.length} certificates...`);
let success = 0;
let failed = 0;
@@ -128,14 +123,12 @@ export const refreshAllCertificates = async (): Promise<{ success: number; faile
await checkCertificateAndNotify(cert);
success++;
} catch (error) {
console.error(`Failed to refresh certificate ${cert.domain}:`, error);
failed++;
}
}
return { success, failed };
} catch (error) {
console.error("Error refreshing certificates:", error);
throw error;
}
};
+8
View File
@@ -1,9 +1,13 @@
// SSL Certificate DTO for adding new certificates
export interface AddSSLCertificateDto {
domain: string;
warning_threshold: number;
expiry_threshold: number;
notification_channel: string;
alert_template?: string; // New field for SSL alert template
notification_id?: string; // Multi notification channels as comma-separated string
template_id?: string; // Alert template ID for PocketBase
check_interval?: number; // New field for check interval in days
}
@@ -25,6 +29,10 @@ export interface SSLCertificate {
warning_threshold: number;
expiry_threshold: number;
notification_channel: string;
alert_template?: string; // New field for SSL alert template
// PocketBase specific fields
notification_id?: string; // Multi notification channels as comma-separated string
template_id?: string; // Alert template ID for PocketBase
last_notified?: string;
created?: string;
updated?: string;
+8
View File
@@ -1,3 +1,4 @@
export interface SSLCertificate {
id: string;
domain: string;
@@ -15,6 +16,10 @@ export interface SSLCertificate {
warning_threshold: number;
expiry_threshold: number;
notification_channel: string;
alert_template?: string; // New field for SSL alert template
// PocketBase specific fields
notification_id?: string; // Multi notification channels as comma-separated string
template_id?: string; // Alert template ID for PocketBase
last_notified?: string;
created?: string;
updated?: string;
@@ -33,5 +38,8 @@ export interface AddSSLCertificateDto {
warning_threshold: number;
expiry_threshold: number;
notification_channel: string;
alert_template?: string; // New field for SSL alert template
notification_id?: string; // Multi notification channels as comma-separated string
template_id?: string; // Alert template ID for PocketBase
check_interval?: number; // New field for check interval in days
}