feat: Implement configuration and job duplication functionality

- Added duplication feature for configurations and jobs, allowing users to create copies of existing entries.
- Introduced new buttons in the UI for duplicating configurations and jobs, enhancing user experience.
- Implemented backend logic to handle duplication requests, ensuring proper ownership checks and data integrity.
- Added notifications for successful duplication actions to inform users of the outcome.
- Updated relevant templates and handlers to support the new duplication functionality.
This commit is contained in:
StarFleetCPTN
2025-03-22 22:19:05 -07:00
parent 49a586db53
commit db6259f8a3
20 changed files with 2433 additions and 347 deletions
@@ -2,6 +2,9 @@ package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
@@ -11,6 +14,60 @@ func InitialSchema() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "001_initial_schema",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Disable foreign key constraints while creating tables
if err := tx.Exec("PRAGMA foreign_keys = OFF").Error; err != nil {
return fmt.Errorf("failed to disable foreign key constraints: %v", err)
@@ -3,6 +3,8 @@ package migrations
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
@@ -14,6 +16,60 @@ func AddTimestampsToJobHistories() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "006_add_timestamps_to_job_histories",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Add created_at column
if err := tx.Exec("ALTER TABLE job_histories ADD COLUMN created_at DATETIME").Error; err != nil {
return fmt.Errorf("failed to add created_at column: %v", err)
@@ -3,6 +3,8 @@ package migrations
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
@@ -14,6 +16,60 @@ func AddNotificationServices() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "007_add_notification_services",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Create notification_services table
type NotificationService struct {
ID uint `gorm:"primaryKey"`
@@ -0,0 +1,122 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddUserNotifications adds the user_notifications table
func AddUserNotifications() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "008_add_user_notifications",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Create the user_notifications table
err := tx.Exec(`
CREATE TABLE IF NOT EXISTS user_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
link TEXT NOT NULL,
job_id INTEGER,
job_run_id INTEGER,
config_id INTEGER,
is_read BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`).Error
if err != nil {
return err
}
// Create an index on user_id for faster lookups
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_user_notifications_user_id ON user_notifications(user_id)
`).Error
if err != nil {
return err
}
// Create an index on created_at for faster sorting
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_user_notifications_created_at ON user_notifications(created_at)
`).Error
if err != nil {
return err
}
// Create an index on is_read for faster filtering of unread notifications
err = tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_user_notifications_is_read ON user_notifications(is_read)
`).Error
if err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS user_notifications`).Error
},
}
}
+1
View File
@@ -18,6 +18,7 @@ func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
AddDefaultRoles(), // 005
AddTimestampsToJobHistories(), // 006
AddNotificationServices(), // 007
AddUserNotifications(), // 008
)
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
+104
View File
@@ -0,0 +1,104 @@
package db
import (
"encoding/json"
"time"
"gorm.io/gorm"
)
// NotificationService represents a notification service configuration
type NotificationService struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Type string `json:"type" gorm:"not null"` // email, webhook
IsEnabled bool `json:"is_enabled" gorm:"default:true"`
Config map[string]string `json:"config" gorm:"-"`
ConfigJSON string `json:"-" gorm:"column:config"`
Description string `json:"description"`
EventTriggers []string `json:"event_triggers" gorm:"-"`
EventTriggersJSON string `json:"-" gorm:"column:event_triggers;default:'[]'"`
PayloadTemplate string `json:"payload_template" gorm:"column:payload_template"`
SecretKey string `json:"secret_key" gorm:"column:secret_key"`
RetryPolicy string `json:"retry_policy" gorm:"column:retry_policy;default:'simple'"`
LastUsed time.Time `json:"last_used" gorm:"column:last_used"`
SuccessCount int `json:"success_count" gorm:"column:success_count;default:0"`
FailureCount int `json:"failure_count" gorm:"column:failure_count;default:0"`
CreatedBy uint `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeSave converts Config map and EventTriggers to JSON strings for storage
func (n *NotificationService) BeforeSave(tx *gorm.DB) error {
configJSON, err := json.Marshal(n.Config)
if err != nil {
return err
}
n.ConfigJSON = string(configJSON)
eventsJSON, err := json.Marshal(n.EventTriggers)
if err != nil {
return err
}
n.EventTriggersJSON = string(eventsJSON)
return nil
}
// AfterFind converts JSON strings back to Config map and EventTriggers
func (n *NotificationService) AfterFind(tx *gorm.DB) error {
if n.ConfigJSON != "" {
if err := json.Unmarshal([]byte(n.ConfigJSON), &n.Config); err != nil {
return err
}
}
if n.EventTriggersJSON != "" {
if err := json.Unmarshal([]byte(n.EventTriggersJSON), &n.EventTriggers); err != nil {
return err
}
}
return nil
}
// GetNotificationServices returns notification services, filtered by enabled status if specified
func (db *DB) GetNotificationServices(onlyEnabled bool) ([]NotificationService, error) {
var services []NotificationService
query := db.DB
if onlyEnabled {
query = query.Where("is_enabled = ?", true)
}
if err := query.Find(&services).Error; err != nil {
return nil, err
}
return services, nil
}
// GetNotificationService returns a notification service by ID
func (db *DB) GetNotificationService(id uint) (*NotificationService, error) {
var service NotificationService
if err := db.First(&service, id).Error; err != nil {
return nil, err
}
return &service, nil
}
// CreateNotificationService creates a new notification service
func (db *DB) CreateNotificationService(service *NotificationService) error {
return db.Create(service).Error
}
// UpdateNotificationService updates an existing notification service
func (db *DB) UpdateNotificationService(service *NotificationService) error {
return db.Save(service).Error
}
// DeleteNotificationService deletes a notification service by ID
func (db *DB) DeleteNotificationService(id uint) error {
return db.Delete(&NotificationService{}, id).Error
}
+133
View File
@@ -0,0 +1,133 @@
package db
import (
"fmt"
"time"
)
// NotificationType defines the type of notification
type NotificationType string
const (
NotificationJobStart NotificationType = "job_start"
NotificationJobComplete NotificationType = "job_complete"
NotificationJobFail NotificationType = "job_fail"
NotificationConfigUpdate NotificationType = "config_update"
NotificationSystemAlert NotificationType = "system_alert"
)
// UserNotification represents a notification shown to users in the UI
type UserNotification struct {
ID uint `json:"id" gorm:"primaryKey"`
UserID uint `json:"user_id" gorm:"index"`
Type NotificationType `json:"type"`
Title string `json:"title"`
Message string `json:"message"`
Link string `json:"link"`
JobID uint `json:"job_id,omitempty"`
JobRunID uint `json:"job_run_id,omitempty"`
ConfigID uint `json:"config_id,omitempty"`
IsRead bool `json:"is_read" gorm:"default:false"`
CreatedAt time.Time `json:"created_at"`
}
// GetUserNotifications returns the latest notifications for a user
func (db *DB) GetUserNotifications(userID uint, limit int) ([]UserNotification, error) {
var notifications []UserNotification
result := db.Where("user_id = ?", userID).Order("created_at DESC").Limit(limit).Find(&notifications)
return notifications, result.Error
}
// GetUnreadNotificationCount returns the count of unread notifications for a user
func (db *DB) GetUnreadNotificationCount(userID uint) (int64, error) {
var count int64
result := db.Model(&UserNotification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&count)
return count, result.Error
}
// MarkNotificationAsRead marks a notification as read
func (db *DB) MarkNotificationAsRead(id uint) error {
return db.Model(&UserNotification{}).Where("id = ?", id).Update("is_read", true).Error
}
// MarkAllNotificationsAsRead marks all notifications for a user as read
func (db *DB) MarkAllNotificationsAsRead(userID uint) error {
return db.Model(&UserNotification{}).Where("user_id = ?", userID).Update("is_read", true).Error
}
// CreateJobNotification creates a notification for a job event
func (db *DB) CreateJobNotification(
userID uint,
jobID uint,
jobRunID uint,
notificationType NotificationType,
title string,
message string,
) error {
notification := UserNotification{
UserID: userID,
Type: notificationType,
Title: title,
Message: message,
JobID: jobID,
JobRunID: jobRunID,
Link: generateJobRunLink(jobRunID),
CreatedAt: time.Now(),
}
return db.Create(&notification).Error
}
// CreateConfigNotification creates a notification for a config update
func (db *DB) CreateConfigNotification(
userID uint,
configID uint,
title string,
message string,
) error {
notification := UserNotification{
UserID: userID,
Type: NotificationConfigUpdate,
Title: title,
Message: message,
ConfigID: configID,
Link: generateConfigLink(configID),
CreatedAt: time.Now(),
}
return db.Create(&notification).Error
}
// CreateSystemNotification creates a system-wide notification
func (db *DB) CreateSystemNotification(
title string,
message string,
) error {
// Get all active users
var users []User
if err := db.Where("active = ?", true).Find(&users).Error; err != nil {
return err
}
// Create a notification for each user
for _, user := range users {
notification := UserNotification{
UserID: user.ID,
Type: NotificationSystemAlert,
Title: title,
Message: message,
CreatedAt: time.Now(),
}
if err := db.Create(&notification).Error; err != nil {
return err
}
}
return nil
}
// Helper functions to generate links
func generateJobRunLink(jobRunID uint) string {
return "/job-runs/" + fmt.Sprintf("%d", jobRunID)
}
func generateConfigLink(configID uint) string {
return "/configs/" + fmt.Sprintf("%d", configID)
}