From c3a22333f7324b25b71b058f73157422fb206873 Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Sat, 8 Mar 2025 14:12:43 -0800 Subject: [PATCH 1/4] chore: Add components package marker file - Create components.go to ensure proper Go package recognition - Update .gitignore to exclude all Go files except components.go --- .gitignore | 3 ++- components/components.go | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 components/components.go diff --git a/.gitignore b/.gitignore index f1397c3..b6f91ad 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ Thumbs.db # Ignore all Go files in the components directory components/*.go +!components/components.go # Ignore the data directory data/ @@ -60,4 +61,4 @@ destination/ archive/ # Ignore binaries -gomft \ No newline at end of file +gomft diff --git a/components/components.go b/components/components.go new file mode 100644 index 0000000..905b4ca --- /dev/null +++ b/components/components.go @@ -0,0 +1,3 @@ +// Package components contains the UI components for the GoMFT application. +// This file serves as a marker for the components package to ensure it's properly recognized by Go. +package components \ No newline at end of file From 1b1d043d50873b99de024dced7ae89c9a1eef99f Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Sat, 8 Mar 2025 15:11:47 -0800 Subject: [PATCH 2/4] feat: Add support for WebDAV, NextCloud, OneDrive, and Google Drive storage - Update config_form.templ to include form fields for new storage types - Modify db.go to support generating rclone configs for WebDAV, NextCloud, OneDrive, and Google Drive - Add migration to include new cloud storage fields in transfer_configs table - Update migrations.go to include new cloud storage migration --- components/config_form.templ | 604 +++++++++++++++++- internal/db/db.go | 144 +++++ .../db/migrations/add_cloud_storage_fields.go | 63 ++ internal/db/migrations/migrations.go | 3 +- 4 files changed, 808 insertions(+), 6 deletions(-) create mode 100644 internal/db/migrations/add_cloud_storage_fields.go diff --git a/components/config_form.templ b/components/config_form.templ index 0e9341e..4d49ac4 100644 --- a/components/config_form.templ +++ b/components/config_form.templ @@ -38,6 +38,11 @@ func getInitialData(config *db.TransferConfig) string { sourceDomain := "" // FTP fields sourcePassiveMode := true + // OneDrive and Google Drive fields + sourceClientId := "" + sourceClientSecret := "" + sourceDriveId := "" + sourceTeamDrive := "" filePattern := "*" outputPattern := "" destinationType := "local" @@ -58,6 +63,11 @@ func getInitialData(config *db.TransferConfig) string { destDomain := "" // FTP fields destPassiveMode := true + // OneDrive and Google Drive fields + destClientId := "" + destClientSecret := "" + destDriveId := "" + destTeamDrive := "" archivePath := "" archiveEnabled := false deleteAfterTransfer := false @@ -83,6 +93,11 @@ func getInitialData(config *db.TransferConfig) string { sourceDomain = config.SourceDomain // FTP fields sourcePassiveMode = config.SourcePassiveMode + // OneDrive and Google Drive fields + sourceClientId = config.SourceClientID + sourceClientSecret = config.SourceClientSecret + sourceDriveId = config.SourceDriveID + sourceTeamDrive = config.SourceTeamDrive filePattern = config.FilePattern outputPattern = config.OutputPattern destinationType = config.DestinationType @@ -103,6 +118,11 @@ func getInitialData(config *db.TransferConfig) string { destDomain = config.DestDomain // FTP fields destPassiveMode = config.DestPassiveMode + // OneDrive and Google Drive fields + destClientId = config.DestClientID + destClientSecret = config.DestClientSecret + destDriveId = config.DestDriveID + destTeamDrive = config.DestTeamDrive archivePath = config.ArchivePath archiveEnabled = config.ArchiveEnabled deleteAfterTransfer = config.DeleteAfterTransfer @@ -126,6 +146,10 @@ func getInitialData(config *db.TransferConfig) string { sourceShare: '%s', sourceDomain: '%s', sourcePassiveMode: %v, + sourceClientId: '%s', + sourceClientSecret: '%s', + sourceDriveId: '%s', + sourceTeamDrive: '%s', filePattern: '%s', outputPattern: '%s', destinationType: '%s', @@ -146,12 +170,18 @@ func getInitialData(config *db.TransferConfig) string { destDomain: '%s', // FTP fields destPassiveMode: %v, + // OneDrive and Google Drive fields + destClientId: '%s', + destClientSecret: '%s', + destDriveId: '%s', + destTeamDrive: '%s', // Existing fields archivePath: '%s', archiveEnabled: %v, deleteAfterTransfer: %v, rcloneFlags: '%s', loading: false, + validate() { if (this.sourceType === 'sftp') { if (!this.sourceHost || !this.sourceUser || (!this.sourcePassword && !this.sourceKeyFile)) { @@ -177,6 +207,22 @@ func getInitialData(config *db.TransferConfig) string { if (!this.sourceHost || !this.sourceUser || !this.sourcePassword) { return false; } + } else if (this.sourceType === 'webdav') { + if (!this.sourceHost || !this.sourceUser || !this.sourcePassword) { + return false; + } + } else if (this.sourceType === 'nextcloud') { + if (!this.sourceHost || !this.sourceUser || !this.sourcePassword) { + return false; + } + } else if (this.sourceType === 'onedrive') { + if (!this.sourceClientId || !this.sourceClientSecret) { + return false; + } + } else if (this.sourceType === 'google_drive') { + if (!this.sourceClientId || !this.sourceClientSecret) { + return false; + } } if (this.destinationType === 'sftp') { @@ -203,15 +249,33 @@ func getInitialData(config *db.TransferConfig) string { if (!this.destHost || !this.destUser || !this.destPassword) { return false; } + } else if (this.destinationType === 'webdav') { + if (!this.destHost || !this.destUser || !this.destPassword) { + return false; + } + } else if (this.destinationType === 'nextcloud') { + if (!this.destHost || !this.destUser || !this.destPassword) { + return false; + } + } else if (this.destinationType === 'onedrive') { + if (!this.destClientId || !this.destClientSecret) { + return false; + } + } else if (this.destinationType === 'google_drive') { + if (!this.destClientId || !this.destClientSecret) { + return false; + } } return this.name && this.sourcePath && this.filePattern && this.destinationPath && (!this.archiveEnabled || this.archivePath); } }`, name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode, + sourceClientId, sourceClientSecret, sourceDriveId, sourceTeamDrive, filePattern, outputPattern, destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode, + destClientId, destClientSecret, destDriveId, destTeamDrive, archivePath, archiveEnabled, deleteAfterTransfer, rcloneFlags) } @@ -284,6 +348,10 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { + + + + @@ -728,6 +796,268 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { + + + + + + + +
@@ -814,6 +1144,10 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { + + + +
@@ -1258,15 +1592,277 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { + + + + + + + +
-
-

Location to move files after successful transfer

@@ -1339,7 +1934,6 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { 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="--transfers 4 --checkers 8"/> -

Optional: Additional rclone flags for fine-tuning the transfer.

diff --git a/internal/db/db.go b/internal/db/db.go index c02adb9..1779f94 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -66,6 +66,11 @@ type TransferConfig struct { SourceDomain string `form:"source_domain"` // FTP source fields SourcePassiveMode bool `gorm:"default:true" form:"source_passive_mode"` + // OneDrive and Google Drive source fields + SourceClientID string `form:"source_client_id"` + SourceClientSecret string `form:"source_client_secret" gorm:"-"` // Not stored in DB, only used for form + SourceDriveID string `form:"source_drive_id"` // For OneDrive + SourceTeamDrive string `form:"source_team_drive"` // For Google Drive // General fields FilePattern string `gorm:"default:'*'" form:"file_pattern"` OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables @@ -87,6 +92,11 @@ type TransferConfig struct { DestDomain string `form:"dest_domain"` // FTP destination fields DestPassiveMode bool `gorm:"default:true" form:"dest_passive_mode"` + // OneDrive and Google Drive destination fields + DestClientID string `form:"dest_client_id"` + DestClientSecret string `form:"dest_client_secret" gorm:"-"` // Not stored in DB, only used for form + DestDriveID string `form:"dest_drive_id"` // For OneDrive + DestTeamDrive string `form:"dest_team_drive"` // For Google Drive // General fields ArchivePath string `form:"archive_path"` ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"` @@ -426,6 +436,73 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error { args = append(args, "passive", "true") } + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output) + } + case "webdav": + args := []string{ + "config", "create", sourceName, "webdav", + "url", config.SourceHost, + "user", config.SourceUser, + "pass", config.SourcePassword, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output) + } + case "nextcloud": + args := []string{ + "config", "create", sourceName, "webdav", + "url", config.SourceHost, + "user", config.SourceUser, + "pass", config.SourcePassword, + "vendor", "nextcloud", + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output) + } + case "onedrive": + args := []string{ + "config", "create", sourceName, "onedrive", + "client_id", config.SourceClientID, + "client_secret", config.SourceClientSecret, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + + if config.SourceDriveID != "" { + args = append(args, "drive_id", config.SourceDriveID) + } + + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output) + } + case "google_drive": + args := []string{ + "config", "create", sourceName, "drive", + "client_id", config.SourceClientID, + "client_secret", config.SourceClientSecret, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + + if config.SourceTeamDrive != "" { + args = append(args, "team_drive", config.SourceTeamDrive) + } + cmd := exec.Command(rclonePath, args...) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to create source config: %v\nOutput: %s", err, output) @@ -547,6 +624,73 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error { args = append(args, "passive", "true") } + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output) + } + case "webdav": + args := []string{ + "config", "create", destName, "webdav", + "url", config.DestHost, + "user", config.DestUser, + "pass", config.DestPassword, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output) + } + case "nextcloud": + args := []string{ + "config", "create", destName, "webdav", + "url", config.DestHost, + "user", config.DestUser, + "pass", config.DestPassword, + "vendor", "nextcloud", + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output) + } + case "onedrive": + args := []string{ + "config", "create", destName, "onedrive", + "client_id", config.DestClientID, + "client_secret", config.DestClientSecret, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + + if config.DestDriveID != "" { + args = append(args, "drive_id", config.DestDriveID) + } + + cmd := exec.Command(rclonePath, args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output) + } + case "google_drive": + args := []string{ + "config", "create", destName, "drive", + "client_id", config.DestClientID, + "client_secret", config.DestClientSecret, + "--non-interactive", + "--config", configPath, + "--log-level", "ERROR", + } + + if config.DestTeamDrive != "" { + args = append(args, "team_drive", config.DestTeamDrive) + } + cmd := exec.Command(rclonePath, args...) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to create destination config: %v\nOutput: %s", err, output) diff --git a/internal/db/migrations/add_cloud_storage_fields.go b/internal/db/migrations/add_cloud_storage_fields.go new file mode 100644 index 0000000..3582df0 --- /dev/null +++ b/internal/db/migrations/add_cloud_storage_fields.go @@ -0,0 +1,63 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// AddCloudStorageFields adds fields for WebDAV, NextCloud, OneDrive, and Google Drive +func AddCloudStorageFields() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "add_cloud_storage_fields", + Migrate: func(tx *gorm.DB) error { + // Add source fields + if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_client_id VARCHAR(255)").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_drive_id VARCHAR(255)").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN source_team_drive VARCHAR(255)").Error; err != nil { + return err + } + + // Add destination fields + if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_client_id VARCHAR(255)").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_drive_id VARCHAR(255)").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE transfer_configs ADD COLUMN dest_team_drive VARCHAR(255)").Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + // Drop source fields + if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_client_id").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_drive_id").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN source_team_drive").Error; err != nil { + return err + } + + // Drop destination fields + if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_client_id").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_drive_id").Error; err != nil { + return err + } + if err := tx.Exec("ALTER TABLE transfer_configs DROP COLUMN dest_team_drive").Error; err != nil { + return err + } + + return nil + }, + } +} \ No newline at end of file diff --git a/internal/db/migrations/migrations.go b/internal/db/migrations/migrations.go index 31161b3..c7705a7 100644 --- a/internal/db/migrations/migrations.go +++ b/internal/db/migrations/migrations.go @@ -6,10 +6,11 @@ import ( ) // InitMigrations initializes the migrations -func InitMigrations() *gormigrate.Gormigrate { +func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate { migrations := []*gormigrate.Migration{ // ... existing migrations AddDeleteAfterTransferColumn(), + AddCloudStorageFields(), } return gormigrate.New(db, gormigrate.DefaultOptions, migrations) From 339299e08ea4fa9cdf55f5429e5712c8f91081a5 Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Sat, 8 Mar 2025 15:55:41 -0800 Subject: [PATCH 3/4] feat: Improve S3-compatible storage path handling in rclone operations - Update executeJob method to handle source and destination paths for S3, MinIO, and B2 - Add bucket support for size, list, transfer, and archive operations - Ensure correct path construction for different storage types - Remove duplicate error logging in archive operation --- internal/scheduler/scheduler.go | 64 +++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 5db0f9d..2f11262 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -161,8 +161,21 @@ func (s *Scheduler) executeJob(jobID uint) { "--config", configPath, "size", "--include", job.Config.FilePattern, - fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath), } + + // Add source path with bucket for S3-compatible storage + var sourceSizePath string + if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" { + sourceSizePath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourceBucket) + if job.Config.SourcePath != "" && job.Config.SourcePath != "/" { + sourceSizePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.SourcePath) + } + } else { + sourceSizePath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath) + } + + sizeArgs = append(sizeArgs, sourceSizePath) + // Get the rclone path from the environment variable or use the default path rclonePath := os.Getenv("RCLONE_PATH") if rclonePath == "" { @@ -202,9 +215,21 @@ func (s *Scheduler) executeJob(jobID uint) { "--config", configPath, "lsf", "--include", job.Config.FilePattern, - fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath), } + // Add source path with bucket for S3-compatible storage + var sourceLsPath string + if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" { + sourceLsPath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourceBucket) + if job.Config.SourcePath != "" && job.Config.SourcePath != "/" { + sourceLsPath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.SourcePath) + } + } else { + sourceLsPath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath) + } + + listArgs = append(listArgs, sourceLsPath) + fmt.Printf("Listing files for job %d: rclone %s\n", jobID, strings.Join(listArgs, " ")) // Get the rclone path from the environment variable or use the default path rclonePath := os.Getenv("RCLONE_PATH") @@ -247,8 +272,26 @@ func (s *Scheduler) executeJob(jobID uint) { } // Source and destination paths - sourcePath := fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourcePath, file) - destPath := fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, file) + var sourcePath, destPath string + + // For S3, MinIO, and B2, include the bucket in the path + if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" { + sourcePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourceBucket, file) + if job.Config.SourcePath != "" && job.Config.SourcePath != "/" { + sourcePath = fmt.Sprintf("source_%d:%s/%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.SourcePath, file) + } + } else { + sourcePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourcePath, file) + } + + if job.Config.DestinationType == "s3" || job.Config.DestinationType == "minio" || job.Config.DestinationType == "b2" { + destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestBucket, file) + if job.Config.DestinationPath != "" && job.Config.DestinationPath != "/" { + destPath = fmt.Sprintf("dest_%d:%s/%s/%s", job.Config.ID, job.Config.DestBucket, job.Config.DestinationPath, file) + } + } else { + destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, file) + } // Add output filename pattern if specified if job.Config.OutputPattern != "" { @@ -299,9 +342,18 @@ func (s *Scheduler) executeJob(jobID uint) { "--config", configPath, "copyto", sourcePath, - fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.ArchivePath, file), } + // Construct archive path with bucket if needed + var archiveDest string + if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" { + archiveDest = fmt.Sprintf("source_%d:%s/%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.ArchivePath, file) + } else { + archiveDest = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.ArchivePath, file) + } + + archiveArgs = append(archiveArgs, archiveDest) + fmt.Printf("Executing rclone archive command for job %d, file %s: rclone %s\n", jobID, file, strings.Join(archiveArgs, " ")) // Get the rclone path from the environment variable or use the default path @@ -320,8 +372,6 @@ func (s *Scheduler) executeJob(jobID uint) { fmt.Printf("Warning: Error archiving file %s for job %d: %v\n", file, jobID, archiveErr) transferErrors = append(transferErrors, fmt.Sprintf("Archive error for file %s: %v", file, archiveErr)) - transferErrors = append(transferErrors, - fmt.Sprintf("Archive error for file %s: %v", file, archiveErr)) } } if job.Config.DeleteAfterTransfer { From 6c96cdbec1ba2fb16495c2538cbe724ce984458e Mon Sep 17 00:00:00 2001 From: StarFleetCPTN Date: Sat, 8 Mar 2025 16:49:52 -0800 Subject: [PATCH 4/4] chore: Remove OneDrive and Google Drive storage options from config form - Comment out OneDrive and Google Drive options in source and destination storage type selects - Remove corresponding form templates for OneDrive and Google Drive authentication - Temporarily disable these cloud storage options from the configuration interface --- components/config_form.templ | 580 +++++++++++++++++------------------ 1 file changed, 290 insertions(+), 290 deletions(-) diff --git a/components/config_form.templ b/components/config_form.templ index 4d49ac4..a38525e 100644 --- a/components/config_form.templ +++ b/components/config_form.templ @@ -350,8 +350,8 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { - - + // + // @@ -907,156 +907,156 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { - -
@@ -1146,8 +1146,8 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { - - + // + //
@@ -1703,156 +1703,156 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) { - -