diff --git a/.gitignore b/.gitignore index 8257975..7d8fffa 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ Thumbs.db components/*.go !components/components.go +components/providers/*.go +!components/providers/providers.go + components/providers/source/*.go !components/providers/source/source.go @@ -68,11 +71,14 @@ tmp/ # Ignore the configs directory configs/ +# Ignore the backups directory +backups/ + # Ignore Dirs /source/ /destination/ - /archive/ + # Ignore binaries gomft diff --git a/Dockerfile b/Dockerfile index 93bb2a9..8a1fe6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,8 +47,8 @@ COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone COPY static/ /app/static/ COPY components/ /app/components/ -# Create data directory -RUN mkdir -p /app/data/gomft +# Create data and backup directories +RUN mkdir -p /app/data /app/backups # Set executable permissions RUN chmod +x /app/gomft diff --git a/README.md b/README.md index fb1a042..ea2fe60 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging ![User Management](screenshots/user.management.gomft.png) *Create user accounts and manage them* +### Admin Tools +![Admin Tools](screenshots/admin.tools.gomft.png) +*Admin dashboard with log viewer and system management tools* ## Features @@ -40,6 +43,12 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging - SMB/CIFS shares - Local filesystem - And more via rclone +- **Webhook Notifications**: Receive real-time notifications of job events: + - Configurable webhook URLs + - HMAC-SHA256 authentication with secrets + - Custom HTTP headers + - Selectable events (job success, job failure) + - Detailed JSON payload with job information - **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options - **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics - **File Metadata Tracking**: Complete history and status of all transferred files with detailed information: @@ -52,6 +61,12 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging - Advanced search interface with multiple criteria - Bulk management and record deletion capabilities - Responsive design with mobile-friendly interface +- **Multi-threaded File Transfers**: Significantly improve performance with concurrent file processing: + - Configurable number of concurrent transfers (1-32) per job + - Automatic queue management to prevent system overload + - Independent configuration for each transfer job + - Optimized for both high-volume small files and large file transfers + - Maximizes bandwidth utilization for cloud storage providers - **Web Interface**: User-friendly interface for managing transfers, built with Templ components - **File Pattern Matching**: Support for file patterns to filter files during transfers - **File Output Patterns**: Dynamic naming of destination files using patterns with date variables @@ -106,6 +121,7 @@ docker run -d \ --name gomft \ -p 8080:8080 \ -v /path/to/data:/app/data \ + -v /path/to/backups:/app/backups \ starfleetcptn/gomft:latest ``` @@ -126,12 +142,12 @@ services: - "8080:8080" volumes: - ./data:/app/data - - ./backups:/app/data/gomft/backups + - ./backups:/app/backups environment: - TZ=UTC - SERVER_ADDRESS=:8080 - - DATA_DIR=/app/data/gomft - - BACKUP_DIR=/app/data/gomft/backups + - DATA_DIR=/app/data + - BACKUP_DIR=/app/backups - JWT_SECRET=change_this_to_a_secure_random_string - BASE_URL=http://localhost:8080 - EMAIL_ENABLED=true @@ -143,6 +159,13 @@ services: - EMAIL_REQUIRE_AUTH=true - EMAIL_USERNAME=smtp_username - EMAIL_PASSWORD=smtp_password + # Logging configuration + - LOGS_DIR=/app/data/logs + - LOG_MAX_SIZE=10 + - LOG_MAX_BACKUPS=5 + - LOG_MAX_AGE=30 + - LOG_COMPRESS=true + - LOG_LEVEL=info ``` Alternatively, you can mount your own .env file to the container: @@ -158,7 +181,7 @@ services: - "8080:8080" volumes: - ./data:/app/data - - ./backups:/app/data/gomft/backups + - ./backups:/app/backups - ./.env:/app/.env environment: - TZ=UTC @@ -178,8 +201,8 @@ GoMFT uses an environment file located at `.env` in the root directory of the ap ``` SERVER_ADDRESS=:8080 -DATA_DIR=./data/gomft -BACKUP_DIR=./data/gomft/backups +DATA_DIR=/app/data +BACKUP_DIR=/app/backups JWT_SECRET=change_this_to_a_secure_random_string BASE_URL=http://localhost:8080 @@ -199,7 +222,7 @@ EMAIL_PASSWORD=smtp_password ### Configuration Options - `SERVER_ADDRESS`: The address and port to run the server on -- `DATA_DIR`: Directory for storing application data +- `DATA_DIR`: Directory for storing application data (database and configs) - `BACKUP_DIR`: Directory for storing database backups - `JWT_SECRET`: Secret key for JWT token generation - `BASE_URL`: Base URL for generating links in emails (e.g., password reset links) @@ -215,6 +238,22 @@ EMAIL_PASSWORD=smtp_password - `EMAIL_ENABLE_TLS`: Set to `true` to use TLS for secure email transmission - `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication +### Logging Configuration + +GoMFT provides configurable logging with rotation support through the following environment variables: + +- `LOGS_DIR`: Directory where log files are stored (default: `./data/logs`) +- `LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`) +- `LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`) +- `LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`) +- `LOG_COMPRESS`: Whether to compress rotated log files (default: `true`) +- `LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`) + - `error`: Only show errors and critical issues + - `info`: Show errors and general operational information (default) + - `debug`: Show all messages including detailed debugging information + +Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing. + ## Usage 1. Start the server: @@ -233,6 +272,11 @@ EMAIL_PASSWORD=smtp_password - Navigate to "Transfer Configs" section - Configure source and destination locations with connection details - Set file patterns and archive options as needed + - Configure performance settings: + - Set "Concurrent Transfers" slider to optimize throughput + - Use higher values (8-16) for many small files or fast networks + - Use lower values (1-4) for large files or limited bandwidth + - Consider source/destination system capabilities when setting 5. Create jobs using your configurations: - Navigate to "Jobs" section @@ -245,7 +289,15 @@ EMAIL_PASSWORD=smtp_password - Check detailed transfer history with performance metrics - View job run details including any error messages -7. Manage file metadata: +7. Configure webhook notifications: + - Enable webhooks in job settings to receive notifications + - Provide a valid webhook URL where notifications will be sent + - Optionally set a webhook secret for HMAC-SHA256 signature verification + - Configure custom HTTP headers in JSON format if needed + - Choose notification triggers (job success, job failure, or both) + - Test your webhook integration with manual job runs + +8. Manage file metadata: - Navigate to the "Files" section to view all processed files - Use filters to quickly find files by status, job ID, or filename - Click on any file to view detailed metadata including timestamps, size, and hash @@ -253,6 +305,14 @@ EMAIL_PASSWORD=smtp_password - Delete file metadata records when no longer needed - View files associated with specific jobs by navigating from the job details +9. Utilize admin tools (administrators only): + - Access the "Admin Tools" section from the navigation menu + - View system statistics and server information + - Create and manage database backups + - Browse and download system log files with the integrated log viewer + - Perform database maintenance and optimization tasks + - View webhook documentation and integration details + ### User Management GoMFT uses a role-based access control system: @@ -275,7 +335,6 @@ User management features: - Local filesystem - Amazon S3 - MinIO (S3-compatible storage) - - Backblaze B2 - SFTP - FTP - SMB/CIFS shares @@ -294,12 +353,29 @@ User management features: - File patterns for filtering (e.g., `*.txt`, `data_*.csv`) - Output patterns for dynamic naming - Archive options for transferred files + - Skip already processed files to avoid duplicates + - Concurrent file transfers (configurable per job) -4. **Schedule Options**: +4. **Performance Options**: + - **Multi-threaded File Transfers**: Process multiple files simultaneously for higher throughput + - Configurable concurrency level (1-32 concurrent transfers) + - Per-job concurrency settings to optimize for different storage types + - Automatic transfer queue management to prevent overloading systems + - Adaptive processing based on source/destination capabilities + +5. **Schedule Options**: - Cron expressions for flexible scheduling - Manual execution - Enable/disable schedules +6. **Webhook Notifications**: + - **Webhook Integration**: Send notifications to external systems when jobs complete + - **Secure Authentication**: HMAC-SHA256 signature for webhook verification + - **Custom Headers**: Add custom HTTP headers to webhook requests + - **Flexible Configuration**: Configure different webhooks for different jobs + - **Event Selection**: Choose to send notifications on success, failure, or both + - **Detailed Payload**: Rich JSON payload with complete job execution details + ### Email Notifications GoMFT supports email notifications for various features: @@ -318,6 +394,87 @@ To configure email functionality: 2. Set `EMAIL_ENABLED=true` in the email configuration section 3. Ensure the `BASE_URL` setting is configured correctly for your deployment +### Webhook Integration + +GoMFT can send webhook notifications to external systems when jobs complete. This allows integration with monitoring tools, chat applications, custom notification systems, or workflow automation platforms. + +#### Webhook Payload Structure + +Webhook notifications are sent as HTTP POST requests with a JSON payload containing detailed information about the job execution: + +```json +{ + "event_type": "job_execution", + "job_id": 123, + "job_name": "Daily Backup", + "config_id": 456, + "config_name": "S3 to Local Backup", + "status": "completed", + "start_time": "2023-07-14T15:30:00Z", + "end_time": "2023-07-14T15:35:42Z", + "duration_seconds": 342, + "bytes_transferred": 1048576, + "files_transferred": 25, + "history_id": 789, + "source": { + "type": "s3", + "path": "my-bucket/data" + }, + "destination": { + "type": "local", + "path": "/backups/data" + } +} +``` + +For failed transfers, additional error information is included: + +```json +{ + "status": "failed", + "error_message": "Permission denied accessing destination path" +} +``` + +#### Webhook Authentication + +When a webhook secret is configured, GoMFT signs the payload using HMAC-SHA256 and includes the signature in the `X-Hub-Signature-256` header. To verify the webhook: + +1. Compute the HMAC-SHA256 of the raw request body using your shared secret +2. Compare it with the value in the `X-Hub-Signature-256` header +3. Process the webhook only if the signatures match + +This ensures that webhook requests are authentic and haven't been tampered with. + +### Admin Tools + +GoMFT provides a comprehensive set of administrative tools for system management and monitoring: + +#### Log Viewer + +The Admin Tools panel includes an integrated log viewer with the following features: + +- **Log File Browser**: View a list of all available log files in the system +- **Real-time Log Viewing**: View log file contents directly in the web interface +- **Refresh Function**: Update the log list and content with the latest information +- **User-friendly Interface**: Clean, readable presentation with custom scrolling +- **Dark Mode Support**: Consistent theming with the rest of the application +- **Navigation**: Easily switch between different log files + +This log viewer allows administrators to: +- Monitor system activity and diagnose issues without requiring server access +- View application logs, scheduler logs, and transfer logs in one place +- Track down errors and warning messages in real-time + +#### Database Management + +The Admin Tools interface also includes database management capabilities: +- Create and manage database backups +- Restore from previous backups +- Download backups for safekeeping +- View system statistics +- Optimize the database with maintenance tools + ## Development ### Project Structure @@ -379,3 +536,23 @@ air ## License MIT License - see LICENSE file for details + +## Directory Structure + +GoMFT uses the following directory structure: + +- `/app/data`: Main application data directory + - Contains the SQLite database (`gomft.db`) + - Contains rclone configurations in `/app/data/configs` + - Contains log files in `/app/data/logs` +- `/app/backups`: Database backup directory + +When using Docker, you should mount volumes to these locations: + +```yaml +volumes: + - /host/path/data:/app/data # For all application data + - /host/path/backups:/app/backups # For database backups +``` + +These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`. diff --git a/components/admin_tools.templ b/components/admin_tools.templ index 58623c5..7a51949 100644 --- a/components/admin_tools.templ +++ b/components/admin_tools.templ @@ -12,6 +12,13 @@ type BackupFile struct { ModTime time.Time } +type LogFile struct { + Name string + Size string + ModTime time.Time + Path string +} + type AdminToolsData struct { JobHistoryCount int DatabaseSize string @@ -26,6 +33,9 @@ type AdminToolsData struct { BackupPath string MaintenanceMessage string BackupFiles []BackupFile + LogFiles []LogFile + LogContent string + CurrentLogFile string } // Dialog component for confirmation dialogs @@ -131,6 +141,64 @@ templ BackupActionDialog(id string, title string, message string, confirmClass s templ AdminTools(ctx context.Context, data AdminToolsData) { @LayoutWithContext("Admin Tools", ctx) { +
@@ -448,6 +516,94 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
+ + +
+ @AdminLogViewer(data) +
+ + +
+
+
+

+ + Webhook Notifications +

+
+
+

+ GoMFT can send webhook notifications when jobs run. You can configure webhooks + for individual jobs in the job edit form. Below is the format of the webhook payload: +

+ +
+
{
+  "event_type": "job_execution",
+  "job_id": 123,
+  "job_name": "Daily Backup",
+  "config_id": 456,
+  "config_name": "Backup 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"
+  }
+}
+
+ +

Authentication

+

+ When configuring a webhook, you can optionally provide a secret key. This will be used to sign + the webhook payload with HMAC-SHA256. The signature is provided in the X-Hub-Signature-256 header. +

+ +

HTTP Request Details

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyValue
MethodPOST
Content-Typeapplication/json
User-AgentGoMFT-Webhook/1.0
X-Hub-Signature-256HMAC SHA256 signature (if secret configured)
Custom HeadersAny additional headers specified in the job configuration
+
+
+
+
} @@ -576,3 +732,167 @@ templ BackupsList(data AdminToolsData) { } } + +// Add this new template after other admin tool templates +templ AdminLogViewer(data AdminToolsData) { +
+

+ Log Files +

+ +
+
+

Available Logs

+
+ if len(data.LogFiles) == 0 { +
+ No log files found +
+ } else { +
+ for _, logFile := range data.LogFiles { + + } +
+ } +
+
+ +
+
+ +
+
+

+ if data.CurrentLogFile != "" { + Log: { data.CurrentLogFile } + } else { + Select a log file + } +

+ if data.CurrentLogFile != "" { +
+ +
+ } +
+ + @AdminLogContent(data) +
+
+
+} + +// AdminLogContent template for log view +templ AdminLogContent(data AdminToolsData) { +
+
+ +
+ if data.CurrentLogFile == "" { +
+
+ +

Select a log file to view its contents

+
+
+ } else { + +
+ +
+ { data.LogContent } +
+ + +
+
+
+
+ + + } +
+} diff --git a/components/config_form.templ b/components/config_form.templ index 5d28504..ae4f575 100644 --- a/components/config_form.templ +++ b/components/config_form.templ @@ -73,6 +73,7 @@ func getInitialData(config *db.TransferConfig) string { archiveEnabled := false deleteAfterTransfer := false skipProcessedFiles := true + maxConcurrentTransfers := 4 rcloneFlags := "" // If editing an existing config, populate with those values @@ -130,7 +131,8 @@ func getInitialData(config *db.TransferConfig) string { archivePath = config.ArchivePath archiveEnabled = config.ArchiveEnabled deleteAfterTransfer = config.DeleteAfterTransfer - skipProcessedFiles = config.SkipProcessedFiles + skipProcessedFiles = config.GetSkipProcessedFiles() + maxConcurrentTransfers = config.MaxConcurrentTransfers rcloneFlags = config.RcloneFlags } @@ -186,6 +188,7 @@ func getInitialData(config *db.TransferConfig) string { archiveEnabled: %v, deleteAfterTransfer: %v, skipProcessedFiles: %v, + maxConcurrentTransfers: %d, rcloneFlags: '%s', }`, name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType, @@ -195,7 +198,7 @@ func getInitialData(config *db.TransferConfig) string { destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, destAuthType, destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode, destClientId, destClientSecret, destDriveId, destTeamDrive, - archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, rcloneFlags) + archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags) } templ ConfigForm(ctx context.Context, data ConfigFormData) { diff --git a/components/dashboard.templ b/components/dashboard.templ index 3323111..16a5a22 100644 --- a/components/dashboard.templ +++ b/components/dashboard.templ @@ -12,6 +12,7 @@ type DashboardData struct { ActiveTransfers int CompletedToday int FailedTransfers int + Configs map[uint]db.TransferConfig } templ Dashboard(ctx context.Context, data DashboardData) { @@ -113,7 +114,7 @@ templ Dashboard(ctx context.Context, data DashboardData) {

- { job.Job.Config.Name } + { getConfigNameForHistory(job, data.Configs) }

diff --git a/components/history.templ b/components/history.templ index 7548bdb..60cc885 100644 --- a/components/history.templ +++ b/components/history.templ @@ -13,6 +13,7 @@ type HistoryData struct { SearchTerm string PageSize int Total int + Configs map[uint]db.TransferConfig // Map of config IDs to configs for quick lookup } // min returns the smaller of x or y @@ -23,6 +24,28 @@ func min(x, y int) int { return y } +// getConfigNameForHistory returns the appropriate name for the config used in a job history entry +func getConfigNameForHistory(history db.JobHistory, configs map[uint]db.TransferConfig) string { + // If ConfigID is set in the history record, use that to get the config name + if history.ConfigID > 0 { + if config, exists := configs[history.ConfigID]; exists { + return config.Name + } + } + + // Fallback to the Job's default Config if it exists + if history.Job.Config.ID > 0 { + return history.Job.Config.Name + } + + // If we can't determine the config name, show a default with the job name + if history.Job.Name != "" { + return fmt.Sprintf("%s (unknown config)", history.Job.Name) + } + + return "Unknown Configuration" +} + // HistoryContent renders only the content part of the history page for HTMX requests templ HistoryContent(ctx context.Context, data HistoryData) { if len(data.History) == 0 { @@ -47,7 +70,7 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
-

{ history.Job.Config.Name }

+

{ getConfigNameForHistory(history, data.Configs) }

if history.Status == "completed" { Completed diff --git a/components/job_form.templ b/components/job_form.templ index d2ff542..92ff20e 100644 --- a/components/job_form.templ +++ b/components/job_form.templ @@ -26,8 +26,59 @@ func getJobTitle(isNew bool) string { return "Edit Job" } +// configSelected checks if a config ID is selected for a job +func configSelected(job *db.Job, configID uint) bool { + if job.ConfigIDs != "" { + // If ConfigIDs is populated, only check against those IDs + for _, id := range job.GetConfigIDsList() { + if id == configID { + return true + } + } + return false + } else { + // If ConfigIDs is empty, fall back to checking the primary ConfigID + return job.ConfigID == configID + } +} + +templ configSearchScript() { + +} + templ JobForm(ctx context.Context, data JobFormData) { @LayoutWithContext(getJobFormTitle(data.IsNew), ctx) { + @configSearchScript()
@@ -47,11 +98,7 @@ templ JobForm(ctx context.Context, data JobFormData) { class="space-y-6" hx-post="/jobs" hx-target="body" - hx-boost="true" - @htmx:before-request="loading = true" - @htmx:after-request="loading = false" - @htmx:response-error="$dispatch('notification', { message: 'Failed to create job: ' + event.detail.xhr.responseText, type: 'error' })" - x-data="{ name: '', configId: '', schedule: '', enabled: true, loading: false, validate() { return this.configId && this.schedule; } }"> + hx-boost="true">
@@ -63,7 +110,6 @@ templ JobForm(ctx context.Context, data JobFormData) { type="text" name="name" id="name" - x-model="name" class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500" placeholder="Daily Production Backup"/>
@@ -74,23 +120,51 @@ templ JobForm(ctx context.Context, data JobFormData) {
- -
-
- + +
+ +
+
+
+ +
+ +
- + +
+ } + } else { +
+ No configurations available. Create one +
} - +
+ +

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

@@ -103,7 +177,6 @@ templ JobForm(ctx context.Context, data JobFormData) { type="text" name="schedule" id="schedule" - x-model="schedule" required class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500" placeholder="*/15 * * * *"/> @@ -119,10 +192,10 @@ templ JobForm(ctx context.Context, data JobFormData) { - + name="enabled" + value="true" + checked + class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>

@@ -130,6 +203,119 @@ templ JobForm(ctx context.Context, data JobFormData) { Disabled jobs will not run automatically.

+ + +
+

+ + Webhook Notifications +

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

+ + The URL where notifications will be sent when jobs run +

+
+ +
+ +
+
+ +
+ +
+

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

+
+ +
+ +
+
+ +
+ +
+

+ + Additional HTTP headers as JSON +

+
+ +
+
+ + +
+ +
+ + +
+
+
+
+
@@ -139,19 +325,9 @@ templ JobForm(ctx context.Context, data JobFormData) {
@@ -160,11 +336,7 @@ templ JobForm(ctx context.Context, data JobFormData) { class="space-y-6" hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) } hx-target="body" - hx-boost="true" - @htmx:before-request="loading = true" - @htmx:after-request="loading = false" - @htmx:response-error="$dispatch('notification', { message: 'Failed to update job: ' + event.detail.xhr.responseText, type: 'error' })" - x-data={ fmt.Sprintf("{ name: '%s', configId: '%d', schedule: '%s', enabled: %v, loading: false, validate() { return this.configId && this.schedule; } }", data.Job.Name, data.Job.ConfigID, data.Job.Schedule, data.Job.Enabled) }> + hx-boost="true">
@@ -176,7 +348,7 @@ templ JobForm(ctx context.Context, data JobFormData) { type="text" name="name" id="name" - x-model="name" + value={ data.Job.Name } class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500" placeholder="Daily Production Backup"/>
@@ -185,25 +357,56 @@ templ JobForm(ctx context.Context, data JobFormData) { Descriptive name for this job (optional). If not provided, the config name will be used.

- +
- -
-
- + +
+ +
+
+
+ +
+ +
- + +
+ } + } else { +
+ No configurations available. Create one +
} - +
+ +

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

@@ -216,7 +419,7 @@ templ JobForm(ctx context.Context, data JobFormData) { type="text" name="schedule" id="schedule" - x-model="schedule" + value={ data.Job.Schedule } required class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500" placeholder="*/15 * * * *"/> @@ -232,9 +435,12 @@ templ JobForm(ctx context.Context, data JobFormData) { -

@@ -242,6 +448,129 @@ templ JobForm(ctx context.Context, data JobFormData) { Disabled jobs will not run automatically.

+ + +
+

+ + Webhook Notifications +

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

+ + The URL where notifications will be sent when jobs run +

+
+ +
+ +
+
+ +
+ +
+

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

+
+ +
+ +
+
+ +
+ +
+

+ + Additional HTTP headers as JSON +

+
+ +
+
+ + +
+ +
+ + +
+
+
+
+
@@ -251,19 +580,9 @@ templ JobForm(ctx context.Context, data JobFormData) {
diff --git a/components/job_run_details.templ b/components/job_run_details.templ index 48001f9..86daaa2 100644 --- a/components/job_run_details.templ +++ b/components/job_run_details.templ @@ -15,225 +15,177 @@ type JobRunDetailsData struct { templ JobRunDetails(ctx context.Context, data JobRunDetailsData) { @LayoutWithContext("Job Run Details", ctx) { -
-
- - -
-

- - Job Run Details -

-
- - -
-
-
-

- if data.Job.Name != "" { - { data.Job.Name } - } else { - { data.Config.Name } - } -

- if data.JobHistory.Status == "completed" { - - Completed - - } else if data.JobHistory.Status == "failed" { - - Failed - - } else { - - { data.JobHistory.Status } - - } -
- if data.Job.Name != "" && data.Job.Name != data.Config.Name { -

- Config: { data.Config.Name } -

+ @JobRunDetailsContent(ctx, data) + } +} + +// JobRunDetailsContent is the same as JobRunDetails but without the layout wrapper +// This is used for testing +templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) { +
+
+ + +
+

+ + Job Run Details +

+
+ + +
+
+
+

{ data.Job.Name }

+ if data.JobHistory.Status == "completed" { + + Completed + + } else if data.JobHistory.Status == "failed" { + + Failed + + } else { + + Running + }
- -
-
-
-
- Start Time -
-
- { data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") } -
-
- -
-
- End Time -
-
- if data.JobHistory.EndTime != nil { - { data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") } - } else { - Still running... - } -
-
- -
-
- Duration -
-
- if data.JobHistory.EndTime != nil { - { formatDuration(data.JobHistory.EndTime.Sub(data.JobHistory.StartTime)) } - } else { - { formatDuration(time.Since(data.JobHistory.StartTime)) } (ongoing) - } -
-
- -
-
- Data Transferred -
-
- { formatBytes(data.JobHistory.BytesTransferred) } -
-
- -
-
- Files Transferred -
-
- { fmt.Sprint(data.JobHistory.FilesTransferred) } -
-
- -
-
- Job Schedule -
-
- { data.Job.Schedule } -
-
-
-
+

Config: { data.Config.Name }

- - -
-
-

- - Transfer Configuration -

-
- -
-
-
-
Source Type
-
- - { data.Config.SourceType } - -
-
- -
-
Destination Type
-
- - { data.Config.DestinationType } - -
-
- -
-
Source Path
-
- if data.Config.SourceType == "sftp" { - { data.Config.SourceUser }{`@`}{ data.Config.SourceHost }{`:`}{ data.Config.SourcePath } - } else if data.Config.SourceType == "s3" || data.Config.SourceType == "minio" || data.Config.SourceType == "b2" { - { data.Config.SourceBucket }{`:`}{ data.Config.SourcePath } - } else { - { data.Config.SourcePath } - } -
-
- -
-
Destination Path
-
- if data.Config.DestinationType == "sftp" { - { data.Config.DestUser }{`@`}{ data.Config.DestHost }{`:`}{ data.Config.DestinationPath } - } else if data.Config.DestinationType == "s3" || data.Config.DestinationType == "minio" || data.Config.DestinationType == "b2" { - { data.Config.DestBucket }{`:`}{ data.Config.DestinationPath } - } else { - { data.Config.DestinationPath } - } -
-
- -
-
File Pattern
-
- { data.Config.FilePattern } -
-
- - if data.Config.ArchiveEnabled { -
-
Archive Path
-
- { data.Config.ArchivePath } -
-
- } -
-
-
- - - if data.JobHistory.ErrorMessage != "" { -
-
-

- - Error Details -

+
+
+
+
+ Start Time +
+
+ { data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") } +
- -
-
{ data.JobHistory.ErrorMessage }
+
+
+ End Time +
+
+ if data.JobHistory.EndTime != nil { + { data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") } + } else { + In progress + } +
-
- } - - -
- - - View All Jobs - - - - - Edit Job - +
+
+ Duration +
+
+ if data.JobHistory.EndTime != nil { + { data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() } + } else { + In progress + } +
+
+
+
+ Data Transferred +
+
+ { formatBytes(data.JobHistory.BytesTransferred) } +
+
+
+
+ Files Transferred +
+
+ { fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) } +
+
+
+
+ Job Schedule +
+
+ { data.Job.Schedule } +
+
+
+ + +
+
+

+ + Transfer Configuration +

+
+
+
+
+
Source Type
+
+ { data.Config.SourceType } +
+
+
+
Destination Type
+
+ { data.Config.DestinationType } +
+
+
+
Source Path
+
{ data.Config.SourcePath }
+
+
+
Destination Path
+
{ data.Config.DestinationPath }
+
+
+
File Pattern
+
{ data.Config.FilePattern }
+
+
+
+
+ + + if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" { +
+
+

+ + Error Information +

+
+
+
+
{ data.JobHistory.ErrorMessage }
+
+
+
+ } + + +
- } +
} // formatDuration formats a duration in a human-readable way diff --git a/components/jobs.templ b/components/jobs.templ index 3167d1f..aec5f3c 100644 --- a/components/jobs.templ +++ b/components/jobs.templ @@ -70,7 +70,8 @@ script triggerJobDelete(dialogId string, jobID uint, jobName string) { } type JobsData struct { - Jobs []db.Job + Jobs []db.Job + ConfigCount map[uint]int // Maps job ID to number of configs } templ Jobs(ctx context.Context, data JobsData) { @@ -334,8 +335,17 @@ templ Jobs(ctx context.Context, data JobsData) {

- - Config: { job.Config.Name } + + Configs: + + if count, ok := data.ConfigCount[job.ID]; ok && count > 1 { + { fmt.Sprintf("%d configurations", count) } + } else if job.ConfigID > 0 { + { job.Config.Name } + } else { + { "None" } + } +

diff --git a/components/providers/common/common.templ b/components/providers/common/common.templ index 5e51803..5249708 100644 --- a/components/providers/common/common.templ +++ b/components/providers/common/common.templ @@ -141,6 +141,21 @@ templ ArchiveOptions() { Files with the same hash that have been successfully processed before will be skipped

+ +
+ +
+ + +
+

+ Number of files to transfer simultaneously (higher values may improve performance but increase resource usage) +

+
} diff --git a/components/providers/destination/ftp.templ b/components/providers/destination/ftp.templ index 391b7bb..9be0242 100644 --- a/components/providers/destination/ftp.templ +++ b/components/providers/destination/ftp.templ @@ -31,6 +31,9 @@ templ FTPDestinationForm() { id="dest_port" x-model="destPort" required + min="1" + max="65535" + value="21" class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500" placeholder="21"/>
diff --git a/components/providers/destination/local.templ b/components/providers/destination/local.templ index deb7465..e88ac2a 100644 --- a/components/providers/destination/local.templ +++ b/components/providers/destination/local.templ @@ -14,9 +14,13 @@ templ LocalDestinationForm() { id="destination_path" x-model="destinationPath" required + aria-describedby="destination_path_help" class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500" placeholder="/path/to/destination"/>
+

+ Absolute path to the local directory where files will be saved. +

} \ No newline at end of file diff --git a/components/providers/destination/s3.templ b/components/providers/destination/s3.templ index 90ba488..bcf2475 100644 --- a/components/providers/destination/s3.templ +++ b/components/providers/destination/s3.templ @@ -48,7 +48,7 @@ templ S3DestinationForm() { id="destination_path" x-model="destinationPath" class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500" - placeholder="path/prefix/"/> + placeholder="optional/path/prefix/"/>

Optional. If specified, files will be uploaded to this path in the bucket. diff --git a/components/providers/destination/sftp.templ b/components/providers/destination/sftp.templ index e224d59..6fde4d2 100644 --- a/components/providers/destination/sftp.templ +++ b/components/providers/destination/sftp.templ @@ -14,9 +14,13 @@ templ SFTPDestinationForm() { id="dest_host" x-model="destHost" required + aria-describedby="dest_host_help" class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500" placeholder="sftp.example.com"/>

+

+ Enter the SFTP server hostname or IP address. +

@@ -31,9 +35,37 @@ templ SFTPDestinationForm() { id="dest_port" x-model="destPort" required + min="1" + max="65535" + value="22" + aria-describedby="dest_port_help" class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500" placeholder="22"/>
+

+ Default SFTP port is 22. +

+
+ +
+ +
+
+ +
+ +
+

+ Absolute path to the files on the remote server. +

@@ -48,71 +80,68 @@ templ SFTPDestinationForm() { id="dest_user" x-model="destUser" required + aria-describedby="dest_user_help" class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
+

+ Username for SFTP authentication. +

-
-
- + +
+
+ +
+
- -
- -
-
- + +
- +

+ Absolute path to SSH private key file. +

+
} \ No newline at end of file diff --git a/components/providers/provider_form.templ b/components/providers/provider_form.templ new file mode 100644 index 0000000..9f74e29 --- /dev/null +++ b/components/providers/provider_form.templ @@ -0,0 +1,191 @@ +package providers + +import ( + "fmt" + "strings" + + "github.com/starfleetcptn/gomft/components/providers/common" + "github.com/starfleetcptn/gomft/components/providers/source" + "github.com/starfleetcptn/gomft/components/providers/destination" +) + +// Returns the form ID based on the form type and whether it's a source or destination +func formID(formType string, isSource bool) string { + if isSource { + return "source_config_form" + } + return "destination_config_form" +} + +// Returns a user-friendly display name for the provider +func providerDisplayName(provider string) string { + switch provider { + case "sftp": + return "SFTP" + case "local": + return "Local Filesystem" + case "s3": + return "Amazon S3" + case "ftp": + return "FTP" + case "azure": + return "Azure Blob Storage" + default: + return strings.Title(provider) + } +} + +templ ProviderForm(formType string, providers []string, isSource bool) { +
+ +
+ @common.NameField() + +
+ +
+
+ +
+ +
+
+ +
+ if isSource { + @source.SFTPSourceForm() + } else { + @destination.SFTPDestinationForm() + } +
+ +
+ if isSource { + @source.LocalSourceForm() + } else { + @destination.LocalDestinationForm() + } +
+ +
+ if isSource { + @source.S3SourceForm() + } else { + @destination.S3DestinationForm() + } +
+ +
+ if isSource { + @source.FTPSourceForm() + } else { + @destination.FTPDestinationForm() + } +
+ +
+
+ +
+ +
+
+ @common.FilePatternFields() + if isSource { + @common.ArchiveOptions() + } +
+
+
+
+
+} + +script formAlpineInit() { + return { + initProviderForm() { + // Initialize with values if editing existing config + if (window.editData && window.editData.configs) { + const config = window.editData.configs.find(c => + isSource ? (c.id === window.editData.source_config_id) : (c.id === window.editData.destination_config_id) + ); + + if (config) { + this[formType + 'Provider'] = config.provider; + this.name = config.name; + + // Provider-specific fields + if (config.provider === 'sftp') { + this.host = config.host; + this.port = config.port; + this.username = config.username; + this.path = config.path; + + if (config.key_file && config.key_file !== '') { + this.authType = 'key_file'; + this.keyFile = config.key_file; + } else { + this.authType = 'password'; + // Password is not included in edit data for security + } + } else if (config.provider === 'local') { + this.path = config.path; + } else if (config.provider === 's3') { + this.bucket = config.bucket; + this.region = config.region; + this.path = config.path; + this.accessKey = config.access_key; + + if (config.endpoint && config.endpoint !== '') { + this.useCustomEndpoint = true; + this.endpoint = config.endpoint; + } else { + this.useCustomEndpoint = false; + } + } else if (config.provider === 'ftp') { + this.host = config.host; + this.port = config.port; + this.username = config.username; + this.path = config.path; + this.useFTPS = config.use_ftps; + } + + // Advanced options + if (config.include_pattern) this.filePattern = config.include_pattern; + if (config.exclude_pattern) this.excludePattern = config.exclude_pattern; + + if (isSource && config.extract_archives) { + this.extractArchives = true; + this.deleteArchives = config.delete_archives; + } + } + } + }, + + providerChanged() { + console.log("Provider changed to: " + this[formType + 'Provider']); + } + }; +} \ No newline at end of file diff --git a/components/providers/providers_test.go b/components/providers/providers_test.go new file mode 100644 index 0000000..d569c73 --- /dev/null +++ b/components/providers/providers_test.go @@ -0,0 +1,357 @@ +package providers + +import ( + "context" + "strings" + "testing" + + "github.com/starfleetcptn/gomft/components/providers/common" + "github.com/starfleetcptn/gomft/components/providers/destination" + "github.com/starfleetcptn/gomft/components/providers/source" + "github.com/stretchr/testify/assert" +) + +// Test that both source and destination providers can be rendered together with common components +func TestProvidersIntegration(t *testing.T) { + // Create context for test + ctx := context.Background() + assert := assert.New(t) + + // Test rendering common components + { + var buf strings.Builder + err := common.NameField().Render(ctx, &buf) + assert.NoError(err, "Failed to render NameField") + html := buf.String() + assert.Contains(html, `