diff --git a/.gitignore b/.gitignore index b6f91ad..8257975 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,22 @@ Thumbs.db components/*.go !components/components.go +components/providers/source/*.go +!components/providers/source/source.go + +components/providers/destination/*.go +!components/providers/destination/destination.go + +components/providers/common/*.go +!components/providers/common/common.go + # Ignore the data directory data/ +# Ignore .env files +.env +*.env + # Ignore the tmp directory tmp/ @@ -56,9 +69,10 @@ tmp/ configs/ # Ignore Dirs -source/ -destination/ -archive/ +/source/ +/destination/ + +/archive/ # Ignore binaries gomft diff --git a/README.md b/README.md index d89bc94..fb1a042 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging ![Job Scheduling](screenshots/new.job.gomft.png) *Scheduling transfers with flexible cron expressions* +### File Metadata +![File Metadata](screenshots/file.metadata.gomft.png) +*Track and manage file metadata with detailed information about transferred files* + ### User Management ![User Management](screenshots/user.management.gomft.png) *Create user accounts and manage them* @@ -29,7 +33,6 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging - **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers: - Amazon S3 - MinIO - - Backblaze B2 - NextCloud - WebDAV - SFTP @@ -39,6 +42,16 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging - And more via rclone - **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: + - Process status (processed, archived, deleted) + - File size and hash information + - Advanced search and filtering capabilities + - Metadata retention for compliance and auditing + - Detailed file view with processing timestamps and job association + - Powerful filtering by status, filename, job, and date ranges + - Advanced search interface with multiple criteria + - Bulk management and record deletion capabilities + - Responsive design with mobile-friendly interface - **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 @@ -100,7 +113,7 @@ docker run -d \ #### Docker Compose Example -For production deployments, you can use Docker Compose: +For production deployments, you can use Docker Compose with environment variables: ```yaml version: '3' @@ -116,6 +129,39 @@ services: - ./backups:/app/data/gomft/backups environment: - TZ=UTC + - SERVER_ADDRESS=:8080 + - DATA_DIR=/app/data/gomft + - BACKUP_DIR=/app/data/gomft/backups + - JWT_SECRET=change_this_to_a_secure_random_string + - BASE_URL=http://localhost:8080 + - EMAIL_ENABLED=true + - EMAIL_HOST=smtp.example.com + - EMAIL_PORT=587 + - EMAIL_FROM_EMAIL=gomft@example.com + - EMAIL_FROM_NAME=GoMFT + - EMAIL_ENABLE_TLS=true + - EMAIL_REQUIRE_AUTH=true + - EMAIL_USERNAME=smtp_username + - EMAIL_PASSWORD=smtp_password +``` + +Alternatively, you can mount your own .env file to the container: + +```yaml +version: '3' +services: + gomft: + image: starfleetcptn/gomft:latest + container_name: gomft + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./data:/app/data + - ./backups:/app/data/gomft/backups + - ./.env:/app/.env + environment: + - TZ=UTC ``` Save this as `docker-compose.yml` and run: @@ -128,48 +174,46 @@ For more information and available tags, visit the [GoMFT Docker Hub page](https ## Configuration -GoMFT uses a configuration file located at `./data/gomft/config.json`. On first run, a default configuration will be created: +GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created: -```json -{ - "server_address": ":8080", - "data_dir": "./data/gomft", - "backup_dir": "./data/gomft/backups", - "jwt_secret": "your-secret-key", - "base_url": "http://localhost:8080", - "email": { - "enabled": false, - "host": "smtp.example.com", - "port": 587, - "username": "user@example.com", - "password": "your-password", - "from_email": "gomft@example.com", - "from_name": "GoMFT", - "reply_to": "", - "enable_tls": true, - "require_auth": true - } -} +``` +SERVER_ADDRESS=:8080 +DATA_DIR=./data/gomft +BACKUP_DIR=./data/gomft/backups +JWT_SECRET=change_this_to_a_secure_random_string +BASE_URL=http://localhost:8080 + +# Email configuration +EMAIL_ENABLED=true +EMAIL_HOST=smtp.example.com +EMAIL_PORT=587 +EMAIL_FROM_EMAIL=gomft@example.com +EMAIL_FROM_NAME=GoMFT +EMAIL_REPLY_TO= +EMAIL_ENABLE_TLS=true +EMAIL_REQUIRE_AUTH=true +EMAIL_USERNAME=smtp_username +EMAIL_PASSWORD=smtp_password ``` ### Configuration Options -- `server_address`: The address and port to run the server on -- `data_dir`: Directory for storing application data -- `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) -- `email`: Email configuration settings for system notifications and password resets - - `enabled`: Set to `true` to enable email functionality - - `host`: SMTP server hostname - - `port`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure) - - `username`: Username for SMTP authentication - - `password`: Password for SMTP authentication - - `from_email`: Email address used as sender - - `from_name`: Name displayed as the sender - - `reply_to`: Optional reply-to email address - - `enable_tls`: Set to `true` to use TLS for secure email transmission - - `require_auth`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication +- `SERVER_ADDRESS`: The address and port to run the server on +- `DATA_DIR`: Directory for storing application data +- `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) +- Email configuration settings for system notifications and password resets: + - `EMAIL_ENABLED`: Set to `true` to enable email functionality + - `EMAIL_HOST`: SMTP server hostname + - `EMAIL_PORT`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure) + - `EMAIL_USERNAME`: Username for SMTP authentication + - `EMAIL_PASSWORD`: Password for SMTP authentication + - `EMAIL_FROM_EMAIL`: Email address used as sender + - `EMAIL_FROM_NAME`: Name displayed as the sender + - `EMAIL_REPLY_TO`: Optional reply-to email address + - `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 ## Usage @@ -201,6 +245,14 @@ GoMFT uses a configuration file located at `./data/gomft/config.json`. On first - Check detailed transfer history with performance metrics - View job run details including any error messages +7. 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 + - Use the advanced search page for complex queries with multiple criteria + - Delete file metadata records when no longer needed + - View files associated with specific jobs by navigating from the job details + ### User Management GoMFT uses a role-based access control system: @@ -262,9 +314,9 @@ GoMFT supports email notifications for various features: To configure email functionality: -1. Edit the `config.json` file and provide your SMTP server details -2. Set `"enabled": true` in the email configuration section -3. Ensure the `base_url` setting is configured correctly for your deployment +1. Edit the `.env` file and provide your SMTP server details +2. Set `EMAIL_ENABLED=true` in the email configuration section +3. Ensure the `BASE_URL` setting is configured correctly for your deployment ## Development diff --git a/components/config_form.templ b/components/config_form.templ index a38525e..5d28504 100644 --- a/components/config_form.templ +++ b/components/config_form.templ @@ -4,6 +4,9 @@ import ( "fmt" "github.com/starfleetcptn/gomft/internal/db" "context" + "github.com/starfleetcptn/gomft/components/providers/source" + "github.com/starfleetcptn/gomft/components/providers/destination" + "github.com/starfleetcptn/gomft/components/providers/common" ) type ConfigFormData struct { @@ -19,6 +22,7 @@ func getConfigFormTitle(isNew bool) string { } func getInitialData(config *db.TransferConfig) string { + // Default values for a new configuration name := "" sourceType := "local" sourcePath := "" @@ -27,24 +31,23 @@ func getInitialData(config *db.TransferConfig) string { sourceUser := "" sourcePassword := "" sourceKeyFile := "" - // S3 fields + sourceAuthType := "password" sourceBucket := "" sourceRegion := "" sourceAccessKey := "" sourceSecretKey := "" sourceEndpoint := "" - // SMB fields sourceShare := "" sourceDomain := "" - // FTP fields - sourcePassiveMode := true - // OneDrive and Google Drive fields + sourcePassiveMode := false sourceClientId := "" sourceClientSecret := "" sourceDriveId := "" sourceTeamDrive := "" - filePattern := "*" - outputPattern := "" + + filePattern := "" + outputPattern := "${filename}" + destinationType := "local" destinationPath := "" destHost := "" @@ -52,27 +55,27 @@ func getInitialData(config *db.TransferConfig) string { destUser := "" destPassword := "" destKeyFile := "" - // S3 fields + destAuthType := "password" destBucket := "" destRegion := "" destAccessKey := "" destSecretKey := "" destEndpoint := "" - // SMB fields destShare := "" destDomain := "" - // FTP fields - destPassiveMode := true - // OneDrive and Google Drive fields + destPassiveMode := false destClientId := "" destClientSecret := "" destDriveId := "" destTeamDrive := "" + archivePath := "" archiveEnabled := false deleteAfterTransfer := false + skipProcessedFiles := true rcloneFlags := "" + // If editing an existing config, populate with those values if config != nil { name = config.Name sourceType = config.SourceType @@ -82,24 +85,25 @@ func getInitialData(config *db.TransferConfig) string { sourceUser = config.SourceUser sourcePassword = config.SourcePassword sourceKeyFile = config.SourceKeyFile - // S3 fields + if sourceKeyFile != "" && sourcePassword == "" { + sourceAuthType = "key" + } sourceBucket = config.SourceBucket sourceRegion = config.SourceRegion sourceAccessKey = config.SourceAccessKey sourceSecretKey = config.SourceSecretKey sourceEndpoint = config.SourceEndpoint - // SMB fields sourceShare = config.SourceShare 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 destinationPath = config.DestinationPath destHost = config.DestHost @@ -107,28 +111,30 @@ func getInitialData(config *db.TransferConfig) string { destUser = config.DestUser destPassword = config.DestPassword destKeyFile = config.DestKeyFile - // S3 fields + if destKeyFile != "" && destPassword == "" { + destAuthType = "key" + } destBucket = config.DestBucket destRegion = config.DestRegion destAccessKey = config.DestAccessKey destSecretKey = config.DestSecretKey destEndpoint = config.DestEndpoint - // SMB fields destShare = config.DestShare 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 + skipProcessedFiles = config.SkipProcessedFiles rcloneFlags = config.RcloneFlags } + // Return the JSON-formatted string with all the data return fmt.Sprintf(`{ name: '%s', sourceType: '%s', @@ -138,6 +144,7 @@ func getInitialData(config *db.TransferConfig) string { sourceUser: '%s', sourcePassword: '%s', sourceKeyFile: '%s', + sourceAuthType: '%s', sourceBucket: '%s', sourceRegion: '%s', sourceAccessKey: '%s', @@ -150,8 +157,10 @@ func getInitialData(config *db.TransferConfig) string { sourceClientSecret: '%s', sourceDriveId: '%s', sourceTeamDrive: '%s', + filePattern: '%s', outputPattern: '%s', + destinationType: '%s', destinationPath: '%s', destHost: '%s', @@ -159,1825 +168,187 @@ func getInitialData(config *db.TransferConfig) string { destUser: '%s', destPassword: '%s', destKeyFile: '%s', - // S3 fields + destAuthType: '%s', destBucket: '%s', destRegion: '%s', destAccessKey: '%s', destSecretKey: '%s', destEndpoint: '%s', - // SMB fields destShare: '%s', 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, + skipProcessedFiles: %v, rcloneFlags: '%s', - loading: false, - - validate() { - if (this.sourceType === 'sftp') { - if (!this.sourceHost || !this.sourceUser || (!this.sourcePassword && !this.sourceKeyFile)) { - return false; - } - } else if (this.sourceType === 's3') { - if (!this.sourceBucket || !this.sourceRegion || !this.sourceAccessKey || !this.sourceSecretKey) { - return false; - } - } else if (this.sourceType === 'minio') { - if (!this.sourceBucket || !this.sourceEndpoint || !this.sourceAccessKey || !this.sourceSecretKey) { - return false; - } - } else if (this.sourceType === 'b2') { - if (!this.sourceBucket || !this.sourceAccessKey || !this.sourceSecretKey) { - return false; - } - } else if (this.sourceType === 'smb') { - if (!this.sourceHost || !this.sourceShare || !this.sourceUser || !this.sourcePassword) { - return false; - } - } else if (this.sourceType === 'ftp') { - 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') { - if (!this.destHost || !this.destUser || (!this.destPassword && !this.destKeyFile)) { - return false; - } - } else if (this.destinationType === 's3') { - if (!this.destBucket || !this.destRegion || !this.destAccessKey || !this.destSecretKey) { - return false; - } - } else if (this.destinationType === 'minio') { - if (!this.destBucket || !this.destEndpoint || !this.destAccessKey || !this.destSecretKey) { - return false; - } - } else if (this.destinationType === 'b2') { - if (!this.destBucket || !this.destAccessKey || !this.destSecretKey) { - return false; - } - } else if (this.destinationType === 'smb') { - if (!this.destHost || !this.destShare || !this.destUser || !this.destPassword) { - return false; - } - } else if (this.destinationType === 'ftp') { - 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) + }`, + name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType, + sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode, + sourceClientId, sourceClientSecret, sourceDriveId, sourceTeamDrive, + filePattern, outputPattern, + 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) } templ ConfigForm(ctx context.Context, data ConfigFormData) { @LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) { -
-
-
-
-
-
- -
-

- if data.IsNew { - Create New Configuration - } else { - Edit Configuration: { data.Config.Name } - } -

-

Set up your file transfer configuration

-
- -
-
-
-
-
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- - - - - - - - - - - - - - - - - - // - - // - -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-

Pattern to match files for transfer (e.g., *.txt, *.csv)

-
- -
- -
-
- -
- -
-

- Pattern for output filenames. Available variables: -
- {`${filename}`} - Original filename without extension (e.g., "report") -
- {`${ext}`} - Original file extension (e.g., ".csv") -
- {`${date:format}`} - Current date using Go's time format: -
- 2006-01-02 → YYYY-MM-DD -
- 20060102 → YYYYMMDD -
- 2006-01-02_15:04:05 → YYYY-MM-DD_HH:MM:SS -
- Example: {`${filename}_[date:2006-01-02]_${ext}`} → "report_2025-03-01.csv" -

-
- -
- -
-
- -
- -
-
- - - - - - - - - - - - - - - - - - // - - // - -
- -
-
- -
- -
-
- -
-
- - - -
-
- -
-
- -
- -
-
-
- -
-
- - - -
-

- - Original files will be permanently deleted from source after successful transfer - and archiving -

-
- -
- -
-
- -
- -
-
-
-
-
- -
-
- - - Cancel - - -
-
-
-
- -
-

- - Configure your file transfer settings carefully for optimal performance -

-
+
+
+
+
+
+ +

+ { getConfigFormTitle(data.IsNew) } +

+

+ Set up your file transfer configuration +

+ +
+ + + @common.NameField() + + + @common.SourceSelection() + + + + + + + + + + + + + + + + + + + + + @common.FilePatternFields() + + + @common.DestinationSelection() + + + + + + + + + + + + + + + + + + + + + @common.ArchiveOptions() + + + @common.RcloneFlags() + + +
+ + Cancel + + +
+
+ +
+

Configure your file transfer settings carefully for optimal performance

+
+
} -} \ No newline at end of file +} \ No newline at end of file diff --git a/components/file_metadata.templ b/components/file_metadata.templ new file mode 100644 index 0000000..453b52e --- /dev/null +++ b/components/file_metadata.templ @@ -0,0 +1,1209 @@ +package components + +import ( + "context" + "strconv" + "fmt" + "github.com/starfleetcptn/gomft/internal/db" +) + +// FileMetadataFilter represents filter parameters for file metadata queries +type FileMetadataFilter struct { + Status string + JobID string + FileName string + Hash string + StartDate string + EndDate string +} + +// FileMetadataListData contains data for the file metadata list template +type FileMetadataListData struct { + Files []db.FileMetadata + TotalCount int64 + Page int + Limit int + TotalPages int + Job *db.Job // Optional: if viewing files for a specific job + Filter FileMetadataFilter +} + +// FileMetadataDetailsData contains data for the file metadata details template +type FileMetadataDetailsData struct { + File db.FileMetadata +} + +// FileMetadataSearchData contains data for the file metadata search template +type FileMetadataSearchData struct { + Files []db.FileMetadata + TotalCount int64 + Page int + Limit int + TotalPages int + Filter FileMetadataFilter +} + +// getStatusBadgeClass returns the appropriate CSS class for a file status badge +func getStatusBadgeClass(status string) string { + switch status { + case "processed": + return "badge-success" + case "archived": + return "badge-info" + case "deleted": + return "badge-warning" + case "archived_and_deleted": + return "badge-warning" + case "error": + return "badge-danger" + default: + return "badge-secondary" + } +} + +// formatFileSize formats a file size in bytes to a human-readable string +func formatFileSize(size int64) string { + if size < 1024 { + return fmt.Sprintf("%d B", size) + } else if size < 1024*1024 { + return fmt.Sprintf("%.2f KB", float64(size)/1024) + } else if size < 1024*1024*1024 { + return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024)) + } else { + return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024)) + } +} + +// Dialog component for confirmation dialogs +templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) { + +} + +script hideFileDialog(id string) { + document.getElementById(id).classList.add("hidden"); +} + +script showFileDialog(id string) { + document.getElementById(id).classList.remove("hidden"); +} + +script triggerFileDelete(dialogId string, fileID uint, fileName string) { + // Hide the dialog + document.getElementById(dialogId).classList.add("hidden"); + + // Store data in a way that's accessible to event handlers + window.lastDeletedFile = { + id: fileID, + name: fileName + }; + + // Add custom marker to track this deletion + window.currentlyDeletingFile = true; +} + +// Rename from FileMetadataListContent to FileMetadataListPartial to match what the handler expects +templ FileMetadataListPartial(data FileMetadataListData) { + +
+ + for _, file := range data.Files { + @FileMetadataDialog( + fmt.Sprintf("delete-file-dialog-%d", file.ID), + "Delete File Metadata", + fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName), + "btn-danger", + "Delete", + "delete", + file.ID, + file.FileName, + "list", + ) + } +
+ + + + + + + + + if data.Job == nil { + + } + + + + + for _, file := range data.Files { + + + + + + + if data.Job == nil && file.Job.ID > 0 { + + } + + + } + +
IDFilenameSizeProcessedStatusJobActions
{ strconv.FormatUint(uint64(file.ID), 10) } + + { file.FileName } + + { formatFileSize(file.FileSize) }{ file.ProcessedTime.Format("2006-01-02 15:04:05") } + + { file.Status } + + + + { file.Job.Name } + + + + + + + +
+ + + if data.TotalPages > 1 { +
+
+ if data.Page > 1 { + + + + } else { + + + + } + + for i := 1; i <= data.TotalPages; i++ { + if i == data.Page { + + { strconv.Itoa(i) } + + } else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) { + + { strconv.Itoa(i) } + + } else if i == data.Page-3 || i == data.Page+3 { + ... + } + } + + if data.Page < data.TotalPages { + + + + } else { + + + + } +
+
+ } +} + +// Update the main FileMetadataList template to use the partial template +templ FileMetadataList(ctx context.Context, data FileMetadataListData) { + @LayoutWithContext("File Metadata", ctx) { + + +
+
+

+ if data.Job != nil { + Files for Job: { data.Job.Name } + } else { + File Metadata + } +

+ +
+ + +
+
+

Filter Files

+
+
+
+ if data.Job == nil { +
+ + +
+ } +
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+ + +
+
+

Files ({ strconv.FormatInt(data.TotalCount, 10) })

+
+ Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) } +
+
+
+ if len(data.Files) > 0 { + @FileMetadataListPartial(data) + } else { +
+ +

No file metadata found matching your criteria.

+
+ } +
+
+
+ } +} + +// FileMetadataDetails renders detailed information about a file +templ FileMetadataDetails(ctx context.Context, data FileMetadataDetailsData) { + @LayoutWithContext("File Details", ctx) { + + +
+ + +
+
+

+ File: { data.File.FileName } +

+
+
+
+
+

File Information

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{ strconv.FormatUint(uint64(data.File.ID), 10) }
Filename{ data.File.FileName }
Size{ formatFileSize(data.File.FileSize) }
Hash + if data.File.FileHash != "" { + { data.File.FileHash } + } else { + Not available + } +
Status + + { data.File.Status } + +
Original Path{ data.File.OriginalPath }
Destination Path{ data.File.DestinationPath }
+
+ +
+

Processing Information

+ + + + + + + + + + + + + + + + + + + if data.File.Status == "error" && data.File.ErrorMessage != "" { + + + + + } + + + + + + + + + +
Job + + { data.File.Job.Name } + +
Processed Time{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }
Creation Time{ data.File.CreationTime.Format("2006-01-02 15:04:05") }
Modification Time{ data.File.ModTime.Format("2006-01-02 15:04:05") }
Error{ data.File.ErrorMessage }
Record Created{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
Record Updated{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
+
+
+ +
+ + Back to Files + + + + @FileMetadataDialog( + fmt.Sprintf("delete-file-dialog-%d", data.File.ID), + "Delete File Metadata", + fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName), + "btn-danger", + "Delete", + "delete", + data.File.ID, + data.File.FileName, + "details", + ) + +
+
+
+
+ } +} + +// FileMetadataSearch renders an advanced search form for file metadata +templ FileMetadataSearch(ctx context.Context, data FileMetadataSearchData) { + @LayoutWithContext("Search Files", ctx) { + + +
+ + +
+
+

+ Advanced File Search +

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

Search Results ({ strconv.FormatInt(data.TotalCount, 10) })

+
+ Page { strconv.Itoa(data.Page) } of { strconv.Itoa(data.TotalPages) } +
+
+
+ @FileMetadataSearchContent(data) +
+
+
+ } +} + +// Similarly, create a separate template for the search results +templ FileMetadataSearchContent(data FileMetadataSearchData) { + +
+ + for _, file := range data.Files { + @FileMetadataDialog( + fmt.Sprintf("delete-file-dialog-%d", file.ID), + "Delete File Metadata", + fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName), + "btn-danger", + "Delete", + "delete", + file.ID, + file.FileName, + "list", + ) + } +
+ + + + + + + + + + + + + + if len(data.Files) > 0 { + for _, file := range data.Files { + + + + + + + + + + } + } else { + + + + } + +
IDFilenameSizeProcessedStatusJobActions
{ strconv.FormatUint(uint64(file.ID), 10) } + + { file.FileName } + + { formatFileSize(file.FileSize) }{ file.ProcessedTime.Format("2006-01-02 15:04:05") } + + { file.Status } + + + if file.Job.ID > 0 { + + { file.Job.Name } + + } else { + N/A + } + + + + + + +
+ +

No files found matching your search criteria.

+
+ + + if data.TotalPages > 1 { +
+
+ if data.Page > 1 { + + + + } else { + + + + } + + for i := 1; i <= data.TotalPages; i++ { + if i == data.Page { + + { strconv.Itoa(i) } + + } else if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) { + + { strconv.Itoa(i) } + + } else if i == data.Page-3 || i == data.Page+3 { + ... + } + } + + if data.Page < data.TotalPages { + + + + } else { + + + + } +
+
+ } +} + diff --git a/components/history.templ b/components/history.templ index ddf63e7..7548bdb 100644 --- a/components/history.templ +++ b/components/history.templ @@ -50,15 +50,15 @@ templ HistoryContent(ctx context.Context, data HistoryData) {

{ history.Job.Config.Name }

if history.Status == "completed" { - Completed + Completed } else if history.Status == "failed" { - Failed + Failed } else { - { history.Status } + { history.Status } }
@@ -108,22 +108,24 @@ templ HistoryContent(ctx context.Context, data HistoryData) { if data.TotalPages > 1 { -
-
+
+
if data.CurrentPage > 1 { } else { - + Previous } @@ -134,19 +136,21 @@ templ HistoryContent(ctx context.Context, data HistoryData) { hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) } hx-indicator="#mobile-next-indicator" - class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"> - Next - - + class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative"> + + Next + + + } else { - + Next }
-