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>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package dataretention
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"service-operation/pocketbase"
|
||||
)
|
||||
|
||||
// Handler handles HTTP requests for data retention operations
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewHandler creates a new data retention handler
|
||||
func NewHandler(pbClient *pocketbase.PocketBaseClient) *Handler {
|
||||
return &Handler{
|
||||
service: NewService(pbClient),
|
||||
}
|
||||
}
|
||||
|
||||
// GetSettings handles GET requests for data retention settings
|
||||
func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.service.GetDataSettings()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(settings)
|
||||
}
|
||||
|
||||
// TriggerCleanup handles POST requests to trigger manual cleanup
|
||||
func (h *Handler) TriggerCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.service.CleanupOldRecords()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(summary)
|
||||
}
|
||||
|
||||
// SetupRoutes sets up the HTTP routes for data retention
|
||||
func (h *Handler) SetupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/data-retention/settings", h.GetSettings)
|
||||
mux.HandleFunc("/data-retention/cleanup", h.TriggerCleanup)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dataretention
|
||||
|
||||
import (
|
||||
"service-operation/pocketbase"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Scheduler handles periodic data retention cleanup
|
||||
type Scheduler struct {
|
||||
service *Service
|
||||
interval time.Duration
|
||||
stopChan chan bool
|
||||
}
|
||||
|
||||
// NewScheduler creates a new cleanup scheduler
|
||||
func NewScheduler(pbClient *pocketbase.PocketBaseClient, interval time.Duration) *Scheduler {
|
||||
return &Scheduler{
|
||||
service: NewService(pbClient),
|
||||
interval: interval,
|
||||
stopChan: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the periodic cleanup process
|
||||
func (s *Scheduler) Start() {
|
||||
// log.Printf("Starting data retention scheduler with interval: %v", s.interval)
|
||||
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run initial cleanup
|
||||
go func() {
|
||||
if err := s.service.ScheduleCleanup(); err != nil {
|
||||
// log.Printf("Initial cleanup failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
go func() {
|
||||
if err := s.service.ScheduleCleanup(); err != nil {
|
||||
// log.Printf("Scheduled cleanup failed: %v", err)
|
||||
}
|
||||
}()
|
||||
case <-s.stopChan:
|
||||
// log.Println("Data retention scheduler stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the scheduler
|
||||
func (s *Scheduler) Stop() {
|
||||
// log.Println("Stopping data retention scheduler...")
|
||||
s.stopChan <- true
|
||||
}
|
||||
|
||||
// RunOnce executes cleanup immediately (for manual triggers)
|
||||
func (s *Scheduler) RunOnce() (*CleanupSummary, error) {
|
||||
// log.Println("Running manual data retention cleanup...")
|
||||
return s.service.CleanupOldRecords()
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package dataretention
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"service-operation/pocketbase"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Service handles data retention operations
|
||||
type Service struct {
|
||||
pbClient *pocketbase.PocketBaseClient
|
||||
}
|
||||
|
||||
// NewService creates a new data retention service
|
||||
func NewService(pbClient *pocketbase.PocketBaseClient) *Service {
|
||||
return &Service{
|
||||
pbClient: pbClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDataSettings fetches the current data retention settings
|
||||
func (s *Service) GetDataSettings() (*DataSettings, error) {
|
||||
resp, err := s.pbClient.GetHTTPClient().Get(
|
||||
fmt.Sprintf("%s/api/collections/data_settings/records", s.pbClient.GetBaseURL()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch data settings: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to fetch data settings, status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var response DataSettingsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode data settings response: %w", err)
|
||||
}
|
||||
|
||||
if len(response.Items) == 0 {
|
||||
return nil, fmt.Errorf("no data settings found")
|
||||
}
|
||||
|
||||
return &response.Items[0], nil
|
||||
}
|
||||
|
||||
// CleanupOldRecords performs cleanup of old records based on retention settings
|
||||
func (s *Service) CleanupOldRecords() (*CleanupSummary, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
settings, err := s.GetDataSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get data settings: %w", err)
|
||||
}
|
||||
|
||||
// Check if cleanup should run (only once per day)
|
||||
if !s.shouldRunCleanup(settings) {
|
||||
// log.Println("Skipping cleanup - less than 24 hours since last run")
|
||||
return &CleanupSummary{
|
||||
StartTime: startTime,
|
||||
EndTime: time.Now(),
|
||||
Duration: 0,
|
||||
Results: make([]CleanupResult, 0),
|
||||
}, nil
|
||||
}
|
||||
|
||||
summary := &CleanupSummary{
|
||||
StartTime: startTime,
|
||||
Results: make([]CleanupResult, 0),
|
||||
}
|
||||
|
||||
// Define collections and their corresponding retention days
|
||||
uptimeCollections := []string{"dns_data", "ping_data", "tcp_data", "uptime_data", "services_metrics"}
|
||||
serverCollections := []string{"server_metrics", "docker_metrics"}
|
||||
|
||||
// Process collections in parallel for better performance
|
||||
var wg sync.WaitGroup
|
||||
resultsChan := make(chan CleanupResult, len(uptimeCollections)+len(serverCollections))
|
||||
|
||||
// Cleanup uptime-related collections in parallel
|
||||
if settings.UptimeRetentionDays > 0 {
|
||||
cutoffDate := time.Now().AddDate(0, 0, -settings.UptimeRetentionDays)
|
||||
// log.Printf("Cleaning uptime collections with %d days retention (cutoff: %s)",
|
||||
// settings.UptimeRetentionDays, cutoffDate.Format("2006-01-02 15:04:05"))
|
||||
for _, collection := range uptimeCollections {
|
||||
wg.Add(1)
|
||||
go func(col string) {
|
||||
defer wg.Done()
|
||||
result := s.cleanupCollection(col, cutoffDate)
|
||||
resultsChan <- result
|
||||
}(collection)
|
||||
}
|
||||
} else {
|
||||
// log.Println("Skipping uptime collections cleanup - retention days is 0")
|
||||
}
|
||||
|
||||
// Cleanup server-related collections in parallel
|
||||
if settings.ServerRetentionDays > 0 {
|
||||
cutoffDate := time.Now().AddDate(0, 0, -settings.ServerRetentionDays)
|
||||
// log.Printf("Cleaning server collections with %d days retention (cutoff: %s)",
|
||||
// settings.ServerRetentionDays, cutoffDate.Format("2006-01-02 15:04:05"))
|
||||
for _, collection := range serverCollections {
|
||||
wg.Add(1)
|
||||
go func(col string) {
|
||||
defer wg.Done()
|
||||
result := s.cleanupCollection(col, cutoffDate)
|
||||
resultsChan <- result
|
||||
}(collection)
|
||||
}
|
||||
} else {
|
||||
// log.Println("Skipping server collections cleanup - retention days is 0")
|
||||
}
|
||||
|
||||
// Wait for all collections to complete and collect results
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultsChan)
|
||||
}()
|
||||
|
||||
// Collect all results
|
||||
for result := range resultsChan {
|
||||
summary.Results = append(summary.Results, result)
|
||||
summary.TotalDeleted += result.RecordsDeleted
|
||||
}
|
||||
|
||||
summary.EndTime = time.Now()
|
||||
summary.Duration = summary.EndTime.Sub(summary.StartTime)
|
||||
|
||||
// Update last cleanup time after successful completion
|
||||
if err := s.updateLastCleanupTime(); err != nil {
|
||||
// log.Printf("Warning: Failed to update last cleanup time: %v", err)
|
||||
}
|
||||
|
||||
// log.Printf("Data retention cleanup completed. Total records deleted: %d, Duration: %v",
|
||||
// summary.TotalDeleted, summary.Duration)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// cleanupCollection deletes records older than the cutoff date from a specific collection
|
||||
func (s *Service) cleanupCollection(collection string, cutoffDate time.Time) CleanupResult {
|
||||
result := CleanupResult{
|
||||
Collection: collection,
|
||||
}
|
||||
|
||||
// Format cutoff date for PocketBase filter (RFC3339 format)
|
||||
cutoffStr := cutoffDate.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
|
||||
// Get records to delete (balanced batch size for performance vs rate limits)
|
||||
batchSize := 50
|
||||
totalDeleted := 0
|
||||
|
||||
for {
|
||||
// Always fetch page 1 since we're deleting records (pagination shifts after deletes)
|
||||
filter := fmt.Sprintf("created < '%s'", cutoffStr)
|
||||
encodedFilter := url.QueryEscape(filter)
|
||||
requestURL := fmt.Sprintf("%s/api/collections/%s/records?page=1&perPage=%d&filter=%s",
|
||||
s.pbClient.GetBaseURL(), collection, batchSize, encodedFilter)
|
||||
|
||||
resp, err := s.pbClient.GetHTTPClient().Get(requestURL)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to fetch records: %v", err)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check if response is successful
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
result.Error = fmt.Sprintf("API request failed with status %d for collection %s", resp.StatusCode, collection)
|
||||
return result
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
TotalItems int `json:"totalItems"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to decode response for collection %s: %v", collection, err)
|
||||
return result
|
||||
}
|
||||
|
||||
if len(response.Items) == 0 {
|
||||
// log.Printf("No more old records found in collection %s", collection)
|
||||
break // No more records to delete
|
||||
}
|
||||
|
||||
// log.Printf("Found %d old records in collection %s (batch), total items in DB: %d",
|
||||
// len(response.Items), collection, response.TotalItems)
|
||||
|
||||
// Delete records with conservative rate limiting to avoid 429 errors
|
||||
semaphore := make(chan struct{}, 3) // Further reduced to 3 concurrent deletes
|
||||
var deleteWg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
batchDeleted := 0
|
||||
rateLimitHit := false
|
||||
|
||||
for _, record := range response.Items {
|
||||
deleteWg.Add(1)
|
||||
go func(recordID string) {
|
||||
defer deleteWg.Done()
|
||||
semaphore <- struct{}{} // Acquire
|
||||
defer func() { <-semaphore }() // Release
|
||||
|
||||
deleted := false
|
||||
retryCount := 0
|
||||
maxRetries := 5
|
||||
|
||||
for !deleted && retryCount < maxRetries {
|
||||
if err := s.deleteRecord(collection, recordID); err != nil {
|
||||
if retryCount < maxRetries-1 {
|
||||
// Progressive backoff with longer delays for rate limits
|
||||
baseWait := time.Duration(1<<retryCount) * 500 * time.Millisecond
|
||||
if retryCount >= 2 {
|
||||
// Extra penalty for persistent rate limits
|
||||
baseWait += time.Duration(retryCount) * 2 * time.Second
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
rateLimitHit = true
|
||||
mu.Unlock()
|
||||
|
||||
time.Sleep(baseWait)
|
||||
retryCount++
|
||||
continue
|
||||
} else {
|
||||
// log.Printf("Failed to delete record %s from %s after %d retries: %v",
|
||||
// recordID, collection, maxRetries, err)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
deleted = true
|
||||
mu.Lock()
|
||||
batchDeleted++
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Increased delay between deletions with adaptive timing
|
||||
mu.Lock()
|
||||
currentRateLimitHit := rateLimitHit
|
||||
mu.Unlock()
|
||||
|
||||
if currentRateLimitHit {
|
||||
time.Sleep(150 * time.Millisecond) // Longer delay if rate limits detected
|
||||
} else {
|
||||
time.Sleep(75 * time.Millisecond) // Standard delay
|
||||
}
|
||||
}(record.ID)
|
||||
}
|
||||
|
||||
deleteWg.Wait()
|
||||
totalDeleted += batchDeleted
|
||||
|
||||
// If we got fewer records than batch size, we're done
|
||||
if len(response.Items) < batchSize {
|
||||
// log.Printf("Finished processing collection %s - no more old records", collection)
|
||||
break
|
||||
}
|
||||
|
||||
// More conservative delay between batches with adaptive timing
|
||||
if len(response.Items) > 0 {
|
||||
mu.Lock()
|
||||
currentRateLimitHit := rateLimitHit
|
||||
mu.Unlock()
|
||||
|
||||
baseDelay := 300 * time.Millisecond
|
||||
if currentRateLimitHit {
|
||||
baseDelay = 1 * time.Second // Much longer delay if rate limits were hit
|
||||
// log.Printf("Processed batch for collection %s (%d deleted), rate limits detected - waiting longer before next batch...", collection, batchDeleted)
|
||||
} else {
|
||||
// log.Printf("Processed batch for collection %s (%d deleted), processing next batch...", collection, batchDeleted)
|
||||
}
|
||||
|
||||
time.Sleep(baseDelay)
|
||||
}
|
||||
}
|
||||
|
||||
result.RecordsDeleted = totalDeleted
|
||||
|
||||
if totalDeleted > 0 {
|
||||
// log.Printf("Deleted %d records from collection %s", totalDeleted, collection)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// deleteRecord deletes a single record from a collection
|
||||
func (s *Service) deleteRecord(collection, recordID string) error {
|
||||
requestURL := fmt.Sprintf("%s/api/collections/%s/records/%s", s.pbClient.GetBaseURL(), collection, recordID)
|
||||
|
||||
req, err := http.NewRequest("DELETE", requestURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := s.pbClient.GetHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to delete record, status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScheduleCleanup runs cleanup based on schedule (can be called periodically)
|
||||
func (s *Service) ScheduleCleanup() error {
|
||||
// log.Println("Starting scheduled data retention cleanup...")
|
||||
|
||||
_, err := s.CleanupOldRecords()
|
||||
if err != nil {
|
||||
// log.Printf("Scheduled cleanup failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// log.Printf("Scheduled cleanup completed successfully. Summary: %+v", summary)
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldRunCleanup checks if cleanup should run based on last cleanup time (once per day)
|
||||
func (s *Service) shouldRunCleanup(settings *DataSettings) bool {
|
||||
if settings.LastCleanup == "" {
|
||||
// log.Println("No previous cleanup found, proceeding with cleanup...")
|
||||
return true
|
||||
}
|
||||
|
||||
lastCleanup, err := time.Parse(time.RFC3339, settings.LastCleanup)
|
||||
if err != nil {
|
||||
// log.Printf("Failed to parse last cleanup time '%s', proceeding with cleanup: %v", settings.LastCleanup, err)
|
||||
return true
|
||||
}
|
||||
|
||||
timeSinceLastCleanup := time.Since(lastCleanup)
|
||||
minInterval := 24 * time.Hour
|
||||
|
||||
if timeSinceLastCleanup < minInterval {
|
||||
// log.Printf("Last cleanup was %v ago (less than 24h), skipping cleanup", timeSinceLastCleanup)
|
||||
return false
|
||||
}
|
||||
|
||||
// log.Printf("Last cleanup was %v ago, proceeding with cleanup", timeSinceLastCleanup)
|
||||
return true
|
||||
}
|
||||
|
||||
// updateLastCleanupTime updates the last cleanup timestamp in data settings
|
||||
func (s *Service) updateLastCleanupTime() error {
|
||||
settings, err := s.GetDataSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data settings: %w", err)
|
||||
}
|
||||
|
||||
// Update the last cleanup time
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// Create the update payload
|
||||
updateData := map[string]interface{}{
|
||||
"last_cleanup": now,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(updateData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal update data: %w", err)
|
||||
}
|
||||
|
||||
// Update the record via PocketBase API
|
||||
requestURL := fmt.Sprintf("%s/api/collections/data_settings/records/%s", s.pbClient.GetBaseURL(), settings.ID)
|
||||
|
||||
req, err := http.NewRequest("PATCH", requestURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create update request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.pbClient.GetHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update last cleanup time: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to update last cleanup time, status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// log.Printf("Updated last cleanup time to: %s", now)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dataretention
|
||||
|
||||
import "time"
|
||||
|
||||
// DataSettings represents the data retention configuration from PocketBase
|
||||
type DataSettings struct {
|
||||
ID string `json:"id"`
|
||||
CollectionId string `json:"collectionId"`
|
||||
CollectionName string `json:"collectionName"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
ServerRetentionDays int `json:"server_retention_days"`
|
||||
UptimeRetentionDays int `json:"uptime_retention_days"`
|
||||
LastCleanup string `json:"last_cleanup"`
|
||||
Backup string `json:"backup"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
// DataSettingsResponse represents the response from PocketBase
|
||||
type DataSettingsResponse struct {
|
||||
Items []DataSettings `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
TotalItems int `json:"totalItems"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
}
|
||||
|
||||
// CleanupResult represents the result of a cleanup operation
|
||||
type CleanupResult struct {
|
||||
Collection string `json:"collection"`
|
||||
RecordsDeleted int `json:"records_deleted"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupSummary represents the overall cleanup summary
|
||||
type CleanupSummary struct {
|
||||
TotalDeleted int `json:"total_deleted"`
|
||||
Results []CleanupResult `json:"results"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"service-operation/config"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
servermonitoring "service-operation/server-monitoring"
|
||||
sslmonitoring "service-operation/ssl-monitoring"
|
||||
uptimemonitoring "service-operation/uptime-monitoring"
|
||||
dataretention "service-operation/data-retention"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -35,6 +37,7 @@ func main() {
|
||||
var sslNotificationService *sslmonitoring.SSLMonitor
|
||||
var serverMonitoringService *servermonitoring.ServerMonitoringService
|
||||
var uptimeMonitoringService *uptimemonitoring.UptimeMonitor
|
||||
var dataRetentionScheduler *dataretention.Scheduler
|
||||
|
||||
if cfg.PocketBaseEnabled {
|
||||
//log.Println("🔧 Initializing PocketBase client...")
|
||||
@@ -80,6 +83,12 @@ func main() {
|
||||
uptimeMonitoringService = uptimemonitoring.NewUptimeMonitor(pbClient)
|
||||
go uptimeMonitoringService.Start()
|
||||
//log.Println("✅ Uptime monitoring started with notification support")
|
||||
|
||||
// Initialize and start data retention scheduler
|
||||
//log.Println("🔧 Initializing data retention scheduler...")
|
||||
dataRetentionScheduler = dataretention.NewScheduler(pbClient, 24*time.Hour) // Run daily
|
||||
go dataRetentionScheduler.Start()
|
||||
//log.Println("✅ Data retention scheduler started (daily cleanup)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +132,9 @@ func main() {
|
||||
log.Printf("✓Uptime monitoring enabled with notification support")
|
||||
}
|
||||
log.Printf("✓Supported operations: ping, dns, tcp, http, ssl")
|
||||
if dataRetentionScheduler != nil {
|
||||
log.Printf("✓Data retention scheduler enabled (daily cleanup)")
|
||||
}
|
||||
|
||||
|
||||
// Setup graceful shutdown
|
||||
@@ -154,6 +166,10 @@ func main() {
|
||||
log.Println("🛑 Stopping uptime monitoring...")
|
||||
uptimeMonitoringService.Stop()
|
||||
}
|
||||
if dataRetentionScheduler != nil {
|
||||
log.Println("🛑 Stopping data retention scheduler...")
|
||||
dataRetentionScheduler.Stop()
|
||||
}
|
||||
|
||||
log.Println("✅ All services stopped gracefully")
|
||||
log.Println("🛑 === SERVICE OPERATION SERVER STOPPED ===")
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GotifyService handles Gotify notifications
|
||||
type GotifyService struct{}
|
||||
|
||||
// NewGotifyService creates a new Gotify notification service
|
||||
func NewGotifyService() *GotifyService {
|
||||
return &GotifyService{}
|
||||
}
|
||||
|
||||
// GotifyPayload represents the payload for Gotify API
|
||||
type GotifyPayload struct {
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// GotifyResponse represents the response from Gotify API
|
||||
type GotifyResponse struct {
|
||||
ID int `json:"id"`
|
||||
AppID int `json:"appid"`
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
Priority int `json:"priority"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// SendNotification sends a notification via Gotify
|
||||
func (gs *GotifyService) SendNotification(config *AlertConfiguration, message string) error {
|
||||
// fmt.Printf("📱 [GOTIFY] Attempting to send notification...\n")
|
||||
// fmt.Printf("📱 [GOTIFY] Config - API Token: %s, Server URL: %s, Notify Name: %s\n",
|
||||
// maskToken(config.APIToken), config.ServerURL, config.NotifyName)
|
||||
// fmt.Printf("📱 [GOTIFY] Message: %s\n", message)
|
||||
|
||||
if config.APIToken == "" || config.ServerURL == "" {
|
||||
return fmt.Errorf("Gotify API token and server URL are required")
|
||||
}
|
||||
|
||||
// Prepare the payload
|
||||
payload := GotifyPayload{
|
||||
Message: message,
|
||||
Title: config.NotifyName, // Use notify_name as the title
|
||||
Priority: 5, // Default priority
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
// fmt.Printf("❌ [GOTIFY] JSON marshal error: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// fmt.Printf("📱 [GOTIFY] Payload: %s\n", string(jsonData))
|
||||
// fmt.Printf("📱 [GOTIFY] Sending POST request to Gotify API...\n")
|
||||
|
||||
// Construct the Gotify API URL
|
||||
apiURL := strings.TrimRight(config.ServerURL, "/") + "/message?token=" + config.APIToken
|
||||
|
||||
// Send the request to Gotify API
|
||||
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
// fmt.Printf("❌ [GOTIFY] HTTP error: %v\n", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// fmt.Printf("❌ [GOTIFY] HTTP status error: %d\n", resp.StatusCode)
|
||||
return fmt.Errorf("Gotify API error: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var gotifyResp GotifyResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&gotifyResp); err != nil {
|
||||
// fmt.Printf("❌ [GOTIFY] Response decode error: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// fmt.Printf("📱 [GOTIFY] API response: %+v\n", gotifyResp)
|
||||
|
||||
// Check if the message was created successfully
|
||||
if gotifyResp.ID == 0 {
|
||||
// fmt.Printf("❌ [GOTIFY] API error: No message ID returned\n")
|
||||
return fmt.Errorf("Gotify API error: message not created")
|
||||
}
|
||||
|
||||
// fmt.Printf("✅ [GOTIFY] Message sent successfully! ID: %d\n", gotifyResp.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendServerNotification sends a server-specific notification via Gotify
|
||||
func (gs *GotifyService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
|
||||
message := gs.generateServerMessage(payload, template, resourceType)
|
||||
return gs.SendNotification(config, message)
|
||||
}
|
||||
|
||||
// SendServiceNotification sends a service-specific notification via Gotify
|
||||
func (gs *GotifyService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
|
||||
message := gs.generateServiceMessage(payload, template)
|
||||
return gs.SendNotification(config, message)
|
||||
}
|
||||
|
||||
// generateServerMessage creates a message for server notifications using server template
|
||||
func (gs *GotifyService) 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 = gs.generateDefaultServerMessage(payload, resourceType)
|
||||
}
|
||||
|
||||
return gs.replacePlaceholders(templateMessage, payload)
|
||||
}
|
||||
|
||||
// generateServiceMessage creates a message for service notifications using service template
|
||||
func (gs *GotifyService) 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 = gs.generateDefaultUptimeMessage(payload)
|
||||
}
|
||||
|
||||
return gs.replacePlaceholders(templateMessage, payload)
|
||||
}
|
||||
|
||||
// replacePlaceholders replaces all placeholders in the message with actual values
|
||||
func (gs *GotifyService) replacePlaceholders(message string, payload *NotificationPayload) string {
|
||||
// Replace basic placeholders
|
||||
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
|
||||
message = strings.ReplaceAll(message, "${server_name}", payload.ServiceName) // server_name maps to service_name
|
||||
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
|
||||
message = strings.ReplaceAll(message, "${host}", gs.safeString(payload.Host))
|
||||
message = strings.ReplaceAll(message, "${hostname}", gs.safeString(payload.Hostname))
|
||||
|
||||
// Replace URL with fallback to host
|
||||
url := gs.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}", gs.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}", gs.safeString(payload.RegionName))
|
||||
message = strings.ReplaceAll(message, "${agent_id}", gs.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}", gs.safeString(payload.CPUUsage))
|
||||
message = strings.ReplaceAll(message, "${ram_usage}", gs.safeString(payload.RAMUsage))
|
||||
message = strings.ReplaceAll(message, "${disk_usage}", gs.safeString(payload.DiskUsage))
|
||||
message = strings.ReplaceAll(message, "${network_usage}", gs.safeString(payload.NetworkUsage))
|
||||
message = strings.ReplaceAll(message, "${cpu_temp}", gs.safeString(payload.CPUTemp))
|
||||
message = strings.ReplaceAll(message, "${disk_io}", gs.safeString(payload.DiskIO))
|
||||
message = strings.ReplaceAll(message, "${threshold}", gs.safeString(payload.Threshold))
|
||||
|
||||
// Replace SSL certificate fields
|
||||
message = strings.ReplaceAll(message, "${certificate_name}", gs.safeString(payload.CertificateName))
|
||||
message = strings.ReplaceAll(message, "${expiry_date}", gs.safeString(payload.ExpiryDate))
|
||||
message = strings.ReplaceAll(message, "${days_left}", gs.safeString(payload.DaysLeft))
|
||||
message = strings.ReplaceAll(message, "${issuer_cn}", gs.safeString(payload.IssuerCN))
|
||||
message = strings.ReplaceAll(message, "${issuer}", gs.safeString(payload.IssuerCN)) // alias
|
||||
|
||||
// Replace error message - important for uptime services
|
||||
message = strings.ReplaceAll(message, "${error_message}", gs.safeString(payload.ErrorMessage))
|
||||
message = strings.ReplaceAll(message, "${error}", gs.safeString(payload.ErrorMessage))
|
||||
message = strings.ReplaceAll(message, "${message}", gs.safeString(payload.Message))
|
||||
|
||||
// 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 (gs *GotifyService) safeString(value string) string {
|
||||
if value == "" {
|
||||
return "N/A"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
|
||||
func (gs *GotifyService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
|
||||
message := fmt.Sprintf("Service %s is %s.", 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 (gs *GotifyService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string {
|
||||
return fmt.Sprintf("Server %s (%s) status: %s", payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status))
|
||||
}
|
||||
@@ -31,6 +31,8 @@ func NewNotificationManager(pbClient *pocketbase.PocketBaseClient) *Notification
|
||||
services["webhook"] = NewWebhookService()
|
||||
services["ntfy"] = NewNtfyService()
|
||||
services["pushover"] = NewPushoverService()
|
||||
services["notifiarr"] = NewNotifiarrService()
|
||||
services["gotify"] = NewGotifyService()
|
||||
|
||||
// log.Printf("✅ Notification services initialized: %v", getKeys(services))
|
||||
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NotifiarrService handles Notifiarr notifications
|
||||
type NotifiarrService struct{}
|
||||
|
||||
// NewNotifiarrService creates a new Notifiarr notification service
|
||||
func NewNotifiarrService() *NotifiarrService {
|
||||
return &NotifiarrService{}
|
||||
}
|
||||
|
||||
// NotifiarrPayload represents the payload for Notifiarr API
|
||||
type NotifiarrPayload struct {
|
||||
Notification NotifiarrNotification `json:"notification"`
|
||||
Discord NotifiarrDiscord `json:"discord"`
|
||||
}
|
||||
|
||||
type NotifiarrNotification struct {
|
||||
Update bool `json:"update"`
|
||||
Name string `json:"name"`
|
||||
Event string `json:"event,omitempty"`
|
||||
}
|
||||
|
||||
type NotifiarrDiscord struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
Ping NotifiarrPing `json:"ping,omitempty"`
|
||||
Images NotifiarrImages `json:"images,omitempty"`
|
||||
Text NotifiarrText `json:"text"`
|
||||
IDs NotifiarrIDs `json:"ids,omitempty"`
|
||||
}
|
||||
|
||||
type NotifiarrPing struct {
|
||||
PingUser int64 `json:"pingUser,omitempty"`
|
||||
PingRole int64 `json:"pingRole,omitempty"`
|
||||
}
|
||||
|
||||
type NotifiarrImages struct {
|
||||
Thumbnail string `json:"thumbnail,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
type NotifiarrText struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Fields []any `json:"fields,omitempty"`
|
||||
Footer string `json:"footer,omitempty"`
|
||||
}
|
||||
|
||||
type NotifiarrIDs struct {
|
||||
Channel int64 `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// SendNotification sends a notification via Notifiarr
|
||||
func (ns *NotifiarrService) SendNotification(config *AlertConfiguration, message string) error {
|
||||
// fmt.Printf("📡 [NOTIFIARR] Attempting to send notification...\n")
|
||||
// fmt.Printf("📡 [NOTIFIARR] Config - API Token present: %v\n", config.APIToken != "")
|
||||
// fmt.Printf("📡 [NOTIFIARR] Message: %s\n", message)
|
||||
|
||||
if config.APIToken == "" {
|
||||
return fmt.Errorf("notifiarr API token is required")
|
||||
}
|
||||
|
||||
// Parse channel ID if provided
|
||||
var channelID int64
|
||||
if config.ChannelID != "" {
|
||||
if parsed, err := strconv.ParseInt(config.ChannelID, 10, 64); err == nil {
|
||||
channelID = parsed
|
||||
}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://notifiarr.com/api/v1/notification/passthrough/%s", config.APIToken)
|
||||
// fmt.Printf("📡 [NOTIFIARR] API URL: %s\n", strings.Replace(url, config.APIToken, "[REDACTED]", 1))
|
||||
|
||||
// Create status-based color
|
||||
color := ns.getStatusColor(message)
|
||||
|
||||
payload := NotifiarrPayload{
|
||||
Notification: NotifiarrNotification{
|
||||
Update: false,
|
||||
Name: "This is an automated notification from CheckCle System",
|
||||
Event: "",
|
||||
},
|
||||
Discord: NotifiarrDiscord{
|
||||
Color: color,
|
||||
Text: NotifiarrText{
|
||||
Title: "Service Alert",
|
||||
Description: message,
|
||||
Footer: "CheckCle Monitoring System",
|
||||
},
|
||||
IDs: NotifiarrIDs{
|
||||
Channel: channelID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fmt.Printf("📡 [NOTIFIARR] Sending POST request...\n")
|
||||
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
// fmt.Printf("❌ [NOTIFIARR] HTTP error: %v\n", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// fmt.Printf("❌ [NOTIFIARR] HTTP status error: %d\n", resp.StatusCode)
|
||||
return fmt.Errorf("notifiarr API error: status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// fmt.Printf("✅ [NOTIFIARR] Message sent successfully!\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendServerNotification sends a server-specific notification via Notifiarr
|
||||
func (ns *NotifiarrService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
|
||||
message := ns.generateServerMessage(payload, template, resourceType)
|
||||
return ns.SendNotification(config, message)
|
||||
}
|
||||
|
||||
// SendServiceNotification sends a service-specific notification via Notifiarr
|
||||
func (ns *NotifiarrService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
|
||||
message := ns.generateServiceMessage(payload, template)
|
||||
return ns.SendNotification(config, message)
|
||||
}
|
||||
|
||||
// generateServerMessage creates a message for server notifications using server template
|
||||
func (ns *NotifiarrService) 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 *NotifiarrService) 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 *NotifiarrService) 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 *NotifiarrService) safeString(value string) string {
|
||||
if value == "" {
|
||||
return "N/A"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// getStatusColor returns appropriate color for status
|
||||
func (ns *NotifiarrService) getStatusColor(message string) string {
|
||||
messageLower := strings.ToLower(message)
|
||||
|
||||
if strings.Contains(messageLower, "down") || strings.Contains(messageLower, "error") || strings.Contains(messageLower, "failed") {
|
||||
return "FF0000" // Red
|
||||
} else if strings.Contains(messageLower, "up") || strings.Contains(messageLower, "restored") || strings.Contains(messageLower, "resolved") {
|
||||
return "00FF00" // Green
|
||||
} else if strings.Contains(messageLower, "warning") || strings.Contains(messageLower, "alert") {
|
||||
return "FFA500" // Orange
|
||||
}
|
||||
|
||||
return "0099FF" // Blue (default)
|
||||
}
|
||||
|
||||
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
|
||||
func (ns *NotifiarrService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
|
||||
// Status emoji mapping
|
||||
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 *NotifiarrService) 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))
|
||||
}
|
||||
@@ -32,6 +32,11 @@ func (ns *NtfyService) SendNotification(config *AlertConfiguration, message stri
|
||||
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{}
|
||||
@@ -111,6 +116,11 @@ func (ns *NtfyService) SendNotificationWithDetails(config *AlertConfiguration, m
|
||||
|
||||
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{}
|
||||
|
||||
@@ -53,7 +53,7 @@ type AlertConfiguration struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
SlackWebhookURL string `json:"slack_webhook_url"`
|
||||
GoogleChatWebhookURL string `json:"google_chat_webhook_url"`
|
||||
Enabled string `json:"enabled"` // String because PocketBase returns it as string
|
||||
Enabled string `json:"enabled"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
EmailSenderName string `json:"email_sender_name"`
|
||||
SMTPServer string `json:"smtp_server"`
|
||||
@@ -66,6 +66,7 @@ type AlertConfiguration struct {
|
||||
NtfyEndpoint string `json:"ntfy_endpoint"`
|
||||
APIToken string `json:"api_token"`
|
||||
UserKey string `json:"user_key"`
|
||||
ServerURL string `json:"server_url"`
|
||||
}
|
||||
|
||||
// ServerNotificationTemplate represents a server notification template
|
||||
|
||||
Reference in New Issue
Block a user