From 914f1aba608a7e97cecf311c36c811034227064e Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Sat, 2 Aug 2025 16:53:15 +0700 Subject: [PATCH] feat: Support multiple notification channels - Update the Notification Settings dashboard to allow users to select multiple notification channels for alerts. - Add webhook to notification channels: Add webhook as a selectable option in the notification channel type dropdown. - Implement template type selection in the Add Template dialog. Based on the selected type. --- .../components/servers/EditServerDialog.tsx | 42 ++- .../services/ServiceMonitoringButton.tsx | 17 +- .../alerts-templates/AlertsTemplates.tsx | 99 ++++-- .../alerts-templates/TemplateDialog.tsx | 155 +++++++-- .../alerts-templates/TemplateList.tsx | 213 ++++++------ .../form/ServerTemplateFields.tsx | 211 ++++++++++++ .../form/ServiceTemplateFields.tsx | 133 ++++++++ .../form/SslTemplateFields.tsx | 79 +++++ .../settings/alerts-templates/hooks/index.ts | 2 +- .../alerts-templates/hooks/useTemplateForm.ts | 208 +++++++----- .../NotificationChannelDialog.tsx | 317 ++++++++++++++---- .../NotificationSettings.tsx | 3 +- application/src/pages/Login.tsx | 140 +++----- .../src/services/alertConfigService.ts | 33 +- .../handlers/serviceStatusHandlers.ts | 81 +---- .../service-status/resumeMonitoring.ts | 43 +-- .../src/services/notification/index.ts | 236 +------------ .../notification/templateProcessor.ts | 26 +- .../src/services/notificationService.ts | 194 ----------- .../serverNotificationTemplateService.ts | 103 ++++++ .../serviceNotificationTemplateService.ts | 93 +++++ .../sslNotificationTemplateService.ts | 87 +++++ application/src/services/templateService.ts | 180 +++++----- application/src/services/webhookService.ts | 66 ++++ 24 files changed, 1712 insertions(+), 1049 deletions(-) create mode 100644 application/src/components/settings/alerts-templates/form/ServerTemplateFields.tsx create mode 100644 application/src/components/settings/alerts-templates/form/ServiceTemplateFields.tsx create mode 100644 application/src/components/settings/alerts-templates/form/SslTemplateFields.tsx delete mode 100644 application/src/services/notificationService.ts create mode 100644 application/src/services/serverNotificationTemplateService.ts create mode 100644 application/src/services/serviceNotificationTemplateService.ts create mode 100644 application/src/services/sslNotificationTemplateService.ts create mode 100644 application/src/services/webhookService.ts diff --git a/application/src/components/servers/EditServerDialog.tsx b/application/src/components/servers/EditServerDialog.tsx index cce5755..3db001e 100644 --- a/application/src/components/servers/EditServerDialog.tsx +++ b/application/src/components/servers/EditServerDialog.tsx @@ -1,3 +1,4 @@ + import React, { useState, useEffect } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; @@ -12,7 +13,7 @@ import { pb } from "@/lib/pocketbase"; import { Server } from "@/types/server.types"; import { RefreshCw, X } from "lucide-react"; import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService"; -import { templateService, NotificationTemplate } from "@/services/templateService"; +import { templateService, ServerNotificationTemplate } from "@/services/templateService"; import { serverThresholdService, ServerThreshold } from "@/services/serverThresholdService"; interface EditServerDialogProps { @@ -66,9 +67,9 @@ export const EditServerDialog: React.FC = ({ const [isSubmitting, setIsSubmitting] = useState(false); const [alertConfigs, setAlertConfigs] = useState([]); - const [templates, setTemplates] = useState([]); + const [templates, setTemplates] = useState([]); const [thresholds, setThresholds] = useState([]); - const [selectedTemplate, setSelectedTemplate] = useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); const [selectedThreshold, setSelectedThreshold] = useState(null); const [loadingAlertConfigs, setLoadingAlertConfigs] = useState(false); const [loadingTemplates, setLoadingTemplates] = useState(false); @@ -78,7 +79,7 @@ export const EditServerDialog: React.FC = ({ // Initialize form data when server changes useEffect(() => { if (server) { - // console.log("Setting form data for server:", server); + // console.log("Setting form data for server:", server); // Parse comma-separated notification_id into array const notificationChannels = server.notification_id ? server.notification_id.split(',').map(id => id.trim()).filter(id => id) @@ -180,8 +181,9 @@ export const EditServerDialog: React.FC = ({ const loadTemplates = async () => { try { setLoadingTemplates(true); - const templateList = await templateService.getTemplates(); - setTemplates(templateList); + const templateList = await templateService.getTemplates('server'); + // Cast to ServerNotificationTemplate[] since we know we're getting server templates + setTemplates(templateList as ServerNotificationTemplate[]); } catch (error) { // console.error('Error loading templates:', error); toast({ @@ -621,27 +623,39 @@ export const EditServerDialog: React.FC = ({
- +

- {selectedTemplate.up_message || "No RAM threshold message defined"} + {selectedTemplate.ram_message || "No RAM message defined"}

- +

- {selectedTemplate.down_message || "No CPU threshold message defined"} + {selectedTemplate.cpu_message || "No CPU message defined"}

- +

- {selectedTemplate.incident_message || "No disk threshold message defined"} + {selectedTemplate.disk_message || "No disk message defined"}

- +

- {selectedTemplate.maintenance_message || "No network threshold message defined"} + {selectedTemplate.network_message || "No network message defined"} +

+
+
+ +

+ {selectedTemplate.up_message || "No up message defined"} +

+
+
+ +

+ {selectedTemplate.down_message || "No down message defined"}

diff --git a/application/src/components/services/ServiceMonitoringButton.tsx b/application/src/components/services/ServiceMonitoringButton.tsx index 3e2d3fc..8a0aed0 100644 --- a/application/src/components/services/ServiceMonitoringButton.tsx +++ b/application/src/components/services/ServiceMonitoringButton.tsx @@ -5,7 +5,6 @@ import { Play, Pause } from "lucide-react"; import { Service } from "@/types/service.types"; import { serviceService } from "@/services/serviceService"; import { useToast } from "@/hooks/use-toast"; -import { notificationService } from "@/services/notificationService"; interface ServiceMonitoringButtonProps { service: Service; @@ -34,16 +33,8 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni if (onStatusChange) onStatusChange("paused"); - // Send notification for paused status (only here, not in pauseMonitoring.ts) - if (service.alerts !== "muted") { - // console.log("Sending pause notification from UI component"); - // IMPORTANT: Direct call to the notification service to ensure a message is sent - await notificationService.sendNotification({ - service: service, - status: "paused", - timestamp: new Date().toISOString(), - }); - } + // Notification handling removed - will be handled by backend + // console.log("Service paused - notifications will be handled by backend"); toast({ title: "Monitoring paused", @@ -51,7 +42,7 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni }); } else { // Start/resume monitoring - // console.log(`Starting monitoring for service ${service.id} (${service.name})`); + // console.log(`Starting monitoring for service ${service.id} (${service.name})`); // First ensure we update the status in the database to not be paused anymore await serviceService.resumeMonitoring(service.id); @@ -100,4 +91,4 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni )} ); -} +} \ No newline at end of file diff --git a/application/src/components/settings/alerts-templates/AlertsTemplates.tsx b/application/src/components/settings/alerts-templates/AlertsTemplates.tsx index b8226d4..de52b33 100644 --- a/application/src/components/settings/alerts-templates/AlertsTemplates.tsx +++ b/application/src/components/settings/alerts-templates/AlertsTemplates.tsx @@ -1,9 +1,10 @@ import React, { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { templateService } from "@/services/templateService"; +import { templateService, TemplateType } from "@/services/templateService"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Plus, RefreshCcw } from "lucide-react"; import { TemplateList } from "./TemplateList"; import { TemplateDialog } from "./TemplateDialog"; @@ -11,8 +12,10 @@ import { useToast } from "@/hooks/use-toast"; export const AlertsTemplates = () => { const { toast } = useToast(); + const [activeTab, setActiveTab] = useState('service'); const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingTemplate, setEditingTemplate] = useState(null); + const [editingTemplateType, setEditingTemplateType] = useState(null); const { data: templates = [], @@ -20,17 +23,19 @@ export const AlertsTemplates = () => { error, refetch } = useQuery({ - queryKey: ['notification_templates'], - queryFn: templateService.getTemplates, + queryKey: ['notification_templates', activeTab], + queryFn: () => templateService.getTemplates(activeTab), }); - const handleAddTemplate = () => { + const handleAddTemplate = (templateType: TemplateType) => { setEditingTemplate(null); + setEditingTemplateType(templateType); setIsDialogOpen(true); }; - const handleEditTemplate = (id: string) => { + const handleEditTemplate = (id: string, templateType: TemplateType) => { setEditingTemplate(id); + setEditingTemplateType(templateType); setIsDialogOpen(true); }; @@ -51,33 +56,83 @@ export const AlertsTemplates = () => { Refresh - - {error ? ( -
-

Error loading templates

- -
- ) : ( - - )} + setActiveTab(value as TemplateType)}> + + Service Uptime + Server Monitoring + SSL Certificate + + + + {error ? ( +
+

Error loading service templates

+ +
+ ) : ( + handleEditTemplate(id, 'service')} + refetchTemplates={refetch} + templateType="service" + /> + )} +
+ + + {error ? ( +
+

Error loading server templates

+ +
+ ) : ( + handleEditTemplate(id, 'server')} + refetchTemplates={refetch} + templateType="server" + /> + )} +
+ + + {error ? ( +
+

Error loading SSL templates

+ +
+ ) : ( + handleEditTemplate(id, 'ssl')} + refetchTemplates={refetch} + templateType="ssl" + /> + )} +
+
{ refetch(); @@ -86,4 +141,4 @@ export const AlertsTemplates = () => { /> ); -}; +}; \ No newline at end of file diff --git a/application/src/components/settings/alerts-templates/TemplateDialog.tsx b/application/src/components/settings/alerts-templates/TemplateDialog.tsx index 4b9b13d..a1afeb7 100644 --- a/application/src/components/settings/alerts-templates/TemplateDialog.tsx +++ b/application/src/components/settings/alerts-templates/TemplateDialog.tsx @@ -1,19 +1,25 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Form } from "@/components/ui/form"; +import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTemplateForm } from "./hooks/useTemplateForm"; -import { BasicTemplateFields } from "./form/BasicTemplateFields"; -import { MessagesTabContent } from "./form/MessagesTabContent"; -import { PlaceholdersTabContent } from "./form/PlaceholdersTabContent"; +import { ServerTemplateFields } from "./form/ServerTemplateFields"; +import { ServiceTemplateFields } from "./form/ServiceTemplateFields"; +import { SslTemplateFields } from "./form/SslTemplateFields"; import { Loader2, ChevronDown } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { TemplateType, templateTypeConfigs } from "@/services/templateService"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; interface TemplateDialogProps { open: boolean; templateId: string | null; + templateType: TemplateType | null; onOpenChange: (open: boolean) => void; onSuccess: () => void; } @@ -21,9 +27,12 @@ interface TemplateDialogProps { export const TemplateDialog: React.FC = ({ open, templateId, + templateType: initialTemplateType, onOpenChange, onSuccess, }) => { + const [selectedTemplateType, setSelectedTemplateType] = useState(initialTemplateType || 'service'); + const { form, isEditMode, @@ -32,28 +41,70 @@ export const TemplateDialog: React.FC = ({ onSubmit } = useTemplateForm({ templateId, + templateType: selectedTemplateType, open, onOpenChange, onSuccess }); - // For debugging purposes + // Update template type when prop changes or dialog opens useEffect(() => { - if (open) { - // console.log("Template dialog opened. Edit mode:", isEditMode, "Template ID:", templateId); - - // Log form values when they change - const subscription = form.watch((value) => { - // console.log("Current form values:", value); - }); - - return () => subscription.unsubscribe(); + if (initialTemplateType) { + setSelectedTemplateType(initialTemplateType); + } else if (open && !isEditMode) { + setSelectedTemplateType('service'); } - }, [open, isEditMode, templateId, form]); + }, [initialTemplateType, open, isEditMode]); + + // Handle template type change + const handleTemplateTypeChange = (newType: TemplateType) => { + if (!isEditMode) { + setSelectedTemplateType(newType); + form.setValue('templateType', newType); + } + }; + + const renderTemplateFields = () => { + switch (selectedTemplateType) { + case 'server': + return ; + case 'service': + return ; + case 'ssl': + return ; + default: + return null; + } + }; + + const renderPlaceholderGuide = () => { + const config = templateTypeConfigs[selectedTemplateType]; + if (!config) return null; + + return ( + + + Available Placeholders + + +

+ {config.description}. Use these placeholders in your messages: +

+
+ {config.placeholders.map((placeholder) => ( +
+ {placeholder} +
+ ))} +
+
+
+ ); + }; return ( - + {isEditMode ? "Edit Template" : "Add Template"} @@ -69,7 +120,69 @@ export const TemplateDialog: React.FC = ({
- + {/* Basic Fields */} +
+ ( + + Template Name + + + + + + )} + /> + + ( + + Template Type + + + + + + )} + /> + + ( + + Custom Placeholder + + + + + + )} + /> +
@@ -78,11 +191,11 @@ export const TemplateDialog: React.FC = ({ - + {renderTemplateFields()} - + {renderPlaceholderGuide()}
@@ -120,4 +233,4 @@ export const TemplateDialog: React.FC = ({
); -}; +}; \ No newline at end of file diff --git a/application/src/components/settings/alerts-templates/TemplateList.tsx b/application/src/components/settings/alerts-templates/TemplateList.tsx index e3c40d2..d820e96 100644 --- a/application/src/components/settings/alerts-templates/TemplateList.tsx +++ b/application/src/components/settings/alerts-templates/TemplateList.tsx @@ -1,17 +1,15 @@ import React, { useState } from "react"; -import { NotificationTemplate, templateService } from "@/services/templateService"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Button } from "@/components/ui/button"; -import { Edit, Trash2 } from "lucide-react"; -import { +import { Badge } from "@/components/ui/badge"; +import { Trash2, Edit, MoreVertical } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, @@ -22,63 +20,75 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { useToast } from "@/hooks/use-toast"; -import { Badge } from "@/components/ui/badge"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { templateService, AnyTemplate, TemplateType } from "@/services/templateService"; interface TemplateListProps { - templates: NotificationTemplate[]; + templates: AnyTemplate[]; isLoading: boolean; onEdit: (id: string) => void; refetchTemplates: () => void; + templateType: TemplateType; } -export const TemplateList: React.FC = ({ - templates, - isLoading, - onEdit, +export const TemplateList: React.FC = ({ + templates, + isLoading, + onEdit, refetchTemplates, + templateType }) => { const { toast } = useToast(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [templateToDelete, setTemplateToDelete] = useState(null); - const [isDeleting, setIsDeleting] = useState(false); + const queryClient = useQueryClient(); + const [deleteTemplateId, setDeleteTemplateId] = useState(null); - const handleDeletePrompt = (template: NotificationTemplate) => { - setTemplateToDelete(template); - setDeleteDialogOpen(true); - }; - - const handleDeleteTemplate = async () => { - if (!templateToDelete) return; - - setIsDeleting(true); - try { - await templateService.deleteTemplate(templateToDelete.id); + // Delete mutation + const deleteMutation = useMutation({ + mutationFn: (id: string) => templateService.deleteTemplate(id, templateType), + onSuccess: () => { toast({ title: "Template deleted", - description: `Template "${templateToDelete.name}" has been removed.`, + description: "The template has been deleted successfully.", }); + queryClient.invalidateQueries({ queryKey: ['notification_templates', templateType] }); refetchTemplates(); - } catch (error) { - console.error("Error deleting template:", error); + }, + onError: (error) => { + // console.error("Error deleting template:", error); toast({ title: "Error", description: "Failed to delete template. Please try again.", variant: "destructive", }); - } finally { - setIsDeleting(false); - setDeleteDialogOpen(false); - setTemplateToDelete(null); + }, + }); + + const handleDelete = (id: string) => { + setDeleteTemplateId(id); + }; + + const confirmDelete = () => { + if (deleteTemplateId) { + deleteMutation.mutate(deleteTemplateId); + setDeleteTemplateId(null); } }; if (isLoading) { return ( -
-
-
-
-
+
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+ ))}
); } @@ -86,83 +96,86 @@ export const TemplateList: React.FC = ({ if (templates.length === 0) { return (
-

No templates found

-

- Create your first notification template to get started. -

+

No templates found. Create your first template to get started.

); } + const getTemplateTypeLabel = (type: TemplateType) => { + switch (type) { + case 'server': return 'Server'; + case 'service': return 'Service'; + case 'ssl': return 'SSL'; + default: return 'Unknown'; + } + }; + return ( <> -
- - - - Name - Type - Created - Actions - - - - {templates.map((template) => ( - - {template.name} - - {template.type} - - - {new Date(template.created).toLocaleDateString()} - - -
- - -
-
-
- ))} -
-
+
+ {templates.map((template) => ( +
+
+
+

{template.name}

+ {getTemplateTypeLabel(templateType)} +
+

+ Created: {new Date(template.created).toLocaleDateString()} + {template.updated !== template.created && + ` • Updated: ${new Date(template.updated).toLocaleDateString()}` + } +

+
+
+ + + + + + + handleDelete(template.id)} + className="text-destructive focus:text-destructive" + > + + Delete + + + +
+
+ ))}
- + setDeleteTemplateId(null)}> - Delete Template + Are you sure? - Are you sure you want to delete the template "{templateToDelete?.name}"? This action cannot be undone. + This action cannot be undone. This will permanently delete the template. - Cancel + Cancel { - e.preventDefault(); - handleDeleteTemplate(); - }} - disabled={isDeleting} + onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {isDeleting ? "Deleting..." : "Delete"} + Delete ); -}; +}; \ No newline at end of file diff --git a/application/src/components/settings/alerts-templates/form/ServerTemplateFields.tsx b/application/src/components/settings/alerts-templates/form/ServerTemplateFields.tsx new file mode 100644 index 0000000..259a2e4 --- /dev/null +++ b/application/src/components/settings/alerts-templates/form/ServerTemplateFields.tsx @@ -0,0 +1,211 @@ + +import React from "react"; +import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form"; +import { Textarea } from "@/components/ui/textarea"; +import { Control } from "react-hook-form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface ServerTemplateFieldsProps { + control: Control; +} + +export const ServerTemplateFields: React.FC = ({ control }) => { + return ( +
+ + + System Resource Messages + + +
+ ( + + CPU Alert Message + +