From bd5d30b40c9b7603b7e51aff1719dc52203d3ac0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:37:29 +0000 Subject: [PATCH 01/30] Initial plan From e68ef3447373d9ec558fc528ff13bcbd7647e274 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:44:54 +0000 Subject: [PATCH 02/30] Add backend notification system with Discord and Email support Co-authored-by: kmendell <25576967+kmendell@users.noreply.github.com> --- backend/internal/api/notification_handler.go | 96 +++++ .../internal/bootstrap/router_bootstrap.go | 1 + .../internal/bootstrap/services_bootstrap.go | 4 +- backend/internal/dto/notification_dto.go | 16 + backend/internal/models/notification.go | 57 +++ .../internal/services/image_update_service.go | 34 +- .../internal/services/notification_service.go | 376 ++++++++++++++++++ .../postgres/019_add_notifications.down.sql | 2 + .../postgres/019_add_notifications.up.sql | 25 ++ .../sqlite/019_add_notifications.down.sql | 2 + .../sqlite/019_add_notifications.up.sql | 25 ++ 11 files changed, 626 insertions(+), 12 deletions(-) create mode 100644 backend/internal/api/notification_handler.go create mode 100644 backend/internal/dto/notification_dto.go create mode 100644 backend/internal/models/notification.go create mode 100644 backend/internal/services/notification_service.go create mode 100644 backend/resources/migrations/postgres/019_add_notifications.down.sql create mode 100644 backend/resources/migrations/postgres/019_add_notifications.up.sql create mode 100644 backend/resources/migrations/sqlite/019_add_notifications.down.sql create mode 100644 backend/resources/migrations/sqlite/019_add_notifications.up.sql diff --git a/backend/internal/api/notification_handler.go b/backend/internal/api/notification_handler.go new file mode 100644 index 000000000..bf4a57099 --- /dev/null +++ b/backend/internal/api/notification_handler.go @@ -0,0 +1,96 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ofkm/arcane-backend/internal/dto" + "github.com/ofkm/arcane-backend/internal/middleware" + "github.com/ofkm/arcane-backend/internal/models" + "github.com/ofkm/arcane-backend/internal/services" +) + +type NotificationHandler struct { + notificationService *services.NotificationService +} + +func NewNotificationHandler(group *gin.RouterGroup, notificationService *services.NotificationService, authMiddleware *middleware.AuthMiddleware) { + handler := &NotificationHandler{ + notificationService: notificationService, + } + + notifications := group.Group("/environments/:id/notifications") + notifications.Use(authMiddleware.WithAdminRequired().Add()) + { + notifications.GET("/settings", handler.GetAllSettings) + notifications.GET("/settings/:provider", handler.GetSettings) + notifications.POST("/settings", handler.CreateOrUpdateSettings) + notifications.DELETE("/settings/:provider", handler.DeleteSettings) + notifications.POST("/test/:provider", handler.TestNotification) + } +} + +func (h *NotificationHandler) GetAllSettings(c *gin.Context) { + settings, err := h.notificationService.GetAllSettings(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, settings) +} + +func (h *NotificationHandler) GetSettings(c *gin.Context) { + provider := c.Param("provider") + + settings, err := h.notificationService.GetSettingsByProvider(c.Request.Context(), provider) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Settings not found"}) + return + } + + c.JSON(http.StatusOK, settings) +} + +func (h *NotificationHandler) CreateOrUpdateSettings(c *gin.Context) { + var req dto.NotificationSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + settings, err := h.notificationService.CreateOrUpdateSettings( + c.Request.Context(), + req.Provider, + req.Enabled, + req.Config, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, settings) +} + +func (h *NotificationHandler) DeleteSettings(c *gin.Context) { + provider := c.Param("provider") + + if err := h.notificationService.DeleteSettings(c.Request.Context(), provider); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Settings deleted successfully"}) +} + +func (h *NotificationHandler) TestNotification(c *gin.Context) { + provider := c.Param("provider") + + if err := h.notificationService.TestNotification(c.Request.Context(), provider); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Test notification sent successfully"}) +} diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index fa33e54a1..048b4ae23 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -106,6 +106,7 @@ func setupRouter(cfg *config.Config, appServices *Services) *gin.Engine { api.NewUpdaterHandler(apiGroup, appServices.Updater, authMiddleware) api.NewVolumeHandler(apiGroup, appServices.Docker, appServices.Volume, authMiddleware) api.NewSettingsHandler(apiGroup, appServices.Settings, authMiddleware) + api.NewNotificationHandler(apiGroup, appServices.Notification, authMiddleware) if cfg.Environment != "production" { for _, registerFunc := range registerPlaywrightRoutes { diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 565bf8f83..b04d8dbbc 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -31,6 +31,7 @@ type Services struct { Updater *services.UpdaterService Event *services.EventService Version *services.VersionService + Notification *services.NotificationService } func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (svcs *Services, dockerSrvice *services.DockerClientService, err error) { @@ -46,7 +47,8 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config svcs.Docker = dockerClient svcs.User = services.NewUserService(db) svcs.ContainerRegistry = services.NewContainerRegistryService(db) - svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event) + svcs.Notification = services.NewNotificationService(db) + svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification) svcs.Image = services.NewImageService(db, svcs.Docker, svcs.ContainerRegistry, svcs.ImageUpdate, svcs.Event) svcs.Project = services.NewProjectService(db, svcs.Settings, svcs.Event, svcs.Image) svcs.Environment = services.NewEnvironmentService(db, httpClient) diff --git a/backend/internal/dto/notification_dto.go b/backend/internal/dto/notification_dto.go new file mode 100644 index 000000000..60edc7969 --- /dev/null +++ b/backend/internal/dto/notification_dto.go @@ -0,0 +1,16 @@ +package dto + +import "github.com/ofkm/arcane-backend/internal/models" + +type NotificationSettingsRequest struct { + Provider string `json:"provider" binding:"required"` + Enabled bool `json:"enabled"` + Config models.JSON `json:"config" binding:"required"` +} + +type NotificationSettingsResponse struct { + ID uint `json:"id"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` + Config models.JSON `json:"config"` +} diff --git a/backend/internal/models/notification.go b/backend/internal/models/notification.go new file mode 100644 index 000000000..b42062c5e --- /dev/null +++ b/backend/internal/models/notification.go @@ -0,0 +1,57 @@ +package models + +import ( + "time" +) + +type NotificationProvider string + +const ( + NotificationProviderDiscord NotificationProvider = "discord" + NotificationProviderEmail NotificationProvider = "email" +) + +type NotificationSettings struct { + ID uint `json:"id" gorm:"primaryKey"` + Provider string `json:"provider" gorm:"not null;index"` + Enabled bool `json:"enabled" gorm:"default:false"` + Config JSON `json:"config" gorm:"type:jsonb"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (NotificationSettings) TableName() string { + return "notification_settings" +} + +type NotificationLog struct { + ID uint `json:"id" gorm:"primaryKey"` + Provider string `json:"provider" gorm:"not null;index"` + ImageRef string `json:"imageRef" gorm:"not null"` + Status string `json:"status" gorm:"not null"` + Error *string `json:"error,omitempty"` + Metadata JSON `json:"metadata" gorm:"type:jsonb"` + SentAt time.Time `json:"sentAt" gorm:"not null;index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (NotificationLog) TableName() string { + return "notification_logs" +} + +type DiscordConfig struct { + WebhookURL string `json:"webhookUrl"` + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatarUrl,omitempty"` +} + +type EmailConfig struct { + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + SMTPUsername string `json:"smtpUsername"` + SMTPPassword string `json:"smtpPassword"` + FromAddress string `json:"fromAddress"` + ToAddresses []string `json:"toAddresses"` + UseTLS bool `json:"useTls"` +} diff --git a/backend/internal/services/image_update_service.go b/backend/internal/services/image_update_service.go index fc3b93d32..d77daec63 100644 --- a/backend/internal/services/image_update_service.go +++ b/backend/internal/services/image_update_service.go @@ -19,11 +19,12 @@ import ( ) type ImageUpdateService struct { - db *database.DB - settingsService *SettingsService - registryService *ContainerRegistryService - dockerService *DockerClientService - eventService *EventService + db *database.DB + settingsService *SettingsService + registryService *ContainerRegistryService + dockerService *DockerClientService + eventService *EventService + notificationService *NotificationService } type ImageParts struct { @@ -32,13 +33,14 @@ type ImageParts struct { Tag string } -func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService) *ImageUpdateService { +func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService, notificationService *NotificationService) *ImageUpdateService { return &ImageUpdateService{ - db: db, - settingsService: settingsService, - registryService: registryService, - dockerService: dockerService, - eventService: eventService, + db: db, + settingsService: settingsService, + registryService: registryService, + dockerService: dockerService, + eventService: eventService, + notificationService: notificationService, } } @@ -102,6 +104,16 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri slog.String("imageRef", imageRef), slog.String("error", saveErr.Error())) } + + // Send notification if update is available + if digestResult.HasUpdate && s.notificationService != nil { + if notifErr := s.notificationService.SendImageUpdateNotification(ctx, imageRef, digestResult); notifErr != nil { + slog.WarnContext(ctx, "Failed to send update notification", + slog.String("imageRef", imageRef), + slog.String("error", notifErr.Error())) + } + } + return digestResult, nil } diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go new file mode 100644 index 000000000..61f97a627 --- /dev/null +++ b/backend/internal/services/notification_service.go @@ -0,0 +1,376 @@ +package services + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/smtp" + "strings" + "time" + + "github.com/ofkm/arcane-backend/internal/database" + "github.com/ofkm/arcane-backend/internal/dto" + "github.com/ofkm/arcane-backend/internal/models" + "github.com/ofkm/arcane-backend/internal/utils" +) + +type NotificationService struct { + db *database.DB +} + +func NewNotificationService(db *database.DB) *NotificationService { + return &NotificationService{ + db: db, + } +} + +func (s *NotificationService) GetAllSettings(ctx context.Context) ([]models.NotificationSettings, error) { + var settings []models.NotificationSettings + if err := s.db.WithContext(ctx).Find(&settings).Error; err != nil { + return nil, fmt.Errorf("failed to get notification settings: %w", err) + } + return settings, nil +} + +func (s *NotificationService) GetSettingsByProvider(ctx context.Context, provider string) (*models.NotificationSettings, error) { + var setting models.NotificationSettings + if err := s.db.WithContext(ctx).Where("provider = ?", provider).First(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +func (s *NotificationService) CreateOrUpdateSettings(ctx context.Context, provider string, enabled bool, config models.JSON) (*models.NotificationSettings, error) { + var setting models.NotificationSettings + + err := s.db.WithContext(ctx).Where("provider = ?", provider).First(&setting).Error + if err != nil { + setting = models.NotificationSettings{ + Provider: provider, + Enabled: enabled, + Config: config, + } + if err := s.db.WithContext(ctx).Create(&setting).Error; err != nil { + return nil, fmt.Errorf("failed to create notification settings: %w", err) + } + } else { + setting.Enabled = enabled + setting.Config = config + if err := s.db.WithContext(ctx).Save(&setting).Error; err != nil { + return nil, fmt.Errorf("failed to update notification settings: %w", err) + } + } + + return &setting, nil +} + +func (s *NotificationService) DeleteSettings(ctx context.Context, provider string) error { + if err := s.db.WithContext(ctx).Where("provider = ?", provider).Delete(&models.NotificationSettings{}).Error; err != nil { + return fmt.Errorf("failed to delete notification settings: %w", err) + } + return nil +} + +func (s *NotificationService) SendImageUpdateNotification(ctx context.Context, imageRef string, updateInfo *dto.ImageUpdateResponse) error { + settings, err := s.GetAllSettings(ctx) + if err != nil { + return fmt.Errorf("failed to get notification settings: %w", err) + } + + var errors []string + for _, setting := range settings { + if !setting.Enabled { + continue + } + + var sendErr error + switch setting.Provider { + case string(models.NotificationProviderDiscord): + sendErr = s.sendDiscordNotification(ctx, imageRef, updateInfo, setting.Config) + case string(models.NotificationProviderEmail): + sendErr = s.sendEmailNotification(ctx, imageRef, updateInfo, setting.Config) + default: + slog.WarnContext(ctx, "Unknown notification provider", "provider", setting.Provider) + continue + } + + status := "success" + var errMsg *string + if sendErr != nil { + status = "failed" + msg := sendErr.Error() + errMsg = &msg + errors = append(errors, fmt.Sprintf("%s: %s", setting.Provider, msg)) + } + + s.logNotification(ctx, setting.Provider, imageRef, status, errMsg, models.JSON{ + "hasUpdate": updateInfo.HasUpdate, + "currentDigest": updateInfo.CurrentDigest, + "latestDigest": updateInfo.LatestDigest, + "updateType": updateInfo.UpdateType, + }) + } + + if len(errors) > 0 { + return fmt.Errorf("notification errors: %s", strings.Join(errors, "; ")) + } + + return nil +} + +func (s *NotificationService) sendDiscordNotification(ctx context.Context, imageRef string, updateInfo *dto.ImageUpdateResponse, config models.JSON) error { + var discordConfig models.DiscordConfig + configBytes, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal Discord config: %w", err) + } + if err := json.Unmarshal(configBytes, &discordConfig); err != nil { + return fmt.Errorf("failed to unmarshal Discord config: %w", err) + } + + if discordConfig.WebhookURL == "" { + return fmt.Errorf("Discord webhook URL not configured") + } + + // Decrypt webhook URL if encrypted + webhookURL := discordConfig.WebhookURL + if decrypted, err := utils.Decrypt(webhookURL); err == nil { + webhookURL = decrypted + } + + username := discordConfig.Username + if username == "" { + username = "Arcane" + } + + color := 3447003 // Blue + if updateInfo.HasUpdate { + color = 15844367 // Gold for updates + } + + fields := []map[string]interface{}{ + { + "name": "Image", + "value": imageRef, + "inline": false, + }, + { + "name": "Update Available", + "value": fmt.Sprintf("%t", updateInfo.HasUpdate), + "inline": true, + }, + { + "name": "Update Type", + "value": updateInfo.UpdateType, + "inline": true, + }, + } + + if updateInfo.CurrentDigest != "" { + fields = append(fields, map[string]interface{}{ + "name": "Current Digest", + "value": truncateDigest(updateInfo.CurrentDigest), + "inline": true, + }) + } + if updateInfo.LatestDigest != "" { + fields = append(fields, map[string]interface{}{ + "name": "Latest Digest", + "value": truncateDigest(updateInfo.LatestDigest), + "inline": true, + }) + } + + payload := map[string]interface{}{ + "username": username, + "embeds": []map[string]interface{}{ + { + "title": "🔔 Container Image Update Available", + "description": fmt.Sprintf("A new update has been detected for **%s**", imageRef), + "color": color, + "fields": fields, + "timestamp": time.Now().Format(time.RFC3339), + }, + }, + } + + if discordConfig.AvatarURL != "" { + payload["avatar_url"] = discordConfig.AvatarURL + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal Discord payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create Discord request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send Discord notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("Discord webhook returned status %d", resp.StatusCode) + } + + return nil +} + +func (s *NotificationService) sendEmailNotification(ctx context.Context, imageRef string, updateInfo *dto.ImageUpdateResponse, config models.JSON) error { + var emailConfig models.EmailConfig + configBytes, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal email config: %w", err) + } + if err := json.Unmarshal(configBytes, &emailConfig); err != nil { + return fmt.Errorf("failed to unmarshal email config: %w", err) + } + + if emailConfig.SMTPHost == "" || emailConfig.SMTPPort == 0 { + return fmt.Errorf("SMTP host or port not configured") + } + if len(emailConfig.ToAddresses) == 0 { + return fmt.Errorf("no recipient email addresses configured") + } + + // Decrypt SMTP password if encrypted + password := emailConfig.SMTPPassword + if decrypted, err := utils.Decrypt(password); err == nil { + password = decrypted + } + + subject := fmt.Sprintf("Container Update Available: %s", imageRef) + body := fmt.Sprintf(`Container Image Update Notification + +Image: %s +Update Available: %t +Update Type: %s +Current Digest: %s +Latest Digest: %s + +Checked at: %s +`, + imageRef, + updateInfo.HasUpdate, + updateInfo.UpdateType, + updateInfo.CurrentDigest, + updateInfo.LatestDigest, + updateInfo.CheckTime.Format(time.RFC3339), + ) + + message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s", + emailConfig.FromAddress, + strings.Join(emailConfig.ToAddresses, ","), + subject, + body, + ) + + auth := smtp.PlainAuth("", emailConfig.SMTPUsername, password, emailConfig.SMTPHost) + addr := fmt.Sprintf("%s:%d", emailConfig.SMTPHost, emailConfig.SMTPPort) + + if emailConfig.UseTLS { + tlsConfig := &tls.Config{ + ServerName: emailConfig.SMTPHost, + } + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("failed to establish TLS connection: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, emailConfig.SMTPHost) + if err != nil { + return fmt.Errorf("failed to create SMTP client: %w", err) + } + defer client.Close() + + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + if err := client.Mail(emailConfig.FromAddress); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + for _, to := range emailConfig.ToAddresses { + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("failed to set recipient %s: %w", to, err) + } + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("failed to get data writer: %w", err) + } + if _, err := w.Write([]byte(message)); err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("failed to close data writer: %w", err) + } + } else { + err = smtp.SendMail(addr, auth, emailConfig.FromAddress, emailConfig.ToAddresses, []byte(message)) + if err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + } + + return nil +} + +func (s *NotificationService) TestNotification(ctx context.Context, provider string) error { + setting, err := s.GetSettingsByProvider(ctx, provider) + if err != nil { + return fmt.Errorf("failed to get settings for provider %s: %w", provider, err) + } + + testUpdate := &dto.ImageUpdateResponse{ + HasUpdate: true, + UpdateType: "digest", + CurrentDigest: "sha256:abc123def456", + LatestDigest: "sha256:xyz789ghi012", + CheckTime: time.Now(), + ResponseTimeMs: 100, + } + + switch provider { + case string(models.NotificationProviderDiscord): + return s.sendDiscordNotification(ctx, "test/image:latest", testUpdate, setting.Config) + case string(models.NotificationProviderEmail): + return s.sendEmailNotification(ctx, "test/image:latest", testUpdate, setting.Config) + default: + return fmt.Errorf("unknown provider: %s", provider) + } +} + +func (s *NotificationService) logNotification(ctx context.Context, provider, imageRef, status string, errMsg *string, metadata models.JSON) { + log := &models.NotificationLog{ + Provider: provider, + ImageRef: imageRef, + Status: status, + Error: errMsg, + Metadata: metadata, + SentAt: time.Now(), + } + + if err := s.db.WithContext(ctx).Create(log).Error; err != nil { + slog.WarnContext(ctx, "Failed to log notification", + slog.String("provider", provider), + slog.String("error", err.Error())) + } +} + +func truncateDigest(digest string) string { + if len(digest) > 19 { + return digest[:19] + "..." + } + return digest +} diff --git a/backend/resources/migrations/postgres/019_add_notifications.down.sql b/backend/resources/migrations/postgres/019_add_notifications.down.sql new file mode 100644 index 000000000..8733d0b5b --- /dev/null +++ b/backend/resources/migrations/postgres/019_add_notifications.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS notification_logs; +DROP TABLE IF EXISTS notification_settings; diff --git a/backend/resources/migrations/postgres/019_add_notifications.up.sql b/backend/resources/migrations/postgres/019_add_notifications.up.sql new file mode 100644 index 000000000..1cc02664e --- /dev/null +++ b/backend/resources/migrations/postgres/019_add_notifications.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS notification_settings ( + id SERIAL PRIMARY KEY, + provider VARCHAR(50) NOT NULL, + enabled BOOLEAN DEFAULT FALSE, + config JSONB NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_settings_provider ON notification_settings(provider); + +CREATE TABLE IF NOT EXISTS notification_logs ( + id SERIAL PRIMARY KEY, + provider VARCHAR(50) NOT NULL, + image_ref VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL, + error TEXT, + metadata JSONB, + sent_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_logs_provider ON notification_logs(provider); +CREATE INDEX idx_notification_logs_sent_at ON notification_logs(sent_at); diff --git a/backend/resources/migrations/sqlite/019_add_notifications.down.sql b/backend/resources/migrations/sqlite/019_add_notifications.down.sql new file mode 100644 index 000000000..8733d0b5b --- /dev/null +++ b/backend/resources/migrations/sqlite/019_add_notifications.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS notification_logs; +DROP TABLE IF EXISTS notification_settings; diff --git a/backend/resources/migrations/sqlite/019_add_notifications.up.sql b/backend/resources/migrations/sqlite/019_add_notifications.up.sql new file mode 100644 index 000000000..019e7093b --- /dev/null +++ b/backend/resources/migrations/sqlite/019_add_notifications.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS notification_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider VARCHAR(50) NOT NULL, + enabled BOOLEAN DEFAULT 0, + config TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_settings_provider ON notification_settings(provider); + +CREATE TABLE IF NOT EXISTS notification_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider VARCHAR(50) NOT NULL, + image_ref VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL, + error TEXT, + metadata TEXT, + sent_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notification_logs_provider ON notification_logs(provider); +CREATE INDEX idx_notification_logs_sent_at ON notification_logs(sent_at); From a4168bbfe52458762d2025843a8d8a67e8bbcfc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 04:47:38 +0000 Subject: [PATCH 03/30] Add frontend notification settings UI Co-authored-by: kmendell <25576967+kmendell@users.noreply.github.com> --- frontend/src/routes/settings/+page.svelte | 25 ++ .../settings/notifications/+page.svelte | 377 ++++++++++++++++++ .../routes/settings/notifications/+page.ts | 7 + 3 files changed, 409 insertions(+) create mode 100644 frontend/src/routes/settings/notifications/+page.svelte create mode 100644 frontend/src/routes/settings/notifications/+page.ts diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 299afb693..baac38300 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -6,6 +6,7 @@ import UserIcon from '@lucide/svelte/icons/user'; import ShieldIcon from '@lucide/svelte/icons/shield'; import NavigationIcon from '@lucide/svelte/icons/navigation'; + import BellIcon from '@lucide/svelte/icons/bell'; import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import { Button } from '$lib/components/ui/button'; import { Card } from '$lib/components/ui/card'; @@ -218,6 +219,30 @@ } ] }, + { + id: 'notifications', + title: 'Notifications', + description: 'Configure notifications for container updates', + icon: BellIcon, + url: '/settings/notifications', + keywords: ['notifications', 'alerts', 'discord', 'email', 'webhook', 'updates', 'notify'], + settings: [ + { + key: 'discordEnabled', + label: 'Discord Notifications', + type: 'boolean', + description: 'Send notifications to Discord when updates are detected', + keywords: ['discord', 'webhook', 'chat', 'message', 'bot'] + }, + { + key: 'emailEnabled', + label: 'Email Notifications', + type: 'boolean', + description: 'Send email notifications when updates are detected', + keywords: ['email', 'smtp', 'mail', 'message', 'alert'] + } + ] + }, { id: 'users', title: m.users_title(), diff --git a/frontend/src/routes/settings/notifications/+page.svelte b/frontend/src/routes/settings/notifications/+page.svelte new file mode 100644 index 000000000..51ea56ec2 --- /dev/null +++ b/frontend/src/routes/settings/notifications/+page.svelte @@ -0,0 +1,377 @@ + + + +
+ {#if isReadOnly} + + Read-only Mode + + Settings are read-only in this environment. Configuration changes are disabled. + + + {/if} + + + + +
+ + Discord Notifications +
+ + Send notifications to Discord when container updates are detected + +
+ + + + {#if discordEnabled} +
+ + + + + +
+ {/if} +
+ + + {#if discordEnabled} + + {/if} + +
+ + + + +
+ + Email Notifications +
+ + Send notifications via email when container updates are detected + +
+ + + + {#if emailEnabled} +
+
+ + +
+ + +

SMTP server port (usually 587 or 465)

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