notifications: initial setup

This commit is contained in:
rainy-mathew
2026-04-14 03:27:33 +05:30
parent cf12d62041
commit d9e3d0aca3
19 changed files with 2321 additions and 4 deletions
+5
View File
@@ -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,
+4
View File
@@ -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{},
+72
View File
@@ -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))
}
}
+15
View File
@@ -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))
+61
View File
@@ -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)
}
+889
View File
@@ -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(&notif, 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(&notif, 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")
}
}
+8
View File
@@ -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;
+43
View File
@@ -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>
+2 -1
View File
@@ -11,7 +11,8 @@
export const reload = $state({
leftPanel: false,
auditLog: false,
clusterDetails: false
clusterDetails: false,
notifications: false
});
export const connection = $state({
+72
View File
@@ -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;
};
};
+1
View File
@@ -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>
+20 -2
View File
@@ -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
};
}
+4 -1
View File
@@ -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"