Files
checkcle/server/service-operation/notification/ntfy.go
T
Tola Leng 257718e9d6 feat: Add notification, integrate data retention service, and improve i18n support (#164)
* fix(ssl):  Ensure edit form saves notification_id and template_id in DB.

- The SSL edit form was not saving the `notification_id` and `template_id` fields to the PB database when re-assigning the Notification Channel and Alert Template.

* chore(partners): add Cloudflare to Ecosystem & Community Partner section

- Cloudflare has been added to the Ecosystem & Community Partner list to acknowledge their support and contribution.

*  chore(project): update development status and progress

- fix(ssl): ensure edit form saves notification_id and template_id in DB
- feat(notifications): add Notifiarr notification channel (planned)

* feat: Add Notifiarr to notification channels (UI)

- Add Notifiarr as a new channel type in the notification settings. The Notifiarr channel will use the `api_token` field for API key storage in PB.

* feat: Add channel_id field for Notifiarr

- Add channel_id field for Notifiarr to store record of the discord channel_id into the PB.

* feat: Implement Notifiarr notification service

- The Notifiarr notification functionality has been implemented. The alert configuration channel will now use the `api_token` and `channel_id` field for send to the Notifiarr API endpoint.

*  chore(project): update development status and progress

- feat: Add channel_id field for Notifiarr
- feat: Implement Notifiarr notification service

Closes: #133

* feat: add SSL history and Notifiarr notification channel schema

* Fix: Remove duplicate close button

- Removed the duplicate close button from the test email dialog.

* feat: Add Gotify to notification channels

- Add Gotify  as a new channel type in the notification settings. The Gotify channel will use the `api_token` and `server_url` field for API key storage in PB.

* feat: Implement Gotify notification service

- The Gotify notification functionality has been implemented. The alert configuration channel will now use the `api_token` and `server_url` field for send to the Gotify API endpoint.

*  chore(project): update development status and progress
- feat: Add server_url field for Gotify
- feat: Implement Gotify notification service

* refactory(i18n): Improve internationalization configuration and add new translations

- Added multiple new translation items
- Updated the two language files: en and zhcn
- Added type definitions for new translation items in types/services.ts
- Modified some components to use the new translation items

* refactory(i18n): Added architecture import function translation for the "About" page

- Added translation items related to schema import in English, Chinese, and type definition files
- Replaced hard-coded text with new translation items in the AboutSystem component

* refactory(i18n): Added architecture import function translation for the "About" page

- Added translation items related to schema import in English, Chinese, and type definition files
- Replaced hard-coded text with new translation items in the AboutSystem component

* refactory(i18n): Add internationalization support for the service statistics card

* feat: Add NTFY API token field for Token-based authentication to ntfy server

- Add the api_token field for NTFY notifications in both the dialog and service to save it to PB. NTFY schema to include api_token field.

* feat(auth): add token support for ntfy

- Updated the NTFY service to support API token authentication by adding Authorization Bearer header when api_token is provided.

Closes: #89

* refactor(i18n): Added Chinese translations for Create Event and Maintenance

- Supplemented missing Chinese translations
- Added translation entries related to Create Event and Maintenance in both English and Chinese translation files

* chore: deactivate GitHub donation

* refactor(i18n): Optimize internationalization configuration and add new translation items

- Add internationalization support to the LoadingState component
- Implement internationalization translations in the Profile page
- Use internationalized text in the TestEmailDialog component
- Update English and Chinese translation files by adding new translation entries

* docs(readme): deactivate donations, accept only infra support (cloud, domain, hosting)

* feat(i18n): Enhanced internationalization support with variable interpolation

- Refactored the `t` function in LanguageContext to support multiple parameter combinations
- Added variable interpolation feature, allowing variables in translated texts
- Optimized translation logic to improve accuracy and performance
- Enhanced type safety by using TypeScript generics to ensure correct typing

* refactor(i18n): Optimize service pagination internationalization copy

- Added service pagination related copy in English and Chinese translation files
- Updated type definitions to include new translation fields
- Modified the ServicesPagination component to use the newly added translation fields

* docs(README_zhcn): Update documentation content and sponsorship policy

- Update image links in the documentation
- Revise the sponsorship policy to no longer accept individual sponsorships, only enterprise-level collaborations
- Update the display method for supporters and partners
- Add Cloudflare and DigitalOcean as new partners

* feat: Integrate data retention service

- Implemented the data retention service in Go (service-operation) that manages cleanup of old records based on configured retention periods. The service runs once per day to ensure outdated data is removed automatically.

* refactor: Remove manual cleanup buttons from Data Retention Settings

- The three manual cleanup buttons have been removed from the dashboard.
- The data retention service now automatically runs once per day, cleaning up older data based on the configured retention settings.

---------

Co-authored-by: YiZixuan <sqkkyzx@qq.com>
Co-authored-by: YiZixuan <sqkkyzx@outlook.com>
2025-09-13 21:38:38 +07:00

400 lines
13 KiB
Go

package notification
import (
"fmt"
"net/http"
"strings"
)
// NtfyService handles NTFY push notifications
type NtfyService struct{}
// NewNtfyService creates a new NTFY notification service
func NewNtfyService() *NtfyService {
return &NtfyService{}
}
// SendNotification sends a notification via NTFY
func (ns *NtfyService) SendNotification(config *AlertConfiguration, message string) error {
if config.NtfyEndpoint == "" {
return fmt.Errorf("ntfy endpoint is required")
}
// Create HTTP request with plain text message
req, err := http.NewRequest("POST", config.NtfyEndpoint, strings.NewReader(message))
if err != nil {
return fmt.Errorf("failed to create NTFY request: %v", err)
}
// Set headers for NTFY
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
req.Header.Set("Title", "🔔 CheckCle Service Alert")
req.Header.Set("Tags", "monitoring")
req.Header.Set("Priority", "default")
// Add API token authentication if provided
if config.APIToken != "" {
req.Header.Set("Authorization", "Bearer "+config.APIToken)
}
// Send the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send NTFY notification: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy API error, status: %d", resp.StatusCode)
}
return nil
}
// SendServerNotification sends a server-specific notification via NTFY
func (ns *NtfyService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
message := ns.generateServerMessage(payload, template, resourceType)
return ns.SendNotificationWithDetails(config, message, "🔔 Server Alert", resourceType)
}
// SendServiceNotification sends a service-specific notification via NTFY
func (ns *NtfyService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
message := ns.generateServiceMessage(payload, template)
return ns.SendNotificationWithDetails(config, message, "🔔 Service Alert", "service")
}
// SendNotificationWithDetails sends a notification with custom title and tags
func (ns *NtfyService) SendNotificationWithDetails(config *AlertConfiguration, message, title, alertType string) error {
if config.NtfyEndpoint == "" {
return fmt.Errorf("ntfy endpoint is required")
}
// Create HTTP request with plain text message
req, err := http.NewRequest("POST", config.NtfyEndpoint, strings.NewReader(message))
if err != nil {
return fmt.Errorf("failed to create NTFY request: %v", err)
}
// Set headers for NTFY with dynamic values
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
req.Header.Set("Title", title)
// Set appropriate tags and priority based on alert type and message content
tags := "monitoring"
priority := "default"
// Determine priority and tags based on message content
messageLower := strings.ToLower(message)
if strings.Contains(messageLower, "down") || strings.Contains(messageLower, "critical") || strings.Contains(messageLower, "expired") {
priority = "high"
tags = "rotating_light,warning"
} else if strings.Contains(messageLower, "warning") || strings.Contains(messageLower, "expiring") {
priority = "default"
tags = "warning"
} else if strings.Contains(messageLower, "up") || strings.Contains(messageLower, "restored") || strings.Contains(messageLower, "resolved") {
priority = "default"
tags = "white_check_mark"
}
// Add resource type specific tags
switch alertType {
case "cpu":
tags += ",cpu"
case "ram", "memory":
tags += ",memory"
case "disk":
tags += ",floppy_disk"
case "network":
tags += ",globe_with_meridians"
case "service":
tags += ",gear"
case "ssl":
tags += ",lock"
}
req.Header.Set("Tags", tags)
req.Header.Set("Priority", priority)
// Add API token authentication if provided
if config.APIToken != "" {
req.Header.Set("Authorization", "Bearer "+config.APIToken)
}
// Send the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send NTFY notification: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy API error, status: %d", resp.StatusCode)
}
return nil
}
// generateServerMessage creates a message for server notifications using server template
func (ns *NtfyService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string {
var templateMessage string
// Select appropriate template message based on status and resource type
switch strings.ToLower(payload.Status) {
case "down":
templateMessage = template.DownMessage
case "up":
templateMessage = template.UpMessage
case "warning":
templateMessage = template.WarningMessage
case "paused":
templateMessage = template.PausedMessage
default:
// Handle resource-specific messages
switch resourceType {
case "cpu":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreCPUMessage
} else {
templateMessage = template.CPUMessage
}
case "ram", "memory":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreRAMMessage
} else {
templateMessage = template.RAMMessage
}
case "disk":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreDiskMessage
} else {
templateMessage = template.DiskMessage
}
case "network":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreNetworkMessage
} else {
templateMessage = template.NetworkMessage
}
case "cpu_temp", "cpu_temperature":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreCPUTempMessage
} else {
templateMessage = template.CPUTempMessage
}
case "disk_io":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreDiskIOMessage
} else {
templateMessage = template.DiskIOMessage
}
default:
templateMessage = template.WarningMessage
}
}
// If no template message found, use a default
if templateMessage == "" {
templateMessage = ns.generateDefaultServerMessage(payload, resourceType)
}
return ns.replacePlaceholders(templateMessage, payload)
}
// generateServiceMessage creates a message for service notifications using service template
func (ns *NtfyService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string {
var templateMessage string
// Select appropriate template message based on status
switch strings.ToLower(payload.Status) {
case "up":
templateMessage = template.UpMessage
case "down":
templateMessage = template.DownMessage
case "maintenance":
templateMessage = template.MaintenanceMessage
case "incident":
templateMessage = template.IncidentMessage
case "resolved":
templateMessage = template.ResolvedMessage
case "warning":
templateMessage = template.WarningMessage
default:
templateMessage = template.WarningMessage
}
// If no template message found, use a default
if templateMessage == "" {
templateMessage = ns.generateDefaultUptimeMessage(payload)
}
return ns.replacePlaceholders(templateMessage, payload)
}
// replacePlaceholders replaces all placeholders in the message with actual values
func (ns *NtfyService) replacePlaceholders(message string, payload *NotificationPayload) string {
// Replace basic placeholders
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
message = strings.ReplaceAll(message, "${host}", ns.safeString(payload.Host))
message = strings.ReplaceAll(message, "${hostname}", ns.safeString(payload.Hostname))
// Replace URL with fallback to host
url := ns.safeString(payload.URL)
if url == "N/A" && payload.Host != "" {
url = payload.Host
}
message = strings.ReplaceAll(message, "${url}", url)
// Replace domain
message = strings.ReplaceAll(message, "${domain}", ns.safeString(payload.Domain))
// Replace service type
if payload.ServiceType != "" {
message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType))
} else {
message = strings.ReplaceAll(message, "${service_type}", "N/A")
}
// Replace region and agent info
message = strings.ReplaceAll(message, "${region_name}", ns.safeString(payload.RegionName))
message = strings.ReplaceAll(message, "${agent_id}", ns.safeString(payload.AgentID))
// Handle numeric fields safely
if payload.Port > 0 {
message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port))
} else {
message = strings.ReplaceAll(message, "${port}", "N/A")
}
if payload.ResponseTime > 0 {
message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime))
} else {
message = strings.ReplaceAll(message, "${response_time}", "N/A")
}
if payload.Uptime > 0 {
message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime))
} else {
message = strings.ReplaceAll(message, "${uptime}", "N/A")
}
// Replace server monitoring fields
message = strings.ReplaceAll(message, "${cpu_usage}", ns.safeString(payload.CPUUsage))
message = strings.ReplaceAll(message, "${ram_usage}", ns.safeString(payload.RAMUsage))
message = strings.ReplaceAll(message, "${disk_usage}", ns.safeString(payload.DiskUsage))
message = strings.ReplaceAll(message, "${network_usage}", ns.safeString(payload.NetworkUsage))
message = strings.ReplaceAll(message, "${cpu_temp}", ns.safeString(payload.CPUTemp))
message = strings.ReplaceAll(message, "${disk_io}", ns.safeString(payload.DiskIO))
message = strings.ReplaceAll(message, "${threshold}", ns.safeString(payload.Threshold))
// Replace error message - important for uptime services
message = strings.ReplaceAll(message, "${error_message}", ns.safeString(payload.ErrorMessage))
message = strings.ReplaceAll(message, "${error}", ns.safeString(payload.ErrorMessage))
// Replace time placeholders
message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05"))
message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05"))
return message
}
// safeString returns the string value or "N/A" if empty
func (ns *NtfyService) safeString(value string) string {
if value == "" {
return "N/A"
}
return value
}
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
func (ns *NtfyService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
// Status emoji mapping for NTFY
statusEmoji := "🔵"
switch strings.ToLower(payload.Status) {
case "up":
statusEmoji = "🟢"
case "down":
statusEmoji = "🔴"
case "warning":
statusEmoji = "🟡"
case "maintenance", "paused":
statusEmoji = "🟠"
}
message := fmt.Sprintf("%s Service %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status))
// Build formatted details
details := []string{}
// Add URL or host
if payload.URL != "" {
details = append(details, fmt.Sprintf("Host URL: %s", payload.URL))
} else if payload.Host != "" {
details = append(details, fmt.Sprintf("Host: %s", payload.Host))
}
// Add service type
if payload.ServiceType != "" {
details = append(details, fmt.Sprintf("Type: %s", strings.ToUpper(payload.ServiceType)))
}
// Add port if available
if payload.Port > 0 {
details = append(details, fmt.Sprintf("Port: %d", payload.Port))
}
// Add domain if available
if payload.Domain != "" {
details = append(details, fmt.Sprintf("Domain: %s", payload.Domain))
}
// Add response time
if payload.ResponseTime > 0 {
details = append(details, fmt.Sprintf("Response time: %dms", payload.ResponseTime))
} else {
details = append(details, "Response time: N/A")
}
// Add region info
if payload.RegionName != "" {
details = append(details, fmt.Sprintf("Region: %s", payload.RegionName))
}
// Add agent info
if payload.AgentID != "" {
details = append(details, fmt.Sprintf("Agent: %s", payload.AgentID))
}
// Add uptime if available
if payload.Uptime > 0 {
details = append(details, fmt.Sprintf("Uptime: %d%%", payload.Uptime))
}
// Add timestamp
details = append(details, fmt.Sprintf("Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05")))
// Combine message with details
if len(details) > 0 {
message += "\n" + strings.Join(details, "\n")
}
return message
}
// generateDefaultServerMessage creates a default server message
func (ns *NtfyService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string {
statusEmoji := "🔵"
switch strings.ToLower(payload.Status) {
case "up":
statusEmoji = "🟢"
case "down":
statusEmoji = "🔴"
case "warning":
statusEmoji = "🟡"
}
return fmt.Sprintf("%s🖥️ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status))
}