From 1a1df435de654cb8d3c06e99010bb4e103ee9da2 Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Wed, 19 Mar 2025 18:30:26 -0700 Subject: [PATCH 1/4] Enhance logging in scheduler for better debugging - Added debug logging for job scheduling, execution, and configuration processing. - Included detailed log messages for cron expression validation and file transfer commands. - Improved visibility into job history and webhook notifications with additional debug information. --- internal/scheduler/scheduler.go | 59 +++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 61da064..0ecc809 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -173,6 +173,11 @@ func NewLogger() *Logger { filepath.Join(logsDir, "scheduler.log"), maxSize, maxBackups, maxAge, compress, logLevel.String()) } + if logLevel >= LogLevelDebug { + logger.Debug.Printf("Log rotation details: file=%s, maxSize=%dMB, maxBackups=%d, maxAge=%d days, compress=%v", + filepath.Join(logsDir, "scheduler.log"), maxSize, maxBackups, maxAge, compress) + } + return logger } @@ -258,6 +263,8 @@ func (s *Scheduler) loadJobs() { } func (s *Scheduler) ScheduleJob(job *db.Job) error { + s.log.LogDebug("Attempting to schedule job ID %d: %+v", job.ID, job) + s.log.LogInfo("Scheduling job %d: %s with schedule %s", job.ID, job.Name, job.Schedule) // Remove existing job if it exists @@ -279,6 +286,8 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error { schedule = "0 " + schedule } + s.log.LogDebug("Converted schedule from '%s' to '%s'", job.Schedule, schedule) + // Validate cron expression parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) _, err := parser.Parse(schedule) @@ -286,6 +295,8 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error { return fmt.Errorf("invalid cron expression '%s': %w", job.Schedule, err) } + s.log.LogDebug("Validated cron expression '%s' for job %d", schedule, job.ID) + // Schedule the job entryID, err := s.cron.AddFunc(job.Schedule, func() { s.executeJob(job.ID) @@ -296,6 +307,8 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error { return err } + s.log.LogDebug("Scheduled job %d with cron entry ID %d", job.ID, entryID) + // Store mapping of job ID to cron entry ID s.jobMutex.Lock() s.jobs[job.ID] = entryID @@ -313,6 +326,9 @@ func (s *Scheduler) ScheduleJob(job *db.Job) error { } func (s *Scheduler) executeJob(jobID uint) { + s.log.LogDebug("Entering executeJob for job ID %d", jobID) + defer s.log.LogDebug("Exiting executeJob for job ID %d", jobID) + s.log.LogInfo("Starting execution of job %d", jobID) // Get job details @@ -322,6 +338,8 @@ func (s *Scheduler) executeJob(jobID uint) { return } + s.log.LogDebug("Loaded job details: %+v", job) + // Get all configurations associated with this job configs, err := s.db.GetConfigsForJob(jobID) if err != nil { @@ -329,6 +347,8 @@ func (s *Scheduler) executeJob(jobID uint) { return } + s.log.LogDebug("Processing %d configurations: %+v", len(configs), configs) + if len(configs) == 0 { s.log.LogError("Error: job %d has no associated configurations", jobID) return @@ -366,6 +386,8 @@ func (s *Scheduler) executeJob(jobID uint) { // processConfiguration processes a single configuration for a job func (s *Scheduler) processConfiguration(job *db.Job, config *db.TransferConfig, index int, totalConfigs int) { + s.log.LogDebug("Processing configuration %d: %+v", config.ID, config) + s.log.LogInfo("Processing configuration %d (%d/%d) for job %d: source=%s:%s, dest=%s:%s", config.ID, index, @@ -392,12 +414,16 @@ func (s *Scheduler) processConfiguration(job *db.Job, config *db.TransferConfig, return } + s.log.LogDebug("Creating job history record: %+v", history) + // Execute the configuration transfer s.executeConfigTransfer(*job, *config, history) } // executeConfigTransfer performs the actual file transfer for a single configuration func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig, history *db.JobHistory) { + s.log.LogDebug("Starting transfer for config %d with params: %+v", config.ID, config) + // Track files already processed in this job execution to prevent duplicates processedFiles := make(map[string]bool) @@ -447,7 +473,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig, listArgs = append(listArgs, sourceListPath) // Execute lsjson command - s.log.LogInfo("Listing files with metadata for job %d, config %d: rclone %s", job.ID, config.ID, strings.Join(listArgs, " ")) + s.log.LogDebug("Full lsjson command: %s %v", os.Getenv("RCLONE_PATH"), listArgs) rclonePath := os.Getenv("RCLONE_PATH") if rclonePath == "" { rclonePath = "rclone" @@ -455,6 +481,19 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig, listCmd := exec.Command(rclonePath, listArgs...) listOutput, listErr := listCmd.CombinedOutput() + // Add debug logging of raw output + if listErr == nil { + s.log.LogDebug("Raw lsjson output for job %d config %d:\n%s", + job.ID, + config.ID, + string(listOutput)) + } else { + s.log.LogDebug("Raw lsjson output (error case) for job %d config %d:\n%s", + job.ID, + config.ID, + string(listOutput)) + } + if listErr != nil { s.log.LogError("Error listing files for job %d, config %d: %v", job.ID, config.ID, listErr) // s.log.Debug.Printf("Output: %s", string(listOutput)) @@ -550,7 +589,7 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig, concurrencySemaphore := make(chan struct{}, maxConcurrent) // Process each file individually - for _, fileEntry := range files { + for i, fileEntry := range files { fileName, ok := fileEntry["Path"].(string) if !ok || fileName == "" { continue @@ -666,7 +705,8 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig, currentModTime := modTime // Log the file information that will be processed - s.log.LogDebug("Processing file: %s, size: %d, hash: %s", currentFileName, currentFileSize, currentFileHash) + s.log.LogDebug("Processing file %d/%d: %s (Size: %d, Hash: %s)", + i+1, len(files), currentFileName, currentFileSize, currentFileHash) // Start goroutine for concurrent processing go func() { @@ -740,13 +780,8 @@ func (s *Scheduler) executeConfigTransfer(job db.Job, config db.TransferConfig, transferArgs = append(transferArgs, sourcePath, destPath) // Execute transfer for this file - s.log.LogInfo("Executing rclone transfer command for job %d, config %d, file %s: rclone %s", - job.ID, config.ID, currentFileName, strings.Join(transferArgs, " ")) - // Get the rclone path from the environment variable or use the default path - rclonePath := os.Getenv("RCLONE_PATH") - if rclonePath == "" { - rclonePath = "rclone" - } + s.log.LogDebug("Full transfer command: %s %v", rclonePath, transferArgs) + s.log.LogDebug("Environment: RCLONE_PATH=%s", os.Getenv("RCLONE_PATH")) cmd := exec.Command(rclonePath, transferArgs...) fileOutput, fileErr := cmd.CombinedOutput() @@ -1087,6 +1122,8 @@ func (s *Scheduler) sendWebhookNotification(job *db.Job, history *db.JobHistory, return } + s.log.LogDebug("Webhook payload: %s", string(jsonPayload)) + // Create HTTP request req, err := http.NewRequest("POST", job.WebhookURL, bytes.NewBuffer(jsonPayload)) if err != nil { @@ -1116,6 +1153,8 @@ func (s *Scheduler) sendWebhookNotification(job *db.Job, history *db.JobHistory, } } + s.log.LogDebug("Webhook headers: %+v", req.Header) + // Send the request with a timeout client := &http.Client{ Timeout: 10 * time.Second, From d6bd471eb02a3956ad01a3ccac9ba2cd0e6d30c2 Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Thu, 20 Mar 2025 18:11:57 -0700 Subject: [PATCH 2/4] Add Email Testing Functionality to Admin Tools - Introduced a new section in the admin tools for testing email configurations. - Added a form to send test emails, including recipient, subject, and message fields. - Implemented backend logic to handle test email requests and send emails using the configured SMTP server. - Created a toast notification component to display success or failure messages for email tests. - Updated routes to include a new endpoint for handling test email submissions. --- components/admin_tools.templ | 169 +++++++++++++++++ internal/email/email.go | 179 ++++++++++++++++++ internal/web/handlers/admin_tools_handlers.go | 61 +++++- internal/web/handlers/routes.go | 3 + 4 files changed, 407 insertions(+), 5 deletions(-) diff --git a/components/admin_tools.templ b/components/admin_tools.templ index 583c721..e5a0a4e 100644 --- a/components/admin_tools.templ +++ b/components/admin_tools.templ @@ -36,6 +36,9 @@ type AdminToolsData struct { LogFiles []LogFile LogContent string CurrentLogFile string + EmailTestSuccess *bool + EmailTestMessage string + SmtpServer string } // Dialog component for confirmation dialogs @@ -463,6 +466,101 @@ templ AdminTools(ctx context.Context, data AdminToolsData) { + +
+
+
+

+ + Email Testing +

+
+
+

+ Test your email configuration by sending a test email to verify the server can send emails properly. +

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

+ + Email Configuration +

+ if data.SmtpServer != "" { +

+ Current SMTP server: { data.SmtpServer } +

+ } else { +

+ Email settings are configured in your application configuration file. Make sure SMTP settings are properly configured before testing. +

+ } +
+
+
+
+
@BackupsList(data) @@ -1000,3 +1098,74 @@ var Commit = "unknown" func getCommit() string { return Commit } + +// EmailTestToast is a component for showing email test results +templ EmailTestToast(success bool, message string) { +
+
+
+ if success { + + } else { + + } +
+
+

+ if success { + Email Sent Successfully + } else { + Email Sending Failed + } +

+
+ { message } +
+
+
+ +
+
+
+} + +// Add a style for the animate-fade-in animation +script fadeInAnimation() { + // Add CSS animation if it doesn't exist + if (!document.getElementById('fade-in-animation')) { + const style = document.createElement('style'); + style.id = 'fade-in-animation'; + style.textContent = ` + @keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } + } + .animate-fade-in { + animation: fadeIn 0.3s ease-out forwards; + } + `; + document.head.appendChild(style); + } +} diff --git a/internal/email/email.go b/internal/email/email.go index 7d7b9da..146c47b 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -270,3 +270,182 @@ func (s *Service) sendEmail(toEmail, subject, htmlContent string) error { return client.Quit() } } + +// SendTestEmail sends a test email to verify email configuration +func (s *Service) SendTestEmail(toEmail, subject, message string) error { + if !s.Config.Email.Enabled { + return fmt.Errorf("email service is disabled") + } + + // Use default subject if not provided + if subject == "" { + subject = "Test Email from GoMFT" + } + + // Use default message if not provided + if message == "" { + message = "This is a test email from GoMFT to verify the email configuration is working correctly." + } + + // Create email data for template + data := map[string]interface{}{ + "Subject": subject, + "Message": message, + "AppName": "GoMFT", + "Year": time.Now().Year(), + "SMTPServer": s.Config.Email.Host, + "SMTPPort": s.Config.Email.Port, + "FromEmail": s.Config.Email.FromEmail, + "CurrentTime": time.Now().Format(time.RFC1123Z), + } + + // Generate email content + htmlContent, err := s.generateTestEmailHTML(data) + if err != nil { + return err + } + + // Send the email + return s.sendEmail(toEmail, subject, htmlContent) +} + +// generateTestEmailHTML generates the HTML content for test emails +func (s *Service) generateTestEmailHTML(data map[string]interface{}) (string, error) { + // HTML template for test email + tmpl, err := template.New("testEmail").Parse(` + + + + + + {{.Subject}} + + + +
+
+ +

{{.Subject}}

+
+
+

{{.Message}}

+ +
+
+
SMTP Server:
+
{{.SMTPServer}}:{{.SMTPPort}}
+
+
+
From:
+
{{.FromEmail}}
+
+
+
Sent:
+
{{.CurrentTime}}
+
+
+ +
+

This is a test email sent from the GoMFT admin interface. If you've received this email, your email configuration is working correctly.

+
+
+ +
+ + +`) + if err != nil { + return "", err + } + + var result bytes.Buffer + if err := tmpl.Execute(&result, data); err != nil { + return "", err + } + + return result.String(), nil +} diff --git a/internal/web/handlers/admin_tools_handlers.go b/internal/web/handlers/admin_tools_handlers.go index 3ef70a9..2654afe 100644 --- a/internal/web/handlers/admin_tools_handlers.go +++ b/internal/web/handlers/admin_tools_handlers.go @@ -63,17 +63,28 @@ func (h *Handlers) HandleAdminTools(c *gin.Context) { data.TotalUsers = int(totalUsers) } - // Get last backup time and backup count - data.LastBackupTime, data.BackupCount = h.getBackupInfo() + // Get backup info (last backup time and count) + lastBackup, backupCount := h.getBackupInfo() + data.LastBackupTime = lastBackup + data.BackupCount = backupCount // Get list of backup files data.BackupFiles = h.getBackupFiles() - // Check for maintenance issues + // Get maintenance message if any data.MaintenanceMessage = h.checkMaintenanceIssues() - // Render the admin tools page - components.AdminTools(components.CreateTemplateContext(c), data).Render(c, c.Writer) + // Add SMTP server info if available + if h.Email != nil && h.Email.Config != nil && h.Email.Config.Email.Host != "" { + smtpServer := h.Email.Config.Email.Host + if h.Email.Config.Email.Port != 0 { + data.SmtpServer = fmt.Sprintf("%s:%d", smtpServer, h.Email.Config.Email.Port) + } else { + data.SmtpServer = smtpServer + } + } + + components.AdminTools(c.Request.Context(), data).Render(c.Request.Context(), c.Writer) } // HandleBackupDatabase handles the backup database request @@ -1368,3 +1379,43 @@ func (h *Handlers) HandleImportConfigsFromFile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d configs imported successfully", imported)}) } + +// HandleTestEmail handles the POST /admin/test-email route +func (h *Handlers) HandleTestEmail(c *gin.Context) { + // Parse the form + recipient := c.PostForm("recipient") + subject := c.PostForm("subject") + message := c.PostForm("message") + + // Validate required fields + if recipient == "" { + components.EmailTestToast(false, "Recipient email is required").Render(c.Request.Context(), c.Writer) + return + } + + // Get SMTP server info for display + smtpServer := "" + if h.Email != nil && h.Email.Config != nil && h.Email.Config.Email.Host != "" { + smtpServer = h.Email.Config.Email.Host + if h.Email.Config.Email.Port != 0 { + smtpServer = fmt.Sprintf("%s:%d", smtpServer, h.Email.Config.Email.Port) + } + } + + // Send the test email + if h.Email == nil { + components.EmailTestToast(false, "Email service is not configured").Render(c.Request.Context(), c.Writer) + return + } + + err := h.Email.SendTestEmail(recipient, subject, message) + if err != nil { + // Failed to send email + components.EmailTestToast(false, fmt.Sprintf("Failed to send email: %v", err)).Render(c.Request.Context(), c.Writer) + return + } + + // Email sent successfully + successMsg := fmt.Sprintf("Test email sent successfully to %s", recipient) + components.EmailTestToast(true, successMsg).Render(c.Request.Context(), c.Writer) +} diff --git a/internal/web/handlers/routes.go b/internal/web/handlers/routes.go index a84cac9..448627e 100644 --- a/internal/web/handlers/routes.go +++ b/internal/web/handlers/routes.go @@ -110,6 +110,9 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) { admin.GET("/logs/refresh", h.HandleRefreshLogs) admin.GET("/logs/view/:fileName", h.HandleViewLog) admin.GET("/logs/download/:fileName", h.HandleDownloadLog) + + // Email test route + admin.POST("/test-email", h.HandleTestEmail) } // API routes From 73362793ad9573c790542f9dedb00830235b926f Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Thu, 20 Mar 2025 18:49:32 -0700 Subject: [PATCH 3/4] Add Webhook Notification Testing to Admin Tools - Introduced a new section for testing webhook notifications within the admin tools. - Added a form to input webhook URL, secret, custom headers, and payload for testing. - Implemented backend logic to handle webhook test requests and display results. - Created a toast notification component to show success or failure messages for webhook tests. - Updated the AdminToolsData structure to include fields for webhook test results. --- components/admin_tools.templ | 223 +++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/components/admin_tools.templ b/components/admin_tools.templ index e5a0a4e..cabe33c 100644 --- a/components/admin_tools.templ +++ b/components/admin_tools.templ @@ -39,6 +39,10 @@ type AdminToolsData struct { EmailTestSuccess *bool EmailTestMessage string SmtpServer string + WebhookTestSuccess *bool + WebhookTestMessage string + WebhookStatusCode int + WebhookResponse string } // Dialog component for confirmation dialogs @@ -561,6 +565,160 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
+ +
+
+
+

+ + Webhook Notification Testing +

+
+
+

+ Test webhook notifications by sending a sample job execution payload to your webhook endpoint. +

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

+ + Used to sign webhook payloads (X-Hub-Signature-256 header) +

+
+ +
+ +
+
+ +
+ +
+

+ + Additional HTTP headers as JSON +

+
+ +
+ + +

+ + Leave empty to use default test payload +

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

+ + Default Webhook Test Payload +

+
+
{
+  "event_type": "job_execution",
+  "job_id": 123,
+  "job_name": "Test Job",
+  "config_id": 456,
+  "config_name": "Test Config",
+  "status": "completed",
+  "start_time": "2023-06-18T15:30:45Z",
+  "end_time": "2023-06-18T15:35:12Z",
+  "duration_seconds": 267,
+  "history_id": 789,
+  "bytes_transferred": 1048576,
+  "files_transferred": 5,
+  "source": {
+    "type": "local",
+    "path": "/path/to/source"
+  },
+  "destination": {
+    "type": "s3",
+    "path": "bucket/path"
+  }
+}
+
+
+
+
+
+
@BackupsList(data) @@ -1169,3 +1327,68 @@ script fadeInAnimation() { document.head.appendChild(style); } } + +// WebhookTestToast is a component for showing webhook test results +templ WebhookTestToast(success bool, message string, statusCode int, responseBody string) { +
+
+
+ if success { + + } else { + + } +
+
+

+ if success { + Webhook Sent Successfully + } else { + Webhook Sending Failed + } +

+
+ { message } +
+ + +
+
Status Code: { fmt.Sprint(statusCode) }
+ if responseBody != "" { +
+
Response:
+
+
{ responseBody }
+
+
+ } +
+
+
+ +
+
+
+} From 9a49d87777365474d72167c3b582baa174732b8b Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Thu, 20 Mar 2025 20:39:26 -0700 Subject: [PATCH 4/4] Implement Job Configuration Ordering in Job Form - Added functionality to allow users to select and reorder job configurations in both new and edit job forms. - Introduced JavaScript logic to handle the display and ordering of selected configurations, including move up/down buttons. - Updated backend to process and store the order of configurations when creating or updating jobs. - Enhanced logging for job creation and update processes to include configuration order details. --- components/job_form.templ | 207 +++++++++++++++++++++++-- internal/db/db.go | 39 ++++- internal/scheduler/scheduler.go | 41 ++++- internal/web/handlers/job_handlers.go | 208 +++++++++++++++++++++----- 4 files changed, 438 insertions(+), 57 deletions(-) diff --git a/components/job_form.templ b/components/job_form.templ index 9e09ee3..9875c5e 100644 --- a/components/job_form.templ +++ b/components/job_form.templ @@ -72,6 +72,172 @@ templ configSearchScript() { }); }); } + + // Handle job ordering + const setupJobOrdering = (configListId, selectedListId, formId, savedOrder) => { + const configList = document.getElementById(configListId); + const selectedList = document.getElementById(selectedListId); + const form = document.getElementById(formId); + + if (!configList || !selectedList || !form) return; + + // Get saved order if available + const orderedIds = savedOrder ? savedOrder.split(',').map(id => id.trim()) : []; + console.log('Initial saved order:', orderedIds); + + // Initialize selected items from checked checkboxes + const updateSelectedItems = (initialLoad = false) => { + // Clear current list + selectedList.innerHTML = ''; + + // Get all checked checkboxes + const checkedItems = configList.querySelectorAll('input[type="checkbox"]:checked'); + + if (checkedItems.length === 0) { + selectedList.innerHTML = '
No configurations selected
'; + return; + } + + // Create a map of config items for easy access + const configItems = {}; + checkedItems.forEach(checkbox => { + configItems[checkbox.value] = { + checkbox: checkbox, + configId: checkbox.value, + configName: checkbox.nextElementSibling.textContent.trim() + }; + }); + + // If we have a saved order and this is the initial load, use that order + let itemsToShow = []; + if (initialLoad && orderedIds.length > 0) { + // First add items in the saved order + orderedIds.forEach(id => { + if (configItems[id]) { + itemsToShow.push(configItems[id]); + delete configItems[id]; // Remove from map to avoid duplicates + } + }); + + // Then add any remaining checked items not in the saved order + Object.values(configItems).forEach(item => { + itemsToShow.push(item); + }); + } else { + // Just add all checked items in their current order + itemsToShow = Object.values(configItems); + } + + // Add each item to the selected list + itemsToShow.forEach((item, index) => { + const configId = item.configId; + const configName = item.configName; + + const listItem = document.createElement('div'); + listItem.className = 'flex items-center justify-between p-2 mb-2 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-700 rounded-lg'; + listItem.setAttribute('data-id', configId); + + listItem.innerHTML = ` +
+ ${index + 1} + ${configName} +
+
+ + +
+ `; + + selectedList.appendChild(listItem); + }); + + // Update hidden order inputs + updateOrderInputs(); + }; + + // Update hidden inputs with the current order + const updateOrderInputs = () => { + const items = selectedList.querySelectorAll('.flex.items-center.justify-between'); + if (items.length === 0) return; + + // Remove any existing order input to avoid duplicates + const existingOrderInput = form.querySelector('input[name="config_order"]'); + if (existingOrderInput) { + existingOrderInput.remove(); + } + + // Create a new input with the current order + const orderedIds = Array.from(items).map(item => item.getAttribute('data-id')); + + // Create a hidden input to store the order + const configOrderInput = document.createElement('input'); + configOrderInput.type = 'hidden'; + configOrderInput.name = 'config_order'; + configOrderInput.value = orderedIds.join(','); + + // Add the input to the form + form.appendChild(configOrderInput); + + // Update the visible order numbers + items.forEach((item, index) => { + const orderNum = index + 1; + const orderSpan = item.querySelector('span.rounded-full'); + if (orderSpan) { + orderSpan.textContent = orderNum; + } + }); + + console.log('Updated order input:', configOrderInput.value); + }; + + // Initialize the selected list with saved order if available + updateSelectedItems(true); + + // Handle checkbox changes + configList.addEventListener('change', (e) => { + if (e.target.matches('input[type="checkbox"]')) { + updateSelectedItems(false); + } + }); + + // Handle reordering + selectedList.addEventListener('click', (e) => { + const listItem = e.target.closest('.flex.items-center.justify-between'); + if (!listItem) return; + + if (e.target.closest('.move-up')) { + const prev = listItem.previousElementSibling; + if (prev) { + selectedList.insertBefore(listItem, prev); + updateOrderInputs(); + } + } else if (e.target.closest('.move-down')) { + const next = listItem.nextElementSibling; + if (next) { + selectedList.insertBefore(next, listItem); + updateOrderInputs(); + } + } + }); + + // Ensure the order input is updated before submission + form.addEventListener('submit', function(e) { + updateOrderInputs(); + console.log('Form submitted with order:', form.querySelector('input[name="config_order"]')?.value); + }); + }; + + // Setup ordering for new job form + setupJobOrdering('config-list', 'selected-configs', 'new-job-form', null); + + // Setup ordering for edit job form + const editJobForm = document.getElementById('edit-job-form'); + const savedOrderEdit = editJobForm ? editJobForm.getAttribute('data-config-order') : null; + setupJobOrdering('config-list-edit', 'selected-configs-edit', 'edit-job-form', savedOrderEdit); }); } @@ -95,6 +261,7 @@ templ JobForm(ctx context.Context, data JobFormData) { if data.IsNew {
-

- - Select one or more configurations to run on this schedule. -

+ +
+ +
+ +
+

+ + Use the arrows to change the order in which configurations will execute. +

+
@@ -341,10 +517,12 @@ templ JobForm(ctx context.Context, data JobFormData) { } else {
+ hx-boost="true" + data-config-order={ data.Job.ConfigIDs }>
@@ -411,10 +589,19 @@ templ JobForm(ctx context.Context, data JobFormData) {
-

- - Select one or more configurations to run on this schedule. -

+ +
+ +
+ +
+

+ + Use the arrows to change the order in which configurations will execute. +

+
@@ -599,7 +786,7 @@ templ JobForm(ctx context.Context, data JobFormData) {

- Jobs will run according to their schedule and execute the selected transfer configuration + Jobs will run according to their schedule and execute the selected transfer configurations in the order specified

diff --git a/internal/db/db.go b/internal/db/db.go index a8d9397..8678a91 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -2,6 +2,7 @@ package db import ( "fmt" + "log" "os" "os/exec" "path/filepath" @@ -186,6 +187,9 @@ func (j *Job) SetConfigIDsList(ids []uint) { // Join with commas j.ConfigIDs = strings.Join(strIDs, ",") + // Debug log the final ConfigIDs string + log.Printf("SetConfigIDsList: Setting ConfigIDs to: %s (from %v)", j.ConfigIDs, ids) + // If there's at least one ID, set ConfigID to the first one for backward compatibility if len(ids) > 0 { j.ConfigID = ids[0] @@ -376,8 +380,25 @@ func (db *DB) GetJob(id uint) (*Job, error) { } func (db *DB) UpdateJob(job *Job) error { + log.Printf("UpdateJob: Updating job ID: %d, ConfigIDs: %s", job.ID, job.ConfigIDs) + // Use Omit to prevent GORM from updating or creating a new config - return db.Omit("Config").Save(job).Error + return db.Model(&Job{}). + Where("id = ?", job.ID). + Omit("Config"). + Updates(map[string]interface{}{ + "name": job.Name, + "config_id": job.ConfigID, + "config_ids": job.ConfigIDs, // Explicitly update config_ids + "schedule": job.Schedule, + "enabled": job.Enabled, + "webhook_enabled": job.WebhookEnabled, + "webhook_url": job.WebhookURL, + "webhook_secret": job.WebhookSecret, + "webhook_headers": job.WebhookHeaders, + "notify_on_success": job.NotifyOnSuccess, + "notify_on_failure": job.NotifyOnFailure, + }).Error } func (db *DB) DeleteJob(id uint) error { @@ -965,7 +986,21 @@ func (db *DB) GetConfigsForJob(jobID uint) ([]TransferConfig, error) { return nil, err } - return configs, nil + // Create a map for quick lookup + configMap := make(map[uint]TransferConfig) + for _, config := range configs { + configMap[config.ID] = config + } + + // Create a new slice with configs in the correct order + orderedConfigs := make([]TransferConfig, 0, len(configs)) + for _, configID := range configIDs { + if config, exists := configMap[configID]; exists { + orderedConfigs = append(orderedConfigs, config) + } + } + + return orderedConfigs, nil } // GetSkipProcessedFiles returns the value of SkipProcessedFiles with a default if nil diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 0ecc809..2a06228 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -347,14 +347,45 @@ func (s *Scheduler) executeJob(jobID uint) { return } - s.log.LogDebug("Processing %d configurations: %+v", len(configs), configs) + s.log.LogDebug("Loaded %d configurations for job %d", len(configs), jobID) if len(configs) == 0 { s.log.LogError("Error: job %d has no associated configurations", jobID) return } - s.log.LogInfo("Loaded job %d with %d configurations", jobID, len(configs)) + // Get the ordered config IDs from the job + orderedConfigIDs := job.GetConfigIDsList() + s.log.LogDebug("Ordered config IDs for job %d: %v", jobID, orderedConfigIDs) + + // Create a map of configs for easy lookup + configMap := make(map[uint]db.TransferConfig) + for _, config := range configs { + configMap[config.ID] = config + } + + // Process configurations in the specified order + var orderedConfigs []db.TransferConfig + + // First, add configs in the order specified in the job's ConfigIDs + for _, configID := range orderedConfigIDs { + if config, exists := configMap[configID]; exists { + orderedConfigs = append(orderedConfigs, config) + delete(configMap, configID) // Remove from map to avoid duplicates + } + } + + // Add any remaining configs not in the ordered list (shouldn't happen, but just in case) + for _, config := range configMap { + orderedConfigs = append(orderedConfigs, config) + } + + s.log.LogInfo("Processing job %d with %d configurations in specified order", jobID, len(orderedConfigs)) + + // Log the order of execution + for i, config := range orderedConfigs { + s.log.LogDebug("Execution order %d/%d: Config ID %d (%s)", i+1, len(orderedConfigs), config.ID, config.Name) + } // Update job last run time startTime := time.Now() @@ -363,9 +394,9 @@ func (s *Scheduler) executeJob(jobID uint) { s.log.LogError("Error updating job last run time for job %d: %v", jobID, err) } - // Process each configuration - for i, config := range configs { - s.processConfiguration(&job, &config, i+1, len(configs)) + // Process each configuration in the specified order + for i, config := range orderedConfigs { + s.processConfiguration(&job, &config, i+1, len(orderedConfigs)) } // Update next run time after execution diff --git a/internal/web/handlers/job_handlers.go b/internal/web/handlers/job_handlers.go index 14e6aa8..47739fd 100644 --- a/internal/web/handlers/job_handlers.go +++ b/internal/web/handlers/job_handlers.go @@ -2,8 +2,10 @@ package handlers import ( "fmt" + "log" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/starfleetcptn/gomft/components" @@ -141,6 +143,9 @@ func (h *Handlers) HandleEditJob(c *gin.Context) { func (h *Handlers) HandleCreateJob(c *gin.Context) { userID := c.GetUint("userID") + // Debug logging + log.Printf("HandleCreateJob: Form data received: %v", c.Request.PostForm) + // Parse form data var job db.Job if err := c.ShouldBind(&job); err != nil { @@ -148,6 +153,9 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) { return } + // Debug logging + log.Printf("HandleCreateJob: Job after binding: %+v", job) + // Get multiple config IDs from form configIDs := c.PostFormArray("config_ids[]") if len(configIDs) == 0 { @@ -155,35 +163,83 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) { return } + // Debug logging + log.Printf("HandleCreateJob: config_ids[]: %v", configIDs) + // Process config IDs var configIDsList []uint - for _, configIDStr := range configIDs { - configID, err := strconv.ParseUint(configIDStr, 10, 32) - if err != nil { - c.String(http.StatusBadRequest, "Invalid configuration ID format") - return - } - // Verify that the config exists and belongs to the user - var config db.TransferConfig - if err := h.DB.First(&config, configID).Error; err != nil { - c.String(http.StatusBadRequest, "Invalid configuration selected") - return - } + // Check if we have an explicit order specified + configOrder := c.PostForm("config_order") + log.Printf("HandleCreateJob: config_order: %s", configOrder) - // Check if the config belongs to the user - if config.CreatedBy != userID { - // Check if user is admin - isAdmin, exists := c.Get("isAdmin") - if !exists || isAdmin != true { - c.String(http.StatusForbidden, "You do not have permission to use this configuration") + if configOrder != "" { + // Parse the ordered list + orderStrings := strings.Split(configOrder, ",") + log.Printf("HandleCreateJob: order strings: %v", orderStrings) + + for _, configIDStr := range orderStrings { + configID, err := strconv.ParseUint(configIDStr, 10, 32) + if err != nil { + log.Printf("HandleCreateJob: Error parsing config ID: %v", err) + c.String(http.StatusBadRequest, "Invalid configuration ID format in order") return } - } - configIDsList = append(configIDsList, uint(configID)) + // Verify that the config exists and belongs to the user + var config db.TransferConfig + if err := h.DB.First(&config, configID).Error; err != nil { + log.Printf("HandleCreateJob: Invalid config ID: %d, error: %v", configID, err) + c.String(http.StatusBadRequest, "Invalid configuration selected") + return + } + + // Check if the config belongs to the user + if config.CreatedBy != userID { + // Check if user is admin + isAdmin, exists := c.Get("isAdmin") + if !exists || isAdmin != true { + c.String(http.StatusForbidden, "You do not have permission to use this configuration") + return + } + } + + configIDsList = append(configIDsList, uint(configID)) + } + } else { + // Fall back to unordered config IDs + log.Printf("HandleCreateJob: No config_order found, using checkbox order") + for _, configIDStr := range configIDs { + configID, err := strconv.ParseUint(configIDStr, 10, 32) + if err != nil { + c.String(http.StatusBadRequest, "Invalid configuration ID format") + return + } + + // Verify that the config exists and belongs to the user + var config db.TransferConfig + if err := h.DB.First(&config, configID).Error; err != nil { + c.String(http.StatusBadRequest, "Invalid configuration selected") + return + } + + // Check if the config belongs to the user + if config.CreatedBy != userID { + // Check if user is admin + isAdmin, exists := c.Get("isAdmin") + if !exists || isAdmin != true { + c.String(http.StatusForbidden, "You do not have permission to use this configuration") + return + } + } + + configIDsList = append(configIDsList, uint(configID)) + } } + // Debug logging + log.Printf("HandleCreateJob: Final configIDsList: %v", configIDsList) + // Set the first config ID for backward compatibility if len(configIDsList) > 0 { job.ConfigID = configIDsList[0] @@ -214,6 +270,10 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) { // Set the config IDs list job.SetConfigIDsList(configIDsList) + // Debug logging + log.Printf("HandleCreateJob: Job after setting ConfigIDsList: %+v", job) + log.Printf("HandleCreateJob: Job.ConfigIDs: %s", job.ConfigIDs) + // Set the boolean fields - handle both "on" and "true" values for checkboxes enabledVal := c.Request.FormValue("enabled") jobEnabledValue := enabledVal == "on" || enabledVal == "true" @@ -239,10 +299,13 @@ func (h *Handlers) HandleCreateJob(c *gin.Context) { // Create the job if err := h.DB.CreateJob(&job); err != nil { + log.Printf("HandleCreateJob: Error creating job: %v", err) c.String(http.StatusInternalServerError, "Failed to create job") return } + log.Printf("HandleCreateJob: Job successfully created with ID: %d", job.ID) + // Schedule the job with the scheduler if err := h.Scheduler.ScheduleJob(&job); err != nil { c.String(http.StatusInternalServerError, "Job created but scheduling failed: "+err.Error()) @@ -257,8 +320,13 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) { id := c.Param("id") userID := c.GetUint("userID") + // Debug logging + log.Printf("HandleUpdateJob: Updating job ID: %s", id) + log.Printf("HandleUpdateJob: Form data received: %v", c.Request.PostForm) + var job db.Job if err := h.DB.First(&job, id).Error; err != nil { + log.Printf("HandleUpdateJob: Job not found: %v", err) c.String(http.StatusNotFound, "Job not found") return } @@ -275,49 +343,102 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) { // Get the old job values for comparison oldJob := job + log.Printf("HandleUpdateJob: Original job: %+v", oldJob) + log.Printf("HandleUpdateJob: Original job ConfigIDs: %s", oldJob.ConfigIDs) // Parse form data if err := c.ShouldBind(&job); err != nil { + log.Printf("HandleUpdateJob: Error binding form data: %v", err) c.String(http.StatusBadRequest, "Invalid form data") return } + log.Printf("HandleUpdateJob: Job after binding: %+v", job) + // Get multiple config IDs from form configIDs := c.PostFormArray("config_ids[]") if len(configIDs) == 0 { + log.Printf("HandleUpdateJob: No config_ids[] found in form data") c.String(http.StatusBadRequest, "At least one configuration must be selected") return } + log.Printf("HandleUpdateJob: config_ids[]: %v", configIDs) + // Process config IDs var configIDsList []uint - for _, configIDStr := range configIDs { - configID, err := strconv.ParseUint(configIDStr, 10, 32) - if err != nil { - c.String(http.StatusBadRequest, "Invalid configuration ID format") - return - } - // Verify that the config exists - var config db.TransferConfig - if err := h.DB.First(&config, configID).Error; err != nil { - c.String(http.StatusBadRequest, "Invalid configuration selected") - return - } + // Check if we have an explicit order specified + configOrder := c.PostForm("config_order") + log.Printf("HandleUpdateJob: config_order: %s", configOrder) - // Check if the config belongs to the user - if config.CreatedBy != userID { - // Check if user is admin - isAdmin, exists := c.Get("isAdmin") - if !exists || isAdmin != true { - c.String(http.StatusForbidden, "You do not have permission to use this configuration") + if configOrder != "" { + // Parse the ordered list + orderStrings := strings.Split(configOrder, ",") + log.Printf("HandleUpdateJob: order strings: %v", orderStrings) + + for _, configIDStr := range orderStrings { + configID, err := strconv.ParseUint(configIDStr, 10, 32) + if err != nil { + log.Printf("HandleUpdateJob: Error parsing config ID: %v", err) + c.String(http.StatusBadRequest, "Invalid configuration ID format in order") return } - } - configIDsList = append(configIDsList, uint(configID)) + // Verify that the config exists + var config db.TransferConfig + if err := h.DB.First(&config, configID).Error; err != nil { + log.Printf("HandleUpdateJob: Invalid config ID: %d, error: %v", configID, err) + c.String(http.StatusBadRequest, "Invalid configuration selected") + return + } + + // Check if the config belongs to the user + if config.CreatedBy != userID { + // Check if user is admin + isAdmin, exists := c.Get("isAdmin") + if !exists || isAdmin != true { + c.String(http.StatusForbidden, "You do not have permission to use this configuration") + return + } + } + + configIDsList = append(configIDsList, uint(configID)) + } + } else { + // Fall back to unordered config IDs + log.Printf("HandleUpdateJob: No config_order found, using checkbox order") + for _, configIDStr := range configIDs { + configID, err := strconv.ParseUint(configIDStr, 10, 32) + if err != nil { + c.String(http.StatusBadRequest, "Invalid configuration ID format") + return + } + + // Verify that the config exists + var config db.TransferConfig + if err := h.DB.First(&config, configID).Error; err != nil { + c.String(http.StatusBadRequest, "Invalid configuration selected") + return + } + + // Check if the config belongs to the user + if config.CreatedBy != userID { + // Check if user is admin + isAdmin, exists := c.Get("isAdmin") + if !exists || isAdmin != true { + c.String(http.StatusForbidden, "You do not have permission to use this configuration") + return + } + } + + configIDsList = append(configIDsList, uint(configID)) + } } + // Debug logging + log.Printf("HandleUpdateJob: Final configIDsList: %v", configIDsList) + // Set the first config ID for backward compatibility if len(configIDsList) > 0 { job.ConfigID = configIDsList[0] @@ -332,6 +453,10 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) { // Set the config IDs list job.SetConfigIDsList(configIDsList) + // Debug logging + log.Printf("HandleUpdateJob: Job after setting ConfigIDsList: %+v", job) + log.Printf("HandleUpdateJob: Job.ConfigIDs: %s", job.ConfigIDs) + // Set the boolean fields - handle both "on" and "true" values for checkboxes enabledVal := c.Request.FormValue("enabled") jobEnabledValue := enabledVal == "on" || enabledVal == "true" @@ -357,10 +482,13 @@ func (h *Handlers) HandleUpdateJob(c *gin.Context) { job.Config = db.TransferConfig{} if err := h.DB.UpdateJob(&job); err != nil { + log.Printf("HandleUpdateJob: Error updating job: %v", err) c.String(http.StatusInternalServerError, "Failed to update job") return } + log.Printf("HandleUpdateJob: Job successfully updated") + // Reschedule the job with the scheduler if err := h.Scheduler.ScheduleJob(&job); err != nil { c.String(http.StatusInternalServerError, "Job updated but scheduling failed: "+err.Error())