mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 15:41:20 +02:00
Merge pull request #18 from StarFleetCPTN/development
New File Metadata Page and other changes
This commit is contained in:
+17
-3
@@ -46,9 +46,22 @@ Thumbs.db
|
|||||||
components/*.go
|
components/*.go
|
||||||
!components/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
|
# Ignore the data directory
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Ignore .env files
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
|
||||||
# Ignore the tmp directory
|
# Ignore the tmp directory
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
@@ -56,9 +69,10 @@ tmp/
|
|||||||
configs/
|
configs/
|
||||||
|
|
||||||
# Ignore Dirs
|
# Ignore Dirs
|
||||||
source/
|
/source/
|
||||||
destination/
|
/destination/
|
||||||
archive/
|
|
||||||
|
/archive/
|
||||||
|
|
||||||
# Ignore binaries
|
# Ignore binaries
|
||||||
gomft
|
gomft
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||

|

|
||||||
*Scheduling transfers with flexible cron expressions*
|
*Scheduling transfers with flexible cron expressions*
|
||||||
|
|
||||||
|
### File Metadata
|
||||||
|

|
||||||
|
*Track and manage file metadata with detailed information about transferred files*
|
||||||
|
|
||||||
### User Management
|
### User Management
|
||||||

|

|
||||||
*Create user accounts and manage them*
|
*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:
|
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
|
||||||
- Amazon S3
|
- Amazon S3
|
||||||
- MinIO
|
- MinIO
|
||||||
- Backblaze B2
|
|
||||||
- NextCloud
|
- NextCloud
|
||||||
- WebDAV
|
- WebDAV
|
||||||
- SFTP
|
- SFTP
|
||||||
@@ -39,6 +42,16 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||
- And more via rclone
|
- And more via rclone
|
||||||
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
|
- **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
|
- **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
|
- **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 Pattern Matching**: Support for file patterns to filter files during transfers
|
||||||
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
|
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
|
||||||
@@ -100,7 +113,7 @@ docker run -d \
|
|||||||
|
|
||||||
#### Docker Compose Example
|
#### Docker Compose Example
|
||||||
|
|
||||||
For production deployments, you can use Docker Compose:
|
For production deployments, you can use Docker Compose with environment variables:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3'
|
version: '3'
|
||||||
@@ -116,6 +129,39 @@ services:
|
|||||||
- ./backups:/app/data/gomft/backups
|
- ./backups:/app/data/gomft/backups
|
||||||
environment:
|
environment:
|
||||||
- TZ=UTC
|
- 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:
|
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
|
## 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
|
||||||
"server_address": ":8080",
|
DATA_DIR=./data/gomft
|
||||||
"data_dir": "./data/gomft",
|
BACKUP_DIR=./data/gomft/backups
|
||||||
"backup_dir": "./data/gomft/backups",
|
JWT_SECRET=change_this_to_a_secure_random_string
|
||||||
"jwt_secret": "your-secret-key",
|
BASE_URL=http://localhost:8080
|
||||||
"base_url": "http://localhost:8080",
|
|
||||||
"email": {
|
# Email configuration
|
||||||
"enabled": false,
|
EMAIL_ENABLED=true
|
||||||
"host": "smtp.example.com",
|
EMAIL_HOST=smtp.example.com
|
||||||
"port": 587,
|
EMAIL_PORT=587
|
||||||
"username": "user@example.com",
|
EMAIL_FROM_EMAIL=gomft@example.com
|
||||||
"password": "your-password",
|
EMAIL_FROM_NAME=GoMFT
|
||||||
"from_email": "gomft@example.com",
|
EMAIL_REPLY_TO=
|
||||||
"from_name": "GoMFT",
|
EMAIL_ENABLE_TLS=true
|
||||||
"reply_to": "",
|
EMAIL_REQUIRE_AUTH=true
|
||||||
"enable_tls": true,
|
EMAIL_USERNAME=smtp_username
|
||||||
"require_auth": true
|
EMAIL_PASSWORD=smtp_password
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration Options
|
### Configuration Options
|
||||||
|
|
||||||
- `server_address`: The address and port to run the server on
|
- `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
|
||||||
- `backup_dir`: Directory for storing database backups
|
- `BACKUP_DIR`: Directory for storing database backups
|
||||||
- `jwt_secret`: Secret key for JWT token generation
|
- `JWT_SECRET`: Secret key for JWT token generation
|
||||||
- `base_url`: Base URL for generating links in emails (e.g., password reset links)
|
- `BASE_URL`: Base URL for generating links in emails (e.g., password reset links)
|
||||||
- `email`: Email configuration settings for system notifications and password resets
|
- Email configuration settings for system notifications and password resets:
|
||||||
- `enabled`: Set to `true` to enable email functionality
|
- `EMAIL_ENABLED`: Set to `true` to enable email functionality
|
||||||
- `host`: SMTP server hostname
|
- `EMAIL_HOST`: SMTP server hostname
|
||||||
- `port`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
|
- `EMAIL_PORT`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
|
||||||
- `username`: Username for SMTP authentication
|
- `EMAIL_USERNAME`: Username for SMTP authentication
|
||||||
- `password`: Password for SMTP authentication
|
- `EMAIL_PASSWORD`: Password for SMTP authentication
|
||||||
- `from_email`: Email address used as sender
|
- `EMAIL_FROM_EMAIL`: Email address used as sender
|
||||||
- `from_name`: Name displayed as the sender
|
- `EMAIL_FROM_NAME`: Name displayed as the sender
|
||||||
- `reply_to`: Optional reply-to email address
|
- `EMAIL_REPLY_TO`: Optional reply-to email address
|
||||||
- `enable_tls`: Set to `true` to use TLS for secure email transmission
|
- `EMAIL_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
|
- `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
|
||||||
|
|
||||||
## Usage
|
## 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
|
- Check detailed transfer history with performance metrics
|
||||||
- View job run details including any error messages
|
- 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
|
### User Management
|
||||||
|
|
||||||
GoMFT uses a role-based access control system:
|
GoMFT uses a role-based access control system:
|
||||||
@@ -262,9 +314,9 @@ GoMFT supports email notifications for various features:
|
|||||||
|
|
||||||
To configure email functionality:
|
To configure email functionality:
|
||||||
|
|
||||||
1. Edit the `config.json` file and provide your SMTP server details
|
1. Edit the `.env` file and provide your SMTP server details
|
||||||
2. Set `"enabled": true` in the email configuration section
|
2. Set `EMAIL_ENABLED=true` in the email configuration section
|
||||||
3. Ensure the `base_url` setting is configured correctly for your deployment
|
3. Ensure the `BASE_URL` setting is configured correctly for your deployment
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+188
-1817
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+43
-49
@@ -50,15 +50,15 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ history.Job.Config.Name }</p>
|
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ history.Job.Config.Name }</p>
|
||||||
if history.Status == "completed" {
|
if history.Status == "completed" {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||||
<i class="fas fa-check mr-1"></i> Completed
|
Completed
|
||||||
</span>
|
</span>
|
||||||
} else if history.Status == "failed" {
|
} else if history.Status == "failed" {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||||
<i class="fas fa-times mr-1"></i> Failed
|
Failed
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||||
<i class="fas fa-sync-alt mr-1"></i> { history.Status }
|
{ history.Status }
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -108,22 +108,24 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
if data.TotalPages > 1 {
|
if data.TotalPages > 1 {
|
||||||
<div class="mt-6 flex items-center justify-between">
|
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
||||||
<div class="flex-1 flex justify-between sm:hidden">
|
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full px-4 mb-4 sm:mb-0">
|
||||||
if data.CurrentPage > 1 {
|
if data.CurrentPage > 1 {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#mobile-prev-indicator"
|
hx-indicator="#mobile-prev-indicator"
|
||||||
class="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">
|
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">
|
||||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
<span class="flex items-center">
|
||||||
<span id="mobile-prev-indicator" class="htmx-indicator ml-1">
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
</span>
|
||||||
|
<span id="mobile-prev-indicator" class="htmx-indicator absolute right-2">
|
||||||
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -134,19 +136,21 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#mobile-next-indicator"
|
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">
|
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 <i class="fas fa-chevron-right ml-1"></i>
|
<span class="flex items-center">
|
||||||
<span id="mobile-next-indicator" class="htmx-indicator ml-1">
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
</span>
|
||||||
|
<span id="mobile-next-indicator" class="htmx-indicator absolute right-2">
|
||||||
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between px-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
||||||
Showing
|
Showing
|
||||||
@@ -158,25 +162,23 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
results
|
results
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex justify-center">
|
||||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
<nav class="flex gap-2" aria-label="Pagination">
|
||||||
if data.CurrentPage > 1 {
|
if data.CurrentPage > 1 {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#prev-indicator"
|
hx-indicator="#prev-indicator"
|
||||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
class="w-10 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">
|
||||||
<span class="sr-only">Previous</span>
|
<i class="fas fa-chevron-left"></i>
|
||||||
<i class="fas fa-chevron-left h-5 w-5"></i>
|
<span id="prev-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
<span id="prev-indicator" class="htmx-indicator ml-1">
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||||
<span class="sr-only">Previous</span>
|
<i class="fas fa-chevron-left"></i>
|
||||||
<i class="fas fa-chevron-left h-5 w-5"></i>
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,17 +191,15 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#next-indicator"
|
hx-indicator="#next-indicator"
|
||||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
class="w-10 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">
|
||||||
<span class="sr-only">Next</span>
|
<i class="fas fa-chevron-right"></i>
|
||||||
<i class="fas fa-chevron-right h-5 w-5"></i>
|
<span id="next-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
<span id="next-indicator" class="htmx-indicator ml-1">
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||||
<span class="sr-only">Next</span>
|
<i class="fas fa-chevron-right"></i>
|
||||||
<i class="fas fa-chevron-right h-5 w-5"></i>
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -303,31 +303,25 @@ templ History(ctx context.Context, data HistoryData) {
|
|||||||
|
|
||||||
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
|
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
|
||||||
// Show at most 5 page numbers with the current page in the middle when possible
|
// Show at most 5 page numbers with the current page in the middle when possible
|
||||||
{{startPage := max(1, currentPage-2)}}
|
for i := 1; i <= totalPages; i++ {
|
||||||
{{endPage := min(totalPages, startPage+4)}}
|
|
||||||
|
|
||||||
// Adjust startPage if we're near the end
|
|
||||||
if endPage - startPage < 4 && startPage > 1 {
|
|
||||||
startPage = max(1, endPage-4)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := startPage; i <= endPage; i++ {
|
|
||||||
if i == currentPage {
|
if i == currentPage {
|
||||||
<span aria-current="page" class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-sm font-medium text-primary-600 dark:text-primary-400">
|
<span aria-current="page" class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
||||||
{ fmt.Sprint(i) }
|
{ fmt.Sprint(i) }
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
||||||
hx-indicator="#page-indicator"
|
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
||||||
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
class="w-10 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">
|
||||||
{ fmt.Sprint(i) }
|
{ fmt.Sprint(i) }
|
||||||
<span id="page-indicator" class="htmx-indicator ml-1">
|
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
} else if i == currentPage-3 || i == currentPage+3 {
|
||||||
|
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,9 @@ templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
|
|||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
||||||
if data.Config.SourceType == "sftp" {
|
if data.Config.SourceType == "sftp" {
|
||||||
{ data.Config.SourceUser } `@` { data.Config.SourceHost }:{ data.Config.SourcePath }
|
{ 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 {
|
} else {
|
||||||
{ data.Config.SourcePath }
|
{ data.Config.SourcePath }
|
||||||
}
|
}
|
||||||
@@ -173,7 +175,9 @@ templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
|
|||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
|
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
||||||
if data.Config.DestinationType == "sftp" {
|
if data.Config.DestinationType == "sftp" {
|
||||||
data.Config.DestUser@data.Config.DestHost:data.Config.DestinationPath
|
{ 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 {
|
} else {
|
||||||
{ data.Config.DestinationPath }
|
{ data.Config.DestinationPath }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,12 +288,10 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
</p>
|
</p>
|
||||||
if job.Enabled {
|
if job.Enabled {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||||
<i class="fas fa-check-circle mr-1"></i>
|
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
||||||
<i class="fas fa-pause-circle mr-1"></i>
|
|
||||||
Inactive
|
Inactive
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-8
@@ -29,7 +29,7 @@ templ Layout(title string) {
|
|||||||
|
|
||||||
templ LayoutWithContext(title string, ctx context.Context) {
|
templ LayoutWithContext(title string, ctx context.Context) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="light h-full">
|
<html lang="en" class="light h-full p-0 m-0">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||||
@@ -128,6 +128,14 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style type="text/tailwindcss">
|
<style type="text/tailwindcss">
|
||||||
|
/* Reset default browser margins and padding */
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden; /* Prevent horizontal scrolling */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||||
@@ -269,7 +277,7 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
background: theme('colors.secondary.600');
|
background: theme('colors.secondary.600');
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: theme('colors.secondary.400');
|
background: theme('colors.secondary.500');
|
||||||
}
|
}
|
||||||
.dark ::-webkit-scrollbar-thumb:hover {
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background: theme('colors.secondary.500');
|
background: theme('colors.secondary.500');
|
||||||
@@ -277,9 +285,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full">
|
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full p-0 m-0">
|
||||||
if isLoggedIn(ctx) {
|
if isLoggedIn(ctx) {
|
||||||
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10">
|
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10 w-full">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between h-16">
|
<div class="flex justify-between h-16">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
@@ -300,6 +308,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<a href="/history" class="nav-link">
|
<a href="/history" class="nav-link">
|
||||||
<i class="fas fa-history mr-1"></i> History
|
<i class="fas fa-history mr-1"></i> History
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/files" class="nav-link">
|
||||||
|
<i class="fas fa-file-alt mr-1"></i> Files
|
||||||
|
</a>
|
||||||
if isAdmin(ctx) {
|
if isAdmin(ctx) {
|
||||||
<a href="/admin/users" class="nav-link">
|
<a href="/admin/users" class="nav-link">
|
||||||
<i class="fas fa-users mr-1"></i> Users
|
<i class="fas fa-users mr-1"></i> Users
|
||||||
@@ -378,9 +389,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
x-transition:leave="transition ease-in duration-150"
|
x-transition:leave="transition ease-in duration-150"
|
||||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||||
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md"
|
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md w-full left-0 right-0"
|
||||||
>
|
>
|
||||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
<div class="max-w-7xl mx-auto px-2 pt-2 pb-3 space-y-1">
|
||||||
<a href="/dashboard" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
<a href="/dashboard" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
<i class="fas fa-tachometer-alt mr-2"></i> Dashboard
|
<i class="fas fa-tachometer-alt mr-2"></i> Dashboard
|
||||||
</a>
|
</a>
|
||||||
@@ -393,6 +404,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<a href="/history" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
<a href="/history" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
<i class="fas fa-history mr-2"></i> History
|
<i class="fas fa-history mr-2"></i> History
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/files" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fas fa-file-alt mr-2"></i> Files
|
||||||
|
</a>
|
||||||
if isAdmin(ctx) {
|
if isAdmin(ctx) {
|
||||||
<a href="/admin/users" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
<a href="/admin/users" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
<i class="fas fa-users mr-2"></i> Users
|
<i class="fas fa-users mr-2"></i> Users
|
||||||
@@ -428,8 +442,8 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
|
|
||||||
if isLoggedIn(ctx) {
|
if isLoggedIn(ctx) {
|
||||||
<!-- Mobile Bottom Navigation -->
|
<!-- Mobile Bottom Navigation -->
|
||||||
<div class="sm:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 shadow-lg mobile-nav-container z-10">
|
<div class="sm:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 shadow-lg w-full p-0 m-0">
|
||||||
<div class="grid grid-cols-5 h-16">
|
<div class="grid grid-cols-6 h-16">
|
||||||
<a href="/dashboard" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
<a href="/dashboard" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
<i class="fas fa-tachometer-alt text-lg"></i>
|
<i class="fas fa-tachometer-alt text-lg"></i>
|
||||||
<span class="text-xs mt-1">Dashboard</span>
|
<span class="text-xs mt-1">Dashboard</span>
|
||||||
@@ -446,6 +460,10 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
|||||||
<i class="fas fa-history text-lg"></i>
|
<i class="fas fa-history text-lg"></i>
|
||||||
<span class="text-xs mt-1">History</span>
|
<span class="text-xs mt-1">History</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/files" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fas fa-file-alt text-lg"></i>
|
||||||
|
<span class="text-xs mt-1">Files</span>
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
|
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
templ NameField() {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="name" id="name" x-model="name" 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="Transfer configuration name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ FilePatternFields() {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="file_pattern" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">File
|
||||||
|
Pattern</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-filter text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
|
||||||
|
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="*.txt, *.csv" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Glob pattern for files to transfer. Leave empty to transfer all files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="output_pattern"
|
||||||
|
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Output Pattern</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-file-export text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
|
||||||
|
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="${filename}" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Pattern for filenames. Available variables:<br />
|
||||||
|
${`filename`} - Original filename without extension (e.g., "report")<br />
|
||||||
|
${`ext`} - Original file extension (e.g., "csv")<br />
|
||||||
|
${`date:format`} - Current date using Go's time format:<br />
|
||||||
|
• 2006-01-02 → YYYY-MM-DD<br />
|
||||||
|
• 20060102 → YYYYMMDD<br />
|
||||||
|
• 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS<br />
|
||||||
|
Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ArchiveOptions() {
|
||||||
|
<div class="border border-gray-200 rounded-lg p-6 bg-gray-50 shadow-sm">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Archive & Delete Options</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="archive_enabled" class="flex items-center cursor-pointer">
|
||||||
|
<div class="relative">
|
||||||
|
<input id="archive_enabled" name="archive_enabled" type="checkbox" x-model="archiveEnabled"
|
||||||
|
class="sr-only"
|
||||||
|
:value="archiveEnabled ? 'true' : 'false'"
|
||||||
|
/>
|
||||||
|
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
|
||||||
|
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
|
||||||
|
:class="archiveEnabled ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-gray-700 font-medium">
|
||||||
|
Enable archiving
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4" x-show="archiveEnabled">
|
||||||
|
<label for="archive_path" class="block text-sm font-medium text-gray-700 mb-1">Archive Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-gray-400"></i>
|
||||||
|
</span>
|
||||||
|
<input id="archive_path" name="archive_path" type="text"
|
||||||
|
class="pl-10 pr-10 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm"
|
||||||
|
placeholder="Path to archive files"
|
||||||
|
x-model="archivePath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Files will be moved here after successful transfer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="delete_after_transfer" class="flex items-center cursor-pointer">
|
||||||
|
<div class="relative">
|
||||||
|
<input id="delete_after_transfer" name="delete_after_transfer" type="checkbox" x-model="deleteAfterTransfer"
|
||||||
|
class="sr-only"
|
||||||
|
:value="deleteAfterTransfer ? 'true' : 'false'"
|
||||||
|
/>
|
||||||
|
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
|
||||||
|
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
|
||||||
|
:class="deleteAfterTransfer ? 'transform translate-x-6 bg-red-500' : ''"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-gray-700 font-medium">
|
||||||
|
Delete source files after transfer
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 ml-14 text-xs text-red-500" x-show="deleteAfterTransfer">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1"></i> Warning: This will permanently delete the original files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="skip_processed_files" class="flex items-center cursor-pointer">
|
||||||
|
<div class="relative">
|
||||||
|
<input id="skip_processed_files" name="skip_processed_files" type="checkbox" x-model="skipProcessedFiles"
|
||||||
|
class="sr-only"
|
||||||
|
:value="skipProcessedFiles ? 'true' : 'false'"
|
||||||
|
/>
|
||||||
|
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
|
||||||
|
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
|
||||||
|
:class="skipProcessedFiles ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-gray-700 font-medium">
|
||||||
|
Skip files that have already been processed
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 ml-14 text-xs text-gray-500">
|
||||||
|
Files with the same hash that have been successfully processed before will be skipped
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ RcloneFlags() {
|
||||||
|
<div>
|
||||||
|
<label for="rclone_flags" class="block text-sm font-medium text-gray-700">Rclone Flags</label>
|
||||||
|
<div class="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-flag text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Optional: Additional rclone flags for fine-tuning the transfer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SourceSelection() {
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<label for="source_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Source
|
||||||
|
Type</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<select id="source_type" name="source_type" x-model="sourceType"
|
||||||
|
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">
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="sftp">SFTP</option>
|
||||||
|
<option value="ftp">FTP</option>
|
||||||
|
<option value="s3">S3</option>
|
||||||
|
<option value="minio">MinIO</option>
|
||||||
|
<option value="smb">SMB</option>
|
||||||
|
<option value="nextcloud">NextCloud</option>
|
||||||
|
<option value="webdav">WebDAV</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ DestinationSelection() {
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<label for="destination_type"
|
||||||
|
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Destination Type</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<select id="destination_type" name="destination_type" x-model="destinationType"
|
||||||
|
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">
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="sftp">SFTP</option>
|
||||||
|
<option value="ftp">FTP</option>
|
||||||
|
<option value="s3">S3</option>
|
||||||
|
<option value="minio">MinIO</option>
|
||||||
|
<option value="smb">SMB</option>
|
||||||
|
<option value="nextcloud">NextCloud</option>
|
||||||
|
<option value="webdav">WebDAV</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package destination contains the UI destination forms for the GoMFT application.
|
||||||
|
// This file serves as a marker for the destination package to ensure it's properly recognized by Go.
|
||||||
|
package destination
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ FTPDestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_host"
|
||||||
|
id="dest_host"
|
||||||
|
x-model="destHost"
|
||||||
|
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="ftp.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="dest_port"
|
||||||
|
id="dest_port"
|
||||||
|
x-model="destPort"
|
||||||
|
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="21"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
id="destination_path"
|
||||||
|
x-model="destinationPath"
|
||||||
|
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="/path/to/destination"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_user"
|
||||||
|
id="dest_user"
|
||||||
|
x-model="destUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="dest_password"
|
||||||
|
id="dest_password"
|
||||||
|
x-model="destPassword"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mb-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
id="dest_passive_mode"
|
||||||
|
name="dest_passive_mode"
|
||||||
|
type="checkbox"
|
||||||
|
x-model="destPassiveMode"
|
||||||
|
:value="destPassiveMode ? 'true' : 'false'"
|
||||||
|
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<label for="dest_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
||||||
|
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ LocalDestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
id="destination_path"
|
||||||
|
x-model="destinationPath"
|
||||||
|
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="/path/to/destination"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ MinIODestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_endpoint"
|
||||||
|
id="dest_endpoint"
|
||||||
|
x-model="destEndpoint"
|
||||||
|
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="https://minio.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_bucket"
|
||||||
|
id="dest_bucket"
|
||||||
|
x-model="destBucket"
|
||||||
|
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="my-bucket"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
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/"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. If specified, files will be uploaded to this path in the bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_access_key"
|
||||||
|
id="dest_access_key"
|
||||||
|
x-model="destAccessKey"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="dest_secret_key"
|
||||||
|
id="dest_secret_key"
|
||||||
|
x-model="destSecretKey"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ NextCloudDestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_host"
|
||||||
|
id="dest_host"
|
||||||
|
x-model="destHost"
|
||||||
|
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="https://nextcloud.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_user"
|
||||||
|
id="dest_user"
|
||||||
|
x-model="destUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="dest_password"
|
||||||
|
id="dest_password"
|
||||||
|
x-model="destPassword"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ S3DestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_bucket"
|
||||||
|
id="dest_bucket"
|
||||||
|
x-model="destBucket"
|
||||||
|
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="my-bucket"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-globe-americas text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_region"
|
||||||
|
id="dest_region"
|
||||||
|
x-model="destRegion"
|
||||||
|
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="us-east-1"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">S3 Path Prefix</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
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/"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. If specified, files will be uploaded to this path in the bucket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_access_key"
|
||||||
|
id="dest_access_key"
|
||||||
|
x-model="destAccessKey"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="dest_secret_key"
|
||||||
|
id="dest_secret_key"
|
||||||
|
x-model="destSecretKey"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ SFTPDestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_host"
|
||||||
|
id="dest_host"
|
||||||
|
x-model="destHost"
|
||||||
|
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="sftp.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="dest_port"
|
||||||
|
id="dest_port"
|
||||||
|
x-model="destPort"
|
||||||
|
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="22"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_user"
|
||||||
|
id="dest_user"
|
||||||
|
x-model="destUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
id="dest_use_password"
|
||||||
|
type="radio"
|
||||||
|
name="dest_auth_type"
|
||||||
|
value="password"
|
||||||
|
x-model="destAuthType"
|
||||||
|
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700"
|
||||||
|
checked>
|
||||||
|
</div>
|
||||||
|
<label for="dest_use_password" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">Use Password</label>
|
||||||
|
<div class="flex items-center h-5 ml-4">
|
||||||
|
<input
|
||||||
|
id="dest_use_key"
|
||||||
|
type="radio"
|
||||||
|
name="dest_auth_type"
|
||||||
|
value="key"
|
||||||
|
x-model="destAuthType"
|
||||||
|
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700">
|
||||||
|
</div>
|
||||||
|
<label for="dest_use_key" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">Use Key File</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="destAuthType === 'password'">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="dest_password"
|
||||||
|
id="dest_password"
|
||||||
|
x-model="destPassword"
|
||||||
|
x-bind:required="destAuthType === 'password'"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destAuthType === 'key'">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_key_file"
|
||||||
|
id="dest_key_file"
|
||||||
|
x-model="destKeyFile"
|
||||||
|
x-bind:required="destAuthType === 'key'"
|
||||||
|
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/key"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ SMBDestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_host"
|
||||||
|
id="dest_host"
|
||||||
|
x-model="destHost"
|
||||||
|
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="server.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_share"
|
||||||
|
id="dest_share"
|
||||||
|
x-model="destShare"
|
||||||
|
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="share_name"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
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\to\destination"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Subdirectory within the share.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_domain"
|
||||||
|
id="dest_domain"
|
||||||
|
x-model="destDomain"
|
||||||
|
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="DOMAIN"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Windows domain for authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_user"
|
||||||
|
id="dest_user"
|
||||||
|
x-model="destUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="dest_password"
|
||||||
|
id="dest_password"
|
||||||
|
x-model="destPassword"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ WebDAVDestinationForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_host"
|
||||||
|
id="dest_host"
|
||||||
|
x-model="destHost"
|
||||||
|
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="https://webdav.example.com/remote.php/webdav/"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="destination_path"
|
||||||
|
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/to/destination"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Relative to the WebDAV URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dest_user"
|
||||||
|
id="dest_user"
|
||||||
|
x-model="destUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="dest_password"
|
||||||
|
id="dest_password"
|
||||||
|
x-model="destPassword"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package providers contains the UI providers for the GoMFT application.
|
||||||
|
// This file serves as a marker for the providers package to ensure it's properly recognized by Go.
|
||||||
|
package providers
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ FTPSourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_host"
|
||||||
|
id="source_host"
|
||||||
|
x-model="sourceHost"
|
||||||
|
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="ftp.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="source_port"
|
||||||
|
id="source_port"
|
||||||
|
x-model="sourcePort"
|
||||||
|
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="21"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
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="/path/to/files"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_user"
|
||||||
|
id="source_user"
|
||||||
|
x-model="sourceUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="source_password"
|
||||||
|
id="source_password"
|
||||||
|
x-model="sourcePassword"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mb-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
id="source_passive_mode"
|
||||||
|
name="source_passive_mode"
|
||||||
|
type="checkbox"
|
||||||
|
x-model="sourcePassiveMode"
|
||||||
|
:value="sourcePassiveMode ? 'true' : 'false'"
|
||||||
|
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm">
|
||||||
|
<label for="source_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
||||||
|
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ LocalSourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
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="/path/to/source"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ MinIOSourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_endpoint"
|
||||||
|
id="source_endpoint"
|
||||||
|
x-model="sourceEndpoint"
|
||||||
|
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="https://minio.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_bucket"
|
||||||
|
id="source_bucket"
|
||||||
|
x-model="sourceBucket"
|
||||||
|
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="my-bucket"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
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/"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. If specified, only files in this path will be processed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_access_key"
|
||||||
|
id="source_access_key"
|
||||||
|
x-model="sourceAccessKey"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="source_secret_key"
|
||||||
|
id="source_secret_key"
|
||||||
|
x-model="sourceSecretKey"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="source_secret_key" :value="sourceSecretKey"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ NextCloudSourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_host"
|
||||||
|
id="source_host"
|
||||||
|
x-model="sourceHost"
|
||||||
|
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="https://nextcloud.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
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="/remote.php/dav/files/username/path/to/files"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. If left empty, root directory will be used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_user"
|
||||||
|
id="source_user"
|
||||||
|
x-model="sourceUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="source_password"
|
||||||
|
id="source_password"
|
||||||
|
x-model="sourcePassword"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ S3SourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_bucket"
|
||||||
|
id="source_bucket"
|
||||||
|
x-model="sourceBucket"
|
||||||
|
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="my-bucket"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-globe-americas text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_region"
|
||||||
|
id="source_region"
|
||||||
|
x-model="sourceRegion"
|
||||||
|
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="us-east-1"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">S3 Path Prefix</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
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/"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. If specified, only files in this path will be processed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_access_key"
|
||||||
|
id="source_access_key"
|
||||||
|
x-model="sourceAccessKey"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="source_secret_key"
|
||||||
|
id="source_secret_key"
|
||||||
|
x-model="sourceSecretKey"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="source_secret_key" :value="sourceSecretKey"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ SFTPSourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_host"
|
||||||
|
id="source_host"
|
||||||
|
x-model="sourceHost"
|
||||||
|
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="sftp.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="source_port"
|
||||||
|
id="source_port"
|
||||||
|
x-model="sourcePort"
|
||||||
|
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="22"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
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="/path/to/files"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_user"
|
||||||
|
id="source_user"
|
||||||
|
x-model="sourceUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
id="source_use_password"
|
||||||
|
type="radio"
|
||||||
|
name="source_auth_type"
|
||||||
|
value="password"
|
||||||
|
x-model="sourceAuthType"
|
||||||
|
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700"
|
||||||
|
checked>
|
||||||
|
</div>
|
||||||
|
<label for="source_use_password" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">Use Password</label>
|
||||||
|
<div class="flex items-center h-5 ml-4">
|
||||||
|
<input
|
||||||
|
id="source_use_key"
|
||||||
|
type="radio"
|
||||||
|
name="source_auth_type"
|
||||||
|
value="key"
|
||||||
|
x-model="sourceAuthType"
|
||||||
|
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700">
|
||||||
|
</div>
|
||||||
|
<label for="source_use_key" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">Use Key File</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="sourceAuthType === 'password'">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="source_password"
|
||||||
|
id="source_password"
|
||||||
|
x-model="sourcePassword"
|
||||||
|
x-bind:required="sourceAuthType === 'password'"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceAuthType === 'key'">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_key_file"
|
||||||
|
id="source_key_file"
|
||||||
|
x-model="sourceKeyFile"
|
||||||
|
x-bind:required="sourceAuthType === 'key'"
|
||||||
|
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/key"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ SMBSourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_host"
|
||||||
|
id="source_host"
|
||||||
|
x-model="sourceHost"
|
||||||
|
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="server.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_share"
|
||||||
|
id="source_share"
|
||||||
|
x-model="sourceShare"
|
||||||
|
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="share_name"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
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\files"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Subdirectory within the share.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_domain"
|
||||||
|
id="source_domain"
|
||||||
|
x-model="sourceDomain"
|
||||||
|
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="DOMAIN"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Windows domain for authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_user"
|
||||||
|
id="source_user"
|
||||||
|
x-model="sourceUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="source_password"
|
||||||
|
id="source_password"
|
||||||
|
x-model="sourcePassword"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package source contains the UI source forms for the GoMFT application.
|
||||||
|
// This file serves as a marker for the source package to ensure it's properly recognized by Go.
|
||||||
|
package source
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ WebDAVSourceForm() {
|
||||||
|
<div class="sm:col-span-6 space-y-6">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_host"
|
||||||
|
id="source_host"
|
||||||
|
x-model="sourceHost"
|
||||||
|
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="https://webdav.example.com/remote.php/webdav/"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_path"
|
||||||
|
id="source_path"
|
||||||
|
x-model="sourcePath"
|
||||||
|
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/files"/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||||
|
Optional. Relative to the WebDAV URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="source_user"
|
||||||
|
id="source_user"
|
||||||
|
x-model="sourceUser"
|
||||||
|
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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="source_password"
|
||||||
|
id="source_password"
|
||||||
|
x-model="sourcePassword"
|
||||||
|
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="Password"/>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"server_address": ":8080",
|
|
||||||
"data_dir": "/app/data/gomft",
|
|
||||||
"backup_dir": "/app/data/gomft/backups",
|
|
||||||
"jwt_secret": "change_this_to_a_secure_random_string",
|
|
||||||
"email": {
|
|
||||||
"enabled": true,
|
|
||||||
"host": "smtp.example.com",
|
|
||||||
"port": 587,
|
|
||||||
"from_email": "gomft@example.com",
|
|
||||||
"from_name": "GoMFT",
|
|
||||||
"reply_to": "",
|
|
||||||
"enable_tls": true,
|
|
||||||
"require_auth": true,
|
|
||||||
"username": "smtp_username",
|
|
||||||
"password": "smtp_password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,7 @@ require (
|
|||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
|||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
|||||||
+107
-46
@@ -1,31 +1,34 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServerAddress string `json:"server_address"`
|
ServerAddress string `json:"server_address"`
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
BackupDir string `json:"backup_dir"`
|
BackupDir string `json:"backup_dir"`
|
||||||
JWTSecret string `json:"jwt_secret"`
|
JWTSecret string `json:"jwt_secret"`
|
||||||
Email EmailConfig `json:"email"`
|
Email EmailConfig `json:"email"`
|
||||||
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
||||||
}
|
}
|
||||||
|
|
||||||
type EmailConfig struct {
|
type EmailConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
FromEmail string `json:"from_email"`
|
FromEmail string `json:"from_email"`
|
||||||
FromName string `json:"from_name"`
|
FromName string `json:"from_name"`
|
||||||
ReplyTo string `json:"reply_to,omitempty"`
|
ReplyTo string `json:"reply_to,omitempty"`
|
||||||
EnableTLS bool `json:"enable_tls"`
|
EnableTLS bool `json:"enable_tls"`
|
||||||
RequireAuth bool `json:"require_auth"`
|
RequireAuth bool `json:"require_auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -37,48 +40,106 @@ func Load() (*Config, error) {
|
|||||||
JWTSecret: "change_this_to_a_secure_random_string",
|
JWTSecret: "change_this_to_a_secure_random_string",
|
||||||
BaseURL: "http://localhost:8080",
|
BaseURL: "http://localhost:8080",
|
||||||
Email: EmailConfig{
|
Email: EmailConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Host: "smtp.example.com",
|
Host: "smtp.example.com",
|
||||||
Port: 587,
|
Port: 587,
|
||||||
Username: "user@example.com",
|
Username: "user@example.com",
|
||||||
Password: "your-password",
|
Password: "your-password",
|
||||||
FromEmail: "gomft@example.com",
|
FromEmail: "gomft@example.com",
|
||||||
FromName: "GoMFT",
|
FromName: "GoMFT",
|
||||||
EnableTLS: true,
|
EnableTLS: true,
|
||||||
RequireAuth: true,
|
RequireAuth: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if config file exists
|
|
||||||
configPath := filepath.Join(cfg.DataDir, "config.json")
|
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
|
||||||
// Read configuration file
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse configuration
|
|
||||||
if err := json.Unmarshal(data, cfg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save configuration if it doesn't exist
|
// First try to load .env from the root directory
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
envPath := ".env"
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
if _, err := os.Stat(envPath); err == nil {
|
||||||
if err != nil {
|
// Load .env file
|
||||||
|
if err := godotenv.Load(envPath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
// Override configuration with environment variables
|
||||||
|
if serverAddr := os.Getenv("SERVER_ADDRESS"); serverAddr != "" {
|
||||||
|
cfg.ServerAddress = serverAddr
|
||||||
|
}
|
||||||
|
if dataDir := os.Getenv("DATA_DIR"); dataDir != "" {
|
||||||
|
cfg.DataDir = dataDir
|
||||||
|
}
|
||||||
|
if backupDir := os.Getenv("BACKUP_DIR"); backupDir != "" {
|
||||||
|
cfg.BackupDir = backupDir
|
||||||
|
}
|
||||||
|
if jwtSecret := os.Getenv("JWT_SECRET"); jwtSecret != "" {
|
||||||
|
cfg.JWTSecret = jwtSecret
|
||||||
|
}
|
||||||
|
if baseURL := os.Getenv("BASE_URL"); baseURL != "" {
|
||||||
|
cfg.BaseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email configuration
|
||||||
|
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
|
||||||
|
cfg.Email.Enabled = strings.ToLower(emailEnabled) == "true"
|
||||||
|
}
|
||||||
|
if emailHost := os.Getenv("EMAIL_HOST"); emailHost != "" {
|
||||||
|
cfg.Email.Host = emailHost
|
||||||
|
}
|
||||||
|
if emailPort := os.Getenv("EMAIL_PORT"); emailPort != "" {
|
||||||
|
if port, err := strconv.Atoi(emailPort); err == nil {
|
||||||
|
cfg.Email.Port = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if emailUsername := os.Getenv("EMAIL_USERNAME"); emailUsername != "" {
|
||||||
|
cfg.Email.Username = emailUsername
|
||||||
|
}
|
||||||
|
if emailPassword := os.Getenv("EMAIL_PASSWORD"); emailPassword != "" {
|
||||||
|
cfg.Email.Password = emailPassword
|
||||||
|
}
|
||||||
|
if emailFromEmail := os.Getenv("EMAIL_FROM_EMAIL"); emailFromEmail != "" {
|
||||||
|
cfg.Email.FromEmail = emailFromEmail
|
||||||
|
}
|
||||||
|
if emailFromName := os.Getenv("EMAIL_FROM_NAME"); emailFromName != "" {
|
||||||
|
cfg.Email.FromName = emailFromName
|
||||||
|
}
|
||||||
|
if emailReplyTo := os.Getenv("EMAIL_REPLY_TO"); emailReplyTo != "" {
|
||||||
|
cfg.Email.ReplyTo = emailReplyTo
|
||||||
|
}
|
||||||
|
if emailEnableTLS := os.Getenv("EMAIL_ENABLE_TLS"); emailEnableTLS != "" {
|
||||||
|
cfg.Email.EnableTLS = strings.ToLower(emailEnableTLS) == "true"
|
||||||
|
}
|
||||||
|
if emailRequireAuth := os.Getenv("EMAIL_REQUIRE_AUTH"); emailRequireAuth != "" {
|
||||||
|
cfg.Email.RequireAuth = strings.ToLower(emailRequireAuth) == "true"
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
// Create default .env file in root directory if it doesn't exist
|
||||||
|
envContent := []string{
|
||||||
|
"SERVER_ADDRESS=" + cfg.ServerAddress,
|
||||||
|
"DATA_DIR=" + cfg.DataDir,
|
||||||
|
"BACKUP_DIR=" + cfg.BackupDir,
|
||||||
|
"JWT_SECRET=" + cfg.JWTSecret,
|
||||||
|
"BASE_URL=" + cfg.BaseURL,
|
||||||
|
"",
|
||||||
|
"# Email configuration",
|
||||||
|
"EMAIL_ENABLED=" + strconv.FormatBool(cfg.Email.Enabled),
|
||||||
|
"EMAIL_HOST=" + cfg.Email.Host,
|
||||||
|
"EMAIL_PORT=" + strconv.Itoa(cfg.Email.Port),
|
||||||
|
"EMAIL_FROM_EMAIL=" + cfg.Email.FromEmail,
|
||||||
|
"EMAIL_FROM_NAME=" + cfg.Email.FromName,
|
||||||
|
"EMAIL_REPLY_TO=" + cfg.Email.ReplyTo,
|
||||||
|
"EMAIL_ENABLE_TLS=" + strconv.FormatBool(cfg.Email.EnableTLS),
|
||||||
|
"EMAIL_REQUIRE_AUTH=" + strconv.FormatBool(cfg.Email.RequireAuth),
|
||||||
|
"EMAIL_USERNAME=" + cfg.Email.Username,
|
||||||
|
"EMAIL_PASSWORD=" + cfg.Email.Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(envPath, []byte(strings.Join(envContent, "\n")), 0644); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-53
@@ -7,23 +7,23 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/starfleetcptn/gomft/internal/auth"
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/auth"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primarykey"`
|
ID uint `gorm:"primarykey"`
|
||||||
Email string `gorm:"unique;not null"`
|
Email string `gorm:"unique;not null"`
|
||||||
PasswordHash string `gorm:"not null"`
|
PasswordHash string `gorm:"not null"`
|
||||||
IsAdmin bool `gorm:"default:false"`
|
IsAdmin bool `gorm:"default:false"`
|
||||||
LastPasswordChange time.Time
|
LastPasswordChange time.Time
|
||||||
FailedLoginAttempts int `gorm:"default:0"`
|
FailedLoginAttempts int `gorm:"default:0"`
|
||||||
AccountLocked bool `gorm:"default:false"`
|
AccountLocked bool `gorm:"default:false"`
|
||||||
LockoutUntil *time.Time
|
LockoutUntil *time.Time
|
||||||
Theme string `gorm:"default:'light'"`
|
Theme string `gorm:"default:'light'"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type PasswordHistory struct {
|
type PasswordHistory struct {
|
||||||
@@ -46,31 +46,31 @@ type PasswordResetToken struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TransferConfig struct {
|
type TransferConfig struct {
|
||||||
ID uint `gorm:"primarykey"`
|
ID uint `gorm:"primarykey"`
|
||||||
Name string `gorm:"not null" form:"name"`
|
Name string `gorm:"not null" form:"name"`
|
||||||
SourceType string `gorm:"not null" form:"source_type"`
|
SourceType string `gorm:"not null" form:"source_type"`
|
||||||
SourcePath string `gorm:"not null" form:"source_path"`
|
SourcePath string `gorm:"not null" form:"source_path"`
|
||||||
SourceHost string `form:"source_host"`
|
SourceHost string `form:"source_host"`
|
||||||
SourcePort int `gorm:"default:22" form:"source_port"`
|
SourcePort int `gorm:"default:22" form:"source_port"`
|
||||||
SourceUser string `form:"source_user"`
|
SourceUser string `form:"source_user"`
|
||||||
SourcePassword string `form:"source_password" gorm:"-"` // Not stored in DB, only used for form
|
SourcePassword string `form:"source_password" gorm:"-"` // Not stored in DB, only used for form
|
||||||
SourceKeyFile string `form:"source_key_file"`
|
SourceKeyFile string `form:"source_key_file"`
|
||||||
// S3 source fields
|
// S3 source fields
|
||||||
SourceBucket string `form:"source_bucket"`
|
SourceBucket string `form:"source_bucket"`
|
||||||
SourceRegion string `form:"source_region"`
|
SourceRegion string `form:"source_region"`
|
||||||
SourceAccessKey string `form:"source_access_key"`
|
SourceAccessKey string `form:"source_access_key"`
|
||||||
SourceSecretKey string `form:"source_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
SourceSecretKey string `form:"source_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
||||||
SourceEndpoint string `form:"source_endpoint"`
|
SourceEndpoint string `form:"source_endpoint"`
|
||||||
// SMB source fields
|
// SMB source fields
|
||||||
SourceShare string `form:"source_share"`
|
SourceShare string `form:"source_share"`
|
||||||
SourceDomain string `form:"source_domain"`
|
SourceDomain string `form:"source_domain"`
|
||||||
// FTP source fields
|
// FTP source fields
|
||||||
SourcePassiveMode bool `gorm:"default:true" form:"source_passive_mode"`
|
SourcePassiveMode bool `gorm:"default:true" form:"source_passive_mode"`
|
||||||
// OneDrive and Google Drive source fields
|
// OneDrive and Google Drive source fields
|
||||||
SourceClientID string `form:"source_client_id"`
|
SourceClientID string `form:"source_client_id"`
|
||||||
SourceClientSecret string `form:"source_client_secret" gorm:"-"` // Not stored in DB, only used for form
|
SourceClientSecret string `form:"source_client_secret" gorm:"-"` // Not stored in DB, only used for form
|
||||||
SourceDriveID string `form:"source_drive_id"` // For OneDrive
|
SourceDriveID string `form:"source_drive_id"` // For OneDrive
|
||||||
SourceTeamDrive string `form:"source_team_drive"` // For Google Drive
|
SourceTeamDrive string `form:"source_team_drive"` // For Google Drive
|
||||||
// General fields
|
// General fields
|
||||||
FilePattern string `gorm:"default:'*'" form:"file_pattern"`
|
FilePattern string `gorm:"default:'*'" form:"file_pattern"`
|
||||||
OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables
|
OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables
|
||||||
@@ -82,30 +82,31 @@ type TransferConfig struct {
|
|||||||
DestPassword string `form:"dest_password" gorm:"-"` // Not stored in DB, only used for form
|
DestPassword string `form:"dest_password" gorm:"-"` // Not stored in DB, only used for form
|
||||||
DestKeyFile string `form:"dest_key_file"`
|
DestKeyFile string `form:"dest_key_file"`
|
||||||
// S3 destination fields
|
// S3 destination fields
|
||||||
DestBucket string `form:"dest_bucket"`
|
DestBucket string `form:"dest_bucket"`
|
||||||
DestRegion string `form:"dest_region"`
|
DestRegion string `form:"dest_region"`
|
||||||
DestAccessKey string `form:"dest_access_key"`
|
DestAccessKey string `form:"dest_access_key"`
|
||||||
DestSecretKey string `form:"dest_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
DestSecretKey string `form:"dest_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
||||||
DestEndpoint string `form:"dest_endpoint"`
|
DestEndpoint string `form:"dest_endpoint"`
|
||||||
// SMB destination fields
|
// SMB destination fields
|
||||||
DestShare string `form:"dest_share"`
|
DestShare string `form:"dest_share"`
|
||||||
DestDomain string `form:"dest_domain"`
|
DestDomain string `form:"dest_domain"`
|
||||||
// FTP destination fields
|
// FTP destination fields
|
||||||
DestPassiveMode bool `gorm:"default:true" form:"dest_passive_mode"`
|
DestPassiveMode bool `gorm:"default:true" form:"dest_passive_mode"`
|
||||||
// OneDrive and Google Drive destination fields
|
// OneDrive and Google Drive destination fields
|
||||||
DestClientID string `form:"dest_client_id"`
|
DestClientID string `form:"dest_client_id"`
|
||||||
DestClientSecret string `form:"dest_client_secret" gorm:"-"` // Not stored in DB, only used for form
|
DestClientSecret string `form:"dest_client_secret" gorm:"-"` // Not stored in DB, only used for form
|
||||||
DestDriveID string `form:"dest_drive_id"` // For OneDrive
|
DestDriveID string `form:"dest_drive_id"` // For OneDrive
|
||||||
DestTeamDrive string `form:"dest_team_drive"` // For Google Drive
|
DestTeamDrive string `form:"dest_team_drive"` // For Google Drive
|
||||||
// General fields
|
// General fields
|
||||||
ArchivePath string `form:"archive_path"`
|
ArchivePath string `form:"archive_path"`
|
||||||
ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
|
ArchiveEnabled bool `gorm:"default:false" form:"archive_enabled"`
|
||||||
RcloneFlags string `form:"rclone_flags"`
|
RcloneFlags string `form:"rclone_flags"`
|
||||||
DeleteAfterTransfer bool `gorm:"default:false" form:"delete_after_transfer"`
|
DeleteAfterTransfer bool `gorm:"default:false" form:"delete_after_transfer"`
|
||||||
CreatedBy uint
|
SkipProcessedFiles bool `gorm:"default:true" form:"skip_processed_files"`
|
||||||
User User `gorm:"foreignkey:CreatedBy"`
|
CreatedBy uint
|
||||||
CreatedAt time.Time
|
User User `gorm:"foreignkey:CreatedBy"`
|
||||||
UpdatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
@@ -135,6 +136,25 @@ type JobHistory struct {
|
|||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileMetadata stores information about processed files
|
||||||
|
type FileMetadata struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
JobID uint `gorm:"not null;index"`
|
||||||
|
Job Job `gorm:"foreignkey:JobID"`
|
||||||
|
FileName string `gorm:"not null"`
|
||||||
|
OriginalPath string `gorm:"not null"`
|
||||||
|
FileSize int64 `gorm:"not null"`
|
||||||
|
FileHash string `gorm:"index"` // MD5 or other hash for file identity
|
||||||
|
CreationTime time.Time
|
||||||
|
ModTime time.Time
|
||||||
|
ProcessedTime time.Time `gorm:"not null"`
|
||||||
|
DestinationPath string `gorm:"not null"`
|
||||||
|
Status string `gorm:"not null"` // processed, archived, deleted, etc.
|
||||||
|
ErrorMessage string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
*gorm.DB
|
*gorm.DB
|
||||||
}
|
}
|
||||||
@@ -153,7 +173,7 @@ func Initialize(dbPath string) (*DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto migrate the schema
|
// Auto migrate the schema
|
||||||
err = db.AutoMigrate(&User{}, &auth.PasswordHistory{}, &PasswordResetToken{}, &TransferConfig{}, &Job{}, &JobHistory{})
|
err = db.AutoMigrate(&User{}, &auth.PasswordHistory{}, &PasswordResetToken{}, &TransferConfig{}, &Job{}, &JobHistory{}, &FileMetadata{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to migrate database: %v", err)
|
return nil, fmt.Errorf("failed to migrate database: %v", err)
|
||||||
}
|
}
|
||||||
@@ -302,16 +322,78 @@ func (db *DB) UpdateJobHistory(history *JobHistory) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) GetJobHistory(jobID uint) ([]JobHistory, error) {
|
func (db *DB) GetJobHistory(jobID uint) ([]JobHistory, error) {
|
||||||
var history []JobHistory
|
var histories []JobHistory
|
||||||
err := db.Where("job_id = ?", jobID).Order("start_time desc").Find(&history).Error
|
err := db.Where("job_id = ?", jobID).Order("start_time desc").Find(&histories).Error
|
||||||
return history, err
|
return histories, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions
|
// CreateFileMetadata creates a new file metadata record
|
||||||
|
func (db *DB) CreateFileMetadata(metadata *FileMetadata) error {
|
||||||
|
return db.Create(metadata).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadata retrieves file metadata by ID
|
||||||
|
func (db *DB) GetFileMetadata(id uint) (*FileMetadata, error) {
|
||||||
|
var metadata FileMetadata
|
||||||
|
err := db.First(&metadata, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadataByJobAndName retrieves file metadata by job ID and filename
|
||||||
|
func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) {
|
||||||
|
var metadata FileMetadata
|
||||||
|
err := db.Where("job_id = ? AND file_name = ?", jobID, fileName).First(&metadata).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadataByHash retrieves file metadata by file hash
|
||||||
|
func (db *DB) GetFileMetadataByHash(fileHash string) (*FileMetadata, error) {
|
||||||
|
var metadata FileMetadata
|
||||||
|
err := db.Where("file_hash = ?", fileHash).First(&metadata).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFileMetadata updates an existing file metadata record
|
||||||
|
func (db *DB) UpdateFileMetadata(metadata *FileMetadata) error {
|
||||||
|
return db.Save(metadata).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadataForJob retrieves all file metadata for a job
|
||||||
|
func (db *DB) GetFileMetadataForJob(jobID uint) ([]FileMetadata, error) {
|
||||||
|
var metadata []FileMetadata
|
||||||
|
err := db.Where("job_id = ?", jobID).Find(&metadata).Error
|
||||||
|
return metadata, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFileMetadata deletes file metadata by ID
|
||||||
|
func (db *DB) DeleteFileMetadata(id uint) error {
|
||||||
|
return db.Delete(&FileMetadata{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigRclonePath returns the path to the rclone config file for a given transfer config
|
||||||
func (db *DB) GetConfigRclonePath(config *TransferConfig) string {
|
func (db *DB) GetConfigRclonePath(config *TransferConfig) string {
|
||||||
return filepath.Join("configs", fmt.Sprintf("config_%d.conf", config.ID))
|
return filepath.Join("configs", fmt.Sprintf("config_%d.conf", config.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSkipProcessedFilesValue gets the current value of SkipProcessedFiles for a config
|
||||||
|
func (db *DB) GetSkipProcessedFilesValue(configID uint) (bool, error) {
|
||||||
|
var value bool
|
||||||
|
err := db.Model(&TransferConfig{}).
|
||||||
|
Where("id = ?", configID).
|
||||||
|
Select("skip_processed_files").
|
||||||
|
Scan(&value).Error
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||||
configPath := db.GetConfigRclonePath(config)
|
configPath := db.GetConfigRclonePath(config)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddSkipProcessedFilesColumn adds the skip_processed_files column to transfer_configs table
|
||||||
|
func AddSkipProcessedFilesColumn() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "20250310_add_skip_processed_files",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Add skip_processed_files column with default value of true
|
||||||
|
return tx.Exec("ALTER TABLE transfer_configs ADD COLUMN skip_processed_files BOOLEAN DEFAULT true").Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
// Drop the column if needed
|
||||||
|
return tx.Exec("ALTER TABLE transfer_configs DROP COLUMN skip_processed_files").Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
|
|||||||
// ... existing migrations
|
// ... existing migrations
|
||||||
AddDeleteAfterTransferColumn(),
|
AddDeleteAfterTransferColumn(),
|
||||||
AddCloudStorageFields(),
|
AddCloudStorageFields(),
|
||||||
|
AddSkipProcessedFilesColumn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
|
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ func (s *Service) generatePasswordResetEmailHTML(data map[string]interface{}) (s
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background-color: #4338ca;
|
background-color:rgb(55, 113, 236);
|
||||||
}
|
}
|
||||||
.reset-link {
|
.reset-link {
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
|
|||||||
+429
-96
@@ -1,7 +1,11 @@
|
|||||||
package scheduler
|
package scheduler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -124,12 +128,23 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Loaded job %d with config: source=%s:%s, dest=%s:%s\n",
|
// Add explicit database reload of the config to ensure we have the latest values
|
||||||
|
var config db.TransferConfig
|
||||||
|
if err := s.db.First(&config, job.Config.ID).Error; err != nil {
|
||||||
|
fmt.Printf("Error loading config %d: %v\n", job.Config.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Replace the job's config with the freshly loaded one
|
||||||
|
job.Config = config
|
||||||
|
|
||||||
|
// Now the rest of your code will use the correct value
|
||||||
|
fmt.Printf("Loaded job %d with config: source=%s:%s, dest=%s:%s, skipProcessedFiles=%v\n",
|
||||||
jobID,
|
jobID,
|
||||||
job.Config.SourceType,
|
job.Config.SourceType,
|
||||||
job.Config.SourcePath,
|
job.Config.SourcePath,
|
||||||
job.Config.DestinationType,
|
job.Config.DestinationType,
|
||||||
job.Config.DestinationPath,
|
job.Config.DestinationPath,
|
||||||
|
job.Config.SkipProcessedFiles,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create job history entry
|
// Create job history entry
|
||||||
@@ -153,113 +168,226 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
fmt.Printf("Error updating job last run time for job %d: %v\n", jobID, err)
|
fmt.Printf("Error updating job last run time for job %d: %v\n", jobID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload the job from the database to get the latest values
|
||||||
|
if err := s.db.Preload("Config").First(&job, jobID).Error; err != nil {
|
||||||
|
fmt.Printf("Error reloading job %d: %v\n", jobID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track files already processed in this job execution to prevent duplicates
|
||||||
|
processedFiles := make(map[string]bool)
|
||||||
|
|
||||||
// Get rclone config path
|
// Get rclone config path
|
||||||
configPath := s.db.GetConfigRclonePath(&job.Config)
|
configPath := s.db.GetConfigRclonePath(&job.Config)
|
||||||
|
|
||||||
// Size of transfer using rclone size
|
// Use lsjson to get file list and metadata in one operation instead of separate size and ls commands
|
||||||
sizeArgs := []string{
|
listArgs := []string{
|
||||||
"--config", configPath,
|
"--config", configPath,
|
||||||
"size",
|
"lsjson",
|
||||||
"--include", job.Config.FilePattern,
|
"--hash",
|
||||||
|
"--recursive",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file pattern filter if specified
|
||||||
|
if job.Config.FilePattern != "" && job.Config.FilePattern != "*" {
|
||||||
|
// Create a temporary filter file for complex patterns
|
||||||
|
filterFile, err := createRcloneFilterFile(job.Config.FilePattern)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating filter file for job %d: %v\n", jobID, err)
|
||||||
|
history.Status = "failed"
|
||||||
|
history.ErrorMessage = fmt.Sprintf("Filter Creation Error: %v", err)
|
||||||
|
endTime := time.Now()
|
||||||
|
history.EndTime = &endTime
|
||||||
|
if err := s.db.UpdateJobHistory(history); err != nil {
|
||||||
|
fmt.Printf("Error updating job history for job %d: %v\n", jobID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(filterFile)
|
||||||
|
listArgs = append(listArgs, "--filter-from", filterFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add source path with bucket for S3-compatible storage
|
// Add source path with bucket for S3-compatible storage
|
||||||
var sourceSizePath string
|
var sourceListPath string
|
||||||
if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" {
|
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)
|
sourceListPath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourceBucket)
|
||||||
if job.Config.SourcePath != "" && job.Config.SourcePath != "/" {
|
if job.Config.SourcePath != "" && job.Config.SourcePath != "/" {
|
||||||
sourceSizePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.SourcePath)
|
sourceListPath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.SourcePath)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sourceSizePath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath)
|
sourceListPath = fmt.Sprintf("source_%d:%s", job.Config.ID, job.Config.SourcePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
sizeArgs = append(sizeArgs, sourceSizePath)
|
listArgs = append(listArgs, sourceListPath)
|
||||||
|
|
||||||
// Get the rclone path from the environment variable or use the default path
|
// Execute lsjson command
|
||||||
|
fmt.Printf("Listing files with metadata for job %d: rclone %s\n", jobID, strings.Join(listArgs, " "))
|
||||||
rclonePath := os.Getenv("RCLONE_PATH")
|
rclonePath := os.Getenv("RCLONE_PATH")
|
||||||
if rclonePath == "" {
|
if rclonePath == "" {
|
||||||
rclonePath = "rclone"
|
rclonePath = "rclone"
|
||||||
}
|
}
|
||||||
output, err := exec.Command(rclonePath, sizeArgs...).CombinedOutput()
|
listCmd := exec.Command(rclonePath, listArgs...)
|
||||||
fmt.Printf("Running rclone size: %s %s\nOutput: %s\n", rclonePath, strings.Join(sizeArgs, " "), output)
|
listOutput, listErr := listCmd.CombinedOutput()
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error running rclone size: %v\nOutput: %s\n", err, output)
|
if listErr != nil {
|
||||||
// Update job history with error
|
fmt.Printf("Error listing files for job %d: %v\n", jobID, listErr)
|
||||||
history.Status = "failed"
|
history.Status = "failed"
|
||||||
history.ErrorMessage = fmt.Sprintf("Size calculation error: %v\nOutput: %s", err, string(output))
|
history.ErrorMessage = fmt.Sprintf("File Listing Error: %v\nOutput: %s", listErr, string(listOutput))
|
||||||
history.EndTime = &startTime // Use start time as end time for a quick failure
|
endTime := time.Now()
|
||||||
|
history.EndTime = &endTime
|
||||||
if err := s.db.UpdateJobHistory(history); err != nil {
|
if err := s.db.UpdateJobHistory(history); err != nil {
|
||||||
fmt.Printf("Error updating job history for job %d: %v\n", jobID, err)
|
fmt.Printf("Error updating job history for job %d: %v\n", jobID, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse rclone size output "Total objects: 1 Total size: 10 B (10 Byte)"
|
// Parse JSON output to get file information
|
||||||
outputStr := string(output)
|
var fileEntries []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(listOutput, &fileEntries); err != nil {
|
||||||
|
fmt.Printf("Error parsing file list JSON for job %d: %v\n", jobID, err)
|
||||||
|
history.Status = "failed"
|
||||||
|
history.ErrorMessage = fmt.Sprintf("JSON Parsing Error: %v", err)
|
||||||
|
endTime := time.Now()
|
||||||
|
history.EndTime = &endTime
|
||||||
|
if err := s.db.UpdateJobHistory(history); err != nil {
|
||||||
|
fmt.Printf("Error updating job history for job %d: %v\n", jobID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
totalObjects := strings.TrimSpace(strings.Split(outputStr, "\n")[0])
|
// Calculate total size and filter out directories
|
||||||
totalObjects = strings.TrimSpace(strings.Split(totalObjects, ":")[1])
|
var files []map[string]interface{}
|
||||||
// totalSize := strings.TrimSpace(strings.Split(outputStr, ":")[2])
|
var totalSize int64
|
||||||
|
for _, entry := range fileEntries {
|
||||||
|
// Skip directories
|
||||||
|
if isDir, ok := entry["IsDir"].(bool); ok && isDir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if totalObjects == "0" {
|
// Add to files list
|
||||||
|
files = append(files, entry)
|
||||||
|
|
||||||
|
// Add to total size
|
||||||
|
if size, ok := entry["Size"].(float64); ok {
|
||||||
|
totalSize += int64(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d files totaling %d bytes to transfer for job %d\n", len(files), totalSize, jobID)
|
||||||
|
|
||||||
|
// Update history with size information
|
||||||
|
history.BytesTransferred = totalSize
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
fmt.Printf("No files to transfer for job %d\n", jobID)
|
fmt.Printf("No files to transfer for job %d\n", jobID)
|
||||||
history.Status = "completed"
|
history.Status = "completed"
|
||||||
history.ErrorMessage = ""
|
history.ErrorMessage = ""
|
||||||
history.FilesTransferred = 0
|
history.FilesTransferred = 0
|
||||||
}
|
} else {
|
||||||
|
|
||||||
if totalObjects != "0" {
|
|
||||||
// First, list all files that match the pattern
|
|
||||||
listArgs := []string{
|
|
||||||
"--config", configPath,
|
|
||||||
"lsf",
|
|
||||||
"--include", job.Config.FilePattern,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
if rclonePath == "" {
|
|
||||||
rclonePath = "rclone"
|
|
||||||
}
|
|
||||||
listCmd := exec.Command(rclonePath, listArgs...)
|
|
||||||
listOutput, listErr := listCmd.CombinedOutput()
|
|
||||||
|
|
||||||
if listErr != nil {
|
|
||||||
fmt.Printf("Error listing files for job %d: %v\n", jobID, listErr)
|
|
||||||
history.Status = "failed"
|
|
||||||
history.ErrorMessage = fmt.Sprintf("File Listing Error: %v\nOutput: %s", listErr, string(listOutput))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split the output by newlines to get individual files
|
|
||||||
files := strings.Split(strings.TrimSpace(string(listOutput)), "\n")
|
|
||||||
fmt.Printf("Found %d files to transfer for job %d\n", len(files), jobID)
|
|
||||||
|
|
||||||
var transferErrors []string
|
var transferErrors []string
|
||||||
filesTransferred := 0
|
filesTransferred := 0
|
||||||
|
|
||||||
// Process each file individually
|
// Process each file individually
|
||||||
for _, file := range files {
|
for _, fileEntry := range files {
|
||||||
if file == "" {
|
fileName, ok := fileEntry["Path"].(string)
|
||||||
|
if !ok || fileName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Processing file: %s for job %d\n", file, jobID)
|
// Skip files that have already been processed in this execution
|
||||||
|
if processedFiles[fileName] {
|
||||||
|
fmt.Printf("Skipping duplicate file entry: %s (already processed in this execution)\n", fileName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file metadata from the JSON entry
|
||||||
|
var fileSize int64
|
||||||
|
if size, ok := fileEntry["Size"].(float64); ok {
|
||||||
|
fileSize = int64(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract modification time
|
||||||
|
modTime := time.Now()
|
||||||
|
if modTimeStr, ok := fileEntry["ModTime"].(string); ok {
|
||||||
|
if parsedTime, err := time.Parse(time.RFC3339, modTimeStr); err == nil {
|
||||||
|
modTime = parsedTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create time is usually not available for remote files, so we'll use modTime
|
||||||
|
createTime := modTime
|
||||||
|
|
||||||
|
// Extract hash if available
|
||||||
|
var fileHash string
|
||||||
|
if hashes, ok := fileEntry["Hashes"].(map[string]interface{}); ok {
|
||||||
|
if md5, ok := hashes["md5"].(string); ok {
|
||||||
|
fileHash = md5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For local files, calculate hash if not available
|
||||||
|
if fileHash == "" && job.Config.SourceType == "local" {
|
||||||
|
localFilePath := filepath.Join(job.Config.SourcePath, fileName)
|
||||||
|
calculatedHash, hashErr := calculateFileHash(localFilePath)
|
||||||
|
if hashErr == nil {
|
||||||
|
fileHash = calculatedHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skipFiles := job.Config.SkipProcessedFiles
|
||||||
|
|
||||||
|
// Check if this file has been processed before (by hash)
|
||||||
|
if fileHash != "" {
|
||||||
|
processed, prevMetadata, _ := s.hasFileBeenProcessed(jobID, fileHash)
|
||||||
|
if processed {
|
||||||
|
fmt.Printf("File %s has been processed before (hash: %s, previous file: %s)\n",
|
||||||
|
fileName, fileHash, prevMetadata.FileName)
|
||||||
|
|
||||||
|
// Determine if we should skip this file
|
||||||
|
shouldSkip := false
|
||||||
|
if skipFiles {
|
||||||
|
if prevMetadata.Status == "processed" ||
|
||||||
|
prevMetadata.Status == "archived" ||
|
||||||
|
prevMetadata.Status == "deleted" ||
|
||||||
|
prevMetadata.Status == "archived_and_deleted" {
|
||||||
|
shouldSkip = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldSkip {
|
||||||
|
fmt.Printf("Skipping unchanged file %s (hash matches previous processing)\n", fileName)
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Re-processing file %s despite previous processing (skipProcessedFiles=%v)\n", fileName, skipFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check the processing history for this specific file name
|
||||||
|
prevMetadata, histErr := s.checkFileProcessingHistory(jobID, fileName)
|
||||||
|
if histErr == nil {
|
||||||
|
fmt.Printf("File %s was previously processed on %s with status: %s\n",
|
||||||
|
fileName, prevMetadata.ProcessedTime.Format(time.RFC3339), prevMetadata.Status)
|
||||||
|
|
||||||
|
// Determine if we should skip this file based on name+hash match
|
||||||
|
shouldSkip := false
|
||||||
|
if skipFiles && fileHash != "" && fileHash == prevMetadata.FileHash {
|
||||||
|
if prevMetadata.Status == "processed" ||
|
||||||
|
prevMetadata.Status == "archived" ||
|
||||||
|
prevMetadata.Status == "deleted" ||
|
||||||
|
prevMetadata.Status == "archived_and_deleted" {
|
||||||
|
shouldSkip = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldSkip {
|
||||||
|
fmt.Printf("Skipping unchanged file %s (hash matches previous processing)\n", fileName)
|
||||||
|
// Skip this file and continue to the next one
|
||||||
|
continue
|
||||||
|
} else if fileHash != "" && fileHash == prevMetadata.FileHash {
|
||||||
|
fmt.Printf("Re-processing file %s despite matching hash (skipProcessedFiles=%v)\n", fileName, skipFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare moveto command for transfer
|
// Prepare moveto command for transfer
|
||||||
transferArgs := []string{
|
transferArgs := []string{
|
||||||
@@ -276,29 +404,40 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
|
|
||||||
// For S3, MinIO, and B2, include the bucket in the path
|
// For S3, MinIO, and B2, include the bucket in the path
|
||||||
if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" {
|
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)
|
sourcePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourceBucket, fileName)
|
||||||
if job.Config.SourcePath != "" && job.Config.SourcePath != "/" {
|
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)
|
sourcePath = fmt.Sprintf("source_%d:%s/%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.SourcePath, fileName)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sourcePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourcePath, file)
|
sourcePath = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.SourcePath, fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var destFile string = fileName
|
||||||
|
|
||||||
if job.Config.DestinationType == "s3" || job.Config.DestinationType == "minio" || job.Config.DestinationType == "b2" {
|
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)
|
destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestBucket, fileName)
|
||||||
if job.Config.DestinationPath != "" && job.Config.DestinationPath != "/" {
|
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)
|
destPath = fmt.Sprintf("dest_%d:%s/%s/%s", job.Config.ID, job.Config.DestBucket, job.Config.DestinationPath, fileName)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, file)
|
destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add output filename pattern if specified
|
// Add output filename pattern if specified
|
||||||
if job.Config.OutputPattern != "" {
|
if job.Config.OutputPattern != "" {
|
||||||
// Process the output pattern for this specific file
|
// Process the output pattern for this specific file
|
||||||
newFilename := ProcessOutputPattern(job.Config.OutputPattern, file)
|
destFile = ProcessOutputPattern(job.Config.OutputPattern, fileName)
|
||||||
destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, newFilename)
|
|
||||||
fmt.Printf("Renaming file from %s to %s for job %d\n", file, newFilename, jobID)
|
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, destFile)
|
||||||
|
if job.Config.DestinationPath != "" && job.Config.DestinationPath != "/" {
|
||||||
|
destPath = fmt.Sprintf("dest_%d:%s/%s/%s", job.Config.ID, job.Config.DestBucket, job.Config.DestinationPath, destFile)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
destPath = fmt.Sprintf("dest_%d:%s/%s", job.Config.ID, job.Config.DestinationPath, destFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Renaming file from %s to %s for job %d\n", fileName, destFile, jobID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom flags if specified
|
// Add custom flags if specified
|
||||||
@@ -313,7 +452,7 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
|
|
||||||
// Execute transfer for this file
|
// Execute transfer for this file
|
||||||
fmt.Printf("Executing rclone transfer command for job %d, file %s: rclone %s\n",
|
fmt.Printf("Executing rclone transfer command for job %d, file %s: rclone %s\n",
|
||||||
jobID, file, strings.Join(transferArgs, " "))
|
jobID, fileName, strings.Join(transferArgs, " "))
|
||||||
// Get the rclone path from the environment variable or use the default path
|
// Get the rclone path from the environment variable or use the default path
|
||||||
rclonePath := os.Getenv("RCLONE_PATH")
|
rclonePath := os.Getenv("RCLONE_PATH")
|
||||||
if rclonePath == "" {
|
if rclonePath == "" {
|
||||||
@@ -323,19 +462,42 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
fileOutput, fileErr := cmd.CombinedOutput()
|
fileOutput, fileErr := cmd.CombinedOutput()
|
||||||
|
|
||||||
// Print the output
|
// Print the output
|
||||||
fmt.Printf("Output for file %s: %s\n", file, string(fileOutput))
|
fmt.Printf("Output for file %s: %s\n", fileName, string(fileOutput))
|
||||||
|
|
||||||
|
// Create file metadata record
|
||||||
|
fileStatus := "processed"
|
||||||
|
var fileErrorMsg string
|
||||||
|
var destPathForDB string
|
||||||
|
|
||||||
// Check if file was successfully transferred
|
// Check if file was successfully transferred
|
||||||
if fileErr != nil {
|
if fileErr != nil {
|
||||||
fmt.Printf("Error transferring file %s for job %d: %v\n", file, jobID, fileErr)
|
fmt.Printf("Error transferring file %s for job %d: %v\n", fileName, jobID, fileErr)
|
||||||
transferErrors = append(transferErrors, fmt.Sprintf("File %s: %v", file, fileErr))
|
transferErrors = append(transferErrors, fmt.Sprintf("File %s: %v", fileName, fileErr))
|
||||||
|
fileStatus = "error"
|
||||||
|
fileErrorMsg = fileErr.Error()
|
||||||
} else {
|
} else {
|
||||||
filesTransferred++
|
filesTransferred++
|
||||||
fmt.Printf("Successfully transferred file %s for job %d\n", file, jobID)
|
fmt.Printf("Successfully transferred file %s for job %d\n", fileName, jobID)
|
||||||
|
|
||||||
|
// Extract the actual destination path (without rclone remote prefix)
|
||||||
|
if job.Config.DestinationType == "local" {
|
||||||
|
destPathForDB = filepath.Join(job.Config.DestinationPath, destFile)
|
||||||
|
} else {
|
||||||
|
// For remote destinations, store the path format
|
||||||
|
if job.Config.DestinationType == "s3" || job.Config.DestinationType == "minio" || job.Config.DestinationType == "b2" {
|
||||||
|
if job.Config.DestinationPath != "" && job.Config.DestinationPath != "/" {
|
||||||
|
destPathForDB = fmt.Sprintf("%s/%s/%s", job.Config.DestBucket, job.Config.DestinationPath, destFile)
|
||||||
|
} else {
|
||||||
|
destPathForDB = fmt.Sprintf("%s/%s", job.Config.DestBucket, destFile)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
destPathForDB = fmt.Sprintf("%s/%s", job.Config.DestinationPath, destFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If archiving is enabled and transfer was successful, move files to archive
|
// If archiving is enabled and transfer was successful, move files to archive
|
||||||
if job.Config.ArchiveEnabled && job.Config.ArchivePath != "" {
|
if job.Config.ArchiveEnabled && job.Config.ArchivePath != "" {
|
||||||
fmt.Printf("Archiving file %s for job %d\n", file, jobID)
|
fmt.Printf("Archiving file %s for job %d\n", fileName, jobID)
|
||||||
|
|
||||||
// We don't need to move the file since we used moveto, but we can copy it to archive
|
// We don't need to move the file since we used moveto, but we can copy it to archive
|
||||||
archiveArgs := []string{
|
archiveArgs := []string{
|
||||||
@@ -347,15 +509,15 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
// Construct archive path with bucket if needed
|
// Construct archive path with bucket if needed
|
||||||
var archiveDest string
|
var archiveDest string
|
||||||
if job.Config.SourceType == "s3" || job.Config.SourceType == "minio" || job.Config.SourceType == "b2" {
|
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)
|
archiveDest = fmt.Sprintf("source_%d:%s/%s/%s", job.Config.ID, job.Config.SourceBucket, job.Config.ArchivePath, fileName)
|
||||||
} else {
|
} else {
|
||||||
archiveDest = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.ArchivePath, file)
|
archiveDest = fmt.Sprintf("source_%d:%s/%s", job.Config.ID, job.Config.ArchivePath, fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveArgs = append(archiveArgs, archiveDest)
|
archiveArgs = append(archiveArgs, archiveDest)
|
||||||
|
|
||||||
fmt.Printf("Executing rclone archive command for job %d, file %s: rclone %s\n",
|
fmt.Printf("Executing rclone archive command for job %d, file %s: rclone %s\n",
|
||||||
jobID, file, strings.Join(archiveArgs, " "))
|
jobID, fileName, strings.Join(archiveArgs, " "))
|
||||||
// Get the rclone path from the environment variable or use the default path
|
// Get the rclone path from the environment variable or use the default path
|
||||||
rclonePath := os.Getenv("RCLONE_PATH")
|
rclonePath := os.Getenv("RCLONE_PATH")
|
||||||
if rclonePath == "" {
|
if rclonePath == "" {
|
||||||
@@ -365,31 +527,64 @@ func (s *Scheduler) executeJob(jobID uint) {
|
|||||||
archiveOutput, archiveErr := archiveCmd.CombinedOutput()
|
archiveOutput, archiveErr := archiveCmd.CombinedOutput()
|
||||||
|
|
||||||
// Print the output
|
// Print the output
|
||||||
fmt.Printf("Output for file %s: %s\n", file, string(archiveOutput))
|
fmt.Printf("Output for file %s: %s\n", fileName, string(archiveOutput))
|
||||||
|
|
||||||
// Check if file was successfully transferred
|
// Check if file was successfully transferred
|
||||||
if archiveErr != nil {
|
if archiveErr != nil {
|
||||||
fmt.Printf("Warning: Error archiving file %s for job %d: %v\n", file, jobID, archiveErr)
|
fmt.Printf("Warning: Error archiving file %s for job %d: %v\n", fileName, jobID, archiveErr)
|
||||||
transferErrors = append(transferErrors,
|
transferErrors = append(transferErrors,
|
||||||
fmt.Sprintf("Archive error for file %s: %v", file, archiveErr))
|
fmt.Sprintf("Archive error for file %s: %v", fileName, archiveErr))
|
||||||
|
} else {
|
||||||
|
fileStatus = "archived"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if job.Config.DeleteAfterTransfer {
|
if job.Config.DeleteAfterTransfer {
|
||||||
fmt.Printf("Deleting file %s for job %d\n", file, jobID)
|
fmt.Printf("Deleting file %s for job %d\n", fileName, jobID)
|
||||||
deleteArgs := []string{
|
deleteArgs := []string{
|
||||||
"--config", configPath,
|
"--config", configPath,
|
||||||
"deletefile",
|
"deletefile",
|
||||||
sourcePath, }
|
sourcePath}
|
||||||
deleteCmd := exec.Command(rclonePath, deleteArgs...)
|
deleteCmd := exec.Command(rclonePath, deleteArgs...)
|
||||||
deleteOutput, deleteErr := deleteCmd.CombinedOutput()
|
deleteOutput, deleteErr := deleteCmd.CombinedOutput()
|
||||||
fmt.Printf("Output for file %s: %s\n", file, string(deleteOutput))
|
fmt.Printf("Output for file %s: %s\n", fileName, string(deleteOutput))
|
||||||
if deleteErr != nil {
|
if deleteErr != nil {
|
||||||
fmt.Printf("Error deleting file %s for job %d: %v\n", file, jobID, deleteErr)
|
fmt.Printf("Error deleting file %s for job %d: %v\n", fileName, jobID, deleteErr)
|
||||||
transferErrors = append(transferErrors,
|
transferErrors = append(transferErrors,
|
||||||
fmt.Sprintf("Delete error for file %s: %v", file, deleteErr))
|
fmt.Sprintf("Delete error for file %s: %v", fileName, deleteErr))
|
||||||
|
} else {
|
||||||
|
if fileStatus == "archived" {
|
||||||
|
fileStatus = "archived_and_deleted"
|
||||||
|
} else {
|
||||||
|
fileStatus = "deleted"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark this file as processed for this execution
|
||||||
|
processedFiles[fileName] = true
|
||||||
|
|
||||||
|
// Create and save file metadata
|
||||||
|
metadata := &db.FileMetadata{
|
||||||
|
JobID: jobID,
|
||||||
|
FileName: fileName,
|
||||||
|
OriginalPath: job.Config.SourcePath,
|
||||||
|
FileSize: fileSize,
|
||||||
|
FileHash: fileHash,
|
||||||
|
CreationTime: createTime,
|
||||||
|
ModTime: modTime,
|
||||||
|
ProcessedTime: time.Now(),
|
||||||
|
DestinationPath: destPathForDB,
|
||||||
|
Status: fileStatus,
|
||||||
|
ErrorMessage: fileErrorMsg,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.CreateFileMetadata(metadata); err != nil {
|
||||||
|
fmt.Printf("Error creating file metadata for %s: %v\n", fileName, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Created file metadata record for %s (ID: %d)\n", fileName, metadata.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update job history with transfer results
|
// Update job history with transfer results
|
||||||
@@ -515,3 +710,141 @@ func (s *Scheduler) RunJobNow(jobID uint) error {
|
|||||||
go s.executeJob(jobID)
|
go s.executeJob(jobID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calculateFileHash computes an MD5 hash for the given file path
|
||||||
|
func calculateFileHash(filePath string) (string, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error opening file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hash := md5.New()
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return "", fmt.Errorf("error calculating hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFileInfo retrieves file stats like size, creation time, and modification time
|
||||||
|
func getFileInfo(filePath string) (int64, time.Time, time.Time, error) {
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, time.Time{}, time.Time{}, fmt.Errorf("error getting file info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := info.Size()
|
||||||
|
modTime := info.ModTime()
|
||||||
|
|
||||||
|
// Get creation time (this is platform-specific)
|
||||||
|
// For simplicity, we'll use modification time as a fallback
|
||||||
|
createTime := modTime
|
||||||
|
|
||||||
|
return size, createTime, modTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasFileBeenProcessed checks if a file with the same hash has been processed before
|
||||||
|
func (s *Scheduler) hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error) {
|
||||||
|
if fileHash == "" {
|
||||||
|
return false, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// First try to find by hash (most reliable)
|
||||||
|
metadata, err := s.db.GetFileMetadataByHash(fileHash)
|
||||||
|
if err == nil && metadata != nil {
|
||||||
|
return true, metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFileProcessingHistory checks processing history for a given file
|
||||||
|
func (s *Scheduler) checkFileProcessingHistory(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||||
|
// Try to find by job and filename
|
||||||
|
metadata, err := s.db.GetFileMetadataByJobAndName(jobID, fileName)
|
||||||
|
if err == nil && metadata != nil {
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no history found for file %s in job %d", fileName, jobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRemoteFileInfo gets metadata for a remote file using rclone lsjson
|
||||||
|
func (s *Scheduler) getRemoteFileInfo(config *db.TransferConfig, file string) (int64, time.Time, time.Time, string, error) {
|
||||||
|
// Get rclone config path
|
||||||
|
configPath := s.db.GetConfigRclonePath(config)
|
||||||
|
|
||||||
|
// Construct the appropriate source path
|
||||||
|
var sourcePath string
|
||||||
|
if config.SourceType == "s3" || config.SourceType == "minio" || config.SourceType == "b2" {
|
||||||
|
sourcePath = fmt.Sprintf("source_%d:%s", config.ID, config.SourceBucket)
|
||||||
|
if config.SourcePath != "" && config.SourcePath != "/" {
|
||||||
|
sourcePath = fmt.Sprintf("source_%d:%s/%s", config.ID, config.SourceBucket, config.SourcePath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sourcePath = fmt.Sprintf("source_%d:%s", config.ID, config.SourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use rclone lsjson to get file details
|
||||||
|
rclonePath := os.Getenv("RCLONE_PATH")
|
||||||
|
if rclonePath == "" {
|
||||||
|
rclonePath = "rclone"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the full path to the file
|
||||||
|
fullPath := fmt.Sprintf("%s/%s", sourcePath, file)
|
||||||
|
|
||||||
|
// Run rclone lsjson command
|
||||||
|
args := []string{
|
||||||
|
"--config", configPath,
|
||||||
|
"lsjson",
|
||||||
|
"--hash",
|
||||||
|
fullPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(rclonePath, args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return 0, time.Time{}, time.Time{}, "", fmt.Errorf("error getting remote file info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON output
|
||||||
|
var files []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(output, &files); err != nil {
|
||||||
|
return 0, time.Time{}, time.Time{}, "", fmt.Errorf("error parsing lsjson output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return 0, time.Time{}, time.Time{}, "", fmt.Errorf("file not found: %s", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo := files[0]
|
||||||
|
|
||||||
|
// Extract file size
|
||||||
|
var fileSize int64
|
||||||
|
if size, ok := fileInfo["Size"].(float64); ok {
|
||||||
|
fileSize = int64(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract modification time
|
||||||
|
modTime := time.Now()
|
||||||
|
if modTimeStr, ok := fileInfo["ModTime"].(string); ok {
|
||||||
|
if parsedTime, err := time.Parse(time.RFC3339, modTimeStr); err == nil {
|
||||||
|
modTime = parsedTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create time is usually not available for remote files, so we'll use modTime
|
||||||
|
createTime := modTime
|
||||||
|
|
||||||
|
// Calculate hash if available
|
||||||
|
var md5Hash string
|
||||||
|
if hashes, ok := fileInfo["Hashes"].(map[string]interface{}); ok {
|
||||||
|
if md5, ok := hashes["md5"].(string); ok {
|
||||||
|
md5Hash = md5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileSize, createTime, modTime, md5Hash, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataHandler handles displaying and searching file metadata
|
||||||
|
type FileMetadataHandler struct {
|
||||||
|
DB *db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers the file metadata routes
|
||||||
|
func (h *FileMetadataHandler) Register(router *gin.RouterGroup) {
|
||||||
|
fileGroup := router.Group("/files")
|
||||||
|
|
||||||
|
fileGroup.GET("", h.ListFileMetadata)
|
||||||
|
fileGroup.GET("/:id", h.GetFileMetadataDetails)
|
||||||
|
fileGroup.GET("/job/:job_id", h.GetFileMetadataForJob)
|
||||||
|
fileGroup.GET("/search", h.SearchFileMetadata)
|
||||||
|
fileGroup.GET("/search/partial", h.HandleFileMetadataSearchPartial)
|
||||||
|
fileGroup.DELETE("/:id", h.DeleteFileMetadata)
|
||||||
|
fileGroup.GET("/partial", h.HandleFileMetadataPartial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFileMetadata displays a list of file metadata with pagination and filtering options
|
||||||
|
func (h *FileMetadataHandler) ListFileMetadata(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Query parameters for pagination and filtering
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
jobIDStr := c.Query("job_id")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
|
||||||
|
// Base query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
query = query.Where("file_metadata.job_id = ?", jobID)
|
||||||
|
} else {
|
||||||
|
// Only show files from jobs created by the current user
|
||||||
|
query = query.Where("jobs.created_by = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("file_metadata.status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_metadata.file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total records for pagination
|
||||||
|
var totalCount int64
|
||||||
|
query.Count(&totalCount)
|
||||||
|
|
||||||
|
// Retrieve file metadata with pagination
|
||||||
|
var fileMetadata []db.FileMetadata
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Job").Preload("Job.Config").
|
||||||
|
Order("file_metadata.processed_time DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&fileMetadata).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Render the file metadata list template
|
||||||
|
data := components.FileMetadataListData{
|
||||||
|
Files: fileMetadata,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: jobIDStr,
|
||||||
|
FileName: fileName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total count is not exactly divisible by limit, add one more page
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an HTMX request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
|
||||||
|
if isHtmxRequest {
|
||||||
|
// For HTMX requests, render just the partial template
|
||||||
|
components.FileMetadataListPartial(data).Render(ctx, c.Writer)
|
||||||
|
} else {
|
||||||
|
// For full page requests, render the complete template
|
||||||
|
components.FileMetadataList(ctx, data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadataDetails displays detailed information about a specific file
|
||||||
|
func (h *FileMetadataHandler) GetFileMetadataDetails(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Get file ID from URL parameter
|
||||||
|
fileID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve file metadata
|
||||||
|
var fileMetadata db.FileMetadata
|
||||||
|
err = h.DB.DB.Preload("Job").Preload("Job.Config").First(&fileMetadata, fileID).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has access to this file (file must belong to a job created by the user)
|
||||||
|
var jobCreator uint
|
||||||
|
err = h.DB.DB.Model(&db.Job{}).Where("id = ?", fileMetadata.JobID).Pluck("created_by", &jobCreator).Error
|
||||||
|
if err != nil || jobCreator != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to view this file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Render the file metadata details template
|
||||||
|
data := components.FileMetadataDetailsData{
|
||||||
|
File: fileMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
components.FileMetadataDetails(ctx, data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileMetadataForJob displays file metadata for a specific job
|
||||||
|
func (h *FileMetadataHandler) GetFileMetadataForJob(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Get job ID from URL parameter
|
||||||
|
jobID, err := strconv.ParseUint(c.Param("job_id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid job ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has access to this job
|
||||||
|
var job db.Job
|
||||||
|
err = h.DB.DB.Where("id = ?", jobID).First(&job).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.CreatedBy != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to view this job's files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query parameters for pagination
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
|
||||||
|
// Base query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Where("job_id = ?", jobID)
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total records for pagination
|
||||||
|
var totalCount int64
|
||||||
|
query.Count(&totalCount)
|
||||||
|
|
||||||
|
// Retrieve file metadata with pagination
|
||||||
|
var fileMetadata []db.FileMetadata
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err = query.Preload("Job").Preload("Job.Config").
|
||||||
|
Order("processed_time DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&fileMetadata).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Render the file metadata list template
|
||||||
|
data := components.FileMetadataListData{
|
||||||
|
Files: fileMetadata,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Job: &job,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: strconv.FormatUint(uint64(job.ID), 10),
|
||||||
|
FileName: fileName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total count is not exactly divisible by limit, add one more page
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an HTMX request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
|
||||||
|
if isHtmxRequest {
|
||||||
|
// For HTMX requests, render just the partial template
|
||||||
|
components.FileMetadataListPartial(data).Render(ctx, c.Writer)
|
||||||
|
} else {
|
||||||
|
// For full page requests, render the complete template
|
||||||
|
components.FileMetadataList(ctx, data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchFileMetadata searches file metadata based on various criteria
|
||||||
|
func (h *FileMetadataHandler) SearchFileMetadata(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Query parameters for search and pagination
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
jobIDStr := c.Query("job_id")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
hash := c.Query("hash")
|
||||||
|
startDate := c.Query("start_date")
|
||||||
|
endDate := c.Query("end_date")
|
||||||
|
|
||||||
|
// Base query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
query = query.Where("file_metadata.job_id = ?", jobID)
|
||||||
|
} else {
|
||||||
|
// Only show files from jobs created by the current user
|
||||||
|
query = query.Where("jobs.created_by = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("file_metadata.status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_metadata.file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash != "" {
|
||||||
|
query = query.Where("file_metadata.file_hash = ?", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startDate != "" {
|
||||||
|
query = query.Where("file_metadata.processed_time >= ?", startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDate != "" {
|
||||||
|
query = query.Where("file_metadata.processed_time <= ?", endDate+" 23:59:59")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total records for pagination
|
||||||
|
var totalCount int64
|
||||||
|
query.Count(&totalCount)
|
||||||
|
|
||||||
|
// Retrieve file metadata with pagination
|
||||||
|
var fileMetadata []db.FileMetadata
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Job").Preload("Job.Config").
|
||||||
|
Order("file_metadata.processed_time DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&fileMetadata).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Render the file metadata search template
|
||||||
|
data := components.FileMetadataSearchData{
|
||||||
|
Files: fileMetadata,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: jobIDStr,
|
||||||
|
FileName: fileName,
|
||||||
|
Hash: hash,
|
||||||
|
StartDate: startDate,
|
||||||
|
EndDate: endDate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total count is not exactly divisible by limit, add one more page
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add HTMX request checking and conditional rendering
|
||||||
|
ctx = context.WithValue(c.Request.Context(), "userID", userID)
|
||||||
|
|
||||||
|
// Check if this is an HTMX request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true" || c.Query("htmx") == "true"
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
|
||||||
|
if isHtmxRequest {
|
||||||
|
// For HTMX requests, render just the partial template
|
||||||
|
components.FileMetadataSearchContent(data).Render(ctx, c.Writer)
|
||||||
|
} else {
|
||||||
|
// For full page requests, render the complete template
|
||||||
|
components.FileMetadataSearch(ctx, data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFileMetadata deletes a file metadata record
|
||||||
|
func (h *FileMetadataHandler) DeleteFileMetadata(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
fmt.Println("Deleting file metadata")
|
||||||
|
|
||||||
|
// Get file ID from URL parameter
|
||||||
|
fileID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has access to this file
|
||||||
|
var fileMetadata db.FileMetadata
|
||||||
|
err = h.DB.DB.Preload("Job").First(&fileMetadata, fileID).Error
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobCreator uint
|
||||||
|
err = h.DB.DB.Model(&db.Job{}).Where("id = ?", fileMetadata.JobID).Pluck("created_by", &jobCreator).Error
|
||||||
|
if err != nil || jobCreator != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to delete this file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the file metadata
|
||||||
|
err = h.DB.DeleteFileMetadata(uint(fileID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("File deleted successfully")
|
||||||
|
|
||||||
|
// Check if this is an HTMX request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true"
|
||||||
|
|
||||||
|
if isHtmxRequest {
|
||||||
|
// For HTMX requests, just return a 200 status - client will handle UI updates
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
} else {
|
||||||
|
// For regular browser requests, redirect to the file list
|
||||||
|
c.Redirect(http.StatusFound, "/files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFileMetadataPartial handles rendering just the partial template for file metadata
|
||||||
|
func (h *FileMetadataHandler) HandleFileMetadataPartial(c *gin.Context) {
|
||||||
|
// Check if this is an HTMX request or a direct browser request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true"
|
||||||
|
|
||||||
|
// If it's a direct browser request (not from HTMX), redirect to the full page
|
||||||
|
if !isHtmxRequest {
|
||||||
|
// Get all query parameters
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
|
||||||
|
// Rebuild query string for the redirect
|
||||||
|
c.Redirect(http.StatusFound, "/files?"+query.Encode())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Query parameters for filtering and pagination
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
jobIDStr := c.Query("job_id")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
|
||||||
|
// Base query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
query = query.Where("file_metadata.job_id = ?", jobID)
|
||||||
|
} else {
|
||||||
|
// Only show files from jobs created by the current user
|
||||||
|
query = query.Where("jobs.created_by = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("file_metadata.status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_metadata.file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total records for pagination
|
||||||
|
var totalCount int64
|
||||||
|
query.Count(&totalCount)
|
||||||
|
|
||||||
|
// Retrieve file metadata with pagination
|
||||||
|
var fileMetadata []db.FileMetadata
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Job").Preload("Job.Config").
|
||||||
|
Order("file_metadata.processed_time DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&fileMetadata).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file metadata"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context for template
|
||||||
|
ctx := components.CreateTemplateContext(c)
|
||||||
|
|
||||||
|
// Prepare job pointer if needed
|
||||||
|
var job *db.Job
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
var jobRecord db.Job
|
||||||
|
if err := h.DB.DB.First(&jobRecord, jobID).Error; err == nil {
|
||||||
|
job = &jobRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the file metadata list template
|
||||||
|
data := components.FileMetadataListData{
|
||||||
|
Files: fileMetadata,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Job: job,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: jobIDStr,
|
||||||
|
FileName: fileName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If total count is not exactly divisible by limit, add one more page
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
components.FileMetadataListPartial(data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFileMetadataSearchPartial handles partial updates for search results
|
||||||
|
func (h *FileMetadataHandler) HandleFileMetadataSearchPartial(c *gin.Context) {
|
||||||
|
// Check if this is an HTMX request or a direct browser request
|
||||||
|
isHtmxRequest := c.GetHeader("HX-Request") == "true"
|
||||||
|
|
||||||
|
// If it's a direct browser request (not from HTMX), redirect to the full page
|
||||||
|
if !isHtmxRequest {
|
||||||
|
// Get all query parameters
|
||||||
|
query := c.Request.URL.Query()
|
||||||
|
|
||||||
|
// Rebuild query string for the redirect
|
||||||
|
c.Redirect(http.StatusFound, "/files/search?"+query.Encode())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.GetUint("userID")
|
||||||
|
|
||||||
|
// Query parameters for search and pagination
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
jobIDStr := c.Query("job_id")
|
||||||
|
fileName := c.Query("filename")
|
||||||
|
hash := c.Query("hash")
|
||||||
|
startDate := c.Query("start_date")
|
||||||
|
endDate := c.Query("end_date")
|
||||||
|
|
||||||
|
// Execute the search query
|
||||||
|
query := h.DB.DB.Model(&db.FileMetadata{}).Joins("JOIN jobs ON file_metadata.job_id = jobs.id")
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if jobIDStr != "" {
|
||||||
|
jobID, _ := strconv.ParseUint(jobIDStr, 10, 64)
|
||||||
|
query = query.Where("file_metadata.job_id = ?", jobID)
|
||||||
|
} else {
|
||||||
|
// Only show files from jobs created by the current user
|
||||||
|
query = query.Where("jobs.created_by = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("file_metadata.status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName != "" {
|
||||||
|
query = query.Where("file_metadata.file_name LIKE ?", "%"+fileName+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash != "" {
|
||||||
|
query = query.Where("file_metadata.file_hash = ?", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startDate != "" {
|
||||||
|
query = query.Where("file_metadata.processed_time >= ?", startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDate != "" {
|
||||||
|
query = query.Where("file_metadata.processed_time <= ?", endDate+" 23:59:59")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total results
|
||||||
|
var totalCount int64
|
||||||
|
if err := query.Count(&totalCount).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order and paginate the results
|
||||||
|
var files []db.FileMetadata
|
||||||
|
if err := query.
|
||||||
|
Preload("Job").
|
||||||
|
Order("file_metadata.processed_time DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Offset((page - 1) * limit).
|
||||||
|
Find(&files).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search files"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the template data
|
||||||
|
data := components.FileMetadataSearchData{
|
||||||
|
Files: files,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
TotalPages: int(totalCount) / limit,
|
||||||
|
Filter: components.FileMetadataFilter{
|
||||||
|
Status: status,
|
||||||
|
JobID: jobIDStr,
|
||||||
|
FileName: fileName,
|
||||||
|
Hash: hash,
|
||||||
|
StartDate: startDate,
|
||||||
|
EndDate: endDate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if int(totalCount)%limit > 0 {
|
||||||
|
data.TotalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(c.Request.Context(), "userID", userID)
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
components.FileMetadataSearchContent(data).Render(ctx, c.Writer)
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/starfleetcptn/gomft/components"
|
"github.com/starfleetcptn/gomft/components"
|
||||||
@@ -187,6 +187,10 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
|
|||||||
authorized.POST("/profile/theme", h.HandleUpdateTheme)
|
authorized.POST("/profile/theme", h.HandleUpdateTheme)
|
||||||
authorized.POST("/logout", h.HandleLogout)
|
authorized.POST("/logout", h.HandleLogout)
|
||||||
|
|
||||||
|
// File metadata routes
|
||||||
|
fileMetadataHandler := &FileMetadataHandler{DB: h.DB}
|
||||||
|
fileMetadataHandler.Register(authorized)
|
||||||
|
|
||||||
// AJAX routes for dashboard
|
// AJAX routes for dashboard
|
||||||
authorized.GET("/dashboard/data", h.HandleDashboardData)
|
authorized.GET("/dashboard/data", h.HandleDashboardData)
|
||||||
authorized.GET("/dashboard/jobs", h.HandleDashboardJobsData)
|
authorized.GET("/dashboard/jobs", h.HandleDashboardJobsData)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
// AuthMiddleware is a middleware function that checks if the request has a valid JWT token
|
// AuthMiddleware is a middleware function that checks if the request has a valid JWT token
|
||||||
func (m *Middleware) AuthMiddleware() gin.HandlerFunc {
|
func (m *Middleware) AuthMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
Reference in New Issue
Block a user