mirror of
https://github.com/AlchemillaHQ/Sylve.git
synced 2026-06-14 00:46:34 +03:00
notifications: initial setup
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// 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"`
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,13 @@ function pulseClusterDetailsReload() {
|
||||
});
|
||||
}
|
||||
|
||||
function pulseNotificationsReload() {
|
||||
reload.notifications = false;
|
||||
queueMicrotask(() => {
|
||||
reload.notifications = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSSEToken(): Promise<string | null> {
|
||||
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;
|
||||
|
||||
@@ -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<NotificationsList> {
|
||||
const query = new URLSearchParams({
|
||||
scope,
|
||||
limit: `${limit}`,
|
||||
offset: `${offset}`
|
||||
});
|
||||
|
||||
return await apiRequest(`/notifications?${query.toString()}`, NotificationsListSchema, 'GET');
|
||||
}
|
||||
|
||||
export async function getNotificationsCount(): Promise<NotificationsCount> {
|
||||
return await apiRequest('/notifications/count', NotificationsCountSchema, 'GET');
|
||||
}
|
||||
|
||||
export async function dismissNotification(id: number): Promise<APIResponse> {
|
||||
return await apiRequest(`/notifications/${id}/dismiss`, APIResponseSchema, 'POST');
|
||||
}
|
||||
|
||||
export async function getNotificationConfig(): Promise<NotificationConfig> {
|
||||
return await apiRequest('/notifications/config', NotificationConfigSchema, 'GET');
|
||||
}
|
||||
|
||||
export async function updateNotificationConfig(
|
||||
payload: UpdateNotificationConfigInput
|
||||
): Promise<NotificationConfig> {
|
||||
return await apiRequest('/notifications/config', NotificationConfigSchema, 'PUT', payload);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { dismissNotification, getNotificationsCount, listNotifications } from '$lib/api/notifications';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { reload } from '$lib/stores/api.svelte';
|
||||
import type { Notification } from '$lib/types/notifications';
|
||||
import { handleAPIError, isAPIResponse } from '$lib/utils/http';
|
||||
import { convertDbTime } from '$lib/utils/time';
|
||||
import { resource, useInterval, watch } from 'runed';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let open = $state(false);
|
||||
let showDismissed = $state(false);
|
||||
let dismissing = $state<number | null>(null);
|
||||
|
||||
const notificationCount = resource(
|
||||
() => 'notification-bell-count',
|
||||
async () => {
|
||||
return await getNotificationsCount();
|
||||
},
|
||||
{ initialValue: { active: 0 } }
|
||||
);
|
||||
|
||||
const notifications = resource(
|
||||
() => `notification-bell-list-${showDismissed ? 'all' : 'active'}`,
|
||||
async () => {
|
||||
return await listNotifications(showDismissed ? 'all' : 'active', 100, 0);
|
||||
},
|
||||
{ initialValue: { items: [] as Notification[], total: 0 } }
|
||||
);
|
||||
|
||||
let count = $derived(notificationCount.current.active ?? 0);
|
||||
let items = $derived(notifications.current.items ?? []);
|
||||
|
||||
watch(
|
||||
() => open,
|
||||
(value) => {
|
||||
if (value) {
|
||||
notifications.refetch();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => showDismissed,
|
||||
() => {
|
||||
if (open) {
|
||||
notifications.refetch();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => reload.notifications,
|
||||
(value) => {
|
||||
if (value) {
|
||||
notificationCount.refetch();
|
||||
if (open) {
|
||||
notifications.refetch();
|
||||
}
|
||||
reload.notifications = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
useInterval(10000, {
|
||||
callback: () => {
|
||||
notificationCount.refetch();
|
||||
if (open) {
|
||||
notifications.refetch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function dismiss(item: Notification) {
|
||||
if (!item?.id || dismissing !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dismissing = item.id;
|
||||
const response = await dismissNotification(item.id);
|
||||
dismissing = null;
|
||||
|
||||
if (isAPIResponse(response) && response.status === 'error') {
|
||||
handleAPIError(response);
|
||||
toast.error('Failed to dismiss notification', {
|
||||
duration: 4000,
|
||||
position: 'bottom-center'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([notificationCount.refetch(), notifications.refetch()]);
|
||||
toast.success('Notification dismissed', {
|
||||
duration: 3000,
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
|
||||
function severityClass(severity: string) {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'text-red-600';
|
||||
case 'error':
|
||||
return 'text-red-500';
|
||||
case 'warning':
|
||||
return 'text-yellow-600';
|
||||
default:
|
||||
return 'text-blue-600';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button size="sm" class="relative h-6" variant="outline" onclick={() => (open = true)} title="Notifications">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="icon-[mdi--bell-outline] h-4 w-4"></span>
|
||||
<span>Notifications</span>
|
||||
</div>
|
||||
{#if count > 0}
|
||||
<span
|
||||
class="bg-destructive text-destructive-foreground absolute -right-2 -top-2 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px]"
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="w-[95%] max-w-5xl p-5">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center justify-between gap-4">
|
||||
<span>Notifications</span>
|
||||
<label class="flex items-center gap-2 text-xs font-normal text-muted-foreground">
|
||||
<input type="checkbox" bind:checked={showDismissed} class="h-3.5 w-3.5" />
|
||||
Show dismissed
|
||||
</label>
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="max-h-[55vh] overflow-auto rounded-md border">
|
||||
<Table.Root class="w-full">
|
||||
<Table.Header class="bg-muted/50 sticky top-0">
|
||||
<Table.Row>
|
||||
<Table.Head class="w-28">Severity</Table.Head>
|
||||
<Table.Head>Notification</Table.Head>
|
||||
<Table.Head class="w-32">Source</Table.Head>
|
||||
<Table.Head class="w-48">Last Seen</Table.Head>
|
||||
<Table.Head class="w-20 text-right">Count</Table.Head>
|
||||
<Table.Head class="w-28 text-right">Action</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if items.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="text-muted-foreground h-24 text-center">
|
||||
No notifications found.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each items as item (item.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<span class={severityClass(item.severity)}>{item.severity}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="space-y-0.5">
|
||||
<p class="font-medium">{item.title}</p>
|
||||
{#if item.body}
|
||||
<p class="text-muted-foreground text-xs">{item.body}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs">{item.source || '-'}</Table.Cell>
|
||||
<Table.Cell class="text-xs">{convertDbTime(item.lastOccurredAt)}</Table.Cell>
|
||||
<Table.Cell class="text-right">{item.occurrenceCount}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if !item.dismissedAt}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-6"
|
||||
onclick={() => dismiss(item)}
|
||||
disabled={dismissing !== null}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-xs">Dismissed</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" class="h-7" onclick={() => notificationCount.refetch()}>Refresh Count</Button>
|
||||
<Button variant="outline" class="h-7" onclick={() => notifications.refetch()}>Refresh List</Button>
|
||||
<Button variant="outline" class="h-7" onclick={() => (open = false)}>Close</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -11,7 +11,8 @@
|
||||
export const reload = $state({
|
||||
leftPanel: false,
|
||||
auditLog: false,
|
||||
clusterDetails: false
|
||||
clusterDetails: false,
|
||||
notifications: false
|
||||
});
|
||||
|
||||
export const connection = $state({
|
||||
|
||||
@@ -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<typeof NotificationSchema>;
|
||||
export type NotificationsList = z.infer<typeof NotificationsListSchema>;
|
||||
export type NotificationsCount = z.infer<typeof NotificationsCountSchema>;
|
||||
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>;
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
@@ -236,6 +236,7 @@
|
||||
<span class="icon-[raphael--ethernet]"></span>
|
||||
<span class="icon-[mdi--internet]"></span>
|
||||
<span class="icon-[mdi--desktop-classic]"></span>
|
||||
<span class="icon-[mdi--bell-ring-outline]"></span>
|
||||
<span class="icon-[fluent--storage-20-filled]"></span>
|
||||
<span class="icon-[mdi--lock]"></span>
|
||||
<span class="icon-[mdi--autorenew]"></span>
|
||||
|
||||
@@ -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 @@
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="flex h-10 w-full items-center justify-between border-b p-2">
|
||||
<span>Node — <b>{node}</b></span>
|
||||
<div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-6"
|
||||
@@ -358,6 +374,8 @@
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<NotificationBell />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-6"
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<script lang="ts">
|
||||
import { getNotificationConfig, updateNotificationConfig } from '$lib/api/notifications';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import type { NotificationConfig } from '$lib/types/notifications';
|
||||
import { handleAPIError, isAPIResponse, updateCache } from '$lib/utils/http';
|
||||
import { resource } from 'runed';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Data {
|
||||
config: NotificationConfig;
|
||||
}
|
||||
|
||||
let { data }: { data: Data } = $props();
|
||||
const fallbackConfig = data.config;
|
||||
|
||||
const configResource = resource(
|
||||
() => 'notification-config',
|
||||
async () => {
|
||||
const loaded = await getNotificationConfig();
|
||||
if (isAPIResponse(loaded)) {
|
||||
return fallbackConfig;
|
||||
}
|
||||
updateCache('notification-config', loaded);
|
||||
return loaded;
|
||||
},
|
||||
{ initialValue: data.config }
|
||||
);
|
||||
|
||||
let loading = $state(false);
|
||||
let ntfyEnabled = $state(configResource.current.ntfy.enabled);
|
||||
let ntfyBaseUrl = $state(configResource.current.ntfy.baseUrl);
|
||||
let ntfyTopic = $state(configResource.current.ntfy.topic);
|
||||
let ntfyToken = $state('');
|
||||
let clearNtfyToken = $state(false);
|
||||
|
||||
let emailEnabled = $state(configResource.current.email.enabled);
|
||||
let smtpHost = $state(configResource.current.email.smtpHost);
|
||||
let smtpPort = $state(configResource.current.email.smtpPort || 587);
|
||||
let smtpUsername = $state(configResource.current.email.smtpUsername);
|
||||
let smtpFrom = $state(configResource.current.email.smtpFrom);
|
||||
let smtpUseTls = $state(configResource.current.email.smtpUseTls);
|
||||
let smtpRecipients = $state((configResource.current.email.recipients || []).join(', '));
|
||||
let smtpPassword = $state('');
|
||||
let clearSMTPPassword = $state(false);
|
||||
|
||||
function hydrateFromConfig(next: NotificationConfig) {
|
||||
ntfyEnabled = next.ntfy.enabled;
|
||||
ntfyBaseUrl = next.ntfy.baseUrl;
|
||||
ntfyTopic = next.ntfy.topic;
|
||||
emailEnabled = next.email.enabled;
|
||||
smtpHost = next.email.smtpHost;
|
||||
smtpPort = next.email.smtpPort || 587;
|
||||
smtpUsername = next.email.smtpUsername;
|
||||
smtpFrom = next.email.smtpFrom;
|
||||
smtpUseTls = next.email.smtpUseTls;
|
||||
smtpRecipients = (next.email.recipients || []).join(', ');
|
||||
smtpPassword = '';
|
||||
ntfyToken = '';
|
||||
clearNtfyToken = false;
|
||||
clearSMTPPassword = false;
|
||||
}
|
||||
|
||||
async function refreshConfig() {
|
||||
await configResource.refetch();
|
||||
hydrateFromConfig(configResource.current);
|
||||
}
|
||||
|
||||
function parseRecipients(input: string): string[] {
|
||||
return input
|
||||
.split(/[\n,]+/g)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
loading = true;
|
||||
|
||||
const payload = {
|
||||
ntfy: {
|
||||
enabled: ntfyEnabled,
|
||||
baseUrl: ntfyBaseUrl,
|
||||
topic: ntfyTopic,
|
||||
...(ntfyToken.trim().length > 0 || clearNtfyToken
|
||||
? { authToken: clearNtfyToken ? '' : ntfyToken.trim() }
|
||||
: {})
|
||||
},
|
||||
email: {
|
||||
enabled: emailEnabled,
|
||||
smtpHost,
|
||||
smtpPort: Number(smtpPort) || 587,
|
||||
smtpUsername,
|
||||
smtpFrom,
|
||||
smtpUseTls,
|
||||
recipients: parseRecipients(smtpRecipients),
|
||||
...(smtpPassword.trim().length > 0 || clearSMTPPassword
|
||||
? { smtpPassword: clearSMTPPassword ? '' : smtpPassword.trim() }
|
||||
: {})
|
||||
}
|
||||
};
|
||||
|
||||
const response = await updateNotificationConfig(payload);
|
||||
loading = false;
|
||||
|
||||
if (isAPIResponse(response) && response.status === 'error') {
|
||||
handleAPIError(response);
|
||||
toast.error('Failed to update notification config', {
|
||||
duration: 5000,
|
||||
position: 'bottom-center'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateCache('notification-config', response);
|
||||
hydrateFromConfig(response as NotificationConfig);
|
||||
toast.success('Notification config updated', {
|
||||
duration: 3500,
|
||||
position: 'bottom-center'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 md:p-6">
|
||||
<div class="mb-5 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Configure notification transports. UI notifications are always enabled.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" variant="outline" class="h-7" onclick={refreshConfig}>Refresh</Button>
|
||||
<Button size="sm" class="h-7" onclick={saveConfig} disabled={loading}>
|
||||
{#if loading}
|
||||
<span class="icon-[mdi--loading] mr-2 h-4 w-4 animate-spin"></span>
|
||||
{/if}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<section class="rounded-md border p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="font-medium">ntfy.sh</h3>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={ntfyEnabled} />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">Base URL</span>
|
||||
<input class="w-full rounded-md border px-2 py-1.5" bind:value={ntfyBaseUrl} />
|
||||
</label>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">Topic</span>
|
||||
<input class="w-full rounded-md border px-2 py-1.5" bind:value={ntfyTopic} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">Auth Token</span>
|
||||
<input
|
||||
type="password"
|
||||
class="w-full rounded-md border px-2 py-1.5"
|
||||
placeholder={configResource.current.ntfy.hasAuthToken
|
||||
? 'Token stored (leave blank to keep)'
|
||||
: 'Optional'}
|
||||
bind:value={ntfyToken}
|
||||
/>
|
||||
</label>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">Stored Token</span>
|
||||
<div class="flex h-[34px] items-center gap-2 rounded-md border px-2 py-1.5">
|
||||
<span class="text-xs">
|
||||
{configResource.current.ntfy.hasAuthToken ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
<label class="ml-auto flex items-center gap-1 text-xs">
|
||||
<input type="checkbox" bind:checked={clearNtfyToken} />
|
||||
Clear
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-md border p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="font-medium">Email (SMTP)</h3>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={emailEnabled} />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">SMTP Host</span>
|
||||
<input class="w-full rounded-md border px-2 py-1.5" bind:value={smtpHost} />
|
||||
</label>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">SMTP Port</span>
|
||||
<input type="number" class="w-full rounded-md border px-2 py-1.5" bind:value={smtpPort} />
|
||||
</label>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">SMTP Username</span>
|
||||
<input class="w-full rounded-md border px-2 py-1.5" bind:value={smtpUsername} />
|
||||
</label>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">From Email</span>
|
||||
<input class="w-full rounded-md border px-2 py-1.5" bind:value={smtpFrom} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">SMTP Password</span>
|
||||
<input
|
||||
type="password"
|
||||
class="w-full rounded-md border px-2 py-1.5"
|
||||
placeholder={configResource.current.email.hasPassword
|
||||
? 'Password stored (leave blank to keep)'
|
||||
: 'Optional'}
|
||||
bind:value={smtpPassword}
|
||||
/>
|
||||
</label>
|
||||
<label class="text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">Stored Password</span>
|
||||
<div class="flex h-[34px] items-center gap-2 rounded-md border px-2 py-1.5">
|
||||
<span class="text-xs">
|
||||
{configResource.current.email.hasPassword ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
<label class="ml-auto flex items-center gap-1 text-xs">
|
||||
<input type="checkbox" bind:checked={clearSMTPPassword} />
|
||||
Clear
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="mt-3 block text-sm">
|
||||
<span class="mb-1 block text-xs text-muted-foreground">Recipients (comma or newline separated)</span>
|
||||
<textarea class="min-h-24 w-full rounded-md border px-2 py-1.5" bind:value={smtpRecipients}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="mt-3 flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={smtpUseTls} />
|
||||
Use TLS/STARTTLS
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 @@
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="flex h-10 w-full items-center justify-between border-b p-2">
|
||||
<span>Data Center</span>
|
||||
<div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-6"
|
||||
@@ -137,6 +138,8 @@
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<NotificationBell />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-6"
|
||||
|
||||
Reference in New Issue
Block a user