diff --git a/cmd/sylve/main.go b/cmd/sylve/main.go index 13d3f352..81399540 100644 --- a/cmd/sylve/main.go +++ b/cmd/sylve/main.go @@ -26,6 +26,7 @@ import ( clusterModels "github.com/alchemillahq/sylve/internal/db/models/cluster" "github.com/alchemillahq/sylve/internal/handlers" "github.com/alchemillahq/sylve/internal/logger" + notificationFacade "github.com/alchemillahq/sylve/internal/notifications" "github.com/alchemillahq/sylve/internal/repl" "github.com/alchemillahq/sylve/internal/services" "github.com/alchemillahq/sylve/internal/services/auth" @@ -37,6 +38,7 @@ import ( "github.com/alchemillahq/sylve/internal/services/libvirt" "github.com/alchemillahq/sylve/internal/services/lifecycle" networkService "github.com/alchemillahq/sylve/internal/services/network" + notificationsService "github.com/alchemillahq/sylve/internal/services/notifications" "github.com/alchemillahq/sylve/internal/services/samba" "github.com/alchemillahq/sylve/internal/services/system" "github.com/alchemillahq/sylve/internal/services/utilities" @@ -136,6 +138,8 @@ func main() { jS := serviceRegistry.JailService cS := serviceRegistry.ClusterService zeltaS := serviceRegistry.ZeltaService + notificationService := notificationsService.NewService(d, aS.(*auth.Service)) + notificationFacade.SetEmitter(notificationService) clusterSvc := cS.(*cluster.Service) if err := clusterSvc.MigrateLegacyPorts(); err != nil { @@ -241,6 +245,7 @@ func main() { zS.(*zfs.Service), dS.(*disk.Service), nS.(*networkService.Service), + notificationService, uS.(*utilities.Service), sysS.(*system.Service), libvirtSvc, diff --git a/internal/db/db.go b/internal/db/db.go index badbc8e6..d1d4b891 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -82,6 +82,10 @@ func SetupDatabase(cfg *internal.SylveConfig, isTest bool) *gorm.DB { err = db.AutoMigrate( &models.BasicSettings{}, + &models.Notification{}, + &models.NotificationSuppression{}, + &models.NotificationKindRule{}, + &models.NotificationTransportConfig{}, &models.System{}, &models.User{}, diff --git a/internal/db/models/notifications.go b/internal/db/models/notifications.go new file mode 100644 index 00000000..83fdddc8 --- /dev/null +++ b/internal/db/models/notifications.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: BSD-2-Clause +// +// Copyright (c) 2025 The FreeBSD Foundation. +// +// This software was developed by Hayzam Sherif +// of Alchemilla Ventures Pvt. Ltd. , +// under sponsorship from the FreeBSD Foundation. + +package models + +import "time" + +type NotificationSeverity string + +const ( + NotificationSeverityInfo NotificationSeverity = "info" + NotificationSeverityWarning NotificationSeverity = "warning" + NotificationSeverityError NotificationSeverity = "error" + NotificationSeverityCritical NotificationSeverity = "critical" +) + +type Notification struct { + ID uint `json:"id" gorm:"primaryKey"` + Kind string `json:"kind" gorm:"index;not null"` + Title string `json:"title" gorm:"not null"` + Body string `json:"body" gorm:"type:text"` + Severity NotificationSeverity `json:"severity" gorm:"index;not null;default:info"` + Source string `json:"source" gorm:"index"` + Fingerprint string `json:"fingerprint" gorm:"uniqueIndex;not null"` + Metadata map[string]string `json:"metadata" gorm:"serializer:json;type:json"` + OccurrenceCount int `json:"occurrenceCount" gorm:"not null;default:1"` + FirstOccurredAt time.Time `json:"firstOccurredAt" gorm:"not null;index"` + LastOccurredAt time.Time `json:"lastOccurredAt" gorm:"not null;index"` + DismissedAt *time.Time `json:"dismissedAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updatedAt" gorm:"autoUpdateTime"` +} + +type NotificationSuppression struct { + ID uint `json:"id" gorm:"primaryKey"` + Fingerprint string `json:"fingerprint" gorm:"uniqueIndex;not null"` + Kind string `json:"kind" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"autoCreateTime"` +} + +type NotificationKindRule struct { + ID uint `json:"id" gorm:"primaryKey"` + Kind string `json:"kind" gorm:"uniqueIndex;not null"` + UIEnabled bool `json:"uiEnabled" gorm:"not null;default:true"` + NtfyEnabled bool `json:"ntfyEnabled" gorm:"not null;default:true"` + EmailEnabled bool `json:"emailEnabled" gorm:"not null;default:true"` + CreatedAt time.Time `json:"createdAt" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updatedAt" gorm:"autoUpdateTime"` +} + +type NotificationTransportConfig struct { + ID uint `json:"id" gorm:"primaryKey"` + NtfyEnabled bool `json:"ntfyEnabled" gorm:"not null;default:false"` + NtfyBaseURL string `json:"ntfyBaseUrl" gorm:"not null;default:https://ntfy.sh"` + NtfyTopic string `json:"ntfyTopic"` + NtfyAuthTokenSecretName string `json:"-" gorm:"not null;default:notifications_ntfy_token"` + EmailEnabled bool `json:"emailEnabled" gorm:"not null;default:false"` + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort" gorm:"not null;default:587"` + SMTPUsername string `json:"smtpUsername"` + SMTPFrom string `json:"smtpFrom"` + SMTPUseTLS bool `json:"smtpUseTls" gorm:"not null;default:true"` + SMTPPasswordSecretName string `json:"-" gorm:"not null;default:notifications_smtp_password"` + EmailRecipients []string `json:"emailRecipients" gorm:"serializer:json;type:json"` + CreatedAt time.Time `json:"createdAt" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updatedAt" gorm:"autoUpdateTime"` +} diff --git a/internal/handlers/notifications/notifications.go b/internal/handlers/notifications/notifications.go new file mode 100644 index 00000000..b6a67914 --- /dev/null +++ b/internal/handlers/notifications/notifications.go @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: BSD-2-Clause +// +// Copyright (c) 2025 The FreeBSD Foundation. +// +// This software was developed by Hayzam Sherif +// of Alchemilla Ventures Pvt. Ltd. , +// under sponsorship from the FreeBSD Foundation. + +package notificationsHandlers + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/alchemillahq/sylve/internal" + "github.com/alchemillahq/sylve/internal/db/models" + "github.com/alchemillahq/sylve/internal/services/notifications" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type NotificationListResponse struct { + Items []models.Notification `json:"items"` + Total int64 `json:"total"` +} + +type NotificationCountResponse struct { + Active int64 `json:"active"` +} + +type notificationConfigUpdateRequest struct { + Ntfy struct { + Enabled bool `json:"enabled"` + BaseURL string `json:"baseUrl"` + Topic string `json:"topic"` + AuthToken *string `json:"authToken"` + } `json:"ntfy"` + Email struct { + Enabled bool `json:"enabled"` + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + SMTPUsername string `json:"smtpUsername"` + SMTPFrom string `json:"smtpFrom"` + SMTPUseTLS bool `json:"smtpUseTls"` + Recipients []string `json:"recipients"` + SMTPPassword *string `json:"smtpPassword"` + } `json:"email"` +} + +func List(service *notifications.Service) gin.HandlerFunc { + return func(c *gin.Context) { + scope := notifications.ListScope(strings.TrimSpace(strings.ToLower(c.DefaultQuery("scope", string(notifications.ListScopeActive))))) + if scope != notifications.ListScopeActive && scope != notifications.ListScopeAll { + c.JSON(http.StatusBadRequest, internal.APIResponse[any]{ + Status: "error", + Message: "invalid_scope", + Error: "invalid_scope", + Data: nil, + }) + return + } + + limit := parseInt(c.Query("limit"), 50) + offset := parseInt(c.Query("offset"), 0) + + items, total, err := service.List(c.Request.Context(), scope, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{ + Status: "error", + Message: "failed_to_list_notifications", + Error: err.Error(), + Data: nil, + }) + return + } + + c.JSON(http.StatusOK, internal.APIResponse[NotificationListResponse]{ + Status: "success", + Message: "notifications_listed", + Error: "", + Data: NotificationListResponse{ + Items: items, + Total: total, + }, + }) + } +} + +func Count(service *notifications.Service) gin.HandlerFunc { + return func(c *gin.Context) { + active, err := service.CountActive(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{ + Status: "error", + Message: "failed_to_count_notifications", + Error: err.Error(), + Data: nil, + }) + return + } + + c.JSON(http.StatusOK, internal.APIResponse[NotificationCountResponse]{ + Status: "success", + Message: "notifications_counted", + Error: "", + Data: NotificationCountResponse{ + Active: active, + }, + }) + } +} + +func Dismiss(service *notifications.Service) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, internal.APIResponse[any]{ + Status: "error", + Message: "invalid_notification_id", + Error: "invalid_notification_id", + Data: nil, + }) + return + } + + err = service.Dismiss(c.Request.Context(), uint(id)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, internal.APIResponse[any]{ + Status: "error", + Message: "notification_not_found", + Error: "notification_not_found", + Data: nil, + }) + return + } + c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{ + Status: "error", + Message: "failed_to_dismiss_notification", + Error: err.Error(), + Data: nil, + }) + return + } + + c.JSON(http.StatusOK, internal.APIResponse[any]{ + Status: "success", + Message: "notification_dismissed", + Error: "", + Data: nil, + }) + } +} + +func GetConfig(service *notifications.Service) gin.HandlerFunc { + return func(c *gin.Context) { + cfg, err := service.GetTransportConfig(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{ + Status: "error", + Message: "failed_to_load_notification_config", + Error: err.Error(), + Data: nil, + }) + return + } + + c.JSON(http.StatusOK, internal.APIResponse[notifications.TransportConfigView]{ + Status: "success", + Message: "notification_config_loaded", + Error: "", + Data: cfg, + }) + } +} + +func UpdateConfig(service *notifications.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var req notificationConfigUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, internal.APIResponse[any]{ + Status: "error", + Message: "invalid_request_body", + Error: err.Error(), + Data: nil, + }) + return + } + + updated, err := service.UpdateTransportConfig(c.Request.Context(), notifications.TransportConfigUpdate{ + Ntfy: notifications.NtfyTransportConfigUpdate{ + Enabled: req.Ntfy.Enabled, + BaseURL: req.Ntfy.BaseURL, + Topic: req.Ntfy.Topic, + AuthToken: req.Ntfy.AuthToken, + }, + Email: notifications.EmailTransportConfigUpdate{ + Enabled: req.Email.Enabled, + SMTPHost: req.Email.SMTPHost, + SMTPPort: req.Email.SMTPPort, + SMTPUsername: req.Email.SMTPUsername, + SMTPFrom: req.Email.SMTPFrom, + SMTPUseTLS: req.Email.SMTPUseTLS, + Recipients: req.Email.Recipients, + SMTPPassword: req.Email.SMTPPassword, + }, + }) + if err != nil { + c.JSON(http.StatusBadRequest, internal.APIResponse[any]{ + Status: "error", + Message: "failed_to_update_notification_config", + Error: err.Error(), + Data: nil, + }) + return + } + + c.JSON(http.StatusOK, internal.APIResponse[notifications.TransportConfigView]{ + Status: "success", + Message: "notification_config_updated", + Error: "", + Data: updated, + }) + } +} + +func parseInt(value string, fallback int) int { + v, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return fallback + } + return v +} diff --git a/internal/handlers/notifications/notifications_test.go b/internal/handlers/notifications/notifications_test.go new file mode 100644 index 00000000..dc0c3c3a --- /dev/null +++ b/internal/handlers/notifications/notifications_test.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: BSD-2-Clause +// +// Copyright (c) 2025 The FreeBSD Foundation. +// +// This software was developed by Hayzam Sherif +// of Alchemilla Ventures Pvt. Ltd. , +// under sponsorship from the FreeBSD Foundation. + +package notificationsHandlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alchemillahq/sylve/internal/db/models" + "github.com/alchemillahq/sylve/internal/handlers/middleware" + notifier "github.com/alchemillahq/sylve/internal/notifications" + "github.com/alchemillahq/sylve/internal/services/notifications" + "github.com/alchemillahq/sylve/internal/testutil" + "github.com/gin-gonic/gin" +) + +type handlerTestSecretStore struct{} + +func (handlerTestSecretStore) GetSecret(name string) (string, error) { return "", nil } +func (handlerTestSecretStore) UpsertSecret(name string, data string) error { return nil } + +func newHandlerTestService(t *testing.T) *notifications.Service { + t.Helper() + + db := testutil.NewSQLiteTestDB( + t, + &models.Notification{}, + &models.NotificationSuppression{}, + &models.NotificationKindRule{}, + &models.NotificationTransportConfig{}, + ) + + return notifications.NewService(db, handlerTestSecretStore{}) +} + +func TestNotificationsCountRequiresAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + group := r.Group("/api/notifications") + group.Use(middleware.EnsureAuthenticated(nil)) + group.GET("/count", Count(nil)) + + req := httptest.NewRequest(http.MethodGet, "/api/notifications/count", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected_401 got: %d", rec.Code) + } +} + +func TestNotificationsListHandlerReturnsItems(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := newHandlerTestService(t) + _, err := svc.Emit(context.Background(), notifier.EventInput{ + Kind: "system.alert", + Title: "Test Alert", + Body: "Something happened", + Severity: "warning", + Fingerprint: "test-alert", + }) + if err != nil { + t.Fatalf("failed_to_seed_notification: %v", err) + } + + r := gin.New() + r.GET("/api/notifications", List(svc)) + + req := httptest.NewRequest(http.MethodGet, "/api/notifications?scope=active", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected_200 got: %d", rec.Code) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed_to_decode_response: %v", err) + } + + dataAny, ok := payload["data"] + if !ok { + t.Fatalf("expected_data_field") + } + dataMap, ok := dataAny.(map[string]any) + if !ok { + t.Fatalf("expected_data_object") + } + itemsAny, ok := dataMap["items"] + if !ok { + t.Fatalf("expected_items_field") + } + items, ok := itemsAny.([]any) + if !ok { + t.Fatalf("expected_items_array") + } + if len(items) != 1 { + t.Fatalf("expected_1_item got: %d", len(items)) + } +} diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go index 640dff20..69b5559f 100644 --- a/internal/handlers/routes.go +++ b/internal/handlers/routes.go @@ -30,6 +30,7 @@ import ( jailHandlers "github.com/alchemillahq/sylve/internal/handlers/jail" "github.com/alchemillahq/sylve/internal/handlers/middleware" networkHandlers "github.com/alchemillahq/sylve/internal/handlers/network" + notificationsHandlers "github.com/alchemillahq/sylve/internal/handlers/notifications" sambaHandlers "github.com/alchemillahq/sylve/internal/handlers/samba" systemHandlers "github.com/alchemillahq/sylve/internal/handlers/system" taskHandlers "github.com/alchemillahq/sylve/internal/handlers/task" @@ -46,6 +47,7 @@ import ( "github.com/alchemillahq/sylve/internal/services/libvirt" "github.com/alchemillahq/sylve/internal/services/lifecycle" networkService "github.com/alchemillahq/sylve/internal/services/network" + notificationsService "github.com/alchemillahq/sylve/internal/services/notifications" "github.com/alchemillahq/sylve/internal/services/samba" systemService "github.com/alchemillahq/sylve/internal/services/system" utilitiesService "github.com/alchemillahq/sylve/internal/services/utilities" @@ -80,6 +82,7 @@ func RegisterRoutes(r *gin.Engine, zfsService *zfsService.Service, diskService *diskService.Service, networkService *networkService.Service, + notificationService *notificationsService.Service, utilitiesService *utilitiesService.Service, systemService *systemService.Service, libvirtService *libvirt.Service, @@ -503,6 +506,18 @@ func RegisterRoutes(r *gin.Engine, events.GET("/stream", eventsHandlers.StreamSSE(authService)) } + notifications := api.Group("/notifications") + notifications.Use(middleware.EnsureAuthenticated(authService)) + notifications.Use(EnsureCorrectHost(db, authService)) + notifications.Use(middleware.RequestLoggerMiddleware(telemetryDB, authService)) + { + notifications.GET("", notificationsHandlers.List(notificationService)) + notifications.GET("/count", notificationsHandlers.Count(notificationService)) + notifications.POST("/:id/dismiss", notificationsHandlers.Dismiss(notificationService)) + notifications.GET("/config", notificationsHandlers.GetConfig(notificationService)) + notifications.PUT("/config", notificationsHandlers.UpdateConfig(notificationService)) + } + users := auth.Group("/users") users.Use(EnsureCorrectHost(db, authService)) users.Use(middleware.RequireLocalAdmin(authService)) diff --git a/internal/notifications/facade.go b/internal/notifications/facade.go new file mode 100644 index 00000000..703f006c --- /dev/null +++ b/internal/notifications/facade.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BSD-2-Clause +// +// Copyright (c) 2025 The FreeBSD Foundation. +// +// This software was developed by Hayzam Sherif +// of Alchemilla Ventures Pvt. Ltd. , +// under sponsorship from the FreeBSD Foundation. + +package notifications + +import ( + "context" + "errors" + "sync" +) + +var ErrEmitterNotConfigured = errors.New("notifications_emitter_not_configured") + +type EventInput struct { + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Severity string `json:"severity"` + Source string `json:"source"` + Fingerprint string `json:"fingerprint"` + Metadata map[string]string `json:"metadata"` +} + +type EmitResult struct { + NotificationID uint `json:"notificationId"` + Suppressed bool `json:"suppressed"` + SentNtfy bool `json:"sentNtfy"` + SentEmail bool `json:"sentEmail"` +} + +type Emitter interface { + Emit(ctx context.Context, input EventInput) (EmitResult, error) +} + +var ( + emitterMu sync.RWMutex + emitter Emitter +) + +func SetEmitter(next Emitter) { + emitterMu.Lock() + emitter = next + emitterMu.Unlock() +} + +func Emit(ctx context.Context, input EventInput) (EmitResult, error) { + emitterMu.RLock() + active := emitter + emitterMu.RUnlock() + + if active == nil { + return EmitResult{}, ErrEmitterNotConfigured + } + + return active.Emit(ctx, input) +} diff --git a/internal/services/notifications/service.go b/internal/services/notifications/service.go new file mode 100644 index 00000000..d7f8be0d --- /dev/null +++ b/internal/services/notifications/service.go @@ -0,0 +1,889 @@ +// SPDX-License-Identifier: BSD-2-Clause +// +// Copyright (c) 2025 The FreeBSD Foundation. +// +// This software was developed by Hayzam Sherif +// of Alchemilla Ventures Pvt. Ltd. , +// under sponsorship from the FreeBSD Foundation. + +package notifications + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/smtp" + "sort" + "strconv" + "strings" + "time" + + "github.com/alchemillahq/sylve/internal/db/models" + hub "github.com/alchemillahq/sylve/internal/events" + notifier "github.com/alchemillahq/sylve/internal/notifications" + "github.com/alchemillahq/sylve/pkg/utils" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + defaultNtfyBaseURL = "https://ntfy.sh" + defaultNtfySecretName = "notifications_ntfy_token" + defaultSMTPPasswordSecret = "notifications_smtp_password" + defaultSMTPPort = 587 + defaultListLimit = 50 + maxListLimit = 500 +) + +type SecretStore interface { + GetSecret(name string) (string, error) + UpsertSecret(name string, data string) error +} + +type NtfySender func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, token string) error + +type EmailSender func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, password string) error + +type Service struct { + DB *gorm.DB + secrets SecretStore + httpClient *http.Client + now func() time.Time + + ntfySender NtfySender + emailSender EmailSender +} + +type ListScope string + +const ( + ListScopeActive ListScope = "active" + ListScopeAll ListScope = "all" +) + +type TransportConfigView struct { + Ntfy NtfyTransportConfigView `json:"ntfy"` + Email EmailTransportConfigView `json:"email"` +} + +type NtfyTransportConfigView struct { + Enabled bool `json:"enabled"` + BaseURL string `json:"baseUrl"` + Topic string `json:"topic"` + HasAuthToken bool `json:"hasAuthToken"` +} + +type EmailTransportConfigView struct { + Enabled bool `json:"enabled"` + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + SMTPUsername string `json:"smtpUsername"` + SMTPFrom string `json:"smtpFrom"` + SMTPUseTLS bool `json:"smtpUseTls"` + Recipients []string `json:"recipients"` + HasPassword bool `json:"hasPassword"` +} + +type TransportConfigUpdate struct { + Ntfy NtfyTransportConfigUpdate `json:"ntfy"` + Email EmailTransportConfigUpdate `json:"email"` +} + +type NtfyTransportConfigUpdate struct { + Enabled bool `json:"enabled"` + BaseURL string `json:"baseUrl"` + Topic string `json:"topic"` + AuthToken *string `json:"authToken,omitempty"` +} + +type EmailTransportConfigUpdate struct { + Enabled bool `json:"enabled"` + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + SMTPUsername string `json:"smtpUsername"` + SMTPFrom string `json:"smtpFrom"` + SMTPUseTLS bool `json:"smtpUseTls"` + Recipients []string `json:"recipients"` + SMTPPassword *string `json:"smtpPassword,omitempty"` +} + +func NewService(db *gorm.DB, secrets SecretStore) *Service { + s := &Service{ + DB: db, + secrets: secrets, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + now: time.Now, + } + + s.ntfySender = s.sendNtfy + s.emailSender = s.sendEmail + + return s +} + +func (s *Service) SetNtfySender(sender NtfySender) { + if sender == nil { + s.ntfySender = s.sendNtfy + return + } + + s.ntfySender = sender +} + +func (s *Service) SetEmailSender(sender EmailSender) { + if sender == nil { + s.emailSender = s.sendEmail + return + } + + s.emailSender = sender +} + +func (s *Service) Emit(ctx context.Context, input notifier.EventInput) (notifier.EmitResult, error) { + if s == nil || s.DB == nil { + return notifier.EmitResult{}, fmt.Errorf("notifications_service_not_initialized") + } + + normalized := normalizeInput(input) + if normalized.Kind == "" { + return notifier.EmitResult{}, fmt.Errorf("notification_kind_required") + } + if normalized.Title == "" { + return notifier.EmitResult{}, fmt.Errorf("notification_title_required") + } + if normalized.Fingerprint == "" { + normalized.Fingerprint = makeFingerprint(normalized) + } + + now := s.now().UTC() + + result := notifier.EmitResult{} + var cfg models.NotificationTransportConfig + var kindRule models.NotificationKindRule + + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var err error + + kindRule, err = s.ensureKindRule(tx, normalized.Kind) + if err != nil { + return err + } + + suppressionFingerprint := suppressionKey(normalized.Kind, normalized.Fingerprint) + + var suppression models.NotificationSuppression + err = tx. + Where("kind = ?", normalized.Kind). + Where("fingerprint = ?", suppressionFingerprint). + First(&suppression).Error + if err == nil { + result.Suppressed = true + return nil + } + if err != nil && err != gorm.ErrRecordNotFound { + return err + } + + var existing models.Notification + err = tx.Where("fingerprint = ?", normalized.Fingerprint).First(&existing).Error + if err == nil { + existing.Kind = normalized.Kind + existing.Title = normalized.Title + existing.Body = normalized.Body + existing.Severity = models.NotificationSeverity(normalized.Severity) + existing.Source = normalized.Source + existing.Metadata = normalized.Metadata + existing.LastOccurredAt = now + existing.OccurrenceCount++ + existing.DismissedAt = nil + existing.UpdatedAt = now + + if updateErr := tx.Save(&existing).Error; updateErr != nil { + return updateErr + } + + result.NotificationID = existing.ID + } else if err == gorm.ErrRecordNotFound { + rec := models.Notification{ + Kind: normalized.Kind, + Title: normalized.Title, + Body: normalized.Body, + Severity: models.NotificationSeverity(normalized.Severity), + Source: normalized.Source, + Fingerprint: normalized.Fingerprint, + Metadata: normalized.Metadata, + OccurrenceCount: 1, + FirstOccurredAt: now, + LastOccurredAt: now, + } + + if createErr := tx.Create(&rec).Error; createErr != nil { + return createErr + } + + result.NotificationID = rec.ID + } else { + return err + } + + cfg, err = s.ensureTransportConfig(tx) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return notifier.EmitResult{}, err + } + + if result.Suppressed { + return result, nil + } + + s.publishRefresh() + + if cfg.NtfyEnabled && kindRule.NtfyEnabled { + token := s.getSecret(cfg.NtfyAuthTokenSecretName) + if err := s.ntfySender(ctx, cfg, normalized, token); err == nil { + result.SentNtfy = true + } + } + + if cfg.EmailEnabled && kindRule.EmailEnabled && len(cfg.EmailRecipients) > 0 { + password := s.getSecret(cfg.SMTPPasswordSecretName) + if err := s.emailSender(ctx, cfg, normalized, password); err == nil { + result.SentEmail = true + } + } + + return result, nil +} + +func (s *Service) List(ctx context.Context, scope ListScope, limit, offset int) ([]models.Notification, int64, error) { + if s == nil || s.DB == nil { + return nil, 0, fmt.Errorf("notifications_service_not_initialized") + } + + if limit <= 0 { + limit = defaultListLimit + } + if limit > maxListLimit { + limit = maxListLimit + } + if offset < 0 { + offset = 0 + } + + q := s.DB.WithContext(ctx).Model(&models.Notification{}) + if scope != ListScopeAll { + q = q.Where("dismissed_at IS NULL") + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + var items []models.Notification + if err := q.Order("last_occurred_at DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + + return items, total, nil +} + +func (s *Service) CountActive(ctx context.Context) (int64, error) { + if s == nil || s.DB == nil { + return 0, fmt.Errorf("notifications_service_not_initialized") + } + + var count int64 + err := s.DB.WithContext(ctx). + Model(&models.Notification{}). + Where("dismissed_at IS NULL"). + Count(&count).Error + if err != nil { + return 0, err + } + + return count, nil +} + +func (s *Service) Dismiss(ctx context.Context, id uint) error { + if s == nil || s.DB == nil { + return fmt.Errorf("notifications_service_not_initialized") + } + + now := s.now().UTC() + + if err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var notif models.Notification + if err := tx.First(¬if, id).Error; err != nil { + return err + } + + if notif.DismissedAt == nil { + if err := tx.Model(&models.Notification{}).Where("id = ?", notif.ID).Updates(map[string]any{ + "dismissed_at": now, + "updated_at": now, + }).Error; err != nil { + return err + } + } + + suppression := models.NotificationSuppression{ + Fingerprint: suppressionKey(notif.Kind, notif.Fingerprint), + Kind: notif.Kind, + } + + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&suppression).Error; err != nil { + return err + } + + return nil + }); err != nil { + return err + } + + s.publishRefresh() + return nil +} + +func (s *Service) GetTransportConfig(ctx context.Context) (TransportConfigView, error) { + if s == nil || s.DB == nil { + return TransportConfigView{}, fmt.Errorf("notifications_service_not_initialized") + } + + cfg, err := s.ensureTransportConfigDB(ctx) + if err != nil { + return TransportConfigView{}, err + } + + return s.toTransportConfigView(cfg), nil +} + +func (s *Service) UpdateTransportConfig(ctx context.Context, input TransportConfigUpdate) (TransportConfigView, error) { + if s == nil || s.DB == nil { + return TransportConfigView{}, fmt.Errorf("notifications_service_not_initialized") + } + + normalizedRecipients := make([]string, 0, len(input.Email.Recipients)) + seen := map[string]struct{}{} + for _, recipient := range input.Email.Recipients { + recipient = strings.TrimSpace(recipient) + if recipient == "" { + continue + } + if !utils.IsValidEmail(recipient) { + return TransportConfigView{}, fmt.Errorf("invalid_email_recipient: %s", recipient) + } + if _, ok := seen[recipient]; ok { + continue + } + seen[recipient] = struct{}{} + normalizedRecipients = append(normalizedRecipients, recipient) + } + sort.Strings(normalizedRecipients) + + var updated models.NotificationTransportConfig + + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + cfg, err := s.ensureTransportConfig(tx) + if err != nil { + return err + } + + cfg.NtfyEnabled = input.Ntfy.Enabled + cfg.NtfyBaseURL = normalizeNtfyBaseURL(input.Ntfy.BaseURL) + cfg.NtfyTopic = strings.TrimSpace(input.Ntfy.Topic) + cfg.EmailEnabled = input.Email.Enabled + cfg.SMTPHost = strings.TrimSpace(input.Email.SMTPHost) + cfg.SMTPPort = input.Email.SMTPPort + if cfg.SMTPPort <= 0 { + cfg.SMTPPort = defaultSMTPPort + } + cfg.SMTPUsername = strings.TrimSpace(input.Email.SMTPUsername) + cfg.SMTPFrom = strings.TrimSpace(input.Email.SMTPFrom) + if cfg.SMTPFrom != "" && !utils.IsValidEmail(cfg.SMTPFrom) { + return fmt.Errorf("invalid_smtp_from_email") + } + cfg.SMTPUseTLS = input.Email.SMTPUseTLS + cfg.EmailRecipients = normalizedRecipients + cfg.NtfyAuthTokenSecretName = ensureSecretName(cfg.NtfyAuthTokenSecretName, defaultNtfySecretName) + cfg.SMTPPasswordSecretName = ensureSecretName(cfg.SMTPPasswordSecretName, defaultSMTPPasswordSecret) + + if input.Ntfy.AuthToken != nil { + if err := upsertSecretTx(tx, cfg.NtfyAuthTokenSecretName, strings.TrimSpace(*input.Ntfy.AuthToken)); err != nil { + return err + } + } + + if input.Email.SMTPPassword != nil { + if err := upsertSecretTx(tx, cfg.SMTPPasswordSecretName, strings.TrimSpace(*input.Email.SMTPPassword)); err != nil { + return err + } + } + + if err := tx.Save(&cfg).Error; err != nil { + return err + } + + updated = cfg + return nil + }) + if err != nil { + return TransportConfigView{}, err + } + + return s.toTransportConfigView(updated), nil +} + +func (s *Service) ensureKindRule(tx *gorm.DB, kind string) (models.NotificationKindRule, error) { + kind = strings.TrimSpace(kind) + var rule models.NotificationKindRule + err := tx.Where("kind = ?", kind).First(&rule).Error + if err == nil { + return rule, nil + } + + if err != gorm.ErrRecordNotFound { + return models.NotificationKindRule{}, err + } + + rule = models.NotificationKindRule{ + Kind: kind, + UIEnabled: true, + NtfyEnabled: true, + EmailEnabled: true, + } + if err := tx.Create(&rule).Error; err != nil { + return models.NotificationKindRule{}, err + } + + return rule, nil +} + +func (s *Service) ensureTransportConfigDB(ctx context.Context) (models.NotificationTransportConfig, error) { + var cfg models.NotificationTransportConfig + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + current, err := s.ensureTransportConfig(tx) + if err != nil { + return err + } + cfg = current + return nil + }) + if err != nil { + return models.NotificationTransportConfig{}, err + } + + return cfg, nil +} + +func (s *Service) ensureTransportConfig(tx *gorm.DB) (models.NotificationTransportConfig, error) { + var cfg models.NotificationTransportConfig + err := tx.First(&cfg).Error + if err == nil { + updated := false + if strings.TrimSpace(cfg.NtfyBaseURL) == "" { + cfg.NtfyBaseURL = defaultNtfyBaseURL + updated = true + } + if cfg.SMTPPort <= 0 { + cfg.SMTPPort = defaultSMTPPort + updated = true + } + if strings.TrimSpace(cfg.NtfyAuthTokenSecretName) == "" { + cfg.NtfyAuthTokenSecretName = defaultNtfySecretName + updated = true + } + if strings.TrimSpace(cfg.SMTPPasswordSecretName) == "" { + cfg.SMTPPasswordSecretName = defaultSMTPPasswordSecret + updated = true + } + if updated { + if saveErr := tx.Save(&cfg).Error; saveErr != nil { + return models.NotificationTransportConfig{}, saveErr + } + } + + return cfg, nil + } + + if err != gorm.ErrRecordNotFound { + return models.NotificationTransportConfig{}, err + } + + cfg = models.NotificationTransportConfig{ + NtfyEnabled: false, + NtfyBaseURL: defaultNtfyBaseURL, + NtfyTopic: "", + NtfyAuthTokenSecretName: defaultNtfySecretName, + EmailEnabled: false, + SMTPHost: "", + SMTPPort: defaultSMTPPort, + SMTPUsername: "", + SMTPFrom: "", + SMTPUseTLS: true, + SMTPPasswordSecretName: defaultSMTPPasswordSecret, + EmailRecipients: []string{}, + } + + if err := tx.Create(&cfg).Error; err != nil { + return models.NotificationTransportConfig{}, err + } + + return cfg, nil +} + +func (s *Service) toTransportConfigView(cfg models.NotificationTransportConfig) TransportConfigView { + return TransportConfigView{ + Ntfy: NtfyTransportConfigView{ + Enabled: cfg.NtfyEnabled, + BaseURL: normalizeNtfyBaseURL(cfg.NtfyBaseURL), + Topic: strings.TrimSpace(cfg.NtfyTopic), + HasAuthToken: s.hasSecret(cfg.NtfyAuthTokenSecretName), + }, + Email: EmailTransportConfigView{ + Enabled: cfg.EmailEnabled, + SMTPHost: strings.TrimSpace(cfg.SMTPHost), + SMTPPort: cfg.SMTPPort, + SMTPUsername: strings.TrimSpace(cfg.SMTPUsername), + SMTPFrom: strings.TrimSpace(cfg.SMTPFrom), + SMTPUseTLS: cfg.SMTPUseTLS, + Recipients: append([]string{}, cfg.EmailRecipients...), + HasPassword: s.hasSecret(cfg.SMTPPasswordSecretName), + }, + } +} + +func (s *Service) upsertSecret(name, value string) error { + if s.secrets == nil { + return fmt.Errorf("secret_store_not_available") + } + if strings.TrimSpace(name) == "" { + return fmt.Errorf("secret_name_required") + } + return s.secrets.UpsertSecret(name, value) +} + +func upsertSecretTx(tx *gorm.DB, name, value string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("secret_name_required") + } + + var secret models.SystemSecrets + err := tx.Where("name = ?", name).First(&secret).Error + if err == nil { + if secret.Data == value { + return nil + } + + return tx.Model(&secret).Update("data", value).Error + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + return tx.Create(&models.SystemSecrets{ + Name: name, + Data: value, + }).Error + } + + return err +} + +func (s *Service) getSecret(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + + if s.DB != nil { + var secret models.SystemSecrets + if err := s.DB.Where("name = ?", name).First(&secret).Error; err == nil { + return strings.TrimSpace(secret.Data) + } + } + + if s.secrets != nil { + value, err := s.secrets.GetSecret(name) + if err == nil { + return strings.TrimSpace(value) + } + } + + return "" +} + +func (s *Service) hasSecret(name string) bool { + return s.getSecret(name) != "" +} + +func (s *Service) publishRefresh() { + hub.SSE.Publish(hub.Event{ + Type: "notifications-refresh", + Timestamp: s.now(), + }) +} + +func (s *Service) sendNtfy(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, token string) error { + baseURL := normalizeNtfyBaseURL(cfg.NtfyBaseURL) + topic := strings.TrimSpace(cfg.NtfyTopic) + if topic == "" { + return fmt.Errorf("ntfy_topic_required") + } + + body := strings.TrimSpace(input.Body) + if body == "" { + body = input.Title + } + + endpoint := fmt.Sprintf("%s/%s", strings.TrimRight(baseURL, "/"), topic) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Title", input.Title) + req.Header.Set("Tags", strings.TrimSpace(input.Severity)) + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + if token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + } + + res, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + + if res.StatusCode >= 400 { + return fmt.Errorf("ntfy_send_failed_status_%d", res.StatusCode) + } + + return nil +} + +func (s *Service) sendEmail(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, password string) error { + host := strings.TrimSpace(cfg.SMTPHost) + if host == "" { + return fmt.Errorf("smtp_host_required") + } + if len(cfg.EmailRecipients) == 0 { + return fmt.Errorf("smtp_recipients_required") + } + + from := strings.TrimSpace(cfg.SMTPFrom) + if from == "" { + return fmt.Errorf("smtp_from_required") + } + + port := cfg.SMTPPort + if port <= 0 { + port = defaultSMTPPort + } + + subject := fmt.Sprintf("[Sylve][%s] %s", strings.ToUpper(strings.TrimSpace(input.Severity)), input.Title) + body := strings.TrimSpace(input.Body) + if body == "" { + body = input.Title + } + + msg := strings.Builder{} + msg.WriteString("To: ") + msg.WriteString(strings.Join(cfg.EmailRecipients, ",")) + msg.WriteString("\r\n") + msg.WriteString("Subject: ") + msg.WriteString(subject) + msg.WriteString("\r\n") + msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + msg.WriteString("\r\n") + msg.WriteString(body) + if strings.TrimSpace(input.Source) != "" { + msg.WriteString("\n\nSource: ") + msg.WriteString(strings.TrimSpace(input.Source)) + } + if strings.TrimSpace(input.Kind) != "" { + msg.WriteString("\nKind: ") + msg.WriteString(strings.TrimSpace(input.Kind)) + } + + client, conn, err := dialSMTPClient(ctx, host, port, cfg.SMTPUseTLS) + if err != nil { + return err + } + defer closeSMTPClient(client, conn) + + var auth smtp.Auth + username := strings.TrimSpace(cfg.SMTPUsername) + if username != "" { + auth = smtp.PlainAuth("", username, password, host) + if ok, _ := client.Extension("AUTH"); !ok { + return fmt.Errorf("smtp_auth_not_supported") + } + if err := client.Auth(auth); err != nil { + return err + } + } + + if err := client.Mail(from); err != nil { + return err + } + + for _, recipient := range cfg.EmailRecipients { + if err := client.Rcpt(recipient); err != nil { + return err + } + } + + wc, err := client.Data() + if err != nil { + return err + } + + if _, err := wc.Write([]byte(msg.String())); err != nil { + _ = wc.Close() + return err + } + if err := wc.Close(); err != nil { + return err + } + + if err := client.Quit(); err != nil { + return err + } + + return nil +} + +func dialSMTPClient(ctx context.Context, host string, port int, useTLS bool) (*smtp.Client, net.Conn, error) { + address := net.JoinHostPort(host, strconv.Itoa(port)) + dialer := &net.Dialer{Timeout: 10 * time.Second} + var conn net.Conn + var err error + + if useTLS && port == 465 { + conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + }) + } else { + conn, err = dialer.DialContext(ctx, "tcp", address) + } + if err != nil { + return nil, nil, err + } + + client, err := smtp.NewClient(conn, host) + if err != nil { + _ = conn.Close() + return nil, nil, err + } + + if useTLS && port != 465 { + if ok, _ := client.Extension("STARTTLS"); !ok { + _ = client.Close() + _ = conn.Close() + return nil, nil, fmt.Errorf("smtp_starttls_not_supported") + } + + if err := client.StartTLS(&tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + }); err != nil { + _ = client.Close() + _ = conn.Close() + return nil, nil, err + } + } + + return client, conn, nil +} + +func closeSMTPClient(client *smtp.Client, conn net.Conn) { + if client != nil { + _ = client.Close() + } + if conn != nil { + _ = conn.Close() + } +} + +func normalizeInput(input notifier.EventInput) notifier.EventInput { + normalized := notifier.EventInput{ + Kind: strings.TrimSpace(strings.ToLower(input.Kind)), + Title: strings.TrimSpace(input.Title), + Body: strings.TrimSpace(input.Body), + Severity: normalizeSeverity(input.Severity), + Source: strings.TrimSpace(input.Source), + Fingerprint: strings.TrimSpace(input.Fingerprint), + Metadata: map[string]string{}, + } + + for key, value := range input.Metadata { + k := strings.TrimSpace(key) + if k == "" { + continue + } + normalized.Metadata[k] = strings.TrimSpace(value) + } + + return normalized +} + +func normalizeSeverity(value string) string { + switch strings.TrimSpace(strings.ToLower(value)) { + case string(models.NotificationSeverityCritical): + return string(models.NotificationSeverityCritical) + case string(models.NotificationSeverityError): + return string(models.NotificationSeverityError) + case string(models.NotificationSeverityWarning): + return string(models.NotificationSeverityWarning) + default: + return string(models.NotificationSeverityInfo) + } +} + +func makeFingerprint(input notifier.EventInput) string { + raw := strings.Join([]string{ + strings.TrimSpace(strings.ToLower(input.Kind)), + strings.TrimSpace(input.Title), + strings.TrimSpace(input.Body), + strings.TrimSpace(strings.ToLower(input.Severity)), + strings.TrimSpace(strings.ToLower(input.Source)), + }, "|") + + digest := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(digest[:]) +} + +func normalizeNtfyBaseURL(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return defaultNtfyBaseURL + } + return strings.TrimRight(value, "/") +} + +func ensureSecretName(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} + +func suppressionKey(kind, fingerprint string) string { + return strings.TrimSpace(strings.ToLower(kind)) + "|" + strings.TrimSpace(fingerprint) +} diff --git a/internal/services/notifications/service_test.go b/internal/services/notifications/service_test.go new file mode 100644 index 00000000..455170b3 --- /dev/null +++ b/internal/services/notifications/service_test.go @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: BSD-2-Clause +// +// Copyright (c) 2025 The FreeBSD Foundation. +// +// This software was developed by Hayzam Sherif +// of Alchemilla Ventures Pvt. Ltd. , +// under sponsorship from the FreeBSD Foundation. + +package notifications + +import ( + "context" + "testing" + + "github.com/alchemillahq/sylve/internal/db/models" + notifier "github.com/alchemillahq/sylve/internal/notifications" + "github.com/alchemillahq/sylve/internal/testutil" +) + +type testSecretStore struct { + data map[string]string +} + +func newTestSecretStore() *testSecretStore { + return &testSecretStore{data: map[string]string{}} +} + +func (s *testSecretStore) GetSecret(name string) (string, error) { + return s.data[name], nil +} + +func (s *testSecretStore) UpsertSecret(name string, data string) error { + s.data[name] = data + return nil +} + +func newTestService(t *testing.T) *Service { + t.Helper() + + db := testutil.NewSQLiteTestDB( + t, + &models.Notification{}, + &models.NotificationSuppression{}, + &models.NotificationKindRule{}, + &models.NotificationTransportConfig{}, + &models.SystemSecrets{}, + ) + + return NewService(db, newTestSecretStore()) +} + +func TestEmitCreatesAndIncrementsByFingerprint(t *testing.T) { + svc := newTestService(t) + + input := notifier.EventInput{ + Kind: "storage.disks", + Title: "Disk failure", + Body: "Disk ada0 is unhealthy", + Severity: "warning", + Source: "smartd", + Fingerprint: "disk-ada0-failure", + } + + res1, err := svc.Emit(context.Background(), input) + if err != nil { + t.Fatalf("emit_1_failed: %v", err) + } + if res1.NotificationID == 0 { + t.Fatalf("expected_notification_id_on_first_emit") + } + if res1.Suppressed { + t.Fatalf("expected_first_emit_not_suppressed") + } + + res2, err := svc.Emit(context.Background(), input) + if err != nil { + t.Fatalf("emit_2_failed: %v", err) + } + if res2.NotificationID != res1.NotificationID { + t.Fatalf("expected_same_notification_id got first=%d second=%d", res1.NotificationID, res2.NotificationID) + } + + var notif models.Notification + if err := svc.DB.First(¬if, res1.NotificationID).Error; err != nil { + t.Fatalf("failed_to_load_notification: %v", err) + } + if notif.OccurrenceCount != 2 { + t.Fatalf("expected_occurrence_count_2 got: %d", notif.OccurrenceCount) + } +} + +func TestDismissSuppressesFutureEmits(t *testing.T) { + svc := newTestService(t) + + input := notifier.EventInput{ + Kind: "storage.disks", + Title: "Disk failure", + Body: "Disk ada0 is unhealthy", + Severity: "critical", + Fingerprint: "disk-ada0-failure", + } + + created, err := svc.Emit(context.Background(), input) + if err != nil { + t.Fatalf("emit_failed: %v", err) + } + + if err := svc.Dismiss(context.Background(), created.NotificationID); err != nil { + t.Fatalf("dismiss_failed: %v", err) + } + + suppressed, err := svc.Emit(context.Background(), input) + if err != nil { + t.Fatalf("emit_after_dismiss_failed: %v", err) + } + if !suppressed.Suppressed { + t.Fatalf("expected_emit_to_be_suppressed") + } + + activeCount, err := svc.CountActive(context.Background()) + if err != nil { + t.Fatalf("count_active_failed: %v", err) + } + if activeCount != 0 { + t.Fatalf("expected_no_active_notifications got: %d", activeCount) + } +} + +func TestTransportSendersRespectConfigAndSuppression(t *testing.T) { + svc := newTestService(t) + + ntfyCalls := 0 + emailCalls := 0 + svc.SetNtfySender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, token string) error { + ntfyCalls++ + return nil + }) + svc.SetEmailSender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, password string) error { + emailCalls++ + return nil + }) + + _, err := svc.UpdateTransportConfig(context.Background(), TransportConfigUpdate{ + Ntfy: NtfyTransportConfigUpdate{ + Enabled: true, + BaseURL: "https://ntfy.sh", + Topic: "sylve", + }, + Email: EmailTransportConfigUpdate{ + Enabled: true, + SMTPHost: "localhost", + SMTPPort: 1025, + SMTPFrom: "alerts@example.com", + Recipients: []string{"ops@example.com"}, + }, + }) + if err != nil { + t.Fatalf("update_transport_config_failed: %v", err) + } + + input := notifier.EventInput{ + Kind: "network.firewall", + Title: "Firewall drop spike", + Body: "Inbound drop threshold crossed", + Severity: "warning", + Fingerprint: "firewall-drop-spike", + } + + created, err := svc.Emit(context.Background(), input) + if err != nil { + t.Fatalf("emit_failed: %v", err) + } + + if ntfyCalls != 1 { + t.Fatalf("expected_ntfy_called_once got: %d", ntfyCalls) + } + if emailCalls != 1 { + t.Fatalf("expected_email_called_once got: %d", emailCalls) + } + + if err := svc.Dismiss(context.Background(), created.NotificationID); err != nil { + t.Fatalf("dismiss_failed: %v", err) + } + + _, err = svc.Emit(context.Background(), input) + if err != nil { + t.Fatalf("emit_after_dismiss_failed: %v", err) + } + + if ntfyCalls != 1 { + t.Fatalf("expected_ntfy_not_called_after_suppression got: %d", ntfyCalls) + } + if emailCalls != 1 { + t.Fatalf("expected_email_not_called_after_suppression got: %d", emailCalls) + } +} + +func TestTransportConfigStoresRecipientsAndSecretFlags(t *testing.T) { + store := newTestSecretStore() + db := testutil.NewSQLiteTestDB( + t, + &models.Notification{}, + &models.NotificationSuppression{}, + &models.NotificationKindRule{}, + &models.NotificationTransportConfig{}, + &models.SystemSecrets{}, + ) + svc := NewService(db, store) + + token := "ntfy-token" + password := "smtp-pass" + view, err := svc.UpdateTransportConfig(context.Background(), TransportConfigUpdate{ + Ntfy: NtfyTransportConfigUpdate{ + Enabled: true, + BaseURL: "https://ntfy.sh", + Topic: "alerts", + AuthToken: &token, + }, + Email: EmailTransportConfigUpdate{ + Enabled: true, + SMTPHost: "smtp.example.com", + SMTPPort: 587, + SMTPUsername: "smtp-user", + SMTPFrom: "alerts@example.com", + SMTPUseTLS: true, + Recipients: []string{"b@example.com", "a@example.com", "a@example.com"}, + SMTPPassword: &password, + }, + }) + if err != nil { + t.Fatalf("update_transport_config_failed: %v", err) + } + + if !view.Ntfy.HasAuthToken { + t.Fatalf("expected_ntfy_secret_flag_true") + } + if !view.Email.HasPassword { + t.Fatalf("expected_smtp_secret_flag_true") + } + + if len(view.Email.Recipients) != 2 { + t.Fatalf("expected_deduplicated_recipients got: %d", len(view.Email.Recipients)) + } + if view.Email.Recipients[0] != "a@example.com" { + t.Fatalf("expected_sorted_recipients got_first=%s", view.Email.Recipients[0]) + } +} + +func TestSuppressionDoesNotCrossKindsWithSameFingerprint(t *testing.T) { + svc := newTestService(t) + + fingerprint := "shared-fingerprint" + first, err := svc.Emit(context.Background(), notifier.EventInput{ + Kind: "storage.disks", + Title: "Storage alert", + Body: "Disk alert body", + Severity: "warning", + Fingerprint: fingerprint, + }) + if err != nil { + t.Fatalf("emit_first_failed: %v", err) + } + + if err := svc.Dismiss(context.Background(), first.NotificationID); err != nil { + t.Fatalf("dismiss_first_failed: %v", err) + } + + second, err := svc.Emit(context.Background(), notifier.EventInput{ + Kind: "network.firewall", + Title: "Network alert", + Body: "Firewall alert body", + Severity: "warning", + Fingerprint: fingerprint, + }) + if err != nil { + t.Fatalf("emit_second_failed: %v", err) + } + + if second.Suppressed { + t.Fatalf("expected_second_kind_not_suppressed") + } +} diff --git a/web/src/lib/api/events.ts b/web/src/lib/api/events.ts index 2ef51032..c01443f9 100644 --- a/web/src/lib/api/events.ts +++ b/web/src/lib/api/events.ts @@ -61,6 +61,13 @@ function pulseClusterDetailsReload() { }); } +function pulseNotificationsReload() { + reload.notifications = false; + queueMicrotask(() => { + reload.notifications = true; + }); +} + async function fetchSSEToken(): Promise { if (!storage.token) { return null; @@ -130,6 +137,7 @@ export async function startSSEEvents() { }); eventSource.addEventListener('cluster-details-refresh', pulseClusterDetailsReload); + eventSource.addEventListener('notifications-refresh', pulseNotificationsReload); eventSource.onerror = () => { connection.sseConnected = false; diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts new file mode 100644 index 00000000..21e38eb5 --- /dev/null +++ b/web/src/lib/api/notifications.ts @@ -0,0 +1,43 @@ +import { APIResponseSchema, type APIResponse } from '$lib/types/common'; +import { + NotificationConfigSchema, + NotificationsCountSchema, + NotificationsListSchema, + type NotificationConfig, + type NotificationsCount, + type NotificationsList, + type UpdateNotificationConfigInput +} from '$lib/types/notifications'; +import { apiRequest } from '$lib/utils/http'; + +export async function listNotifications( + scope: 'active' | 'all' = 'active', + limit = 50, + offset = 0 +): Promise { + const query = new URLSearchParams({ + scope, + limit: `${limit}`, + offset: `${offset}` + }); + + return await apiRequest(`/notifications?${query.toString()}`, NotificationsListSchema, 'GET'); +} + +export async function getNotificationsCount(): Promise { + return await apiRequest('/notifications/count', NotificationsCountSchema, 'GET'); +} + +export async function dismissNotification(id: number): Promise { + return await apiRequest(`/notifications/${id}/dismiss`, APIResponseSchema, 'POST'); +} + +export async function getNotificationConfig(): Promise { + return await apiRequest('/notifications/config', NotificationConfigSchema, 'GET'); +} + +export async function updateNotificationConfig( + payload: UpdateNotificationConfigInput +): Promise { + return await apiRequest('/notifications/config', NotificationConfigSchema, 'PUT', payload); +} diff --git a/web/src/lib/components/custom/Notifications/Bell.svelte b/web/src/lib/components/custom/Notifications/Bell.svelte new file mode 100644 index 00000000..f3b00307 --- /dev/null +++ b/web/src/lib/components/custom/Notifications/Bell.svelte @@ -0,0 +1,205 @@ + + + + + + + + + Notifications + + + + +
+ + + + Severity + Notification + Source + Last Seen + Count + Action + + + + {#if items.length === 0} + + + No notifications found. + + + {:else} + {#each items as item (item.id)} + + + {item.severity} + + +
+

{item.title}

+ {#if item.body} +

{item.body}

+ {/if} +
+
+ {item.source || '-'} + {convertDbTime(item.lastOccurredAt)} + {item.occurrenceCount} + + {#if !item.dismissedAt} + + {:else} + Dismissed + {/if} + +
+ {/each} + {/if} +
+
+
+ + + + + + +
+
diff --git a/web/src/lib/stores/api.svelte.ts b/web/src/lib/stores/api.svelte.ts index fa825a5f..7402aafd 100644 --- a/web/src/lib/stores/api.svelte.ts +++ b/web/src/lib/stores/api.svelte.ts @@ -11,7 +11,8 @@ export const reload = $state({ leftPanel: false, auditLog: false, - clusterDetails: false + clusterDetails: false, + notifications: false }); export const connection = $state({ diff --git a/web/src/lib/types/notifications/index.ts b/web/src/lib/types/notifications/index.ts new file mode 100644 index 00000000..6ce1e807 --- /dev/null +++ b/web/src/lib/types/notifications/index.ts @@ -0,0 +1,72 @@ +import { z } from 'zod/v4'; + +export const NotificationSeveritySchema = z.enum(['info', 'warning', 'error', 'critical']); + +export const NotificationSchema = z.object({ + id: z.number(), + kind: z.string(), + title: z.string(), + body: z.string(), + severity: NotificationSeveritySchema, + source: z.string(), + fingerprint: z.string(), + metadata: z.record(z.string(), z.string()).default({}), + occurrenceCount: z.number(), + firstOccurredAt: z.string(), + lastOccurredAt: z.string(), + dismissedAt: z.string().nullable().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional() +}); + +export const NotificationsListSchema = z.object({ + items: z.array(NotificationSchema), + total: z.number() +}); + +export const NotificationsCountSchema = z.object({ + active: z.number() +}); + +export const NotificationConfigSchema = z.object({ + ntfy: z.object({ + enabled: z.boolean(), + baseUrl: z.string(), + topic: z.string(), + hasAuthToken: z.boolean() + }), + email: z.object({ + enabled: z.boolean(), + smtpHost: z.string(), + smtpPort: z.number(), + smtpUsername: z.string(), + smtpFrom: z.string(), + smtpUseTls: z.boolean(), + recipients: z.array(z.string()), + hasPassword: z.boolean() + }) +}); + +export type Notification = z.infer; +export type NotificationsList = z.infer; +export type NotificationsCount = z.infer; +export type NotificationConfig = z.infer; + +export type UpdateNotificationConfigInput = { + ntfy: { + enabled: boolean; + baseUrl: string; + topic: string; + authToken?: string; + }; + email: { + enabled: boolean; + smtpHost: string; + smtpPort: number; + smtpUsername: string; + smtpFrom: string; + smtpUseTls: boolean; + recipients: string[]; + smtpPassword?: string; + }; +}; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index ac0a2e24..92315b2c 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -236,6 +236,7 @@ + diff --git a/web/src/routes/[node]/+layout.svelte b/web/src/routes/[node]/+layout.svelte index 307c06e8..5c83789c 100644 --- a/web/src/routes/[node]/+layout.svelte +++ b/web/src/routes/[node]/+layout.svelte @@ -3,6 +3,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { storage } from '$lib'; + import NotificationBell from '$lib/components/custom/Notifications/Bell.svelte'; import NodeTreeView from '$lib/components/custom/NodeTreeView.svelte'; import { Button } from '$lib/components/ui/button/index.js'; import * as Resizable from '$lib/components/ui/resizable'; @@ -284,7 +285,22 @@ label: 'Settings', icon: 'material-symbols--settings', children: [ - { label: 'System', icon: 'mdi--desktop-classic', href: `/${node}/settings/system` }, + { + label: 'System', + icon: 'mdi--desktop-classic', + children: [ + { + label: 'General', + icon: 'mdi--cog-outline', + href: `/${node}/settings/system` + }, + { + label: 'Notifications', + icon: 'mdi--bell-ring-outline', + href: `/${node}/settings/system/notifications` + } + ] + }, { label: 'PCI Passthrough', icon: 'eos-icons--hardware-circuit', @@ -345,7 +361,7 @@
Node — {node} -
+
+ + + +
+
+ +
+
+
+

ntfy.sh

+ +
+ +
+ + +
+ +
+ + +
+
+ +
+
+

Email (SMTP)

+ +
+ +
+ + + + +
+ +
+ + +
+ + + + +
+
+
diff --git a/web/src/routes/[node]/settings/system/notifications/+page.ts b/web/src/routes/[node]/settings/system/notifications/+page.ts new file mode 100644 index 00000000..b9a81df3 --- /dev/null +++ b/web/src/routes/[node]/settings/system/notifications/+page.ts @@ -0,0 +1,36 @@ +import { getNotificationConfig } from '$lib/api/notifications'; +import { SEVEN_DAYS } from '$lib/utils'; +import { cachedFetch, isAPIResponse } from '$lib/utils/http'; + +export async function load() { + const response = await cachedFetch( + 'notification-config', + async () => await getNotificationConfig(), + SEVEN_DAYS + ); + + const config = isAPIResponse(response) + ? { + ntfy: { + enabled: false, + baseUrl: 'https://ntfy.sh', + topic: '', + hasAuthToken: false + }, + email: { + enabled: false, + smtpHost: '', + smtpPort: 587, + smtpUsername: '', + smtpFrom: '', + smtpUseTls: true, + recipients: [], + hasPassword: false + } + } + : response; + + return { + config + }; +} diff --git a/web/src/routes/datacenter/+layout.svelte b/web/src/routes/datacenter/+layout.svelte index 32470944..bcb873e8 100644 --- a/web/src/routes/datacenter/+layout.svelte +++ b/web/src/routes/datacenter/+layout.svelte @@ -2,6 +2,7 @@ import { storage } from '$lib'; import { getDetails } from '$lib/api/cluster/cluster'; import TreeView from '$lib/components/custom/TreeView.svelte'; + import NotificationBell from '$lib/components/custom/Notifications/Bell.svelte'; import { Button } from '$lib/components/ui/button/index.js'; import * as Resizable from '$lib/components/ui/resizable'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; @@ -124,7 +125,7 @@
Data Center -
+
+ +