Compare commits

..
82 Commits
Author SHA1 Message Date
StarFleetCPTN 7ba40483e0 Add toast deduplication and enhance provider test status notifications 2025-04-19 17:03:33 -07:00
StarFleetCPTN 4b5929331f Update destination_type column name in drive type migration 2025-04-19 14:49:33 -07:00
StarFleetCPTN d19f13f137 Fix column name in drive type migration from dest_type to destination_type 2025-04-19 14:39:10 -07:00
StarFleetCPTN 25609dfe6f update package-lock.json 2025-04-19 13:26:18 -07:00
StarFleetCPTN b9acf47530 Add gomftctl CLI tool and encryption key rotation utilities 2025-04-19 13:22:40 -07:00
StarFleetCPTN 495b844c00 Add audit logging to storage providers 2025-04-19 07:58:14 -07:00
StarFleetCPTN 98e20ea843 Merge branch 'transfer-calendar' into development 2025-04-19 06:25:32 -07:00
StarFleetCPTN 4c5accaae4 Add development builds 2025-04-18 21:30:52 -07:00
StarFleetCPTN 6e8fe9ad72 Update provider type from "drive" to "gdrive" across codebase for Google Drive storage 2025-04-18 21:12:47 -07:00
StarFleetCPTN d9cb1cac03 Add storage provider import UI with credential management and user guide documentation 2025-04-18 16:51:57 -07:00
StarFleetCPTN 5da298ef0b Add storage provider import feature with rclone config support 2025-04-18 16:16:49 -07:00
StarFleetCPTN 23db71c4a8 Add Google Drive and Photos support with headless auth for storage providers 2025-04-18 12:41:10 -07:00
StarFleetCPTN b819162732 feat: Update storage provider form and validation enhancements
- Added new HTML elements and JavaScript functionality to improve the user experience for WebDAV and Hetzner storage providers.
- Updated form field descriptions and placeholders for clarity, ensuring users understand the required input.
- Enhanced validation logic for WebDAV to ensure the host includes the protocol.
- Improved logging for debugging during form submissions, particularly for WebDAV credentials.
- Removed the deprecated provider form component to streamline the codebase.
2025-04-18 03:54:48 -07:00
StarFleetCPTN dc665b9fa4 feat: Enhance configuration forms and validation for storage providers
- Added hidden input fields to ensure critical values are submitted with the form for S3 and other storage providers.
- Implemented JavaScript functionality to auto-populate hidden fields based on user input for S3-compatible providers.
- Updated validation logic for WebDAV and SMB providers to ensure required fields are checked.
- Enhanced the configuration handler to explicitly set fields for various storage provider types, including S3, Wasabi, MinIO, and B2.
- Improved error handling and logging for form submissions and provider credential management.
2025-04-18 02:43:22 -07:00
StarFleetCPTN 776c4971a6 feat: Add UI testing setup and storage provider tests
- Introduced a new script for installing UI testing dependencies, including Node.js and Playwright browsers.
- Added comprehensive tests for managing storage providers, covering creation, editing, deletion, and connection testing.
- Implemented helper functions for user login and provider management in the test setup.
- Enhanced test coverage for various storage provider types, including SFTP, FTP, SMB, S3, and cloud services.
2025-04-16 19:25:41 -07:00
StarFleetCPTN 130a3b0d1c feat: Enhance storage provider form and configuration
- Updated the storage provider form to categorize options into server-based, object storage, web storage, and cloud storage.
- Improved input placeholders for clarity and added descriptions for fields.
- Introduced new storage provider types: WebDAV, Nextcloud, Wasabi, MinIO, and B2.
- Added JavaScript functionality to auto-populate default ports based on selected provider type.
- Updated .gitignore to include test results and logs, and added Playwright configuration for testing.
2025-04-16 19:24:16 -07:00
StarFleetCPTN 31871bd16e feat: Implement storage provider management functionality
- Added new routes and handlers for managing storage providers, including creation, editing, and deletion.
- Introduced a new StorageProvider form component for user input.
- Enhanced the database schema to support storage provider references in transfer configurations.
- Implemented encryption for sensitive fields in storage provider data.
- Added tests for storage provider API endpoints and integration with the database.
- Updated frontend components to support storage provider selection and testing.
2025-04-16 17:18:53 -07:00
StarFleetCPTN 37d507ade6 feat: Implement calendar view for scheduled jobs
- Added a new calendar view to display scheduled jobs for the next two months.
- Introduced components for generating calendar events from job data.
- Integrated FullCalendar for enhanced calendar functionality.
- Updated routing to include a new endpoint for the calendar view.
- Enhanced layout to include a link to the calendar in the navigation.
- Added necessary CSS for FullCalendar styling and functionality.
- Included tooltips for event details and filtering options for job visibility.
2025-04-13 08:49:00 -07:00
StarFleetCPTN 88d0ac815a Merge pull request #92 from StarFleetCPTN/development
style: Add hero logo styling to custom CSS
2025-04-12 15:56:02 -05:00
StarFleetCPTN ecb9ec874c style: Add hero logo styling to custom CSS
- Introduced new styles for the hero logo, including width, height, and margin adjustments.
2025-04-12 13:55:32 -07:00
StarFleetCPTN ee97a7f9be Merge pull request #91 from StarFleetCPTN/development
chore: Update Docusaurus deployment workflow and image handling
2025-04-12 15:50:59 -05:00
StarFleetCPTN 5f4db03982 chore: Update Docusaurus deployment workflow and image handling
- Enhanced the deployment workflow to ensure screenshots are copied to the correct static directory.
- Added a new script to fix image paths in Markdown files for better compatibility with the updated structure.
- Updated Docusaurus configuration to streamline static directory management and support image loading.
- Adjusted documentation to reference the new image paths.
2025-04-12 13:50:30 -07:00
StarFleetCPTN 4c8d122488 Merge pull request #90 from StarFleetCPTN/development
chore: Enhance Docusaurus configuration and update image paths
2025-04-12 15:44:22 -05:00
StarFleetCPTN 05d4861dbb chore: Enhance Docusaurus configuration and update image paths
- Added explicit configuration for static directories in Docusaurus.
- Updated npm script to ensure multiple screenshot paths are created and populated.
- Adjusted image paths in documentation to use relative links for better compatibility.
2025-04-12 13:43:28 -07:00
StarFleetCPTN 3d103cb6f6 Merge pull request #89 from StarFleetCPTN/development
chore: Update Docusaurus deployment workflow and documentation structure
2025-04-12 15:36:47 -05:00
StarFleetCPTN 2ada2b2ea6 chore: Update Docusaurus deployment workflow and documentation structure
- Added a step to copy screenshots to the static directory during deployment.
- Updated .gitignore to keep the README file in the screenshots directory.
- Added a new README file in the screenshots directory to explain its purpose and usage.
- Introduced a new npm script to prepare screenshots for the documentation site.
2025-04-12 13:36:00 -07:00
StarFleetCPTN 7e630f9337 Update docusaurus-deploy.yml 2025-04-12 13:26:47 -07:00
StarFleetCPTN a59da16b4c Update docusaurus-deploy.yml 2025-04-12 13:23:34 -07:00
StarFleetCPTN adccd4314b Merge pull request #88 from StarFleetCPTN/development
chore: Update GitHub Actions to use latest versions of actions for Do…
2025-04-12 15:17:00 -05:00
StarFleetCPTN 4d570e502b chore: Update GitHub Actions to use latest versions of actions for Docusaurus deployment
- Upgraded actions/checkout, actions/setup-node, actions/configure-pages, actions/upload-pages-artifact, and actions/deploy-pages to their respective v4 versions in the Docusaurus deployment workflow.
2025-04-12 13:15:59 -07:00
StarFleetCPTN d948849915 Merge pull request #87 from StarFleetCPTN/development
Documentation and Docker Arm Support
2025-04-12 15:14:08 -05:00
StarFleetCPTN 834f066704 feat: Integrate Docusaurus for documentation and enhance project structure
- Added Docusaurus configuration and initial setup for documentation.
- Created multiple documentation pages covering installation, configuration, and core concepts.
- Implemented a GitHub Actions workflow for automatic deployment of documentation to GitHub Pages.
- Updated .gitignore to exclude Docusaurus build artifacts and dependencies.
- Enhanced README with a link to the online documentation.
2025-04-12 13:01:53 -07:00
StarFleetCPTN 0aa68bc8e7 feat: Add architecture support and improve Dockerfile for multi-platform builds
- Introduced architecture-related build arguments (TARGETOS, TARGETARCH, TARGETVARIANT) to the Dockerfile.
- Updated the build command to use dynamic OS and architecture values.
- Enhanced rclone installation to support multiple architectures (amd64, arm64, arm-v7).
- Updated GitHub workflows to specify supported platforms for Docker builds.
2025-04-11 20:32:48 -07:00
StarFleetCPTN e9a005b658 feat: Implement version checking and update notification in dashboard
- Added functionality to fetch the latest GitHub release version.
- Enhanced the dashboard to display current and latest version information.
- Updated the version comparison logic to handle special cases and non-semver formats.
- Improved the dashboard template to link to the latest release on GitHub.
2025-04-11 20:29:47 -07:00
StarFleetCPTN 82f73cfe60 Merge pull request #86 from StarFleetCPTN/development
New UI Schedule Builder and Bug Fixes
2025-04-11 20:50:34 -05:00
StarFleetCPTN 032e188a24 feat: Enhance README and layout with Discord integration
- Added a Discord badge to the README for community engagement.
- Integrated Discord link in the layout template for easy access to the server.
- Updated job scheduling instructions in the README to include a visual schedule builder and advanced options for better user experience.
2025-04-11 18:49:40 -07:00
StarFleetCPTN 2dbf8de089 feat: Revamp layout and notification components for improved user experience
- Replaced icons with images and updated text labels in the layout template for better clarity.
- Enhanced mobile menu structure and added logo for consistent branding.
- Removed deprecated notification form and dialog components to streamline the codebase.
- Improved notification handling in the backend, ensuring correct URL formatting and payload structure for notifications.
- Added debug logging for notification requests to facilitate troubleshooting.
2025-04-11 18:32:36 -07:00
StarFleetCPTN 1c0459683b feat: Add version check functionality to dashboard
- Introduced new fields for LatestVersion and CurrentVersion in DashboardData struct.
- Implemented isNewerVersionAvailable function to compare current and latest versions.
- Updated dashboard template to display application version and notify users of available updates with appropriate styling.
2025-04-11 18:14:52 -07:00
StarFleetCPTN 856a8403b3 feat: Update notification count display with improved styling and accessibility
- Enhanced the notification count element with a border for better visibility.
- Added a hidden span element to maintain layout consistency when the notification count is zero.
2025-04-11 18:14:41 -07:00
StarFleetCPTN dc6209dbc1 feat: Enhance configuration form with validation and error handling
- Implemented validation for the configuration form to ensure required fields are filled before submission.
- Added a dynamic error display for user feedback, including validation messages for configuration name and paths.
- Ensured at least one concurrent transfer is set by default to improve user experience.
- Updated form layout to include accessibility features and improved error handling for various input fields.
2025-04-11 12:17:03 -07:00
StarFleetCPTN 4b540faec4 feat: Implement form validation for job creation and editing
- Added a comprehensive validation script for the job form to ensure all fields are correctly filled before submission.
- Introduced error handling for job name, schedule, and configuration selection, displaying relevant messages to users.
- Enhanced the user interface with error containers that dynamically show validation messages.
- Updated the job form layout to include validation attributes and improve accessibility.
2025-04-11 12:03:16 -07:00
StarFleetCPTN 2f6c95dd51 feat: Add schedule builder functionality to job form
- Introduced a new scheduling interface allowing users to select between interval, daily, weekly, monthly, or custom cron expressions.
- Implemented dynamic UI updates to show relevant input fields based on the selected schedule type.
- Enhanced cron expression parsing to pre-fill the form based on existing values.
- Updated job form layout to accommodate the new scheduling options and improve user experience.
2025-04-11 07:29:29 -07:00
StarFleetCPTN beb77d3fe9 Merge pull request #85 from StarFleetCPTN/development
feat: Enhance log viewer with CSS class handling and code cleanup
2025-04-10 23:39:58 -05:00
StarFleetCPTN 152f137cad feat: Enhance log viewer with CSS class handling and code cleanup
- Added a helper function to determine CSS classes based on log levels for improved visual representation.
- Refactored log entry rendering to use string concatenation for better readability.
- Updated CSV export logic to ensure proper escaping of messages.
- Cleaned up redundant code by removing the previous implementation of the CSS class function.
2025-04-10 21:38:31 -07:00
StarFleetCPTN 1358cb0e6d Merge pull request #84 from StarFleetCPTN/development
New Log Viwer and Bug Fixes
2025-04-10 22:57:59 -05:00
StarFleetCPTN ca54b450de refactor: Remove log generator functionality and update UI components
- Eliminated the log generator feature from the admin logs template and related handlers.
- Updated icons in the log viewer for consistency and improved visual clarity.
- Adjusted mobile dropdown functionality in the layout template for better user experience.
- Cleaned up debug logging statements in the WebSocket handler for recent logs.
2025-04-10 20:57:07 -07:00
StarFleetCPTN 2221e6a29c feat: Implement log viewer and WebSocket logging
- Added a new log viewer component to display real-time logs.
- Integrated WebSocket support for broadcasting log entries to connected clients.
- Updated main application to initialize logging and handle log directory creation.
- Enhanced error handling for log loading and broadcasting.
- Introduced new routes for accessing the log viewer and WebSocket stream.
2025-04-10 20:11:34 -07:00
StarFleetCPTN db44823ecf fix: Improve database initialization and connection handling
- Added logic to close the database connection after migrations to ensure a clean state.
- Implemented reconnection to the database post-migrations to prevent potential issues.
- Updated rclone connection logic to use 'host' instead of 'endpoint' for webdav and nextcloud providers, enhancing consistency in configuration arguments.
2025-04-09 19:15:17 -07:00
StarFleetCPTN 66623693df Merge pull request #80 from StarFleetCPTN/development
Bug Fixes + New Providers
2025-04-08 23:36:15 -05:00
StarFleetCPTN e2ab2eb603 refactor: Update rclone connection logic for provider handling
- Consolidated provider handling for Hetzner and S3-compatible providers (Minio, Wasabi) to improve clarity and maintainability.
- Adjusted configuration arguments to use the primary provider name consistently across different cases.
- Enhanced endpoint handling for Minio and B2 providers, ensuring correct parameter usage in rclone configuration generation.
2025-04-08 21:35:19 -07:00
StarFleetCPTN 87bdfc5e91 update readme.md with new providers 2025-04-08 21:05:45 -07:00
StarFleetCPTN 78fe589fcf refactor: Remove recovery migration files for transfer_configs, notification_services, and auth_providers
- Deleted migration files that handled recovery for inconsistent states in transfer_configs, notification_services, and auth_providers tables.
- Updated migration management to reflect the removal of these recovery steps, ensuring cleaner migration logic.
2025-04-08 21:00:41 -07:00
StarFleetCPTN bcd09af2d6 fix: Consolidate Hetzner support under SFTP provider
- Updated the rclone connection logic to treat Hetzner as an SFTP provider, simplifying the configuration process.
- Removed redundant case handling for Hetzner, streamlining the code and enhancing maintainability.
2025-04-08 20:33:32 -07:00
StarFleetCPTN de97ad6b50 feat: Add support for Hetzner Storage Box integration
- Introduced new source and destination forms for Hetzner Storage Box, allowing users to configure their storage settings.
- Updated configuration generation logic to handle Hetzner, including necessary parameters such as host, port, username, and authentication type.
- Enhanced user interface with clear instructions and security notes for Hetzner, improving user experience and security awareness.
- Updated existing logic to accommodate Hetzner in rclone configuration generation for both source and destination types.
2025-04-08 20:24:13 -07:00
StarFleetCPTN 417841b3d2 refactor: Enhance user and group management in entrypoint script
- Added checks for existing groups and users before creation to prevent conflicts and improve error handling.
- Updated user and group creation logic to use existing identifiers when available, ensuring smoother execution.
- Improved ownership setting for application directories and .env file to accommodate potential changes in group names.
- Enhanced verification of UID/GID changes with clearer warning messages for better user feedback.
2025-04-08 17:45:10 -07:00
StarFleetCPTN 11f8631929 feat: Add support for Backblaze B2 and Wasabi cloud storage
- Introduced new source and destination forms for Backblaze B2 and Wasabi, allowing users to configure their cloud storage settings.
- Updated the configuration generation logic to handle B2 and Wasabi, including necessary parameters such as access keys, bucket names, and endpoints.
- Enhanced the user interface to provide clear instructions and security notes for both storage options, improving user experience and security awareness.
2025-04-08 17:30:35 -07:00
StarFleetCPTN d650247817 Merge pull request #78 from StarFleetCPTN/development
Bug fixes
2025-04-07 21:15:08 -05:00
StarFleetCPTN 46df27e8c6 feat: Add rclone config regeneration after API updates
- Implemented logic to regenerate the rclone config file after updating configurations in the database.
- Added logging to capture warnings if the regeneration fails, while ensuring the update process continues smoothly.
- Enhanced user feedback by logging successful regeneration of the rclone config.
2025-04-07 19:02:02 -07:00
StarFleetCPTN bb75bc9400 refactor: Remove deprecated file metadata templates and associated JavaScript
- Deleted several unused templates related to file metadata, including details, dialog, list, search, and utility scripts, to streamline the codebase.
- This cleanup enhances maintainability by removing legacy code that is no longer in use.
2025-04-07 13:31:03 -07:00
StarFleetCPTN 8acdfc216d refactor: Remove check location buttons from local destination and source forms
- Eliminated the "Check Location" button from both LocalDestinationForm and LocalSourceForm templates to streamline the user interface.
- This change simplifies the forms by removing unnecessary elements, enhancing overall usability.
2025-04-07 11:32:58 -07:00
StarFleetCPTN 16e7b7e6e5 refactor: Improve toast notification rendering and event handling
- Refactored the ShowToastJS function to create toast elements using DOM methods for better safety and maintainability.
- Changed the event listener for the 'showToast' event from document.body to document for improved event handling.
- Ensured that the message content is set using textContent to prevent potential XSS vulnerabilities.
2025-04-07 11:21:11 -07:00
StarFleetCPTN 0f46b20fc4 refactor: Update form field identifiers and improve URL handling for NextCloud and WebDAV
- Changed input field IDs and names from 'dest_endpoint' and 'source_endpoint' to 'dest_host' and 'source_host' for consistency across NextCloud and WebDAV forms.
- Updated placeholder texts for username, password, and file path fields to be more user-friendly.
- Enhanced the URL parsing and validation logic in the transfer configuration generation to ensure proper handling of WebDAV and NextCloud URLs, improving robustness and error handling.
2025-04-07 11:05:08 -07:00
StarFleetCPTN 3654161245 feat: Enhance login functionality to support external authentication providers
- Updated the Login template to conditionally display external authentication options based on the presence of enabled providers.
- Modified the HandleLoginPage and HandleLogin functions to check for available external providers and pass this information to the Login template.
- Improved user experience by dynamically showing or hiding external sign-in options during the login process.
2025-04-07 09:24:46 -07:00
StarFleetCPTN d691fc837e feat: Add recovery migrations for notification_services and auth_providers tables
- Introduced two new migrations (011b_recover_notification_services_rename and 011c_recover_auth_providers_rename) to check and recover the notification_services and auth_providers tables if they were incorrectly renamed during a previous migration.
- Implemented logic to handle various states of the tables, including logging warnings for potential issues and ensuring safe recovery actions.
- Updated the migration sequence to include these new recovery steps, enhancing the robustness of the database migration process.
2025-04-07 09:06:22 -07:00
StarFleetCPTN b9af8fc051 Merge pull request #76 from StarFleetCPTN/development
Fixes
2025-04-07 02:34:31 -05:00
StarFleetCPTN e14d90e37e feat: Add recovery migration for transfer_configs table rename
- Introduced a new migration (011a_recover_transfer_configs_rename) to check and recover the transfer_configs table if it was incorrectly renamed to _transfer_configs_old during a previous migration.
- Implemented logic to handle various states of the tables, including logging warnings for potential issues.
- Ensured that the migration can be safely added to the existing migration sequence.

This migration enhances the robustness of the database migration process by addressing potential inconsistencies.
2025-04-07 00:24:10 -07:00
StarFleetCPTN 71ec298db5 fix: Update admin user deletion logic to prevent deletion of the last administrator
- Changed the logic in the HandleDeleteUser function to check for other administrators using the actual 'is_admin' column instead of counting all admins.
- Prevented deletion if no other administrators exist, ensuring at least one admin remains in the system.

https://github.com/StarFleetCPTN/GoMFT/issues/67
2025-04-06 23:58:56 -07:00
StarFleetCPTN 53cf4bc3c5 feat: Add migration to clean up invalid boolean values in database
- Introduced a new migration (013_cleanup_invalid_booleans) to update boolean columns represented as integers in the transfer_configs, notification_services, and auth_providers tables.
- Ensured that only valid values (0, 1, or NULL) are retained, setting any invalid values to 0.
- Added error handling for potential issues during the migration process, including checks for table existence before attempting updates.

This migration improves data integrity by standardizing boolean representations across the database.

https://github.com/StarFleetCPTN/GoMFT/issues/69
2025-04-06 23:52:46 -07:00
StarFleetCPTN 0f9a4cffd2 fix: Improve UID/GID management in entrypoint script
- Enhanced the logic for updating user and group IDs by deleting and recreating the user/group if the current IDs do not match the specified values.
- Added verification steps to ensure the UID/GID changes were successful, with appropriate error handling and logging for failures.
- Streamlined the process for handling Alpine Linux user management, improving clarity and maintainability of the script.

https://github.com/StarFleetCPTN/GoMFT/issues/68
2025-04-06 23:35:48 -07:00
StarFleetCPTN 105d223be4 refactor: Simplify job scheduling validation logic
- Removed manual parsing and validation of cron expressions, relying on the cron instance's AddFunc for validation.
- Enhanced error logging to provide more informative messages when scheduling fails, including the original schedule string.
- Streamlined the scheduling process by using the original schedule directly, improving code clarity and maintainability.

https://github.com/StarFleetCPTN/GoMFT/issues/71
2025-04-06 23:27:00 -07:00
StarFleetCPTN 7a54a83c8d fix: Improve migration process by handling potential leftover temporary tables
- Added logic to drop any existing temporary tables from previous failed migration runs before renaming the current table.
- Enhanced error logging to provide warnings if the drop operation fails, ensuring smoother migration execution.

https://github.com/StarFleetCPTN/GoMFT/issues/72
2025-04-06 23:11:41 -07:00
StarFleetCPTN c52267bbb6 Merge pull request #66 from StarFleetCPTN/development
Refactoring and improvements
2025-03-30 12:44:24 -07:00
StarFleetCPTN e93556e537 update readme.md with new env variable. 2025-03-30 12:43:04 -07:00
StarFleetCPTN 391cfbc50c feat: Enhance notification form handling and logging improvements
- Added a hidden input for the notification form to ensure proper handling of the 'is_enabled' state.
- Updated the configuration loading to allow skipping SSL verification with a new environment variable.
- Introduced temporary log file creation for rclone commands, improving error handling and logging of file processing.
- Enhanced the transfer executor to parse rclone logs for file metadata, ensuring accurate tracking of processed files.
- Added debug logging for received form data in the notification service update handler to aid in troubleshooting.
2025-03-30 12:26:50 -07:00
StarFleetCPTN 608da9ce1c feat: Enhance notification service configuration and refactor related components
- Updated notification service models to use pointers for boolean fields, allowing for better handling of enabled states.
- Introduced helper methods for getting and setting enabled states in notification and auth provider models.
- Refactored notification form templates to consolidate event trigger handling, improving code maintainability.
- Added SSL verification configuration to the application settings, allowing users to skip SSL verification for outgoing notifications.
- Enhanced the notification sending logic to respect the SSL verification setting.
- Introduced new migration to alter boolean defaults in the database schema for better compatibility with the updated models.
- Added unit tests for email and Rclone service functionalities to ensure reliability.
2025-03-30 09:56:55 -07:00
StarFleetCPTN b35174f857 feat: Introduce scheduler and notification enhancements
- Integrated new components for job scheduling, including a cron-based scheduler and improved job execution logic.
- Refactored job executor to utilize interfaces for better dependency management and testing.
- Enhanced notification system with a new Notifier interface, allowing for flexible notification service integration.
- Added comprehensive unit tests for job executor, scheduler, and notification components to ensure reliability and correctness.
- Updated metadata handling to support new database interfaces, improving testability and maintainability.
2025-03-29 21:23:52 -07:00
StarFleetCPTN ae18cd1a12 refactor: job scheduling and execution components
- Refactored existing scheduler logic to integrate new components and improve job management.
2025-03-29 19:27:19 -07:00
StarFleetCPTN 68dd9fc173 feat: Add connection testing functionality for Rclone providers
- Introduced a new endpoint to test connections for source and destination providers.
- Implemented a TestResult component to display success or error messages based on connection outcomes.
- Enhanced the configuration form with buttons to initiate connection tests, improving user experience.
- Updated backend logic to handle connection testing and return appropriate feedback to the frontend.
- Refactored existing handlers and routes to accommodate the new testing functionality.
2025-03-29 11:27:48 -07:00
StarFleetCPTN f95720758e feat: Enhance Rclone command configuration with pre-rendered flags and region support
- Added fields for pre-rendering flags in the config form, allowing for better user experience during edits.
- Updated Rclone command options to accept current command ID for pre-selection.
- Introduced region input fields for MinIO source and destination forms, enhancing configuration flexibility.
- Modified backend logic to handle region parameters when generating Rclone configurations.
- Improved flag handling by parsing selected flags and values for better state management in the UI.
2025-03-29 07:59:37 -07:00
StarFleetCPTN d4c07fdc8e refactor: database files 2025-03-29 06:02:03 -07:00
StarFleetCPTN 28fa65df9d feat: Enhance file metadata and notification components
- Added sorting capabilities to file metadata lists, including SortBy and SortDir fields.
- Integrated a toast notification system for user feedback on actions.
- Updated templates and JavaScript for improved user interaction and responsiveness.
- Refactored file metadata handlers to utilize new sorting features and ensure proper data flow.
- Introduced new notification dialog components for better user confirmation on actions.
2025-03-28 22:22:35 -07:00
StarFleetCPTN c52edb75df feat: Implement file metadata management components
- Added new types for file metadata filtering and data structures.
- Created templates for displaying file metadata details, lists, and search functionalities.
- Implemented JavaScript utilities for handling modals and notifications related to file metadata actions.
- Enhanced the user interface with Tailwind CSS for better styling and responsiveness.
- Introduced pagination and search capabilities for file metadata management.
2025-03-28 08:24:09 -07:00
280 changed files with 64623 additions and 8633 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ args_bin = []
bin = "./tmp/main" bin = "./tmp/main"
cmd = "templ generate && go build -o ./tmp/main ." cmd = "templ generate && go build -o ./tmp/main ."
delay = 1000 delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"] exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules", "docs"]
exclude_file = [] exclude_file = []
exclude_regex = ["_test.go", "_templ.go"] exclude_regex = ["_test.go", "_templ.go"]
exclude_unchanged = false exclude_unchanged = false
+224
View File
@@ -0,0 +1,224 @@
name: Development Build
on:
push:
branches:
- 'development'
- 'feature/**'
pull_request:
branches: [ development, feature/** ]
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy to'
required: true
default: 'development'
type: choice
options:
- development
- staging
env:
# Use github.repository as the default image name
IMAGE_NAME: ${{ github.repository }}
REGISTRY: ghcr.io
DOCKERHUB_IMAGE: starfleetcptn/gomft
jobs:
build:
name: Build Development Binaries
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Node.js dependencies
run: npm ci
- name: Build frontend assets
run: |
# Build JavaScript and CSS assets
node build.js
# Ensure the dist directory exists
mkdir -p static/dist
# Verify the build output
ls -la static/dist
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24.x'
cache: true
- name: Install dependencies
run: |
go mod download
# Install templ compiler for template generation
go install github.com/a-h/templ/cmd/templ@latest
- name: Generate template files
run: templ generate
- name: Set Version
id: version
run: |
# For development builds, use branch name or PR number with commit hash
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
VERSION="pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD)"
else
BRANCH=${GITHUB_REF#refs/heads/}
VERSION="${BRANCH//\//-}-$(git rev-parse --short HEAD)"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Also set build timestamp for versioning
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build for Linux (amd64)
run: |
mkdir -p dist
# Define common ldflags with version information
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
# Only build for Linux amd64 for development builds
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
# Create checksums
cd dist
sha256sum * > SHA256SUMS.txt
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dev-binary
path: dist/
retention-days: 7 # Keep development builds for 7 days
docker:
name: Build and Push Development Docker Image
runs-on: ubuntu-latest
needs: build
# Set the permissions needed for the GitHub token to push to GHCR
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# Set up Node.js for frontend build
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Install dependencies
- name: Install dependencies
run: npm ci
# Build frontend assets
- name: Build frontend assets
run: |
node build.js
ls -la static/dist/
# Set version information
- name: Set Version
id: version
run: |
# For development builds, use branch name or PR number with commit hash
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
VERSION="pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD)"
else
BRANCH=${GITHUB_REF#refs/heads/}
VERSION="${BRANCH//\//-}-$(git rev-parse --short HEAD)"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Also set build timestamp for versioning
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Login to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Login to DockerHub
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Extract metadata for GitHub Container Registry
- name: Extract GitHub Container Registry metadata
id: meta-ghcr
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,format=short
type=raw,value=dev-latest
# Extract metadata for DockerHub
- name: Extract DockerHub metadata
id: meta-dockerhub
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKERHUB_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,format=short
type=raw,value=dev-latest
# Build and push Docker image to both registries
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ steps.meta-ghcr.outputs.tags }}
${{ steps.meta-dockerhub.outputs.tags }}
labels: ${{ steps.meta-ghcr.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -96,6 +96,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
build-args: | build-args: |
VERSION=${{ env.VERSION }} VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }} BUILD_TIME=${{ env.BUILD_TIME }}
+1
View File
@@ -97,6 +97,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
build-args: | build-args: |
VERSION=${{ env.VERSION }} VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }} BUILD_TIME=${{ env.BUILD_TIME }}
+83
View File
@@ -0,0 +1,83 @@
name: Deploy Docusaurus to GitHub Pages
on:
push:
branches: [main]
paths: ['docs/**']
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
name: Deploy Docusaurus
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./docs
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
cache-dependency-path: ./docs/package-lock.json
- name: Install dependencies
run: npm ci
- name: Copy screenshots to static directory
run: |
npm run prepare-screenshots
# Ensure the images referenced in the docs are definitely available
mkdir -p static/img
cp -v ../screenshots/dashboard.gomft.png static/img/
cp -v ../screenshots/transfer.config.gomft.png static/img/
ls -la static/img/dashboard.gomft.png static/img/transfer.config.gomft.png
- name: Fix Markdown image paths
run: npm run fix-image-paths
- name: Verify screenshots exist
run: |
echo "Checking if screenshots were copied correctly..."
if [ -f "static/img/dashboard.gomft.png" ]; then
echo "✅ Found dashboard.gomft.png in static/img/"
else
echo "❌ Missing dashboard.gomft.png in static/img/"
exit 1
fi
if [ -f "static/img/transfer.config.gomft.png" ]; then
echo "✅ Found transfer.config.gomft.png in static/img/"
else
echo "❌ Missing transfer.config.gomft.png in static/img/"
exit 1
fi
- name: Build
run: npm run build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./docs/build
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+35 -14
View File
@@ -43,20 +43,7 @@ node_modules/
Thumbs.db Thumbs.db
# Ignore all Go files in the components directory # Ignore all Go files in the components directory
components/*.go *_templ.go
!components/components.go
components/providers/*.go
!components/providers/providers.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/
@@ -74,6 +61,9 @@ configs/
# Ignore the backups directory # Ignore the backups directory
backups/ backups/
# Ignore the tests directory
test-results/
playwright-report/
# Ignore Dirs # Ignore Dirs
/source/ /source/
/destination/ /destination/
@@ -84,3 +74,34 @@ static/dist/
# Ignore binaries # Ignore binaries
gomft gomft
# Docusaurus files
docs/.docusaurus/
docs/.cache-loader/
docs/build/
docs/build-searchindex/
docs/static/search/
docs/static/js/
docs/node_modules/
docs/.env*
docs/*.log
# Added by Claude Task Master
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dev-debug.log
# Environment variables
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS specific
# Task files
tasks.json
tasks/
+27 -8
View File
@@ -35,6 +35,10 @@ WORKDIR /app
ARG VERSION=dev ARG VERSION=dev
ARG BUILD_TIME=unknown ARG BUILD_TIME=unknown
ARG COMMIT=unknown ARG COMMIT=unknown
# Architecture-related build arguments
ARG TARGETOS=linux
ARG TARGETARCH=amd64
ARG TARGETVARIANT=""
# Install build dependencies # Install build dependencies
RUN apk add --no-cache git build-base RUN apk add --no-cache git build-base
@@ -65,16 +69,28 @@ COPY . .
# Generate template files from .templ files # Generate template files from .templ files
RUN templ generate RUN templ generate
# Compile the application with version information # Compile the main application with version information
RUN CGO_ENABLED=0 GOOS=linux go build \ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \ -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
-o /app/gomft -o /app/gomft
# Install rclone # Compile the command line tool with version information
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT}" \
-o /app/gomftctl ./cmd/gomftctl
# Install rclone with appropriate architecture
RUN apk add --no-cache curl unzip && \ RUN apk add --no-cache curl unzip && \
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip && \ if [ "$TARGETARCH" = "arm64" ]; then \
unzip rclone-current-linux-amd64.zip && \ RCLONE_ARCH="arm64"; \
cd rclone-*-linux-amd64 && \ elif [ "$TARGETARCH" = "arm" ]; then \
RCLONE_ARCH="arm-v7"; \
else \
RCLONE_ARCH="amd64"; \
fi && \
curl -O https://downloads.rclone.org/rclone-current-linux-${RCLONE_ARCH}.zip && \
unzip rclone-current-linux-${RCLONE_ARCH}.zip && \
cd rclone-*-linux-${RCLONE_ARCH} && \
cp rclone /usr/local/bin/ && \ cp rclone /usr/local/bin/ && \
chmod 755 /usr/local/bin/rclone && \ chmod 755 /usr/local/bin/rclone && \
cd .. && \ cd .. && \
@@ -87,6 +103,8 @@ FROM alpine:3.19
ARG UID=1000 ARG UID=1000
ARG GID=1000 ARG GID=1000
ARG USERNAME=gomft ARG USERNAME=gomft
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app WORKDIR /app
@@ -99,8 +117,9 @@ RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
RUN addgroup -g ${GID} ${USERNAME} && \ RUN addgroup -g ${GID} ${USERNAME} && \
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME} adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
# Copy the binary from the builder stage # Copy the binaries from the builder stage
COPY --from=builder /app/gomft /app/ COPY --from=builder /app/gomft /app/
COPY --from=builder /app/gomftctl /app/
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
# Copy components # Copy components
@@ -117,7 +136,7 @@ RUN mkdir -p /app/data /app/backups
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
# Set executable permissions # Set executable permissions
RUN chmod +x /app/gomft RUN chmod +x /app/gomft /app/gomftctl
# Set ownership of application files # Set ownership of application files
RUN chown -R ${USERNAME}:${USERNAME} /app RUN chown -R ${USERNAME}:${USERNAME} /app
+59 -592
View File
@@ -6,6 +6,29 @@
GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers. GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers.
<p align="center">
<a href="https://discord.gg/f9dwtM3j">
<img src="https://img.shields.io/discord/1351354052654403675?color=7289da&logo=discord&logoColor=white&label=Discord" alt="Join our Discord server!" />
</a>
<a href="https://starfleetcptn.github.io/GoMFT/">
<img src="https://img.shields.io/badge/docs-online-blue.svg" alt="Documentation" />
</a>
</p>
## 📚 Documentation
Comprehensive documentation is available at [https://starfleetcptn.github.io/GoMFT/](https://starfleetcptn.github.io/GoMFT/).
The documentation includes:
- [Getting Started Guide](https://starfleetcptn.github.io/GoMFT/docs/getting-started)
- [Installation Instructions](https://starfleetcptn.github.io/GoMFT/docs/installation)
- [Configuration Reference](https://starfleetcptn.github.io/GoMFT/docs/configuration)
- [User Guide](https://starfleetcptn.github.io/GoMFT/docs/user-guide)
- [Storage Provider Setup](https://starfleetcptn.github.io/GoMFT/docs/storage-providers)
- [Advanced Features](https://starfleetcptn.github.io/GoMFT/docs/advanced)
- [Troubleshooting](https://starfleetcptn.github.io/GoMFT/docs/troubleshooting)
- [API Reference](https://starfleetcptn.github.io/GoMFT/docs/api)
> [!WARNING] > [!WARNING]
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating. > This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
@@ -44,14 +67,11 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
- SFTP - SFTP
- FTP - FTP
- SMB/CIFS shares - SMB/CIFS shares
- Hetzner Storage Box
- Backblaze B2
- Wasabi
- Local filesystem - Local filesystem
- And more via rclone - And more via rclone
- **Webhook Notifications**: Receive real-time notifications of job events:
- Configurable webhook URLs
- HMAC-SHA256 authentication with secrets
- Custom HTTP headers
- Selectable events (job success, job failure)
- Detailed JSON payload with job information
- **Multiple Notification Services**: Get job status updates through various notification channels: - **Multiple Notification Services**: Get job status updates through various notification channels:
- Email notifications with configurable SMTP settings - Email notifications with configurable SMTP settings
- Webhooks with authentication for custom integrations - Webhooks with authentication for custom integrations
@@ -80,6 +100,7 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
- Optimized for both high-volume small files and large file transfers - Optimized for both high-volume small files and large file transfers
- Maximizes bandwidth utilization for cloud storage providers - Maximizes bandwidth utilization for cloud storage providers
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components - **Web Interface**: User-friendly interface for managing transfers, built with Templ components
- **Command Line Tools**: Administrative tasks can be performed via the `gomftctl` CLI tool
- **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
- **Archive Function**: Option to archive transferred files for backup and compliance - **Archive Function**: Option to archive transferred files for backup and compliance
@@ -123,11 +144,18 @@ cd gomft
2. Install dependencies: 2. Install dependencies:
```bash ```bash
go mod download go mod download
go install github.com/a-h/templ/cmd/templ@latest
``` ```
3. Build the application: 3. Generate template code:
```bash
templ generate
```
4. Build the application and CLI tools:
```bash ```bash
go build -o gomft go build -o gomft
go build -o gomftctl ./cmd/gomftctl
``` ```
### Docker Installation ### Docker Installation
@@ -218,6 +246,7 @@ services:
- GOOGLE_CLIENT_ID=your_google_client_id - GOOGLE_CLIENT_ID=your_google_client_id
- GOOGLE_CLIENT_SECRET=your_google_client_secret - GOOGLE_CLIENT_SECRET=your_google_client_secret
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here - TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
- GOMFT_ENCRYPTION_KEY=your_32_byte_encryption_key_here
- EMAIL_ENABLED=true - EMAIL_ENABLED=true
- EMAIL_HOST=smtp.example.com - EMAIL_HOST=smtp.example.com
- EMAIL_PORT=587 - EMAIL_PORT=587
@@ -243,602 +272,40 @@ docker-compose up -d
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft). For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
--- ## Command Line Tools
## Configuration GoMFT includes a command line tool called `gomftctl` for administrative tasks:
GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created:
```ini
# Basic configuration
SERVER_ADDRESS=:8080
DATA_DIR=/app/data
BACKUP_DIR=/app/backups
JWT_SECRET=change_this_to_a_secure_random_string
BASE_URL=http://localhost:8080
# Google OAuth configuration (optional, for built-in authentication)
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Email configuration
EMAIL_ENABLED=true
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_FROM_EMAIL=gomft@example.com
EMAIL_FROM_NAME=GoMFT
EMAIL_REPLY_TO=
EMAIL_ENABLE_TLS=true
EMAIL_REQUIRE_AUTH=true
EMAIL_USERNAME=smtp_username
EMAIL_PASSWORD=smtp_password
# Two-Factor Authentication configuration
TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
# UserID and GroupID
PUID=1000
PGID=1000
```
### Configuration Options
- `SERVER_ADDRESS`: The address and port to run the server on
- `DATA_DIR`: Directory for storing application data (database and configs)
- `BACKUP_DIR`: Directory for storing database backups
- `JWT_SECRET`: Secret key for JWT token generation
- `BASE_URL`: Base URL for generating links in emails (e.g., password reset links)
- Google OAuth configuration for built-in authentication:
- `GOOGLE_CLIENT_ID`: Your Google OAuth client ID
- `GOOGLE_CLIENT_SECRET`: Your Google OAuth client secret
- Email configuration settings for system notifications and password resets:
- `EMAIL_ENABLED`: Set to `true` to enable email functionality
- `EMAIL_HOST`: SMTP server hostname
- `EMAIL_PORT`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
- `EMAIL_USERNAME`: Username for SMTP authentication
- `EMAIL_PASSWORD`: Password for SMTP authentication
- `EMAIL_FROM_EMAIL`: Email address used as sender
- `EMAIL_FROM_NAME`: Name displayed as the sender
- `EMAIL_REPLY_TO`: Optional reply-to email address
- `EMAIL_ENABLE_TLS`: Set to `true` to use TLS for secure email transmission
- `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
- Two-Factor Authentication (2FA) configuration:
- `TOTP_ENCRYPTION_KEY`: Secret key used to encrypt/decrypt TOTP secrets (for 2FA)
- Should be exactly 32 bytes (characters) for optimal security
- If not set, a default development key will be used (not secure for production)
- If shorter than 32 bytes, it will be automatically padded (less secure)
- If longer than 32 bytes, it will be truncated to 32 bytes
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456`
### Logging Configuration
GoMFT provides configurable logging with rotation support through the following environment variables:
- `LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
- `LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`)
- `LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`)
- `LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`)
- `LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
- `LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
- `error`: Only show errors and critical issues
- `info`: Show errors and general operational information (default)
- `debug`: Show all messages including detailed debugging information
Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing.
---
## Usage
1. Start the server:
```bash ```bash
./gomft # View available commands
./gomftctl --help
# Migrate provider data
./gomftctl migrate-providers
# Rotate security keys
./gomftctl rotate-key --type jwt
# Manage users
./gomftctl user create --email admin@example.com --password secure_password --admin
./gomftctl user list
# Backup database
./gomftctl backup
``` ```
2. Access the web interface at `http://localhost:8080` See the [Admin Tools documentation](https://starfleetcptn.github.io/GoMFT/docs/advanced/admin-tools) for more details.
3. Log in with the default admin account:
- Email: `admin@example.com`
- Password: `admin`
- **Important**: Change this password immediately after first login
4. Create transfer configurations:
- Navigate to "Transfer Configs" section
- Configure source and destination locations with connection details
- Set file patterns and archive options as needed
- Configure performance settings:
- Set "Concurrent Transfers" slider to optimize throughput
- Use higher values (8-16) for many small files or fast networks
- Use lower values (1-4) for large files or limited bandwidth
- Consider source/destination system capabilities when setting
5. Create jobs using your configurations:
- Navigate to "Jobs" section
- Select an existing transfer config
- Set up a schedule using cron expressions or run manually
- Enable/disable as needed
6. Monitor transfers:
- View active and completed transfers on the Dashboard
- Check detailed transfer history with performance metrics
- View job run details including any error messages
7. Configure webhook notifications:
- Enable webhooks in job settings to receive notifications
- Provide a valid webhook URL where notifications will be sent
- Optionally set a webhook secret for HMAC-SHA256 signature verification
- Configure custom HTTP headers in JSON format if needed
- Choose notification triggers (job success, job failure, or both)
- Test your webhook integration with manual job runs
8. **Webhook Notifications**:
- **Webhook Integration**: Send notifications to external systems when jobs complete
- **Secure Authentication**: HMAC-SHA256 signature for webhook verification
- **Custom Headers**: Add custom HTTP headers to webhook requests
- **Flexible Configuration**: Configure different webhooks for different jobs
- **Event Selection**: Choose to send notifications on success, failure, or both
- **Detailed Payload**: Rich JSON payload with complete job execution details
9. **Multiple Notification Services**:
- **Pushbullet Integration**: Send notifications to your devices through Pushbullet
- Device targeting support for specific device delivery
- Customizable title and message templates
- API key-based authentication
- **Ntfy Integration**: Use public ntfy.sh or self-hosted ntfy server
- Topic-based routing of notifications
- Priority levels for different job events
- Optional username/password authentication for private servers
- Customizable title and message templates
- **Gotify Integration**: Send notifications to self-hosted Gotify servers
- Application token-based authentication
- Priority levels (1-10) for different notification importance
- Customizable title and message templates
- **Pushover Integration**: Professional notification delivery service
- Application and user key authentication
- Device targeting for selective delivery
- Sound selection for different notification types
- Priority levels from lowest to emergency
- Customizable title and message templates
- **Common Features**:
- Variable substitution in notification templates
- Job data access in templates (status, files, bytes, times)
- Event-based filtering (job start, completion, errors)
- Success/failure tracking for diagnostic purposes
10. 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
11. Utilize admin tools (administrators only):
- Access the "Admin Tools" section from the navigation menu
- View system statistics and server information
- Create and manage database backups
- Browse and download system log files with the integrated log viewer
- Perform database maintenance and optimization tasks
- View webhook documentation and integration details
### User Management
GoMFT uses a role-based access control system with flexible authentication options:
- **Administrators**: Can create and manage users, access all features
- **Regular Users**: Can manage transfers and view history
#### Authentication Options
1. **Built-in Authentication**:
- Email/password login with secure password hashing
- JWT-based session management
- Password history tracking
- Account lockout protection
- Self-service password reset
2. **External Authentication Providers**:
- **Authentik Integration**:
- Enterprise-grade SSO capabilities
- Automatic user provisioning
- Role synchronization
- Group mapping support
- Secure token exchange
- **OpenID Connect (OIDC)**:
- Standard-compliant identity provider support
- Automatic user creation and updates
- Role mapping from OIDC claims
- Multiple provider support
- Secure token validation
- **OAuth2 Providers**:
- Google authentication
- GitHub integration
- Other OAuth2-compliant providers
- Custom provider configuration
- Automatic profile synchronization
3. **Security Features**:
- Secure password hashing with bcrypt
- JWT-based authentication with tokens
- Password history tracking prevents reuse
- Account lockout after failed attempts
- Two-factor authentication support
- Session management and timeout
- Secure token storage and handling
4. **User Profile Management**:
- Theme preferences (light/dark mode)
- Profile information updates
- Password change functionality
- Two-factor authentication setup
- External account linking
### Two-Factor Authentication (2FA) Implementation
#### Overview
This implementation adds TOTP-based (Time-based One-Time Password) two-factor authentication support to the application, compatible with standard authenticator apps like Google Authenticator, Authy, and others.
#### Features
- TOTP-based authentication (RFC 6238 compliant)
- QR code setup for easy enrollment
- Backup codes for account recovery
- Rate-limited verification attempts
- Secure secret storage
#### Database Changes
The following fields have been added to the `users` table:
- `two_factor_secret`: Stores the TOTP secret key
- `two_factor_enabled`: Boolean flag indicating if 2FA is enabled
- `backup_codes`: Stores recovery backup codes
#### Setup Process
1. Navigate to `/profile/2fa/setup`
2. Scan the displayed QR code with your authenticator app
3. Enter the verification code to confirm setup
4. Save your backup codes in a secure location
#### Login Flow
1. Enter email and password as usual
2. If 2FA is enabled:
- Enter the 6-digit code from your authenticator app
- Alternatively, use a backup code if you can't access your authenticator
#### Security Considerations
- The TOTP secrets are encrypted using AES-256-GCM
- You must set the `TOTP_ENCRYPTION_KEY` environment variable in production
- This key should be 32 bytes (characters) long and kept confidential
- Changing this key after users have set up 2FA will invalidate their existing 2FA configurations
- For high-security deployments, store this key in a secure vault and inject it at runtime
### Transfer Configuration Options
1. **Source/Destination Types**:
- Google Drive
- Google Photos
- Local filesystem
- Amazon S3
- MinIO (S3-compatible storage)
- SFTP
- FTP
- SMB/CIFS shares
- And many more via rclone
2. **Connection Options**:
- Host/server addresses
- Authentication (username/password or key files)
- OAuth2 authentication for Google services
- Port configurations
- Cloud credentials (access keys, secret keys)
- Bucket and region settings
- Custom endpoints
- Custom rclone flags
3. **Google Photos Specific Options**:
- Read-only mode for safer operations
- Start year filter for historical photos
- Include/exclude archived media
- Album path configuration
- Built-in or custom OAuth authentication
4. **Google Drive Specific Options**:
- Folder ID for specific directory access
- Team/Shared Drive ID support
- Built-in or custom OAuth authentication
- Path-based navigation
5. **File Options**:
- File patterns for filtering (e.g., `*.txt`, `data_*.csv`)
- Output patterns for dynamic naming
- Archive options for transferred files
- Skip already processed files to avoid duplicates
- Concurrent file transfers (configurable per job)
6. **Performance Options**:
- **Multi-threaded File Transfers**: Process multiple files simultaneously for higher throughput
- Configurable concurrency level (1-32 concurrent transfers)
- Per-job concurrency settings to optimize for different storage types
- Automatic transfer queue management to prevent overloading systems
- Adaptive processing based on source/destination capabilities
7. **Schedule Options**:
- Cron expressions for flexible scheduling
- Manual execution
- Enable/disable schedules
8. **Notification Options**:
- **Email Notifications**: Receive job status updates via email
- **Webhook Notifications**: Integration with external systems
- **Pushbullet**: Push notifications to your devices
- **Ntfy**: Simple push notifications via ntfy.sh
- **Gotify**: Self-hosted notification server integration
- **Pushover**: Professional notification service
- Configure event triggers (start, complete, error)
- Customize notification message templates
- Selective notification based on job status
### Email Notifications
GoMFT supports email notifications for various features:
- **Password Reset**: Users can request password reset links sent to their registered email
- **Styled Emails**: Professional HTML emails that match the application's design theme
- **Secure Tokens**: One-time use secure tokens with 15-minute expiration for enhanced security
- **Flexible Configuration**: Easily configure your SMTP server settings
- **Authentication Options**: Support for both authenticated and unauthenticated SMTP servers
- **TLS Support**: Secure communication with your SMTP server
- **Development Mode**: When emails are disabled, reset links are logged to the console
To configure email functionality:
1. Edit the `.env` file and provide your SMTP server details
2. Set `EMAIL_ENABLED=true` in the email configuration section
3. Ensure the `BASE_URL` setting is configured correctly for your deployment
### Webhook Integration
GoMFT can send webhook notifications to external systems when jobs complete. This allows integration with monitoring tools, chat applications, custom notification systems, or workflow automation platforms.
#### Webhook Payload Structure
Webhook notifications are sent as HTTP POST requests with a JSON payload containing detailed information about the job execution:
```json
{
"event_type": "job_execution",
"job_id": 123,
"job_name": "Daily Backup",
"config_id": 456,
"config_name": "S3 to Local Backup",
"status": "completed",
"start_time": "2023-07-14T15:30:00Z",
"end_time": "2023-07-14T15:35:42Z",
"duration_seconds": 342,
"bytes_transferred": 1048576,
"files_transferred": 25,
"history_id": 789,
"source": {
"type": "s3",
"path": "my-bucket/data"
},
"destination": {
"type": "local",
"path": "/backups/data"
}
}
```
For failed transfers, additional error information is included:
```json
{
"status": "failed",
"error_message": "Permission denied accessing destination path"
}
```
#### Webhook Authentication
When a webhook secret is configured, GoMFT signs the payload using HMAC-SHA256 and includes the signature in the `X-Hub-Signature-256` header. To verify the webhook:
1. Compute the HMAC-SHA256 of the raw request body using your shared secret
2. Compare it with the value in the `X-Hub-Signature-256` header
3. Process the webhook only if the signatures match
This ensures that webhook requests are authentic and haven't been tampered with.
### Admin Tools
GoMFT provides a comprehensive set of administrative tools for system management and monitoring:
#### Log Viewer
The Admin Tools panel includes an integrated log viewer with the following features:
- **Log File Browser**: View a list of all available log files in the system
- **Real-time Log Viewing**: View log file contents directly in the web interface
- **Refresh Function**: Update the log list and content with the latest information
- **User-friendly Interface**: Clean, readable presentation with custom scrolling
- **Dark Mode Support**: Consistent theming with the rest of the application
- **Navigation**: Easily switch between different log files
This log viewer allows administrators to:
- Monitor system activity and diagnose issues without requiring server access
- View application logs, scheduler logs, and transfer logs in one place
- Track down errors and warning messages in real-time
#### Database Management
The Admin Tools interface also includes database management capabilities:
- Create and manage database backups
- Restore from previous backups
- Download backups for safekeeping
- View system statistics
- Optimize the database with maintenance tools
---
## Development
### Project Structure
```
.
├── components/ # Templ components for UI
├── internal/
│ ├── api/ # REST API handlers
│ ├── auth/ # Authentication/authorization
│ ├── config/ # Configuration management
│ ├── db/ # Database models and operations
│ ├── email/ # Email service for notifications and password resets
│ ├── scheduler/ # Job scheduling and execution
│ └── web/ # Web interface handlers
├── static/ # Static assets
│ ├── css/
│ └── js/
└── main.go # Application entry point
```
### Technology Stack
- **Backend**: Go with Gin web framework
- **Frontend**: Templ for Go HTML components
- **UI Enhancement**: HTMX for dynamic interactions
- **Styling**: Tailwind CSS
- **Authentication**: JWT (JSON Web Tokens)
- **Database**: GORM with SQLite
- **File Transfer**: rclone
- **Deployment**: Docker containerization and traditional installation
### Building from Source
1. Install development dependencies:
```bash
go install github.com/cosmtrek/air@latest # Hot reload for development
go install github.com/a-h/templ/cmd/templ@latest # Templ template compiler
```
2. Generate template code:
```bash
templ generate
```
3. Run in development mode:
```bash
air
```
---
## Contributing ## Contributing
Contributions are welcome! Please see our [Contributing Guide](https://starfleetcptn.github.io/GoMFT/docs/contributing) for details on how to get started.
1. Fork the repository 1. Fork the repository
2. Create a feature branch 2. Create a feature branch
3. Commit your changes 3. Make your changes
4. Push to the branch 4. Submit a pull request
5. Create a Pull Request
--- We also welcome documentation improvements. The documentation source is available in the `docs/` directory.
## Directory Structure
GoMFT uses the following directory structure:
- `/app/data`: Main application data directory
- Contains the SQLite database (`gomft.db`)
- Contains rclone configurations in `/app/data/configs`
- Contains log files in `/app/data/logs`
- `/app/backups`: Database backup directory
When using Docker, you should mount volumes to these locations:
```yaml
volumes:
- /host/path/data:/app/data # For all application data
- /host/path/backups:/app/backups # For database backups
```
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
---
## Security Considerations
### Running as a Non-Root User
By default, Docker containers run as the root user, which can pose security risks. GoMFT supports running as a non-root user, which is recommended for production environments.
#### Benefits of Running as Non-Root
- **Improved Security**: Limits the potential damage if the container is compromised
- **Better File Permissions**: Files created by the container will match your host user permissions
- **Compliance**: Many security policies and best practices require containers to run as non-root
#### Methods to Run as Non-Root
1. **Using PUID/PGID environment variables (recommended)**:
```bash
# Using current user's ID
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
# Or in docker-compose.yml
environment:
- PUID=1000
- PGID=1000
```
This is the most flexible method as it allows changing the user at runtime without rebuilding the image.
2. **Using the `--user` flag with Docker run**:
```bash
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
```
3. **Using Docker Compose with environment variables for `user` directive**:
```yaml
services:
gomft:
image: starfleetcptn/gomft:latest
user: "${UID:-1000}:${GID:-1000}"
```
4. **Building a custom image with specified UID/GID**:
```yaml
services:
gomft:
build:
context: .
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
```
#### Environment Variables for User Management
| Variable | Description | Default |
|----------|-------------|---------|
| `PUID` | User ID to run as | Built-in user ID (1000) |
| `PGID` | Group ID to run as | Built-in group ID (1000) |
| `USERNAME` | Username to use | `gomft` |
These environment variables allow you to change the user/group IDs at runtime without rebuilding the image.
#### Volume Permissions
When mounting volumes, ensure that the directories on the host have appropriate permissions for the container user:
```bash
# Create directories with correct ownership
mkdir -p data backups
chown -R $(id -u):$(id -g) data backups
# Or adjust permissions to allow the container user to write
mkdir -p data backups
chmod -R 777 data backups # Less secure, but easier for testing
```
---
## License ## License
+772
View File
@@ -0,0 +1,772 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/starfleetcptn/gomft/internal/config"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/encryption"
"github.com/starfleetcptn/gomft/internal/encryption/keyrotation"
"golang.org/x/crypto/bcrypt"
)
func main() {
// Create root command
rootCmd := &cobra.Command{
Use: "gomftctl",
Short: "GoMFT Control Tool - Command line utilities for GoMFT",
Long: `GoMFT Control Tool (gomftctl) provides command line utilities for managing
your GoMFT installation, including database migrations, security key rotation,
and other administrative functions.`,
}
// Add commands
rootCmd.AddCommand(createMigrateCmd())
rootCmd.AddCommand(createKeyRotationCmd())
rootCmd.AddCommand(createVersionCmd())
rootCmd.AddCommand(createBackupCmd())
rootCmd.AddCommand(createUserCmd())
rootCmd.AddCommand(createEncryptionKeyRotationCmd())
// Execute the root command
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
// createMigrateCmd creates the migrate command for provider data migration
func createMigrateCmd() *cobra.Command {
var dryRun, validationOnly, force, debugMode, autoFill bool
var backupDir string
migrateCmd := &cobra.Command{
Use: "migrate-providers",
Short: "Migrate provider data to the new storage provider model",
Long: `Migrate provider data extracts unique provider configurations from existing
transfer configs and creates dedicated storage provider records.
This command should be run when upgrading from older versions of GoMFT that
stored provider configuration directly in transfer configs.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set backup directory if not provided
if backupDir == "" {
backupDir = cfg.BackupDir
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Create migration options
options := db.MigrateProviderDataOptions{
DryRun: dryRun,
ValidationOnly: validationOnly,
Force: force,
BackupDir: backupDir,
DebugMode: debugMode,
AutoFill: autoFill,
}
// Run migration
fmt.Println("Starting provider data migration...")
stats, err := database.MigrateProviderData(options)
if err != nil {
fmt.Println("\nMigration failed with error:")
fmt.Printf("Error: %v\n", err)
// Add more detailed error information
fmt.Println("\nDetailed error information:")
fmt.Println("===========================")
// Unwrap nested errors if possible
var currentErr error = err
depth := 1
for currentErr != nil {
fmt.Printf("%d. %v\n", depth, currentErr)
if unwrapped, ok := currentErr.(interface{ Unwrap() error }); ok {
currentErr = unwrapped.Unwrap()
depth++
} else {
break
}
}
// Print database connection information (without sensitive details)
fmt.Println("\nDatabase information:")
fmt.Printf("- Database path: %s\n", dbPath)
fmt.Printf("- Migration options: dryRun=%v, validationOnly=%v, force=%v\n",
options.DryRun, options.ValidationOnly, options.Force)
log.Fatalf("Migration failed. See details above.")
}
// Print report
fmt.Println(db.FormatMigrationReport(stats))
},
}
// Add flags
migrateCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate migration without making changes")
migrateCmd.Flags().BoolVar(&validationOnly, "validate-only", false, "Only validate if migration is possible without making changes")
migrateCmd.Flags().BoolVar(&force, "force", false, "Force migration even if validation fails")
migrateCmd.Flags().StringVar(&backupDir, "backup-dir", "", "Directory to store backup data (defaults to config backup_dir)")
migrateCmd.Flags().BoolVar(&debugMode, "debug", false, "Enable debug mode with more detailed error messages")
migrateCmd.Flags().BoolVar(&autoFill, "auto-fill", false, "Automatically fill missing required fields with placeholder values")
return migrateCmd
}
// createKeyRotationCmd creates the key rotation command
func createKeyRotationCmd() *cobra.Command {
var keyType string
var writeToEnv bool
keyRotationCmd := &cobra.Command{
Use: "rotate-key",
Short: "Rotate security keys used by GoMFT",
Long: `Rotate security keys generates new cryptographic keys for GoMFT.
Available key types:
- jwt: JSON Web Token signing key
- totp: TOTP encryption key
- encryption: General encryption key used for sensitive data
This command will generate a new key and provide instructions for updating
your configuration. The application must be restarted for changes to take effect.`,
Run: func(cmd *cobra.Command, args []string) {
// Validate key type
validTypes := map[string]string{
"jwt": "JWT_SECRET",
"totp": "TOTP_ENCRYPTION_KEY",
"encryption": "GOMFT_ENCRYPTION_KEY",
}
envVar, valid := validTypes[keyType]
if !valid {
log.Fatalf("Invalid key type: %s. Valid types are: jwt, totp, encryption", keyType)
}
// Load configuration
_, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Generate a new key
newKey, err := generateSecureKey()
if err != nil {
log.Fatalf("Failed to generate secure key: %v", err)
}
fmt.Printf("Generated new %s key: %s\n\n", keyType, newKey)
if writeToEnv {
// Read current .env file
envPath := ".env"
envContent, err := os.ReadFile(envPath)
if err != nil {
log.Fatalf("Failed to read .env file: %v", err)
}
// Update .env file with new key
updatedEnv, updated := updateEnvVar(string(envContent), envVar, newKey)
if !updated {
// If the variable wasn't found, append it
updatedEnv = updatedEnv + fmt.Sprintf("\n%s=%s\n", envVar, newKey)
}
// Write updated content back to .env file
if err := os.WriteFile(envPath, []byte(updatedEnv), 0644); err != nil {
log.Fatalf("Failed to write updated .env file: %v", err)
}
fmt.Printf("Updated %s in .env file\n", envVar)
fmt.Println("Please restart the GoMFT application for changes to take effect.")
} else {
// Print instructions for manual update
fmt.Println("To use this key, update your .env file with:")
fmt.Printf("%s=%s\n\n", envVar, newKey)
fmt.Println("Then restart the GoMFT application for changes to take effect.")
}
},
}
// Add flags
keyRotationCmd.Flags().StringVar(&keyType, "type", "", "Type of key to rotate (jwt, totp, encryption)")
keyRotationCmd.Flags().BoolVar(&writeToEnv, "write", false, "Write the new key directly to .env file")
keyRotationCmd.MarkFlagRequired("type")
return keyRotationCmd
}
// createVersionCmd creates the version command
func createVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Display version information",
Run: func(cmd *cobra.Command, args []string) {
// Import the version from the components package
fmt.Println("GoMFT Control Tool")
fmt.Println("Version: Same as GoMFT application")
fmt.Println("Visit https://github.com/starfleetcptn/gomft for more information")
},
}
}
// createBackupCmd creates the backup command
func createBackupCmd() *cobra.Command {
var outputDir string
backupCmd := &cobra.Command{
Use: "backup",
Short: "Create a backup of the GoMFT database",
Long: `Create a backup of the GoMFT database and configuration.
The backup includes the SQLite database file and the .env configuration file.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set output directory if not provided
if outputDir == "" {
outputDir = cfg.BackupDir
}
// Ensure output directory exists
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Fatalf("Failed to create backup directory: %v", err)
}
// Create timestamp for backup filename
timestamp := fmt.Sprintf("%s", filepath.Base(os.Args[0]))
// Create backup
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
backupPath := filepath.Join(outputDir, fmt.Sprintf("gomft-backup-%s.db", timestamp))
// Copy database file
if err := copyFile(dbPath, backupPath); err != nil {
log.Fatalf("Failed to create database backup: %v", err)
}
// Copy .env file if it exists
envPath := ".env"
backupEnvPath := filepath.Join(outputDir, fmt.Sprintf("gomft-env-backup-%s.env", timestamp))
if _, err := os.Stat(envPath); err == nil {
if err := copyFile(envPath, backupEnvPath); err != nil {
log.Fatalf("Failed to backup .env file: %v", err)
}
fmt.Printf("Configuration backed up to: %s\n", backupEnvPath)
}
fmt.Printf("Database backed up to: %s\n", backupPath)
},
}
// Add flags
backupCmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to store backup files (defaults to config backup_dir)")
return backupCmd
}
// createUserCmd creates the user management command
func createUserCmd() *cobra.Command {
userCmd := &cobra.Command{
Use: "user",
Short: "User management commands",
Long: `Commands for managing GoMFT users, including creating, updating, and listing users.`,
}
// Add subcommands
userCmd.AddCommand(createUserCreateCmd())
userCmd.AddCommand(createUserResetPasswordCmd())
userCmd.AddCommand(createUserListCmd())
return userCmd
}
// createUserCreateCmd creates the user create command
func createUserCreateCmd() *cobra.Command {
var email, password string
var isAdmin bool
createCmd := &cobra.Command{
Use: "create",
Short: "Create a new user",
Long: `Create a new user with the specified email and password.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Create user by first generating password hash
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
// Create user object
user := &db.User{
Email: email,
PasswordHash: string(hashedPassword),
LastPasswordChange: time.Now(),
}
// Set admin status if requested
if isAdmin {
user.SetIsAdmin(true)
}
// Save user to database
if err := database.CreateUser(user); err != nil {
log.Fatalf("Failed to create user: %v", err)
}
fmt.Printf("User created successfully:\n")
fmt.Printf(" ID: %d\n", user.ID)
fmt.Printf(" Email: %s\n", user.Email)
fmt.Printf(" Admin: %t\n", user.GetIsAdmin())
},
}
// Add flags
createCmd.Flags().StringVar(&email, "email", "", "User email address")
createCmd.Flags().StringVar(&password, "password", "", "User password")
createCmd.Flags().BoolVar(&isAdmin, "admin", false, "Grant admin privileges to the user")
createCmd.MarkFlagRequired("email")
createCmd.MarkFlagRequired("password")
return createCmd
}
// createUserResetPasswordCmd creates the user reset-password command
func createUserResetPasswordCmd() *cobra.Command {
var email, newPassword string
resetCmd := &cobra.Command{
Use: "reset-password",
Short: "Reset a user's password",
Long: `Reset the password for a user with the specified email address.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Find user by email
var user db.User
if err := database.Where("email = ?", email).First(&user).Error; err != nil {
log.Fatalf("Failed to find user with email %s: %v", email, err)
}
// Generate new password hash
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
// Update user password
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
// Save user to database
if err := database.Save(&user).Error; err != nil {
log.Fatalf("Failed to update user: %v", err)
}
fmt.Printf("Password reset successfully for user: %s\n", email)
},
}
// Add flags
resetCmd.Flags().StringVar(&email, "email", "", "User email address")
resetCmd.Flags().StringVar(&newPassword, "password", "", "New password")
resetCmd.MarkFlagRequired("email")
resetCmd.MarkFlagRequired("password")
return resetCmd
}
// createUserListCmd creates the user list command
func createUserListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all users",
Long: `List all users in the GoMFT system.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Get all users
var users []db.User
if err := database.Find(&users).Error; err != nil {
log.Fatalf("Failed to get users: %v", err)
}
// Print users
fmt.Println("GoMFT Users:")
fmt.Println("ID\tEmail\tAdmin\tLast Updated")
fmt.Println("--------------------------------------------------")
for _, user := range users {
lastUpdated := "Never"
if !user.UpdatedAt.IsZero() {
lastUpdated = user.UpdatedAt.Format("2006-01-02 15:04:05")
}
fmt.Printf("%d\t%s\t%t\t%s\n", user.ID, user.Email, user.GetIsAdmin(), lastUpdated)
}
},
}
}
// createEncryptionKeyRotationCmd creates the encryption key rotation command
func createEncryptionKeyRotationCmd() *cobra.Command {
var dryRun bool
var batchSize, maxErrors int
var backupDir string
var skipBackup bool
var oldKeyEnvVar string
var modelsFlag string
rotateCmd := &cobra.Command{
Use: "rotate-encryption-key",
Short: "Rotate encryption keys for sensitive data",
Long: `Rotate encryption keys for sensitive data stored in the database.
This command will:
1. Create a backup of your database (unless --skip-backup is specified)
2. Re-encrypt all sensitive data with a new encryption key
3. Provide instructions for updating your configuration
The application must be stopped before running this command to prevent data corruption.
`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set backup directory if not provided
if backupDir == "" {
backupDir = cfg.BackupDir
}
// Create backup if needed
if !skipBackup {
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
backupPath := filepath.Join(backupDir, fmt.Sprintf("gomft_backup_before_key_rotation_%s.db",
time.Now().Format("20060102_150405")))
fmt.Printf("Creating database backup at %s...\n", backupPath)
if err := copyFile(dbPath, backupPath); err != nil {
log.Fatalf("Failed to create backup: %v", err)
}
fmt.Println("Backup created successfully.")
} else {
fmt.Println("Skipping database backup as requested.")
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Setup old encryption service
if oldKeyEnvVar == "" {
oldKeyEnvVar = encryption.DefaultKeyEnvVar
}
// Get the current encryption service
oldService, err := encryption.GetGlobalEncryptionService()
if err != nil {
log.Fatalf("Failed to get current encryption service: %v", err)
}
// Generate new key
newKey, err := encryption.GenerateKey(encryption.AES256KeySize)
if err != nil {
log.Fatalf("Failed to generate new encryption key: %v", err)
}
// Create new key manager for the new key
newKeyManager := &keyManager{key: newKey}
// Setup new encryption service with the new key
newService, err := encryption.NewEncryptionService(newKeyManager)
if err != nil {
log.Fatalf("Failed to create new encryption service: %v", err)
}
// Create rotation options
options := keyrotation.RotationOptions{
DryRun: dryRun,
BatchSize: batchSize,
MaxErrors: maxErrors,
Timeout: 24 * time.Hour,
ProgressCallback: func(modelName string, processed, total int) {
fmt.Printf("\rProcessing %s: %d/%d records (%.1f%%)",
modelName, processed, total, float64(processed)/float64(total)*100)
},
}
// Create rotation utility
rotationUtil, err := keyrotation.NewRotationUtility(
database.DB, // Use the underlying gorm.DB
oldService,
newService,
nil, // No auditor needed, keyrotation will use the global one
options,
)
if err != nil {
log.Fatalf("Failed to create rotation utility: %v", err)
}
// Find models with encrypted fields
var models []interface{}
if modelsFlag == "auto" {
fmt.Println("Automatically detecting models with encrypted fields...")
models, err = rotationUtil.FindModelsWithEncryptedFields()
if err != nil {
log.Fatalf("Failed to find models with encrypted fields: %v", err)
}
if len(models) == 0 {
log.Fatalf("No models with encrypted fields found")
}
} else if modelsFlag != "" {
// TODO: Support manual model specification
log.Fatalf("Manual model specification not yet implemented, use --models=auto")
} else {
log.Fatalf("No models specified, use --models=auto to automatically detect models")
}
// Create migration plan
fmt.Println("Creating encryption migration plan...")
plan, err := rotationUtil.CreateEncryptionMigrationPlan(models)
if err != nil {
log.Fatalf("Failed to create migration plan: %v", err)
}
// Print plan
fmt.Println("\nEncryption Migration Plan:")
fmt.Printf("Total models: %d\n", len(plan.ModelPlans))
fmt.Printf("Total records: %d\n", plan.EstimatedRecords)
fmt.Printf("Estimated duration: %s\n", plan.EstimatedDuration.Round(time.Second))
fmt.Println("\nModels to process:")
for name, modelPlan := range plan.ModelPlans {
fmt.Printf("- %s: %d records, %d encrypted fields\n",
name, modelPlan.RecordCount, len(modelPlan.EncryptedFields))
}
// Confirm if not in dry run mode
if !dryRun {
fmt.Println("\nWARNING: This operation will re-encrypt all sensitive data with a new key.")
fmt.Println("Make sure the application is stopped before proceeding.")
fmt.Print("\nDo you want to continue? [y/N]: ")
var response string
fmt.Scanln(&response)
if strings.ToLower(response) != "y" {
fmt.Println("Operation cancelled.")
return
}
}
// Perform key rotation
fmt.Println("\nStarting key rotation...")
startTime := time.Now()
stats, err := rotationUtil.RotateKeysForModels(context.Background(), models)
if err != nil {
fmt.Println("\nKey rotation failed with error:")
fmt.Printf("Error: %v\n", err)
// Add more detailed error information
fmt.Println("\nDetailed error information:")
fmt.Println("===========================")
// Unwrap nested errors if possible
var currentErr error = err
depth := 1
for currentErr != nil {
fmt.Printf("%d. %v\n", depth, currentErr)
if unwrapped, ok := currentErr.(interface{ Unwrap() error }); ok {
currentErr = unwrapped.Unwrap()
depth++
} else {
break
}
}
// Print rotation configuration details
fmt.Println("\nRotation configuration:")
fmt.Printf("- Dry run: %v\n", dryRun)
fmt.Printf("- Batch size: %d\n", batchSize)
fmt.Printf("- Max errors: %d\n", maxErrors)
fmt.Printf("- Models: %s\n", modelsFlag)
fmt.Printf("- Old key env var: %s\n", oldKeyEnvVar)
log.Fatalf("Key rotation failed. See details above.")
}
duration := time.Since(startTime).Round(time.Second)
// Print results
fmt.Println("\nKey rotation completed successfully!")
fmt.Printf("Total records processed: %d/%d\n", stats.ProcessedRecords, stats.TotalRecords)
fmt.Printf("Failed records: %d\n", stats.FailedRecords)
fmt.Printf("Duration: %s\n", duration)
if len(stats.Errors) > 0 {
fmt.Printf("\nErrors (%d):\n", len(stats.Errors))
for i, err := range stats.Errors {
if i >= 10 {
fmt.Printf("... and %d more errors\n", len(stats.Errors)-10)
break
}
fmt.Printf("- %s\n", err)
}
}
// Print next steps
if !dryRun {
fmt.Println("\nNext steps:")
fmt.Println("1. Update your environment variable or .env file with the new encryption key:")
fmt.Printf(" %s=%s\n", oldKeyEnvVar, base64.StdEncoding.EncodeToString(newKey))
fmt.Println("2. Restart your GoMFT application")
fmt.Println("\nIMPORTANT: Keep a backup of both the old and new keys until you verify everything works correctly.")
} else {
fmt.Println("\nDry run completed. No changes were made to the database.")
fmt.Println("Run without --dry-run to perform the actual key rotation.")
}
},
}
// Add flags
rotateCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate key rotation without making changes")
rotateCmd.Flags().IntVar(&batchSize, "batch-size", 100, "Number of records to process in each batch")
rotateCmd.Flags().IntVar(&maxErrors, "max-errors", 50, "Maximum number of errors before aborting")
rotateCmd.Flags().StringVar(&backupDir, "backup-dir", "", "Directory to store backup data (defaults to config backup_dir)")
rotateCmd.Flags().BoolVar(&skipBackup, "skip-backup", false, "Skip database backup (not recommended)")
rotateCmd.Flags().StringVar(&oldKeyEnvVar, "old-key-env", "", "Environment variable containing the old encryption key (defaults to GOMFT_ENCRYPTION_KEY)")
rotateCmd.Flags().StringVar(&modelsFlag, "models", "auto", "Models to process (use 'auto' for automatic detection)")
return rotateCmd
}
// keyManager is a simple implementation of the encryption.KeyManager interface
// that uses a fixed key for the new encryption service
type keyManager struct {
key []byte
}
func (km *keyManager) Initialize() error {
// Already initialized with the key
return nil
}
func (km *keyManager) GetPrimaryKey() ([]byte, error) {
return km.key, nil
}
func (km *keyManager) GetEnvironmentVariableName() string {
return "TEMP_KEY_MANAGER"
}
func (km *keyManager) StoreKeyEnvironment(key []byte) error {
// Not needed for this implementation
return nil
}
// Helper functions
// generateSecureKey creates a cryptographically secure random key encoded as base64
func generateSecureKey() (string, error) {
return config.GenerateSecureKey()
}
// updateEnvVar updates an environment variable in the .env file content
func updateEnvVar(content, key, value string) (string, bool) {
lines := strings.Split(content, "\n")
prefix := key + "="
updated := false
for i, line := range lines {
if strings.HasPrefix(line, prefix) {
lines[i] = prefix + value
updated = true
break
}
}
return strings.Join(lines, "\n"), updated
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
+538
View File
@@ -0,0 +1,538 @@
package components
import (
"context"
"time"
)
// LogEntry represents a log entry for display
type LogEntry struct {
Timestamp time.Time
Level string
Message string
Source string
Details map[string]interface{}
}
// LogViewerData represents the data for the log viewer component
type LogViewerData struct {
Logs []LogEntry
CurrentFilter string
LogFilePath string
}
// Helper function to get the appropriate CSS class for log levels
func getLogLevelClass(level string) string {
baseClass := "px-4 py-2 text-sm font-medium whitespace-nowrap "
switch level {
case "debug":
return baseClass + "text-purple-500 dark:text-purple-400"
case "info":
return baseClass + "text-blue-500 dark:text-blue-400"
case "warn":
return baseClass + "text-yellow-500 dark:text-yellow-400"
case "error":
return baseClass + "text-red-500 dark:text-red-400"
case "fatal":
return baseClass + "text-red-700 dark:text-red-600 font-bold"
default:
return baseClass + "text-gray-500 dark:text-gray-400"
}
}
// AdminLogs renders the log viewer page
templ AdminLogs(ctx context.Context, data LogViewerData) {
@LayoutWithContext("Log Viewer", ctx) {
<div class="log-viewer-page">
<!-- Page Header -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-stream w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i> Log Viewer
</h1>
<div class="flex gap-2">
<button id="pause-logs" class="flex items-center justify-center text-white bg-yellow-500 hover:bg-yellow-600 focus:ring-4 focus:ring-yellow-300 font-medium rounded-lg px-4 py-2 dark:bg-yellow-600 dark:hover:bg-yellow-700 focus:outline-none dark:focus:ring-yellow-800">
<i class="fas fa-pause w-4 h-4 mr-2"></i> Pause
</button>
<button id="resume-logs" class="hidden flex items-center justify-center text-white bg-green-500 hover:bg-green-600 focus:ring-4 focus:ring-green-300 font-medium rounded-lg px-4 py-2 dark:bg-green-600 dark:hover:bg-green-700 focus:outline-none dark:focus:ring-green-800">
<i class="fas fa-play w-4 h-4 mr-2"></i> Resume
</button>
<button id="clear-logs" class="flex items-center justify-center text-white bg-red-500 hover:bg-red-600 focus:ring-4 focus:ring-red-300 font-medium rounded-lg px-4 py-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
<i class="fas fa-trash w-4 h-4 mr-2"></i> Clear
</button>
<button id="download-logs" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-download w-4 h-4 mr-2"></i> Download
</button>
</div>
</div>
<!-- Log Information -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-4 p-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
<p><i class="fas fa-info-circle mr-2 text-blue-500 dark:text-blue-400"></i> Viewing logs from: <span class="font-mono">{ data.LogFilePath }</span></p>
<p><i class="fas fa-circle text-green-500 dark:text-green-400 mr-2"></i> Real-time log streaming is active, logs are automatically captured and displayed</p>
</div>
</div>
<!-- Filters -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-6">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Filter Logs</h3>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-4">
<!-- Log Level Filter -->
<div class="flex-1 min-w-[200px]">
<label for="filter-level" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Log Level</label>
<select id="filter-level" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Levels</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="fatal">Fatal</option>
</select>
</div>
<!-- Source Filter -->
<div class="flex-1 min-w-[200px]">
<label for="filter-source" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source</label>
<select id="filter-source" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Sources</option>
<option value="api">API</option>
<option value="web">Web</option>
<option value="scheduler">Scheduler</option>
<option value="auth">Authentication</option>
<option value="database">Database</option>
</select>
</div>
<!-- Search Filter -->
<div class="flex-1 min-w-[200px]">
<label for="filter-search" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Search</label>
<input type="text" id="filter-search" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Search logs...">
</div>
</div>
</div>
</div>
<!-- Log Table -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Live Logs</h3>
<div class="flex items-center">
<span id="connection-status" class="flex items-center text-sm text-green-500 dark:text-green-400">
<span class="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-2"></span>
Connected
</span>
</div>
</div>
<div class="overflow-x-auto" style="max-height: 60vh; overflow-y: auto;">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase w-[100px]">Level</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase w-[120px]">Source</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Message</th>
</tr>
</thead>
<tbody id="log-entries" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Log entries will be inserted here dynamically -->
if len(data.Logs) == 0 {
<tr>
<td colspan="4" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">Waiting for logs...</td>
</tr>
} else {
for _, log := range data.Logs {
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{ log.Timestamp.Format("2006-01-02 15:04:05.000") }</td>
<td class={ getLogLevelClass(log.Level) }>{ log.Level }</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{ log.Source }</td>
<td class="px-4 py-2 text-sm text-gray-900 dark:text-white font-mono">{ log.Message }</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const logEntries = document.getElementById('log-entries');
const pauseButton = document.getElementById('pause-logs');
const resumeButton = document.getElementById('resume-logs');
const clearButton = document.getElementById('clear-logs');
const downloadButton = document.getElementById('download-logs');
const connectionStatus = document.getElementById('connection-status');
const filterLevel = document.getElementById('filter-level');
const filterSource = document.getElementById('filter-source');
const filterSearch = document.getElementById('filter-search');
let isPaused = false;
let logs = [];
let filteredLogs = [];
let ws;
let knownSources = new Set();
let reconnectTimer = null;
let pingInterval = null;
// Connect to WebSocket
function connectWebSocket() {
// Clear any existing reconnect timer
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Clear any existing ping interval
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + window.location.host + '/admin/logs/ws';
console.log("Connecting to WebSocket:", wsUrl);
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-yellow-500 dark:bg-yellow-400 rounded-full mr-2"></span>Connecting...';
connectionStatus.className = 'flex items-center text-sm text-yellow-500 dark:text-yellow-400';
try {
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log("WebSocket connection established");
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-2"></span>Connected';
connectionStatus.className = 'flex items-center text-sm text-green-500 dark:text-green-400';
// Set up ping interval to keep connection alive
pingInterval = setInterval(function() {
if (ws && ws.readyState === WebSocket.OPEN) {
console.debug("Sending ping to server");
// Send a simple ping message
ws.send(JSON.stringify({type: "ping"}));
}
}, 30000); // 30 seconds
};
ws.onclose = function(event) {
console.log("WebSocket connection closed", event);
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Disconnected';
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
// Clear the ping interval
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
// Attempt to reconnect after 5 seconds
console.log("Scheduling reconnect in 5 seconds...");
reconnectTimer = setTimeout(connectWebSocket, 5000);
};
ws.onerror = function(error) {
console.error("WebSocket error:", error);
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Error';
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
// Don't set up reconnect here, let onclose handle it
};
ws.onmessage = function(event) {
// Debug log the received data
console.debug("Raw log entry received:", event.data);
try {
const logEntry = JSON.parse(event.data);
// Debug log the parsed entry
console.debug("Parsed log entry:", logEntry);
// Extract source and add to known sources for filtering
const source = logEntry.Source || logEntry.source || '';
if (source && !knownSources.has(source)) {
knownSources.add(source);
updateSourceFilter();
}
// Handle potential log prefixes in the message
const message = logEntry.Message || logEntry.message || '';
if (message.startsWith("DEBUG:")) {
logEntry.Level = "debug";
logEntry.Message = message.substring(7).trim();
} else if (message.startsWith("INFO:")) {
logEntry.Level = "info";
logEntry.Message = message.substring(6).trim();
} else if (message.startsWith("ERROR:")) {
logEntry.Level = "error";
logEntry.Message = message.substring(7).trim();
} else if (message.startsWith("WARN:")) {
logEntry.Level = "warn";
logEntry.Message = message.substring(6).trim();
} else if (message.startsWith("WARNING:")) {
logEntry.Level = "warn";
logEntry.Message = message.substring(9).trim();
} else if (message.startsWith("FATAL:")) {
logEntry.Level = "fatal";
logEntry.Message = message.substring(7).trim();
}
// Add to logs array
logs.push(logEntry);
// Apply filters and update display if not paused
if (!isPaused) {
applyFilters();
}
} catch (error) {
console.error("Error processing log entry:", error);
}
};
} catch (error) {
console.error("Error creating WebSocket:", error);
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Connection Failed';
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
// Retry connection after 5 seconds
reconnectTimer = setTimeout(connectWebSocket, 5000);
}
}
// Update the source filter dropdown with dynamically discovered sources
function updateSourceFilter() {
// Remember the current selection
const currentValue = filterSource.value;
// Clear existing options except the first "All Sources" option
while (filterSource.options.length > 1) {
filterSource.remove(1);
}
// Add sorted sources to dropdown
Array.from(knownSources).sort().forEach(source => {
const option = document.createElement('option');
option.value = source.toLowerCase();
option.textContent = source;
filterSource.appendChild(option);
});
// Restore previous selection if it still exists
if (currentValue) {
for (let i = 0; i < filterSource.options.length; i++) {
if (filterSource.options[i].value === currentValue) {
filterSource.selectedIndex = i;
break;
}
}
}
}
// Apply filters to logs
function applyFilters() {
const levelFilter = filterLevel.value.toLowerCase();
const sourceFilter = filterSource.value.toLowerCase();
const searchFilter = filterSearch.value.toLowerCase();
filteredLogs = logs.filter(log => {
// Handle capitalized properties from the server
const level = (log.Level || log.level || '').toLowerCase();
const source = (log.Source || log.source || '').toLowerCase();
const message = (log.Message || log.message || '').toLowerCase();
return (levelFilter === '' || level === levelFilter) &&
(sourceFilter === '' || source === sourceFilter) &&
(searchFilter === '' || message.includes(searchFilter));
});
renderLogs();
}
// Render logs to the table
function renderLogs() {
// Clear existing logs
logEntries.innerHTML = '';
if (filteredLogs.length === 0) {
const emptyRow = document.createElement('tr');
emptyRow.innerHTML = `<td colspan="4" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">No logs found</td>`;
logEntries.appendChild(emptyRow);
return;
}
// Add filtered logs
filteredLogs.forEach(log => {
// Handle capitalized property names from the server
const timestamp = log.Timestamp || log.timestamp;
const level = log.Level || log.level || 'unknown';
const source = log.Source || log.source || 'unknown';
const message = log.Message || log.message || '';
let formattedTime;
try {
// Convert to date object
const date = new Date(timestamp);
// Format in local time with milliseconds
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
// Format main part of the timestamp
formattedTime = date.toLocaleString(undefined, options);
// Add milliseconds
const ms = String(date.getMilliseconds()).padStart(3, '0');
formattedTime += "." + ms;
} catch (e) {
console.error("Error formatting timestamp:", e);
formattedTime = String(timestamp);
}
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700';
let levelClass = 'px-4 py-2 text-sm font-medium whitespace-nowrap ';
switch(level.toLowerCase()) {
case 'debug':
levelClass += 'text-purple-500 dark:text-purple-400';
break;
case 'info':
levelClass += 'text-blue-500 dark:text-blue-400';
break;
case 'warn':
levelClass += 'text-yellow-500 dark:text-yellow-400';
break;
case 'error':
levelClass += 'text-red-500 dark:text-red-400';
break;
case 'fatal':
levelClass += 'text-red-700 dark:text-red-600 font-bold';
break;
default:
levelClass += 'text-gray-500 dark:text-gray-400';
}
row.innerHTML =
'<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">' + formattedTime + '</td>' +
'<td class="' + levelClass + '">' + level + '</td>' +
'<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">' + source + '</td>' +
'<td class="px-4 py-2 text-sm text-gray-900 dark:text-white font-mono">' + message + '</td>';
logEntries.appendChild(row);
});
// Auto-scroll to bottom unless user has scrolled up
const container = logEntries.parentElement;
if (container.scrollTop + container.clientHeight >= container.scrollHeight - 100) {
container.scrollTop = container.scrollHeight;
}
}
// Pause button click
pauseButton.addEventListener('click', function() {
isPaused = true;
pauseButton.classList.add('hidden');
resumeButton.classList.remove('hidden');
});
// Resume button click
resumeButton.addEventListener('click', function() {
isPaused = false;
resumeButton.classList.add('hidden');
pauseButton.classList.remove('hidden');
applyFilters(); // Re-apply filters and update
});
// Clear button click
clearButton.addEventListener('click', function() {
logs = [];
applyFilters();
});
// Download button click
downloadButton.addEventListener('click', function() {
// Create CSV from logs
let csv = 'Timestamp,Level,Source,Message\n';
logs.forEach(log => {
// Handle capitalized property names from the server
const timestamp = log.Timestamp || log.timestamp;
const level = log.Level || log.level || 'unknown';
const source = log.Source || log.source || 'unknown';
const message = log.Message || log.message || '';
let formattedTime;
try {
// Convert to date object
const date = new Date(timestamp);
// Format in local time with milliseconds
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
// Format main part of the timestamp
formattedTime = date.toLocaleString(undefined, options);
// Add milliseconds
const ms = String(date.getMilliseconds()).padStart(3, '0');
formattedTime += "." + ms;
} catch (e) {
formattedTime = String(timestamp);
}
// Properly escape CSV fields
let escapedMessage = '';
if (message) {
escapedMessage = message.split('"').join('""');
}
csv += '"' + formattedTime + '","' + level + '","' + source + '","' + escapedMessage + '"\n';
});
// Create and trigger download
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
const date = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
a.setAttribute('href', url);
a.setAttribute('download', 'gomft-logs-' + date + '.csv');
a.click();
});
// Filter change handlers
filterLevel.addEventListener('change', applyFilters);
filterSource.addEventListener('change', applyFilters);
// Debounce search input
let searchTimeout;
filterSearch.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 300);
});
// Initial connection
connectWebSocket();
});
</script>
}
}
+1 -1
View File
@@ -275,7 +275,7 @@ templ formContent(provider *db.AuthProvider, isNew bool) {
<!-- Enabled --> <!-- Enabled -->
<div> <div>
<div class="flex items-center"> <div class="flex items-center">
if provider == nil || provider.Enabled { if provider == nil || provider.GetEnabled() {
<input <input
type="checkbox" type="checkbox"
id="enabled" id="enabled"
+1 -1
View File
@@ -80,7 +80,7 @@ templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
{ string(provider.Type) } { string(provider.Type) }
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
if provider.Enabled { if provider.GetEnabled() {
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> <span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active Active
</span> </span>
+1 -1
View File
@@ -35,7 +35,7 @@ templ AuthProviderButtons(providers []db.AuthProvider) {
} else { } else {
<div class="space-y-2 w-full"> <div class="space-y-2 w-full">
for _, provider := range providers { for _, provider := range providers {
if provider.Enabled { if provider.GetEnabled() {
<a <a
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) } href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600" class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
+651
View File
@@ -0,0 +1,651 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
"time"
"encoding/json"
"strings"
"strconv"
"sort"
)
// JobCalendarData contains the data for the calendar view
type JobCalendarData struct {
Jobs []db.Job
}
// generateCalendarEvents converts jobs to calendar events in JSON format
func generateCalendarEvents(jobs []db.Job) string {
type CalendarEvent struct {
ID string `json:"id"`
Title string `json:"title"`
Start string `json:"start"`
End string `json:"end,omitempty"`
AllDay bool `json:"allDay,omitempty"`
URL string `json:"url,omitempty"`
ClassName string `json:"className,omitempty"`
Description string `json:"description,omitempty"`
Enabled bool `json:"enabled"`
JobID uint `json:"jobId"`
JobName string `json:"jobName"`
RunTimes []string `json:"runTimes,omitempty"` // Store additional run times for this day
RunCount int `json:"runCount,omitempty"` // Count of runs on this day
Schedule string `json:"schedule,omitempty"` // Store the schedule for tooltip
}
var events []CalendarEvent
// Set the range for future occurrences - 2 months seems to be a good balance
now := time.Now()
twoMonthsLater := now.AddDate(0, 2, 0)
// Map to track events by job ID and date to consolidate multiple occurrences
eventsByJobAndDay := make(map[string][]time.Time)
// Store job information for easy access
jobInfo := make(map[uint]struct {
Name string
Enabled bool
Schedule string
})
// First, gather all runs and group them by job ID and day
for _, job := range jobs {
// Skip jobs with no next run time
if job.NextRun == nil {
continue
}
// Store job information
jobName := job.Name
if jobName == "" {
jobName = job.Config.Name
}
jobInfo[job.ID] = struct {
Name string
Enabled bool
Schedule string
}{
Name: jobName,
Enabled: job.GetEnabled(),
Schedule: job.Schedule,
}
// Create the first occurrence based on NextRun
nextRun := *job.NextRun
// Skip past events that are more than a day old
oneDayAgo := now.AddDate(0, 0, -1)
if nextRun.Before(oneDayAgo) {
// For past events, if we have LastRun, use that instead
if job.LastRun != nil {
nextRun = *job.LastRun
// Still skip if it's too old
if nextRun.Before(oneDayAgo) {
continue
}
} else {
continue
}
}
// Add initial run to the map
dateKey := fmt.Sprintf("%d-%s", job.ID, nextRun.Format("2006-01-02"))
eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], nextRun)
// Try to determine future occurrences based on the cron schedule
var interval time.Duration
schedule := strings.ToLower(job.Schedule)
// Determine interval based on schedule
switch {
case strings.Contains(schedule, "every minute") || strings.Contains(schedule, "* * * * *"):
interval = 1 * time.Minute
case strings.Contains(schedule, "every 5 minutes") || strings.Contains(schedule, "*/5 * * * *"):
interval = 5 * time.Minute
case strings.Contains(schedule, "every 10 minutes") || strings.Contains(schedule, "*/10 * * * *"):
interval = 10 * time.Minute
case strings.Contains(schedule, "every 15 minutes") || strings.Contains(schedule, "*/15 * * * *"):
interval = 15 * time.Minute
case strings.Contains(schedule, "every 30 minutes") || strings.Contains(schedule, "*/30 * * * *"):
interval = 30 * time.Minute
case strings.Contains(schedule, "hourly") || strings.Contains(schedule, "0 * * * *"):
interval = 1 * time.Hour
case strings.Contains(schedule, "every 2 hours") || strings.Contains(schedule, "0 */2 * * *"):
interval = 2 * time.Hour
case strings.Contains(schedule, "every 3 hours") || strings.Contains(schedule, "0 */3 * * *"):
interval = 3 * time.Hour
case strings.Contains(schedule, "every 4 hours") || strings.Contains(schedule, "0 */4 * * *"):
interval = 4 * time.Hour
case strings.Contains(schedule, "every 6 hours") || strings.Contains(schedule, "0 */6 * * *"):
interval = 6 * time.Hour
case strings.Contains(schedule, "every 12 hours") || strings.Contains(schedule, "0 */12 * * *"):
interval = 12 * time.Hour
case strings.Contains(schedule, "daily") || strings.Contains(schedule, "0 0 * * *"):
interval = 24 * time.Hour
case strings.Contains(schedule, "weekly") || strings.Contains(schedule, "0 0 * * 0"):
interval = 7 * 24 * time.Hour
case strings.Contains(schedule, "monthly") || strings.Contains(schedule, "0 0 1 * *"):
// Approximate as 30 days
interval = 30 * 24 * time.Hour
default:
// For other schedules, try a simple cron expression check
if strings.Contains(schedule, "* * * * *") {
// Every minute
interval = 1 * time.Minute
} else if strings.Contains(schedule, "*/") {
// Likely a recurring job with specific interval
interval = 1 * time.Hour // Default to hourly as a safe guess
} else {
continue
}
}
// Generate future occurrences
// Limit the number of runs we'll capture per day
maxRunsPerDay := 20
// Generate future occurrences up to our limits
currentTime := nextRun.Add(interval)
for currentTime.Before(twoMonthsLater) {
dateKey := fmt.Sprintf("%d-%s", job.ID, currentTime.Format("2006-01-02"))
// Check if we already have too many runs for this day
if len(eventsByJobAndDay[dateKey]) < maxRunsPerDay {
eventsByJobAndDay[dateKey] = append(eventsByJobAndDay[dateKey], currentTime)
}
currentTime = currentTime.Add(interval)
}
}
// Now convert the map to calendar events, consolidating runs on the same day
for dateKey, runTimes := range eventsByJobAndDay {
// Parse job ID from date key
parts := strings.Split(dateKey, "-")
jobIDStr := parts[0]
jobID, _ := strconv.ParseUint(jobIDStr, 10, 32)
// Get the job info
job, exists := jobInfo[uint(jobID)]
if !exists {
continue
}
// Sort run times chronologically
sort.Slice(runTimes, func(i, j int) bool {
return runTimes[i].Before(runTimes[j])
})
// Use the first run time as the event time
firstRunTime := runTimes[0]
// Format run times for display in tooltip
formattedTimes := make([]string, 0, len(runTimes))
for i, rt := range runTimes {
// Limit to showing max 10 times in tooltip
if i >= 10 {
formattedTimes = append(formattedTimes, fmt.Sprintf("... and %d more", len(runTimes)-10))
break
}
formattedTimes = append(formattedTimes, rt.Format("15:04:05"))
}
// Set class based on job enabled status
className := ""
if job.Enabled {
className = "bg-blue-200 border-blue-600 text-blue-800 dark:bg-blue-800 dark:border-blue-500 dark:text-blue-100"
} else {
className = "bg-gray-200 border-gray-400 text-gray-700 dark:bg-gray-700 dark:border-gray-500 dark:text-gray-300"
}
// Create a single event for this job on this day
title := job.Name
if len(runTimes) > 1 {
title = fmt.Sprintf("%s (%d runs)", job.Name, len(runTimes))
}
// Create event
events = append(events, CalendarEvent{
ID: fmt.Sprintf("job-%d-%s", jobID, firstRunTime.Format("20060102")),
Title: title,
Start: firstRunTime.Format(time.RFC3339),
AllDay: false,
URL: fmt.Sprintf("/jobs/%d", jobID),
ClassName: className,
Description: fmt.Sprintf("Schedule: %s", job.Schedule),
Enabled: job.Enabled,
JobID: uint(jobID),
JobName: job.Name,
RunTimes: formattedTimes,
RunCount: len(runTimes),
Schedule: job.Schedule,
})
}
// Debug info
fmt.Printf("Generated %d consolidated calendar events\n", len(events))
eventsJSON, err := json.Marshal(events)
if err != nil {
return "[]" // Return empty array if marshaling fails
}
return string(eventsJSON)
}
// JobCalendar displays scheduled jobs in a calendar view
templ JobCalendar(ctx context.Context, data JobCalendarData) {
@LayoutWithContext("Transfer Calendar", ctx) {
<div class="p-6">
<div class="w-full">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div class="flex items-center mb-4 md:mb-0">
<i class="fas fa-calendar-alt text-blue-500 mr-2"></i>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Transfer Calendar</h1>
</div>
<a href="/jobs/new" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 flex items-center">
<i class="fas fa-plus mr-2"></i>
New Job
</a>
</div>
<div class="mb-6">
<div class="flex flex-wrap gap-2">
<button id="showAll" class="flex items-center text-sm px-4 py-2 rounded-lg bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-800 dark:text-blue-100 dark:hover:bg-blue-700">
<i class="fas fa-calendar-check mr-2"></i>All Jobs
</button>
<button id="showActive" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
<i class="fas fa-toggle-on mr-2"></i>Active Only
</button>
<button id="showInactive" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
<i class="fas fa-toggle-off mr-2"></i>Inactive Only
</button>
<button id="showNext" class="flex items-center text-sm px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600">
<i class="fas fa-step-forward mr-2"></i>Next Occurrences Only
</button>
</div>
</div>
<!-- Hidden div to store calendar event data -->
<div id="calendar-data" style="display: none;">{ generateCalendarEvents(data.Jobs) }</div>
<!-- Loading indicator -->
<div id="calendar-loading" class="flex items-center justify-center p-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 dark:border-blue-400"></div>
<span class="ml-3 text-gray-600 dark:text-gray-400">Loading calendar...</span>
</div>
<div id="calendar" class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hidden"></div>
</div>
</div>
<!-- Info section -->
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mb-8 mt-6 mx-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">About the Calendar View</h2>
<p class="text-gray-700 dark:text-gray-300 mb-4">
This calendar displays your scheduled transfer jobs for the next 2 months. Click on any event to view or edit the job details.
</p>
<div class="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg mb-4">
<p class="text-blue-700 dark:text-blue-300 text-sm">
<i class="fas fa-info-circle mr-2"></i>
Jobs with multiple runs on the same day are consolidated into a single event. Hover over any event to see all scheduled run times for that day.
</p>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mt-4 mb-2">Filter Options</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div class="flex items-start">
<i class="fas fa-calendar-check mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">All Jobs</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows all scheduled occurrences in the selected timeframe.</p>
</div>
</div>
<div class="flex items-start">
<i class="fas fa-toggle-on mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Active Only</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows only enabled jobs that will actually run.</p>
</div>
</div>
<div class="flex items-start">
<i class="fas fa-toggle-off mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Inactive Only</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows disabled jobs that won't run unless re-enabled.</p>
</div>
</div>
<div class="flex items-start">
<i class="fas fa-step-forward mt-1 text-blue-500 dark:text-blue-400 mr-2"></i>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Next Occurrences Only</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Shows only the next upcoming occurrence of each job.</p>
</div>
</div>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mt-4 mb-2">Legend</h3>
<div class="flex flex-wrap gap-4">
<div class="flex items-center">
<div class="w-4 h-4 rounded-full bg-blue-500 mr-2"></div>
<span class="text-gray-700 dark:text-gray-300">Active Jobs</span>
</div>
<div class="flex items-center">
<div class="w-4 h-4 rounded-full bg-gray-500 mr-2"></div>
<span class="text-gray-700 dark:text-gray-300">Inactive Jobs</span>
</div>
</div>
</div>
<!-- Local assets are now loaded through vendor.js bundle -->
<script>
// Function to initialize the calendar
function initializeCalendar() {
// Get the event data from the hidden div
try {
// Show loading indicator
const loadingIndicator = document.getElementById('calendar-loading');
const calendarElement = document.getElementById('calendar');
// Parse the data in a non-blocking way
try {
const eventsData = JSON.parse(document.getElementById('calendar-data').textContent);
// Check if FullCalendar is available
if (typeof FullCalendar === 'undefined') {
console.error('FullCalendar is not loaded yet. Waiting...');
setTimeout(initializeCalendar, 100);
return;
}
// Initialize calendar with events data
const calendar = new FullCalendar.Calendar(calendarElement, {
initialView: 'dayGridMonth',
plugins: [
FullCalendar.dayGridPlugin,
FullCalendar.timeGridPlugin,
FullCalendar.listPlugin,
FullCalendar.interactionPlugin
],
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,listWeek'
},
events: eventsData,
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: true
},
eventDidMount: function(info) {
// Log the event data for debugging
console.log('Event mounted:', info.event.title, 'Enabled:', info.event.extendedProps.enabled);
// Add tooltips to events
if (info.event.extendedProps) {
// Only add tooltips to visible events to improve performance
if (info.view.type === 'dayGridMonth' &&
info.event.start >= calendar.view.activeStart &&
info.event.start <= calendar.view.activeEnd) {
// Create enhanced tooltip content with run times
let tooltipContent = `<div class="p-2">`;
tooltipContent += `<div class="font-bold mb-1">${info.event.title}</div>`;
tooltipContent += `<div class="text-sm mb-2">Schedule: ${info.event.extendedProps.schedule || 'Unknown'}</div>`;
// Show the run times if available
if (info.event.extendedProps.runTimes && info.event.extendedProps.runTimes.length > 0) {
tooltipContent += `<div class="font-bold text-xs mt-1">Run Times:</div>`;
tooltipContent += `<div class="text-xs">`;
// Show the run times in a list
info.event.extendedProps.runTimes.forEach(time => {
tooltipContent += `<div>${time}</div>`;
});
tooltipContent += `</div>`;
}
tooltipContent += `<div class="text-xs mt-2">Click to view/edit job</div>`;
tooltipContent += `</div>`;
tippy(info.el, {
content: tooltipContent,
allowHTML: true,
placement: 'top',
arrow: true,
interactive: true,
maxWidth: 300,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light'
});
}
}
},
eventClick: function(info) {
// Use the URL property to navigate to job details
if (info.event.url) {
window.location.href = info.event.url;
return false; // Prevents the default action
}
},
eventWillUnmount: function(info) {
// Cleanup any tooltips to prevent memory leaks
if (info.el._tippy) {
info.el._tippy.destroy();
}
},
themeSystem: 'standard',
loading: function(isLoading) {
if (!isLoading) {
// Hide loading indicator and show calendar
loadingIndicator.classList.add('hidden');
calendarElement.classList.remove('hidden');
}
}
});
calendar.render();
// Handle filter buttons with optimized filtering
document.getElementById('showAll').addEventListener('click', function() {
updateActiveButton(this);
// Show all events
calendar.getEvents().forEach(event => {
event.setProp('display', '');
});
calendar.render(); // Re-render to apply changes
});
document.getElementById('showActive').addEventListener('click', function() {
updateActiveButton(this);
// Show only active events
calendar.getEvents().forEach(event => {
const isEnabled = event.extendedProps.enabled;
event.setProp('display', isEnabled ? '' : 'none');
});
calendar.render(); // Re-render to apply changes
});
document.getElementById('showInactive').addEventListener('click', function() {
updateActiveButton(this);
// Show only inactive events
calendar.getEvents().forEach(event => {
const isEnabled = event.extendedProps.enabled;
event.setProp('display', !isEnabled ? '' : 'none');
});
calendar.render(); // Re-render to apply changes
});
document.getElementById('showNext').addEventListener('click', function() {
updateActiveButton(this);
console.log("Next Occurrences Only filter clicked");
// First reset all events to make sure none are hidden
calendar.getEvents().forEach(event => {
event.setProp('display', 'none');
});
// Get a map of all jobs
const jobIDs = new Set();
calendar.getEvents().forEach(event => {
if (event.extendedProps && event.extendedProps.jobId) {
jobIDs.add(event.extendedProps.jobId);
}
});
console.log("Found jobs:", Array.from(jobIDs));
// For each job, find the next occurrence and show it
const now = new Date();
jobIDs.forEach(jobId => {
// Get all events for this job
const jobEvents = calendar.getEvents().filter(event =>
event.extendedProps &&
event.extendedProps.jobId === jobId
);
console.log(`Job ${jobId}: Found ${jobEvents.length} events`);
// Find future events
const futureEvents = jobEvents.filter(event =>
new Date(event.start) >= now
).sort((a, b) =>
new Date(a.start) - new Date(b.start)
);
// If we have future events, show the earliest one
if (futureEvents.length > 0) {
console.log(`Job ${jobId}: Next occurrence at ${futureEvents[0].start}`);
futureEvents[0].setProp('display', '');
} else {
// If no future events, find most recent past event
const pastEvents = jobEvents.filter(event =>
new Date(event.start) < now
).sort((a, b) =>
new Date(b.start) - new Date(a.start)
);
if (pastEvents.length > 0) {
console.log(`Job ${jobId}: Most recent occurrence at ${pastEvents[0].start}`);
pastEvents[0].setProp('display', '');
}
}
});
calendar.render(); // Re-render to apply changes
});
function updateActiveButton(activeBtn) {
// Reset all buttons
document.querySelectorAll('#showAll, #showActive, #showInactive, #showNext').forEach(btn => {
btn.classList.remove('bg-blue-100', 'text-blue-800', 'dark:bg-blue-800', 'dark:text-blue-100');
btn.classList.add('bg-gray-100', 'text-gray-800', 'dark:bg-gray-700', 'dark:text-gray-100');
});
// Set active button
activeBtn.classList.remove('bg-gray-100', 'text-gray-800', 'dark:bg-gray-700', 'dark:text-gray-100');
activeBtn.classList.add('bg-blue-100', 'text-blue-800', 'dark:bg-blue-800', 'dark:text-blue-100');
}
// Handle theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
// Update tooltips theme
document.querySelectorAll('[data-tippy-root]').forEach(tooltip => {
tooltip.className = document.documentElement.classList.contains('dark')
? 'tippy-box dark-theme'
: 'tippy-box light-theme';
});
}, 100);
});
}
} catch (error) {
console.error("Error initializing calendar:", error);
loadingIndicator.innerHTML = '<div class="text-red-500"><i class="fas fa-exclamation-triangle mr-2"></i>Error loading calendar data</div>';
}
} catch (error) {
console.error("Error during initial calendar setup:", error);
}
}
// Wait for vendor.js to load before initializing calendar
document.addEventListener('DOMContentLoaded', function() {
setTimeout(initializeCalendar, 100);
});
</script>
<style>
.fc-event {
cursor: pointer;
border-radius: 4px;
padding: 2px 4px;
border-left-width: 4px;
}
/* Style consolidated events */
.fc-event-title {
font-weight: 500;
font-size: 0.85em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Tooltip styles */
.tippy-box {
border-radius: 6px;
font-size: 13px;
}
.tippy-box[data-theme~='dark'] {
background-color: #1f2937;
color: #f3f4f6;
}
.tippy-box[data-theme~='light'] {
background-color: #ffffff;
color: #1f2937;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
@media (prefers-color-scheme: dark) {
.fc {
--fc-page-bg-color: #1f2937;
--fc-border-color: #374151;
--fc-neutral-bg-color: #374151;
--fc-neutral-text-color: #e5e7eb;
--fc-today-bg-color: rgba(59, 130, 246, 0.15);
}
.fc-day-today {
background-color: rgba(59, 130, 246, 0.15) !important;
}
.fc-col-header-cell {
background-color: #111827;
}
.fc-scrollgrid-sync-inner {
color: #e5e7eb;
}
.fc-daygrid-day-number {
color: #e5e7eb;
}
}
</style>
}
}
+384 -39
View File
@@ -12,6 +12,13 @@ import (
type ConfigFormData struct { type ConfigFormData struct {
Config *db.TransferConfig Config *db.TransferConfig
IsNew bool IsNew bool
// Fields for pre-rendering flags on edit
InitialCommand *db.RcloneCommand
SelectedFlagsMap map[uint]bool
SelectedFlagValues map[uint]string
// Add source and destination providers
SourceProviders []db.StorageProvider
DestinationProviders []db.StorageProvider
} }
func getConfigFormTitle(isNew bool) string { func getConfigFormTitle(isNew bool) string {
@@ -88,6 +95,13 @@ func getInitialData(config *db.TransferConfig) string {
useBuiltinAuthSource := true useBuiltinAuthSource := true
useBuiltinAuthDest := true useBuiltinAuthDest := true
// Provider configuration
useSourceProvider := false
sourceProviderId := uint(0)
useDestinationProvider := false
destinationProviderId := uint(0)
// If editing an existing config, populate with those values // If editing an existing config, populate with those values
if config != nil { if config != nil {
name = config.Name name = config.Name
@@ -163,6 +177,9 @@ func getInitialData(config *db.TransferConfig) string {
deleteAfterTransfer = config.GetDeleteAfterTransfer() deleteAfterTransfer = config.GetDeleteAfterTransfer()
skipProcessedFiles = config.GetSkipProcessedFiles() skipProcessedFiles = config.GetSkipProcessedFiles()
maxConcurrentTransfers = config.MaxConcurrentTransfers maxConcurrentTransfers = config.MaxConcurrentTransfers
if maxConcurrentTransfers <= 0 {
maxConcurrentTransfers = 1 // Ensure at least 1 concurrent transfer
}
rcloneFlags = config.RcloneFlags rcloneFlags = config.RcloneFlags
commandId = config.CommandID commandId = config.CommandID
commandFlags = config.CommandFlags commandFlags = config.CommandFlags
@@ -176,6 +193,17 @@ func getInitialData(config *db.TransferConfig) string {
} else if destClientId != "" || destClientSecret != "" { } else if destClientId != "" || destClientSecret != "" {
useBuiltinAuthDest = false useBuiltinAuthDest = false
} }
// Provider reference fields
if config.IsUsingSourceProviderReference() {
useSourceProvider = true
sourceProviderId = *config.SourceProviderID
}
if config.IsUsingDestinationProviderReference() {
useDestinationProvider = true
destinationProviderId = *config.DestinationProviderID
}
} }
// Return the JSON-formatted string with all the data, add new path validation states // Return the JSON-formatted string with all the data, add new path validation states
@@ -205,6 +233,9 @@ func getInitialData(config *db.TransferConfig) string {
sourceStartYear: %d, sourceStartYear: %d,
sourceIncludeArchived: %v, sourceIncludeArchived: %v,
useSourceProvider: %v,
sourceProviderId: %d,
filePattern: '%s', filePattern: '%s',
outputPattern: '%s', outputPattern: '%s',
@@ -232,6 +263,9 @@ func getInitialData(config *db.TransferConfig) string {
destStartYear: %d, destStartYear: %d,
destIncludeArchived: %v, destIncludeArchived: %v,
useDestinationProvider: %v,
destinationProviderId: %d,
useBuiltinAuthSource: %v, useBuiltinAuthSource: %v,
useBuiltinAuthDest: %v, useBuiltinAuthDest: %v,
@@ -286,33 +320,34 @@ func getInitialData(config *db.TransferConfig) string {
// This will need coordination with your backend to ensure the IDs match the commands // This will need coordination with your backend to ensure the IDs match the commands
let commandName = ''; let commandName = '';
switch(parseInt(this.commandId)) { switch(parseInt(this.commandId)) {
// Map command IDs to command names - adjust these based on your actual command IDs // Correct mapping based on internal/db/migrations/009_add_rclone_tables.go
case 1: commandName = 'copy'; break; case 1: commandName = 'copy'; break;
case 2: commandName = 'move'; break; case 2: commandName = 'sync'; break;
case 3: commandName = 'sync'; break; case 3: commandName = 'bisync'; break;
case 4: commandName = 'ls'; break; case 4: commandName = 'move'; break;
case 5: commandName = 'lsd'; break; case 5: commandName = 'delete'; break;
case 6: commandName = 'lsl'; break; case 6: commandName = 'purge'; break;
case 7: commandName = 'lsf'; break; case 7: commandName = 'mkdir'; break;
case 8: commandName = 'lsjson'; break; case 8: commandName = 'rmdir'; break;
case 9: commandName = 'md5sum'; break; case 9: commandName = 'rmdirs'; break;
case 10: commandName = 'sha1sum'; break; case 10: commandName = 'check'; break;
case 11: commandName = 'size'; break; case 11: commandName = 'ls'; break;
case 12: commandName = 'delete'; break; case 12: commandName = 'lsd'; break;
case 13: commandName = 'purge'; break; case 13: commandName = 'lsl'; break;
case 14: commandName = 'mkdir'; break; case 14: commandName = 'lsf'; break;
case 15: commandName = 'rmdir'; break; case 15: commandName = 'lsjson'; break;
case 16: commandName = 'rmdirs'; break; case 16: commandName = 'md5sum'; break;
case 17: commandName = 'check'; break; case 17: commandName = 'sha1sum'; break;
case 18: commandName = 'cleanup'; break; case 18: commandName = 'size'; break;
case 19: commandName = 'dedupe'; break; case 19: commandName = 'version'; break;
case 20: commandName = 'version'; break; case 20: commandName = 'cleanup'; break;
case 21: commandName = 'listremotes'; break; case 21: commandName = 'dedupe'; break;
case 22: commandName = 'cryptcheck'; break; case 22: commandName = 'copyto'; break;
case 23: commandName = 'bisync'; break; case 23: commandName = 'moveto'; break;
case 24: commandName = 'copyto'; break; case 24: commandName = 'listremotes'; break;
case 25: commandName = 'moveto'; break; case 25: commandName = 'obscure'; break;
default: commandName = 'copy'; // Default to copy case 26: commandName = 'cryptcheck'; break;
default: commandName = 'copy'; // Default to copy if ID is unknown
} }
console.log('Command ID:', this.commandId, 'Command Name:', commandName); console.log('Command ID:', this.commandId, 'Command Name:', commandName);
@@ -339,11 +374,13 @@ func getInitialData(config *db.TransferConfig) string {
sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode, sourceBucket, sourceRegion, sourceAccessKey, sourceSecretKey, sourceEndpoint, sourceShare, sourceDomain, sourcePassiveMode,
sourceClientId, sourceClientSecret, sourceDriveId, sourceTeamDrive, sourceClientId, sourceClientSecret, sourceDriveId, sourceTeamDrive,
sourceReadOnly, sourceStartYear, sourceIncludeArchived, sourceReadOnly, sourceStartYear, sourceIncludeArchived,
useSourceProvider, sourceProviderId,
filePattern, outputPattern, filePattern, outputPattern,
destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, destAuthType, destinationType, destinationPath, destHost, destPort, destUser, destPassword, destKeyFile, destAuthType,
destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode, destBucket, destRegion, destAccessKey, destSecretKey, destEndpoint, destShare, destDomain, destPassiveMode,
destClientId, destClientSecret, destDriveId, destTeamDrive, destClientId, destClientSecret, destDriveId, destTeamDrive,
destReadOnly, destStartYear, destIncludeArchived, destReadOnly, destStartYear, destIncludeArchived,
useDestinationProvider, destinationProviderId,
useBuiltinAuthSource, useBuiltinAuthDest, useBuiltinAuthSource, useBuiltinAuthDest,
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags, archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags,
commandId, commandFlags) commandId, commandFlags)
@@ -372,6 +409,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<!-- Main Form --> <!-- Main Form -->
<form <form
id="config-form"
class="space-y-6" class="space-y-6"
if data.IsNew { if data.IsNew {
hx-post="/configs" hx-post="/configs"
@@ -393,6 +431,8 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
sourcePort = 22; sourcePort = 22;
} else if (sourceType === 'ftp') { } else if (sourceType === 'ftp') {
sourcePort = 21; sourcePort = 21;
} else if (sourceType === 'hetzner') {
sourcePort = 23;
} }
} }
@@ -401,28 +441,119 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
destPort = 22; destPort = 22;
} else if (destinationType === 'ftp') { } else if (destinationType === 'ftp') {
destPort = 21; destPort = 21;
} else if (destinationType === 'hetzner') {
destPort = 23;
} }
} }
// Ensure maxConcurrentTransfers is at least 1
if (!maxConcurrentTransfers || maxConcurrentTransfers < 1) {
maxConcurrentTransfers = 1;
}
// Initialize provider selection states
useSourceProvider = useSourceProvider || false;
useDestinationProvider = useDestinationProvider || false;
// Initialize command requirements // Initialize command requirements
updateCommandRequirements(); updateCommandRequirements();
// Initialize hidden input fields with their values to ensure they're included in form submission
document.getElementById('hidden_name').value = name;
document.getElementById('hidden_source_path').value = sourcePath;
document.getElementById('hidden_destination_path').value = destinationPath;
// Initialize S3 source fields if applicable
if (sourceType === 's3' || sourceType === 'b2' || sourceType === 'wasabi' || sourceType === 'minio') {
document.getElementById('hidden_source_access_key').value = sourceAccessKey;
document.getElementById('hidden_source_secret_key').value = sourceSecretKey;
document.getElementById('hidden_source_endpoint').value = sourceEndpoint;
document.getElementById('hidden_source_bucket').value = sourceBucket;
document.getElementById('hidden_source_region').value = sourceRegion;
}
// Initialize S3 destination fields if applicable
if (destinationType === 's3' || destinationType === 'b2' || destinationType === 'wasabi' || destinationType === 'minio') {
document.getElementById('hidden_dest_access_key').value = destAccessKey;
document.getElementById('hidden_dest_secret_key').value = destSecretKey;
document.getElementById('hidden_dest_endpoint').value = destEndpoint;
document.getElementById('hidden_dest_bucket').value = destBucket;
document.getElementById('hidden_dest_region').value = destRegion;
}
})" })"
x-effect="if (sourceType === 'sftp' && (sourcePort === 0 || sourcePort === 21)) { x-effect="if (sourceType === 'sftp' && (sourcePort === 0 || sourcePort === 21 || sourcePort === 23)) {
sourcePort = 22; sourcePort = 22;
console.log('Updating source port to 22 for SFTP'); console.log('Updating source port to 22 for SFTP');
} else if (sourceType === 'ftp' && (sourcePort === 0 || sourcePort === 22)) { } else if (sourceType === 'ftp' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 23)) {
sourcePort = 21; sourcePort = 21;
console.log('Updating source port to 21 for FTP'); console.log('Updating source port to 21 for FTP');
}" } else if (sourceType === 'hetzner' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 21)) {
x-effect="if (destinationType === 'sftp' && (destPort === 0 || destPort === 21)) { sourcePort = 23;
console.log('Updating source port to 23 for Hetzner');
} else if (destinationType === 'sftp' && (destPort === 0 || destPort === 21 || destPort === 23)) {
destPort = 22; destPort = 22;
console.log('Updating destination port to 22 for SFTP'); console.log('Updating destination port to 22 for SFTP');
} else if (destinationType === 'ftp' && (destPort === 0 || destPort === 22)) { } else if (destinationType === 'ftp' && (destPort === 0 || destPort === 22 || destPort === 23)) {
destPort = 21; destPort = 21;
console.log('Updating destination port to 21 for FTP'); console.log('Updating destination port to 21 for FTP');
} else if (destinationType === 'hetzner' && (destPort === 0 || destPort === 22 || destPort === 21)) {
destPort = 23;
console.log('Updating destination port to 23 for Hetzner');
}" }"
@formvalidation
@submit="
// Basic fields
document.getElementById('hidden_name').value = name;
document.getElementById('hidden_source_path').value = sourcePath;
document.getElementById('hidden_destination_path').value = destinationPath;
// S3 source fields
if (sourceType === 's3' || sourceType === 'b2' || sourceType === 'wasabi' || sourceType === 'minio') {
document.getElementById('hidden_source_access_key').value = sourceAccessKey;
document.getElementById('hidden_source_secret_key').value = sourceSecretKey;
document.getElementById('hidden_source_endpoint').value = sourceEndpoint;
document.getElementById('hidden_source_bucket').value = sourceBucket;
document.getElementById('hidden_source_region').value = sourceRegion;
}
// S3 destination fields
if (destinationType === 's3' || destinationType === 'b2' || destinationType === 'wasabi' || destinationType === 'minio') {
document.getElementById('hidden_dest_access_key').value = destAccessKey;
document.getElementById('hidden_dest_secret_key').value = destSecretKey;
document.getElementById('hidden_dest_endpoint').value = destEndpoint;
document.getElementById('hidden_dest_bucket').value = destBucket;
document.getElementById('hidden_dest_region').value = destRegion;
}
"
> >
<!-- Hidden fields to ensure values are submitted with the form -->
<input type="hidden" id="hidden_name" name="name" />
<input type="hidden" id="hidden_source_path" name="source_path" />
<input type="hidden" id="hidden_destination_path" name="destination_path" />
<!-- Hidden fields for S3-compatible providers -->
<input type="hidden" id="hidden_source_access_key" name="source_access_key" />
<input type="hidden" id="hidden_source_secret_key" name="source_secret_key" />
<input type="hidden" id="hidden_source_endpoint" name="source_endpoint" />
<input type="hidden" id="hidden_source_bucket" name="source_bucket" />
<input type="hidden" id="hidden_source_region" name="source_region" />
<input type="hidden" id="hidden_dest_access_key" name="dest_access_key" />
<input type="hidden" id="hidden_dest_secret_key" name="dest_secret_key" />
<input type="hidden" id="hidden_dest_endpoint" name="dest_endpoint" />
<input type="hidden" id="hidden_dest_bucket" name="dest_bucket" />
<input type="hidden" id="hidden_dest_region" name="dest_region" />
<!-- Form Error Container -->
<div id="form-errors" class="hidden p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-800/20 dark:text-red-400 border border-red-200 dark:border-red-900" role="alert">
<div class="flex items-center mb-2">
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-500 mr-2"></i>
<h3 class="text-base font-medium text-red-800 dark:text-red-400">Please correct the following errors:</h3>
</div>
<ul id="error-list" class="ml-5 list-disc space-y-1"></ul>
</div>
<!-- Configuration Details Section --> <!-- Configuration Details Section -->
<div class="p-4 mb-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900"> <div class="p-4 mb-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
@@ -436,13 +567,17 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<!-- Name field --> <!-- Name field -->
<div class="mb-2"> <div class="mb-2">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label> <label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Configuration Name <span class="text-red-500">*</span>
</label>
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
x-model="name" x-model="name"
@input="document.getElementById('hidden_name').value = name"
required required
aria-required="true"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="My Transfer Configuration" placeholder="My Transfer Configuration"
/> />
@@ -455,8 +590,18 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<i class="fas fa-terminal mr-2 text-blue-500 dark:text-blue-400"></i>Command Configuration <i class="fas fa-terminal mr-2 text-blue-500 dark:text-blue-400"></i>Command Configuration
</h3> </h3>
<!-- Use the RcloneFlags component from common package --> <!-- Additonal Rclone Flags -->
@common.RcloneFlags() @common.RcloneFlags(data.Config.CommandID) // Pass current command ID
<!-- Container for flags, pre-rendered on edit, loaded via HTMX on new/change -->
<div id="command-flags-container" class="mt-4">
if !data.IsNew && data.InitialCommand != nil {
// Pre-render flags if editing and command data is available
@common.RcloneCommandFlagsContent(data.InitialCommand, data.SelectedFlagsMap, data.SelectedFlagValues)
}
</div>
</div> </div>
<!-- Source Configuration Section --> <!-- Source Configuration Section -->
@@ -466,9 +611,25 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
</h3> </h3>
<!-- Source selection --> <!-- Source selection -->
@common.SourceSelection() @common.SourceSelection(data.SourceProviders)
<!-- Source type specific forms --> <!-- Test Source Connection button -->
<div class="mt-4">
<button type="button"
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
hx-post="/configs/test-connection"
hx-include="closest form"
hx-vals='{"providerType": "source"}'
hx-swap="none"
hx-indicator="#source-test-spinner">
<i class="fas fa-plug mr-1"></i> Test Source
<span id="source-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
</button>
</div>
<!-- Source type specific forms - only show when not using a provider -->
<template x-if="!useSourceProvider">
<div class="mt-4">
<template x-if="sourceType === 'local'"> <template x-if="sourceType === 'local'">
@source.LocalSourceForm() @source.LocalSourceForm()
</template> </template>
@@ -485,6 +646,14 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
@source.S3SourceForm() @source.S3SourceForm()
</template> </template>
<template x-if="sourceType === 'b2'">
@source.B2SourceForm()
</template>
<template x-if="sourceType === 'wasabi'">
@source.WasabiSourceForm()
</template>
<template x-if="sourceType === 'minio'"> <template x-if="sourceType === 'minio'">
@source.MinIOSourceForm() @source.MinIOSourceForm()
</template> </template>
@@ -508,6 +677,52 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<template x-if="sourceType === 'gphotos'"> <template x-if="sourceType === 'gphotos'">
@source.GooglePhotosSourceForm() @source.GooglePhotosSourceForm()
</template> </template>
<template x-if="sourceType === 'hetzner'">
@source.HetznerSourceForm()
</template>
</div>
</template>
<!-- Provider usage notice -->
<template x-if="useSourceProvider">
<div class="mt-4 p-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-800">
<div class="flex">
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mt-0.5 mr-2"></i>
<div>
<p class="text-sm text-blue-800 dark:text-blue-300">
Using storage provider configuration. Customize source path options below if needed.
</p>
</div>
</div>
</div>
</template>
<!-- Always show source path field with provider or without provider -->
<template x-if="useSourceProvider && sourceProviderId > 0">
<div class="mt-4">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
id="source_path"
name="source_path"
x-model="sourcePath"
@input="document.getElementById('hidden_source_path').value = sourcePath"
@blur="checkPath(sourcePath, 'source')"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/source"
/>
<template x-if="sourcePathValid === false">
<p class="mt-2 text-sm text-red-600 dark:text-red-500" x-text="sourcePathError"></p>
</template>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Specify source path within the provider</p>
</div>
</template>
</div> </div>
<!-- File Pattern Section --> <!-- File Pattern Section -->
@@ -520,16 +735,32 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
@common.FilePatternFields() @common.FilePatternFields()
</div> </div>
<!-- Destination Configuration Section (only shown if required) --> <!-- Destination Configuration Section -->
<div x-show="requiresDestination" x-transition class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700"> <div x-show="requiresDestination" x-transition class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center"> <h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-download mr-2 text-blue-500 dark:text-blue-400"></i>Destination Configuration <i class="fas fa-download mr-2 text-blue-500 dark:text-blue-400"></i>Destination Configuration
</h3> </h3>
<!-- Destination selection --> <!-- Destination selection -->
@common.DestinationSelection() @common.DestinationSelection(data.DestinationProviders)
<!-- Destination type specific forms --> <!-- Test Destination button -->
<div class="mt-4">
<button type="button"
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
hx-post="/configs/test-connection"
hx-include="closest form"
hx-vals='{"providerType": "destination"}'
hx-swap="none"
hx-indicator="#dest-test-spinner">
<i class="fas fa-plug mr-1"></i> Test Destination
<span id="dest-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
</button>
</div>
<!-- Destination type specific forms - only show when not using a provider -->
<template x-if="!useDestinationProvider">
<div class="mt-4">
<template x-if="destinationType === 'local'"> <template x-if="destinationType === 'local'">
@destination.LocalDestinationForm() @destination.LocalDestinationForm()
</template> </template>
@@ -546,6 +777,14 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
@destination.S3DestinationForm() @destination.S3DestinationForm()
</template> </template>
<template x-if="destinationType === 'b2'">
@destination.B2DestinationForm()
</template>
<template x-if="destinationType === 'wasabi'">
@destination.WasabiDestinationForm()
</template>
<template x-if="destinationType === 'minio'"> <template x-if="destinationType === 'minio'">
@destination.MinIODestinationForm() @destination.MinIODestinationForm()
</template> </template>
@@ -569,6 +808,52 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
<template x-if="destinationType === 'gphotos'"> <template x-if="destinationType === 'gphotos'">
@destination.GooglePhotosDestinationForm() @destination.GooglePhotosDestinationForm()
</template> </template>
<template x-if="destinationType === 'hetzner'">
@destination.HetznerDestinationForm()
</template>
</div>
</template>
<!-- Provider usage notice -->
<template x-if="useDestinationProvider">
<div class="mt-4 p-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-800">
<div class="flex">
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mt-0.5 mr-2"></i>
<div>
<p class="text-sm text-blue-800 dark:text-blue-300">
Using storage provider configuration. Customize destination path options below if needed.
</p>
</div>
</div>
</div>
</template>
<!-- Always show destination path field with provider or without provider -->
<template x-if="useDestinationProvider && destinationProviderId > 0">
<div class="mt-4">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
</div>
<input
type="text"
id="destination_path"
name="destination_path"
x-model="destinationPath"
@input="document.getElementById('hidden_destination_path').value = destinationPath"
@blur="checkPath(destinationPath, 'dest')"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/destination"
/>
<template x-if="destPathValid === false">
<p class="mt-2 text-sm text-red-600 dark:text-red-500" x-text="destPathError"></p>
</template>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Specify destination path within the provider</p>
</div>
</template>
</div> </div>
<!-- Advanced Options Section --> <!-- Advanced Options Section -->
@@ -584,6 +869,8 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
</div> </div>
</div> </div>
<!-- Form Actions --> <!-- Form Actions -->
@@ -626,3 +913,61 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
</section> </section>
} }
} }
templ formvalidation() {
<script>
document.addEventListener('htmx:beforeRequest', function(evt) {
if (evt.detail.elt.id === 'config-form') {
const formErrors = document.getElementById('form-errors');
const errorList = document.getElementById('error-list');
let errors = [];
let hasErrors = false;
// Clear previous errors
errorList.innerHTML = '';
formErrors.classList.add('hidden');
// Validate name
const name = document.getElementById('name').value;
if (!name || name.trim() === '') {
errors.push('Configuration name is required');
hasErrors = true;
}
// Get form data to check provider usage
const useSourceProvider = document.getElementById('use_source_provider')?.checked || false;
const useDestinationProvider = document.getElementById('use_destination_provider')?.checked || false;
// Validate source provider selection if using provider
if (useSourceProvider) {
const sourceProviderId = document.getElementById('source_provider_id').value;
if (!sourceProviderId || sourceProviderId === '') {
errors.push('Source provider selection is required');
hasErrors = true;
}
}
// Validate destination provider selection if using provider
if (useDestinationProvider) {
const destProviderId = document.getElementById('destination_provider_id').value;
if (!destProviderId || destProviderId === '') {
errors.push('Destination provider selection is required');
hasErrors = true;
}
}
// Display errors if any
if (hasErrors) {
formErrors.classList.remove('hidden');
errors.forEach(function(error) {
const li = document.createElement('li');
li.textContent = error;
errorList.appendChild(li);
});
evt.preventDefault(); // Prevent form submission
}
}
});
</script>
}
+3 -3
View File
@@ -394,7 +394,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
</p> </p>
<!-- Google Drive Authentication Badge --> <!-- Google Drive Authentication Badge -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() { if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300"> <span class="ml-2 bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1 inline"></i> <i class="fas fa-exclamation-triangle w-3 h-3 mr-1 inline"></i>
Authentication Required Authentication Required
@@ -402,7 +402,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
} }
<!-- Google Drive Authentication Status Indicator --> <!-- Google Drive Authentication Status Indicator -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() { if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
<span class="ml-2 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300"> <span class="ml-2 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i> <i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
Authenticated Authenticated
@@ -411,7 +411,7 @@ templ Configs(ctx context.Context, data ConfigsData) {
</div> </div>
<div class="ml-2 flex-shrink-0 flex space-x-2"> <div class="ml-2 flex-shrink-0 flex space-x-2">
<!-- Google Drive Authentication Button --> <!-- Google Drive Authentication Button -->
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() { if (config.DestinationType == "drive" || config.SourceType == "drive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800"> <a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800">
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i> <i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
Authenticate Authenticate
+85
View File
@@ -16,6 +16,8 @@ type DashboardData struct {
FailedTransfers int FailedTransfers int
Configs map[uint]db.TransferConfig Configs map[uint]db.TransferConfig
RcloneVersion string RcloneVersion string
LatestVersion string
CurrentVersion string
} }
// GetRcloneVersion executes the rclone --version command and returns the version string // GetRcloneVersion executes the rclone --version command and returns the version string
@@ -41,6 +43,64 @@ func GetRcloneVersion() string {
return "Unknown" return "Unknown"
} }
// isNewerVersionAvailable checks if the latest version is newer than the current version
func isNewerVersionAvailable(current, latest string) bool {
// If either version is empty, we can't do a comparison
if current == "" || latest == "" {
return false
}
// Special case for DEV versions - always show update available
if current == "DEV" {
return true
}
// Strip 'v' prefix if present for comparison
if strings.HasPrefix(current, "v") {
current = current[1:]
}
if strings.HasPrefix(latest, "v") {
latest = latest[1:]
}
// Split versions into components
currentParts := strings.Split(current, ".")
latestParts := strings.Split(latest, ".")
// Handle non-semver format in either version
if len(currentParts) < 2 || len(latestParts) < 2 {
// If format doesn't match semver pattern, do string comparison
return current != latest && latest != ""
}
// Compare major, minor, patch versions
for i := 0; i < len(currentParts) && i < len(latestParts); i++ {
// Parse to integers
currentNum, err1 := strconv.Atoi(currentParts[i])
latestNum, err2 := strconv.Atoi(latestParts[i])
// If either can't be parsed, do string comparison
if err1 != nil || err2 != nil {
if currentParts[i] < latestParts[i] {
return true
} else if currentParts[i] > latestParts[i] {
return false
}
continue
}
// Compare numbers
if latestNum > currentNum {
return true
} else if latestNum < currentNum {
return false
}
}
// If all components are equal but latest has more components, it's newer
return len(latestParts) > len(currentParts)
}
templ Dashboard(ctx context.Context, data DashboardData) { templ Dashboard(ctx context.Context, data DashboardData) {
@LayoutWithContext("Dashboard", ctx) { @LayoutWithContext("Dashboard", ctx) {
<div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900"> <div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
@@ -253,6 +313,31 @@ templ Dashboard(ctx context.Context, data DashboardData) {
} }
</span> </span>
</div> </div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Application Version</span>
if data.CurrentVersion != "" && data.LatestVersion != "" && isNewerVersionAvailable(data.CurrentVersion, data.LatestVersion) {
<div class="flex items-center">
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300 mr-2">
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1"></i>
{ data.CurrentVersion }
</span>
<a href={ templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", data.LatestVersion)) } target="_blank"
class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800">
<i class="fas fa-arrow-circle-up w-3 h-3 mr-1"></i>
{ data.LatestVersion } Available
</a>
</div>
} else {
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<i class="fas fa-check-circle w-3 h-3 mr-1"></i>
if data.CurrentVersion != "" {
{ data.CurrentVersion }
} else {
Up to date
}
</span>
}
</div>
</div> </div>
</div> </div>
</div> </div>
+4 -1
View File
@@ -177,12 +177,15 @@ templ NotificationDropdown(data NotificationsData) {
templ NotificationCount(count int64) { templ NotificationCount(count int64) {
if count > 0 { if count > 0 {
<div class="absolute inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full -top-1 -right-1" id="notification-count"> <div class="absolute inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 border border-white dark:border-gray-800 rounded-full -translate-y-1/2 translate-x-1/2" style="top: 0; right: 0;" id="notification-count">
if count > 99 { if count > 99 {
99+ 99+
} else { } else {
{ fmt.Sprintf("%d", count) } { fmt.Sprintf("%d", count) }
} }
</div> </div>
} else {
<!-- Empty element to ensure proper swap when count is zero -->
<span class="hidden"></span>
} }
} }
@@ -0,0 +1,256 @@
package details
import (
"context"
"fmt"
"strconv"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/dialog" // Import dialog package
"github.com/starfleetcptn/gomft/components/file_metadata/utils" // Import utils package
)
// FileMetadataDetails renders the details view for a file metadata, matching original structure
templ FileMetadataDetails(ctx context.Context, data file_metadata.FileMetadataDetailsData) {
@components.LayoutWithContext("File Details", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
@utils.FileMetadataJS() // Include JS for toasts, etc.
<div id="file-details-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
File Details: { data.File.FileName }
</h1>
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<!-- Card header -->
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
{ data.File.FileName }
</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
</p>
</div>
<!-- Card content -->
<div class="p-4 md:p-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- File Information -->
<div>
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
</h6>
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Filename
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.FileName }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Size
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ utils.FormatFileSize(data.File.FileSize) }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Hash
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
if data.File.FileHash != "" {
<span class="font-mono">{ data.File.FileHash }</span>
} else {
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
}
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Status
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(data.File.Status) }>
{ data.File.Status }
</span>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Original Path
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-folder mr-2 text-yellow-500"></i>
<span>{ data.File.OriginalPath }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Destination Path
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
<span>{ data.File.DestinationPath }</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Processing Information -->
<div>
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
</h6>
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<tbody>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Job
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
{ data.File.Job.Name }
</a>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Processed Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="far fa-clock mr-2 text-gray-500"></i>
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Creation Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Modification Time
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
<div class="flex items-center">
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
</div>
</td>
</tr>
if data.File.Status == "error" && data.File.ErrorMessage != "" {
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Error
</th>
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
</div>
</td>
</tr>
}
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Record Created
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
</td>
</tr>
<tr class="border-b dark:border-gray-700">
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
Record Updated
</th>
<td class="py-3 px-4 bg-white dark:bg-gray-800">
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Delete dialog component call -->
@dialog.FileMetadataDialog(
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
"Delete File Metadata",
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800", // Use correct classes
"Delete",
"delete",
data.File.ID,
data.File.FileName,
"details", // Indicate this is from the details view
)
<!-- Action buttons -->
<div class="mt-6 flex flex-wrap justify-end gap-3">
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-list mr-2"></i> Back to Files
</a>
<button
type="button"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", data.File.ID)} }
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
<i class="fas fa-trash mr-2"></i> Delete Record
</button>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('file-details-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('file-details-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
@@ -0,0 +1,57 @@
package dialog
import (
"fmt"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
)
// FileMetadataDialog renders a confirmation dialog for file metadata actions
templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) {
@utils.FileMetadataJS()
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
}
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
if section == "list" {
<button
type="button"
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
hx-target={ fmt.Sprintf("#file-row-%d", fileID) }
hx-swap="delete"
data-file-name={ fileName }
data-file-id={ fmt.Sprint(fileID) }
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
{ confirmText }
</button>
} else {
<button
type="button"
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
hx-redirect="/files"
data-file-name={ fileName }
data-file-id={ fmt.Sprint(fileID) }
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
{ confirmText }
</button>
}
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,116 @@
package list
import (
"context"
"strconv"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
"github.com/starfleetcptn/gomft/components" // Import the main components package
)
// FileMetadataList renders the list of file metadata
templ FileMetadataList(ctx context.Context, data file_metadata.FileMetadataListData) {
@components.LayoutWithContext("Files", ctx) { // Call using components package
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
@utils.FileMetadataJS() // Use capitalized function name
<div id="list-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
Files
</h1>
<div class="flex gap-3">
<a href="/files/search" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-search mr-2"></i> Advanced Search
</a>
</div>
</div>
<!-- Filter Form -->
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Filter Files</h5>
<form
hx-get="/files/partial"
hx-target="#file-list-container"
hx-swap="innerHTML"
hx-indicator="#filter-loading"
hx-headers='{"X-HX-Request": "true"}'
class="grid grid-cols-1 md:grid-cols-3 gap-4">
if data.Job == nil {
<div>
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job</label>
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
</div>
}
<div>
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Statuses</option>
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
</select>
</div>
<div>
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div class="md:col-span-3 flex justify-end items-center">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-filter mr-2"></i> Apply Filters
</button>
<div id="filter-loading" class="htmx-indicator ml-2 flex items-center">
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
</div>
</div>
</form>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<!-- Card header -->
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
File List
</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Showing { strconv.FormatInt(int64((data.Page - 1) * data.Limit + 1), 10) } to { strconv.FormatInt(int64(min(data.Page * data.Limit, int(data.TotalCount))), 10) } of { strconv.FormatInt(data.TotalCount, 10) } files
</p>
</div>
<!-- Card content -->
<div id="file-list-container" class="p-4 md:p-5">
@FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container")
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('list-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('list-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
@@ -0,0 +1,269 @@
package list
import (
"context"
"fmt"
"net/url"
"strconv"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/dialog"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
)
// Helper function to generate sorting links
func sortLink(currentSortBy, currentSortDir, targetSortBy, basePath string, filter file_metadata.FileMetadataFilter, limit int) string {
nextSortDir := "asc"
if currentSortBy == targetSortBy && currentSortDir == "asc" {
nextSortDir = "desc"
}
q := url.Values{}
q.Set("page", "1")
q.Set("limit", strconv.Itoa(limit))
q.Set("sort_by", targetSortBy)
q.Set("sort_dir", nextSortDir)
if filter.Status != "" {
q.Set("status", filter.Status)
}
if filter.FileName != "" {
q.Set("filename", filter.FileName)
}
if filter.JobID != "" {
q.Set("job_id", filter.JobID)
}
if filter.Hash != "" {
q.Set("hash", filter.Hash)
}
if filter.StartDate != "" {
q.Set("start_date", filter.StartDate)
}
if filter.EndDate != "" {
q.Set("end_date", filter.EndDate)
}
return fmt.Sprintf("%s?%s", basePath, q.Encode())
}
// Helper function to get sort icon class
func sortIconClass(currentSortBy, currentSortDir, targetSortBy string) string {
if currentSortBy == targetSortBy {
if currentSortDir == "asc" {
return "fas fa-sort-up ml-1"
}
return "fas fa-sort-down ml-1"
}
return "fas fa-sort text-gray-400 ml-1"
}
// FileMetadataListPartial renders the list of file metadata in a table format
// Added basePath and targetContainerID parameters
templ FileMetadataListPartial(ctx context.Context, data file_metadata.FileMetadataListData, basePath string, targetContainerID string) {
<!-- Container for dynamically generated dialogs -->
<div id="dialog-container">
for _, file := range data.Files {
@dialog.FileMetadataDialog(
fmt.Sprintf("delete-file-dialog-%d", file.ID),
"Delete File Metadata",
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
"Delete",
"delete",
file.ID,
file.FileName,
"list",
)
}
</div>
<!-- File List Table -->
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "id", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
ID <i class={ sortIconClass(data.SortBy, data.SortDir, "id") }></i>
</a>
</th>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "filename", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
Filename <i class={ sortIconClass(data.SortBy, data.SortDir, "filename") }></i>
</a>
</th>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "size", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
Size <i class={ sortIconClass(data.SortBy, data.SortDir, "size") }></i>
</a>
</th>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "processed_time", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
Processed time <i class={ sortIconClass(data.SortBy, data.SortDir, "processed_time") }></i>
</a>
</th>
<th scope="col" class="px-6 py-3">
<a
href="#"
hx-get={ sortLink(data.SortBy, data.SortDir, "status", basePath, data.Filter, data.Limit) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
>
Status <i class={ sortIconClass(data.SortBy, data.SortDir, "status") }></i>
</a>
</th>
if data.Job == nil {
<th scope="col" class="px-6 py-3">Job</th>
}
<th scope="col" class="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody>
for _, file := range data.Files {
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white">
{ strconv.FormatUint(uint64(file.ID), 10) }
</td>
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
{ file.FileName }
</a>
</td>
<td class="px-6 py-4">
{ utils.FormatFileSize(file.FileSize) }
</td>
<td class="px-6 py-4">
{ file.ProcessedTime.Format("2006-01-02 15:04:05") }
</td>
<td class="px-6 py-4">
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(file.Status) }>
{ file.Status }
</span>
</td>
if data.Job == nil {
<td class="px-6 py-4">
if file.Job.ID > 0 {
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
{ file.Job.Name }
</a>
} else {
<span class="text-gray-400 dark:text-gray-500 italic">N/A</span>
}
</td>
}
<td class="px-6 py-4">
<div class="flex space-x-3">
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
<i class="fas fa-eye"></i>
</a>
<button
type="button"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", file.ID)} }
data-file-id={ strconv.FormatUint(uint64(file.ID), 10) }
data-file-name={ file.FileName }
class="font-medium text-red-600 dark:text-red-500 hover:underline">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination with HTMX (Update links to include sorting and targetContainerID) -->
if data.TotalPages > 1 {
<nav class="flex items-center flex-column flex-wrap md:flex-row justify-between p-4" aria-label="Table navigation">
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0">
Showing <span class="font-semibold text-gray-900 dark:text-white">{ strconv.Itoa((data.Page-1)*data.Limit+1) }-{ strconv.Itoa(func() int {
end := data.Page*data.Limit
if int64(end) > data.TotalCount {
return int(data.TotalCount)
}
return end
}()) }</span> of <span class="font-semibold text-gray-900 dark:text-white">{ strconv.FormatInt(data.TotalCount, 10) }</span>
</span>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
if data.Page == 1 {
<span class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
Previous
</span>
} else {
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page - 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
Previous
</a>
}
</li>
for i := 1; i <= data.TotalPages; i++ {
if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
<li>
if i == data.Page {
<span aria-current="page" class="flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white">
{ strconv.Itoa(i) }
</span>
} else {
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, i, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
{ strconv.Itoa(i) }
</a>
}
</li>
} else if (i == 2 && data.Page > 4) || (i == data.TotalPages-1 && data.Page < data.TotalPages-3) {
<li>
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
...
</span>
</li>
}
}
<li>
if data.Page == data.TotalPages {
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
Next
</span>
} else {
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page + 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
hx-target={ targetContainerID }
hx-swap="innerHTML"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
Next
</a>
}
</li>
</ul>
</nav>
}
}
@@ -0,0 +1,131 @@
package search
import (
"context"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
"github.com/starfleetcptn/gomft/components" // Import the main components package
)
// FileMetadataSearch renders the search interface for file metadata
templ FileMetadataSearch(ctx context.Context, data file_metadata.FileMetadataSearchData) {
@components.LayoutWithContext("Search Files", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
@utils.FileMetadataJS() // Include JS for toasts, etc.
<div id="search-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-search w-6 h-6 mr-2 text-blue-500"></i>
Search Files
</h1>
<div class="flex gap-3">
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Files
</a>
</div>
</div>
<!-- Advanced Search Form -->
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
<form
hx-get="/files/search/partial"
hx-target="#search-results-container"
hx-swap="innerHTML"
hx-indicator="#search-form-loading"
hx-headers='{"X-HX-Request": "true"}'
hx-boost="false"
class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">All Statuses</option>
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
</select>
</div>
<div>
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div>
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
<div class="md:col-span-2 flex justify-end">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-search mr-2"></i> Search Files
</button>
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
</div>
</div>
</form>
</div>
<!-- Results Container (Initially empty, populated by HTMX) -->
<div id="search-results-container" class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full mt-6">
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
Search Results
</h5>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Enter search criteria above and click "Search Files".
</p>
</div>
<div class="p-4 md:p-5">
<!-- Content will be loaded here by HTMX -->
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
No results yet.
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('search-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('search-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
</div>
}
}
@@ -0,0 +1,33 @@
package search
import (
"context"
"github.com/starfleetcptn/gomft/components/file_metadata"
"github.com/starfleetcptn/gomft/components/file_metadata/list"
)
// FileMetadataSearchContent renders only the search results table and pagination
templ FileMetadataSearchContent(ctx context.Context, data file_metadata.FileMetadataSearchData) {
<!-- Search Results -->
<div id="search-results">
if len(data.Files) > 0 {
@list.FileMetadataListPartial(ctx, file_metadata.FileMetadataListData{
Files: data.Files,
Page: data.Page,
Limit: data.Limit,
TotalCount: data.TotalCount,
TotalPages: data.TotalPages,
Filter: data.Filter, // Pass filter data for pagination links
SortBy: data.SortBy,
SortDir: data.SortDir,
}, "/files/search/partial", "#search-results-container") // Pass correct base path and target ID
} else {
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
<svg class="mx-auto mb-4 w-12 h-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
<p>No files found matching your search criteria.</p>
</div>
}
</div>
}
+43
View File
@@ -0,0 +1,43 @@
package file_metadata
import "github.com/starfleetcptn/gomft/internal/db"
// FileMetadataFilter represents filter parameters for file metadata queries
type FileMetadataFilter struct {
Status string
JobID string
FileName string
Hash string
StartDate string
EndDate string
}
// FileMetadataListData contains data for the file metadata list template
type FileMetadataListData struct {
Files []db.FileMetadata
TotalCount int64
Page int
Limit int
TotalPages int
Job *db.Job // Optional: if viewing files for a specific job
Filter FileMetadataFilter
SortBy string // Added for sorting
SortDir string // Added for sorting ("asc" or "desc")
}
// FileMetadataDetailsData contains data for the file metadata details template
type FileMetadataDetailsData struct {
File db.FileMetadata
}
// FileMetadataSearchData contains data for the file metadata search template
type FileMetadataSearchData struct {
Files []db.FileMetadata
TotalCount int64
Page int
Limit int
TotalPages int
Filter FileMetadataFilter
SortBy string // Added for sorting
SortDir string // Added for sorting ("asc" or "desc")
}
@@ -0,0 +1,114 @@
package utils
// FileMetadataJS provides common JavaScript functions for file metadata components,
// including HTMX event listeners for delete toasts.
templ FileMetadataJS() {
<script>
// Function to create and show a toast (Defined locally for guaranteed availability)
function showToast(message, type = 'info') {
const toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
console.error("Toast container not found!"); // Keep this error log
return;
}
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
// Use classes similar to the original file_metadata.templ for consistency
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
} else { // Default to info
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => { toast.remove(); }, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => { toast.remove(); }, 300);
}, 5000);
}
// --- HTMX Event Listener for Delete Toasts ---
// Ensure listener is attached only once using a flag
if (!window._gomft_fileMetadataListenerAttached) {
document.body.addEventListener('htmx:afterRequest', function(event) {
const triggerElement = event.detail.elt;
// Check if the element that triggered this request was the file delete button from the dialog
if (triggerElement && triggerElement.id && triggerElement.id.startsWith('delete-file-btn-')) {
// Get the path directly from the element's hx-delete attribute
const path = triggerElement.getAttribute('hx-delete');
// Ensure requestConfig exists before accessing verb (robustness)
const method = event.detail.requestConfig ? event.detail.requestConfig.verb : null;
// Check if the method was delete and the path from the attribute matches the expected pattern
if (method === 'delete' && path && path.match(/^\/files\/\d+$/)) {
const fileName = triggerElement.getAttribute('data-file-name') || "Unknown"; // Get filename from the button
// Call the locally defined showToast function
if (event.detail.successful) {
showToast(`File "${fileName}" metadata deleted successfully`, 'success');
} else {
let errorMsg = `Failed to delete file "${fileName}" metadata`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const responseJson = JSON.parse(event.detail.xhr.responseText);
errorMsg = responseJson.error ? `Error: ${responseJson.error}` : `Error: ${event.detail.xhr.responseText}`;
} catch (e) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
}
showToast(errorMsg, 'error');
}
}
}
});
// Set the flag to true after attaching the listener
window._gomft_fileMetadataListenerAttached = true;
console.log("[FileMetadataJS] HTMX afterRequest listener attached."); // Log attachment once
}
</script>
}
@@ -0,0 +1,34 @@
package utils
import "fmt"
// GetStatusBadgeClass returns the appropriate CSS class for a file status badge
func GetStatusBadgeClass(status string) string {
switch status {
case "processed":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
case "archived":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
case "deleted":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
case "archived_and_deleted":
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300"
case "error":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}
}
// FormatFileSize formats a file size in bytes to a human-readable string
func FormatFileSize(size int64) string {
if size < 1024 {
return fmt.Sprintf("%d B", size)
} else if size < 1024*1024 {
return fmt.Sprintf("%.2f KB", float64(size)/1024)
} else if size < 1024*1024*1024 {
return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
} else {
return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
}
}
+169
View File
@@ -0,0 +1,169 @@
package components
import (
"context"
)
// GDriveHeadlessAuthData contains data needed for rendering the headless auth page
type GDriveHeadlessAuthData struct {
AuthCommand string
ConfigID string
}
// GDriveHeadlessAuth renders the headless authentication page for Google Drive/Photos
templ GDriveHeadlessAuth(ctx context.Context, data GDriveHeadlessAuthData) {
// Force the layout to display as authenticated content
@LayoutWithContext("Google Authentication - Headless Mode", ctx) {
<style>
/* Ensure proper styling for the headless auth page */
body.dark .auth-page {
background-color: #111827 !important;
}
</style>
<div id="auth-container" class="auth-page w-full pb-8 bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; background-color: rgb(249, 250, 251);">
<div class="max-w-4xl mx-auto">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Headless Google Authentication
</h1>
<a href="/configs" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
Back to Configurations
</a>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
<div class="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0 mt-0.5">
<i class="fas fa-info-circle h-5 w-5 text-blue-500"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-300">
You need to authenticate with Google using a web browser. Since you're running GoMFT behind a reverse proxy or in a headless environment, you'll need to complete authentication on a machine with a web browser.
</p>
</div>
</div>
</div>
<div class="mb-8">
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 1: Run the following command on a machine with a web browser</h2>
<div class="relative mb-4">
<pre id="auth-command-text" class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 overflow-x-auto text-sm font-mono">{ data.AuthCommand }</pre>
<button id="copy-command" class="absolute top-2 right-2 bg-gray-200 dark:bg-gray-700 p-1.5 rounded hover:bg-gray-300 dark:hover:bg-gray-600" title="Copy to clipboard">
<i class="fas fa-copy h-5 w-5 text-gray-700 dark:text-gray-300"></i>
</button>
</div>
<div>
<h3 class="text-md font-medium mb-2 text-gray-900 dark:text-white">What this command does:</h3>
<ul class="list-disc ml-6 text-sm text-gray-700 dark:text-gray-300 space-y-1">
<li>Opens a browser window on the machine where you run it</li>
<li>Allows you to authenticate with Google</li>
<li>Generates an authentication token</li>
</ul>
</div>
</div>
<div class="mb-6">
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 2: Paste the authentication token below</h2>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
</p>
<form action="/configs/gdrive-headless-token" method="POST" class="space-y-4">
<input type="hidden" name="config_id" value={ data.ConfigID } />
<div>
<label for="auth_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication Token</label>
<textarea
id="auth_token"
name="auth_token"
rows="5"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
placeholder="Paste your authentication token here..."
required
></textarea>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">The token will look like a long JSON string containing access credentials.</p>
</div>
<div class="flex items-center justify-end mt-6">
<a href="/configs" class="mr-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400">
Cancel
</a>
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Submit Token
</button>
</div>
</form>
</div>
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.</p>
<p class="mt-1 text-gray-600 dark:text-gray-400">The token is only used for authentication and is stored securely. You'll only need to complete this process once for each configuration.</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
document.addEventListener('DOMContentLoaded', function() {
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('auth-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Store the actual command text
const actualCommand = document.getElementById('auth-command-text').textContent.trim();
// Add click handler to copy button
document.getElementById('copy-command').addEventListener('click', function() {
// Copy the actual command text, not the template variable
navigator.clipboard.writeText(actualCommand).then(function() {
// Show a success message
const button = document.getElementById('copy-command');
const originalTitle = button.getAttribute('title');
button.setAttribute('title', 'Copied!');
// Also show visual feedback
button.classList.add('bg-green-200', 'dark:bg-green-700');
button.classList.remove('bg-gray-200', 'dark:bg-gray-700');
setTimeout(function() {
button.setAttribute('title', originalTitle);
button.classList.remove('bg-green-200', 'dark:bg-green-700');
button.classList.add('bg-gray-200', 'dark:bg-gray-700');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy command. Please select and copy it manually.');
});
});
});
</script>
}
}
+1168 -14
View File
File diff suppressed because it is too large Load Diff
+85 -29
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"time" "time"
"github.com/starfleetcptn/gomft/components/shared/toast"
) )
// AppVersion will be set at build time using ldflags // AppVersion will be set at build time using ldflags
@@ -141,7 +142,7 @@ templ LayoutWithContext(title string, ctx context.Context) {
} }
</style> </style>
</head> </head>
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;"> <body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;" hx-on::after-swap="initFlowbite()">
if isLoggedIn(ctx) { if isLoggedIn(ctx) {
<!-- Application Shell --> <!-- Application Shell -->
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-900"> <div class="flex min-h-screen bg-gray-50 dark:bg-gray-900">
@@ -150,27 +151,35 @@ templ LayoutWithContext(title string, ctx context.Context) {
<div class="flex flex-col flex-grow pt-5 bg-white dark:bg-gray-800 overflow-y-auto border-r border-gray-200 dark:border-gray-700"> <div class="flex flex-col flex-grow pt-5 bg-white dark:bg-gray-800 overflow-y-auto border-r border-gray-200 dark:border-gray-700">
<div class="flex items-center flex-shrink-0 px-4"> <div class="flex items-center flex-shrink-0 px-4">
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400"> <a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
<i class="fas fa-exchange-alt mr-2"></i> <img src="/static/img/logo.png" alt="GoMFT" class="w-8 h-8 mr-2">
GoMFT GoMFT
</a> </a>
</div> </div>
<nav class="flex-1 px-2 py-4 bg-white dark:bg-gray-800"> <nav class="flex-1 px-2 py-4 bg-white dark:bg-gray-800">
<div class="space-y-1"> <div class="space-y-1">
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-tachometer-alt mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-chart-pie mr-3 text-gray-500 dark:text-gray-400"></i>
Dashboard Dashboard
</a> </a>
<a href="/storage-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-server mr-3 text-gray-500 dark:text-gray-400"></i>
Storage Providers
</a>
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-exchange-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Configs Transfer Configurations
</a> </a>
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Jobs Scheduled Jobs
</a>
<a href="/calendar" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-calendar-week mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer Calendar
</a> </a>
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
History Transfer History
</a> </a>
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
@@ -182,16 +191,20 @@ templ LayoutWithContext(title string, ctx context.Context) {
</p> </p>
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Users User Management
</a> </a>
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Roles Role Management
</a> </a>
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Audit Logs Audit Logs
</a> </a>
<a href="/admin/logs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-stream w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Log Viewer
</a>
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Database Tools Database Tools
@@ -245,8 +258,8 @@ templ LayoutWithContext(title string, ctx context.Context) {
<div id="mobile-menu" class="fixed top-0 left-0 z-40 h-screen p-4 overflow-y-auto transition-transform -translate-x-full bg-white w-64 dark:bg-gray-800" tabindex="-1"> <div id="mobile-menu" class="fixed top-0 left-0 z-40 h-screen p-4 overflow-y-auto transition-transform -translate-x-full bg-white w-64 dark:bg-gray-800" tabindex="-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400"> <a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
<i class="fas fa-exchange-alt mr-2"></i> <!-- GoMFT Logo -->
GoMFT <img src="/static/img/logo.png" alt="GoMFT" class="w-8 h-8 mr-2">
</a> </a>
<button type="button" data-drawer-hide="mobile-menu" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"> <button type="button" data-drawer-hide="mobile-menu" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
@@ -257,20 +270,28 @@ templ LayoutWithContext(title string, ctx context.Context) {
<div class="space-y-1"> <div class="space-y-1">
<!-- Copy the same links from the desktop sidebar --> <!-- Copy the same links from the desktop sidebar -->
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-tachometer-alt mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-chart-pie mr-3 text-gray-500 dark:text-gray-400"></i>
Dashboard Dashboard
</a> </a>
<a href="/storage-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-server mr-3 text-gray-500 dark:text-gray-400"></i>
Storage Providers
</a>
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-exchange-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Configs Transfer Configurations
</a> </a>
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
Jobs Scheduled Jobs
</a>
<a href="/calendar" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-calendar-week mr-3 text-gray-500 dark:text-gray-400"></i>
Transfer Calendar
</a> </a>
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
History Transfer History
</a> </a>
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
@@ -282,29 +303,33 @@ templ LayoutWithContext(title string, ctx context.Context) {
</p> </p>
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Users User Management
</a> </a>
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Roles Role Management
</a> </a>
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Audit Logs Audit Logs
</a> </a>
<a href="/admin/logs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-stream w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Log Viewer
</a>
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"> <a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
Database Tools Database Tools
</a> </a>
// Settings Dropdown // Settings Dropdown
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings"> <button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings-mobile" data-collapse-toggle="dropdown-settings-mobile">
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i> <i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span> <span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6"> <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg> </svg>
</button> </button>
<ul id="dropdown-settings" class="hidden py-2 space-y-2"> <ul id="dropdown-settings-mobile" class="hidden py-2 space-y-2">
// <li> // <li>
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a> // <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
// </li> // </li>
@@ -331,7 +356,7 @@ templ LayoutWithContext(title string, ctx context.Context) {
<header class="bg-white dark:bg-gray-800 shadow-sm"> <header class="bg-white dark:bg-gray-800 shadow-sm">
<div class="flex items-center justify-between px-4 py-3 sm:px-6 lg:px-8"> <div class="flex items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">{ title }</h1> // <h1 class="text-lg font-semibold text-gray-900 dark:text-white">{ title }</h1>
</div> </div>
<!-- Theme toggle and user menu --> <!-- Theme toggle and user menu -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
@@ -355,19 +380,21 @@ templ LayoutWithContext(title string, ctx context.Context) {
hx-get="/notifications/dropdown" hx-get="/notifications/dropdown"
hx-trigger="click once" hx-trigger="click once"
hx-target="#notification-dropdown-content" hx-target="#notification-dropdown-content"
data-dropdown-placement="bottom-end" data-dropdown-placement="bottom-start"
> >
<span class="sr-only">View notifications</span> <span class="sr-only">View notifications</span>
<i class="fas fa-bell"></i> <i class="fas fa-bell"></i>
<!-- Notification badge will be loaded dynamically --> <!-- Notification count will be placed here via HTMX -->
<div hx-get="/notifications/count" hx-trigger="load, notification-updated from:body" id="notification-count-container"></div>
</button> </button>
<!-- Place the notification count container outside but still in the relative container -->
<div id="notification-count-container" class="absolute top-0 right-0" hx-get="/notifications/count" hx-trigger="load, notification-updated from:body"></div>
</div>
<!-- Notification dropdown --> <!-- Notification dropdown -->
<div <div
class="hidden overflow-hidden z-50 my-4 max-w-sm md:max-w-md w-full text-base list-none bg-white rounded divide-y divide-gray-100 shadow-lg dark:divide-gray-600 dark:bg-gray-700 rounded-xl" class="hidden overflow-hidden z-50 my-4 max-w-sm md:max-w-md w-full text-base list-none bg-white rounded divide-y divide-gray-100 shadow-lg dark:divide-gray-600 dark:bg-gray-700 rounded-xl fixed"
id="notification-dropdown" id="notification-dropdown"
style="min-width: 320px; width: 100%;" style="min-width: 320px; width: 100%;"
data-dropdown-placement="bottom-end" data-dropdown-placement="bottom-start"
> >
<div id="notification-dropdown-content"> <div id="notification-dropdown-content">
<!-- Content will be loaded dynamically via HTMX --> <!-- Content will be loaded dynamically via HTMX -->
@@ -394,7 +421,6 @@ templ LayoutWithContext(title string, ctx context.Context) {
</a> </a>
</div> </div>
</div> </div>
</div>
<!-- User menu dropdown --> <!-- User menu dropdown -->
<div class="relative"> <div class="relative">
<button <button
@@ -444,6 +470,8 @@ templ LayoutWithContext(title string, ctx context.Context) {
</div> </div>
</div> </div>
</header> </header>
<!-- Toast Container -->
@toast.Container()
<!-- Page Content --> <!-- Page Content -->
<main class="flex-1 bg-gray-50 dark:bg-gray-900"> <main class="flex-1 bg-gray-50 dark:bg-gray-900">
<div class="py-6 bg-gray-50 dark:bg-gray-900"> <div class="py-6 bg-gray-50 dark:bg-gray-900">
@@ -463,6 +491,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400"> <a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fab fa-github"></i> <i class="fab fa-github"></i>
</a> </a>
<a href="https://discord.gg/f9dwtM3j" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank">
<i class="fab fa-discord"></i>
</a>
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400"> <a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tag"></i> { AppVersion } <i class="fas fa-tag"></i> { AppVersion }
</a> </a>
@@ -486,6 +517,9 @@ templ LayoutWithContext(title string, ctx context.Context) {
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400"> <a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fab fa-github"></i> <i class="fab fa-github"></i>
</a> </a>
<a href="https://discord.gg/f9dwtM3j" class="hover:text-primary-600 dark:hover:text-primary-400" target="_blank">
<i class="fab fa-discord"></i>
</a>
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400"> <a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
<i class="fas fa-tag"></i> { AppVersion } <i class="fas fa-tag"></i> { AppVersion }
</a> </a>
@@ -498,9 +532,31 @@ templ LayoutWithContext(title string, ctx context.Context) {
<!-- Scripts --> <!-- Scripts -->
<!-- Alpine.js and dependencies --> <!-- Alpine.js and dependencies -->
<script defer src="/static/dist/vendor.js"></script> <script defer src="/static/dist/vendor.js"></script>
<!-- Shared Scripts -->
@toast.ShowToastJS()
<!-- Application scripts --> <!-- Application scripts -->
<script defer src="/static/dist/app.js"></script> <script defer src="/static/dist/app.js"></script>
<script defer src="/static/dist/init.js"></script> <script defer src="/static/dist/init.js"></script>
<!-- Dropdown fix for mobile -->
<script>
document.addEventListener("DOMContentLoaded", function() {
// Initialize the mobile dropdown separately
const mobileDropdownButton = document.querySelector('[data-collapse-toggle="dropdown-settings-mobile"]');
const mobileDropdown = document.getElementById("dropdown-settings-mobile");
if (mobileDropdownButton && mobileDropdown) {
// Show dropdown if on admin pages
if (window.location.pathname.startsWith("/admin")) {
// Keep it hidden by default, will be toggled by button
mobileDropdown.classList.add("hidden");
}
mobileDropdownButton.addEventListener("click", function() {
mobileDropdown.classList.toggle("hidden");
});
}
});
</script>
</body> </body>
</html> </html>
} }
+3 -1
View File
@@ -5,7 +5,7 @@ import (
"strings" "strings"
) )
templ Login(ctx context.Context, errorMessage string) { templ Login(ctx context.Context, errorMessage string, hasExternalProviders bool) {
@LayoutWithContext("Login", ctx) { @LayoutWithContext("Login", ctx) {
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900"> <div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
<div class="max-w-md w-full space-y-8"> <div class="max-w-md w-full space-y-8">
@@ -116,6 +116,7 @@ templ Login(ctx context.Context, errorMessage string) {
Contact an administrator to create an account Contact an administrator to create an account
</p> </p>
if hasExternalProviders {
<!-- External Authentication Providers --> <!-- External Authentication Providers -->
<div id="external-auth-providers" class="mt-4"> <div id="external-auth-providers" class="mt-4">
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p> <p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
@@ -125,6 +126,7 @@ templ Login(ctx context.Context, errorMessage string) {
</div> </div>
</div> </div>
</div> </div>
}
</div> </div>
</div> </div>
-985
View File
@@ -1,985 +0,0 @@
package components
import (
"context"
"fmt"
)
type NotificationFormData struct {
NotificationService *struct {
ID uint
Name string
Description string
Type string
IsEnabled bool
EventTriggers []string
RetryPolicy string
WebhookURL string
Method string
Headers string
PayloadTemplate string
SecretKey string
PushbulletAPIKey string
PushbulletDeviceID string
PushbulletTitleTemplate string
PushbulletBodyTemplate string
NtfyServer string
NtfyTopic string
NtfyPriority string
NtfyUsername string
NtfyPassword string
NtfyTitleTemplate string
NtfyMessageTemplate string
GotifyURL string
GotifyToken string
GotifyPriority string
GotifyTitleTemplate string
GotifyMessageTemplate string
PushoverAPIToken string
PushoverUserKey string
PushoverDevice string
PushoverPriority string
PushoverSound string
PushoverTitleTemplate string
PushoverMessageTemplate string
}
IsNew bool
SuccessMessage string
ErrorMessage string
}
// Helper function to check if a string is in a slice
func contains(slice []string, str string) bool {
for _, s := range slice {
if s == str {
return true
}
}
return false
}
// Helper function to convert bool to string for HTML attributes
func boolToString(b bool) string {
if b {
return "true"
}
return ""
}
func getNotificationFormTitle(isNew bool) string {
if isNew {
return "Add Notification Service"
}
return "Edit Notification Service"
}
templ NotificationForm(ctx context.Context, data NotificationFormData) {
@LayoutWithContext(getNotificationFormTitle(data.IsNew), ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<script>
// Notification system
function showToast(message, type) {
const toastContainer = document.getElementById('toast-container');
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else {
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Toggle notification fields based on selection
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
const allFields = document.querySelectorAll('.notification-fields');
const commonFields = document.querySelectorAll('.common-fields');
typeSelector.addEventListener('change', function() {
// Hide all fields first
allFields.forEach(field => field.classList.add('hidden'));
// Show/hide common fields based on selection
const selectedType = this.value;
if (selectedType) {
// Show common fields (name, description)
commonFields.forEach(field => field.classList.remove('hidden'));
// Show the selected type's specific fields
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
} else {
// Hide common fields if no type selected
commonFields.forEach(field => field.classList.add('hidden'));
}
});
// Initialize form if editing
if (document.getElementById('notification_type').value) {
// Trigger the change event to show the appropriate fields
document.getElementById('notification_type').dispatchEvent(new Event('change'));
}
});
</script>
<div id="notification-form-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="notifications-page bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full max-w-4xl mx-auto">
<!-- Success Message (hidden, used for HTMX responses) -->
if data.SuccessMessage != "" {
<div class="hidden success-message">{ data.SuccessMessage }</div>
}
<!-- Error Message (hidden, used for HTMX responses) -->
if data.ErrorMessage != "" {
<div class="hidden error-message">{ data.ErrorMessage }</div>
}
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
{ getNotificationFormTitle(data.IsNew) }
</h1>
<a href="/admin/settings/notifications" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
Back to Notification Services
</a>
</div>
<!-- Add Notification Service Form -->
<div class="mb-6 p-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<form id="notification-form"
if data.IsNew {
hx-post="/admin/settings/notifications"
} else {
hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) }
}
hx-target="body">
<div class="mb-6">
<label for="notification_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Type</label>
<select id="notification_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">Select a type</option>
if data.NotificationService != nil && data.NotificationService.Type == "webhook" {
<option value="webhook" selected="selected">Webhook</option>
} else {
<option value="webhook">Webhook</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "pushbullet" {
<option value="pushbullet" selected="selected">Pushbullet</option>
} else {
<option value="pushbullet">Pushbullet</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "ntfy" {
<option value="ntfy" selected="selected">Ntfy</option>
} else {
<option value="ntfy">Ntfy</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "gotify" {
<option value="gotify" selected="selected">Gotify</option>
} else {
<option value="gotify">Gotify</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "pushover" {
<option value="pushover" selected="selected">Pushover</option>
} else {
<option value="pushover">Pushover</option>
}
<option value="email" disabled>Email (Coming Soon)</option>
</select>
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
if data.NotificationService.Name != "" {
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value={ data.NotificationService.Name }/>
} else {
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value=""/>
}
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
if data.NotificationService.Description != "" {
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service">{ data.NotificationService.Description }</textarea>
} else {
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service"></textarea>
}
</div>
<!-- Dynamic fields based on notification type -->
<div id="email_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="smtp_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Host</label>
<input type="text" id="smtp_host" name="smtp_host" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="smtp.example.com"/>
</div>
<div class="mb-6">
<label for="smtp_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Port</label>
<input type="number" id="smtp_port" name="smtp_port" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="587"/>
</div>
<div class="mb-6">
<label for="smtp_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Username</label>
<input type="text" id="smtp_username" name="smtp_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="user@example.com"/>
</div>
<div class="mb-6">
<label for="smtp_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Password</label>
<input type="password" id="smtp_password" name="smtp_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
</div>
<div class="mb-6">
<label for="from_email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Email</label>
<input type="email" id="from_email" name="from_email" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="notifications@example.com"/>
</div>
</div>
<div id="webhook_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
if data.NotificationService.WebhookURL != "" {
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value={ data.NotificationService.WebhookURL }/>
} else {
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value=""/>
}
</div>
<div class="mb-6">
<label for="method" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">HTTP Method</label>
<select id="method" name="method" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
if data.NotificationService.Method != "" {
if data.NotificationService.Method == "POST" {
<option value="POST" selected="selected">POST</option>
} else {
<option value="POST">POST</option>
}
if data.NotificationService.Method == "PUT" {
<option value="PUT" selected="selected">PUT</option>
} else {
<option value="PUT">PUT</option>
}
} else {
<option value="POST">POST</option>
<option value="PUT">PUT</option>
}
</select>
</div>
<div class="mb-6">
<label for="headers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Headers (JSON)</label>
if data.NotificationService.Headers != "" {
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'>{ data.NotificationService.Headers }</textarea>
} else {
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
}
</div>
<div class="mb-6">
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
<textarea
id="payload_template"
name="payload_template"
rows="5"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder='{
"event": "{{job.event}}",
"job": {
"id": "{{job.id}}",
"name": "{{job.name}}",
"status": "{{job.status}}",
"message": "{{job.message}}",
"started_at": "{{job.started_at}}",
"completed_at": "{{job.completed_at}}",
"duration_seconds": {{job.duration_seconds}},
"config_id": "{{job.config_id}}",
"config_name": "{{job.config_name}}",
"transfer_bytes": {{job.transfer_bytes}},
"file_count": {{job.file_count}}
},
"instance": {
"id": "{{instance.id}}",
"name": "{{instance.name}}",
"version": "{{instance.version}}",
"environment": "{{instance.environment}}"
},
"timestamp": "{{timestamp}}",
"notification_id": "{{notification.id}}"
}'
>
if data.NotificationService.PayloadTemplate != "" {
data.NotificationService.PayloadTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
<div class="space-y-2">
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
}
<label for="trigger_job_start" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Start</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
}
<label for="trigger_job_complete" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Complete</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
}
<label for="trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<div class="mb-6">
<label for="secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (for signature verification)</label>
if data.NotificationService.SecretKey != "" {
<input type="text" id="secret_key" name="secret_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Optional signature verification key" value={ data.NotificationService.SecretKey }/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If provided, all webhooks will include an X-GoMFT-Signature header</p>
</div>
<div class="mb-6">
<label for="retry_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Retry Policy</label>
<select id="retry_policy" name="retry_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
if data.NotificationService.RetryPolicy != "" {
if data.NotificationService.RetryPolicy == "none" {
<option value="none" selected="selected">No retries</option>
} else {
<option value="none">No retries</option>
}
if data.NotificationService.RetryPolicy == "simple" {
<option value="simple" selected="selected">Simple (3 retries)</option>
} else {
<option value="simple">Simple (3 retries)</option>
}
if data.NotificationService.RetryPolicy == "exponential" {
<option value="exponential" selected="selected">Exponential backoff</option>
} else {
<option value="exponential">Exponential backoff</option>
}
} else {
<option value="none">No retries</option>
<option value="simple">Simple (3 retries)</option>
<option value="exponential">Exponential backoff</option>
}
</select>
</div>
<!-- Test notification button -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-webhook-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<!-- Pushbullet Fields -->
<div id="pushbullet_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="pushbullet_api_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Key</label>
if data.NotificationService.PushbulletAPIKey != "" {
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value={ data.NotificationService.PushbulletAPIKey }/>
} else {
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get your API key from <a href="https://www.pushbullet.com/#settings/account" target="_blank" class="text-blue-500 hover:underline">Pushbullet Account Settings</a></p>
</div>
<div class="mb-6">
<label for="pushbullet_device_iden" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Identifier (Optional)</label>
if data.NotificationService.PushbulletDeviceID != "" {
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushbulletDeviceID }/>
} else {
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
}
</div>
<div class="mb-6">
<label for="pushbullet_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
if data.NotificationService.PushbulletTitleTemplate != "" {
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushbulletTitleTemplate }/>
} else {
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="pushbullet_body_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="pushbullet_body_template"
name="pushbullet_body_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.PushbulletBodyTemplate != "" {
data.NotificationService.PushbulletBodyTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
<div class="space-y-2">
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="pb_trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
} else {
<input id="pb_trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="pb_trigger_job_start" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Start</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="pb_trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
} else {
<input id="pb_trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="pb_trigger_job_complete" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Complete</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="pb_trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
} else {
<input id="pb_trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="pb_trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<!-- Test notification button for Pushbullet -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-pushbullet-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Pushbullet configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<!-- Ntfy Fields -->
<div id="ntfy_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="ntfy_server" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Ntfy Server</label>
if data.NotificationService.NtfyServer != "" {
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value={ data.NotificationService.NtfyServer }/>
} else {
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value="https://ntfy.sh"/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The Ntfy server URL (default: ntfy.sh)</p>
</div>
<div class="mb-6">
<label for="ntfy_topic" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Topic</label>
if data.NotificationService.NtfyTopic != "" {
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value={ data.NotificationService.NtfyTopic }/>
} else {
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value="gomft"/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a unique, unguessable topic name</p>
</div>
<div class="mb-6">
<label for="ntfy_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="ntfy_priority" name="ntfy_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="1">Low (1)</option>
if data.NotificationService.NtfyPriority == "3" {
<option value="3" selected="selected">Default (3)</option>
} else {
<option value="3">Default (3)</option>
}
<option value="4">High (4)</option>
<option value="5">Urgent (5)</option>
</select>
</div>
<div class="mb-6">
<label for="ntfy_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username (Optional)</label>
if data.NotificationService.NtfyUsername != "" {
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value={ data.NotificationService.NtfyUsername }/>
} else {
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password (Optional)</label>
if data.NotificationService.NtfyPassword != "" {
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value={ data.NotificationService.NtfyPassword }/>
} else {
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
if data.NotificationService.NtfyTitleTemplate != "" {
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.NtfyTitleTemplate }/>
} else {
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="ntfy_message_template"
name="ntfy_message_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.NtfyMessageTemplate != "" {
data.NotificationService.NtfyMessageTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
<div class="space-y-2">
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="ntfy_trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
} else {
<input id="ntfy_trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="ntfy_trigger_job_start" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Start</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="ntfy_trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
} else {
<input id="ntfy_trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="ntfy_trigger_job_complete" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Complete</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="ntfy_trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
} else {
<input id="ntfy_trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="ntfy_trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<!-- Test notification button for Ntfy -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-ntfy-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Ntfy configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<!-- Gotify Fields -->
<div id="gotify_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="gotify_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Gotify Server URL</label>
if data.NotificationService.GotifyURL != "" {
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value={ data.NotificationService.GotifyURL }/>
} else {
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">URL of your Gotify server</p>
</div>
<div class="mb-6">
<label for="gotify_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Token</label>
if data.NotificationService.GotifyToken != "" {
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value={ data.NotificationService.GotifyToken }/>
} else {
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Find this in your Gotify application settings</p>
</div>
<div class="mb-6">
<label for="gotify_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="gotify_priority" name="gotify_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="0">Low (0)</option>
if data.NotificationService.GotifyPriority != "" && data.NotificationService.GotifyPriority == "5" {
<option value="5" selected="selected">Normal (5)</option>
} else {
<option value="5">Normal (5)</option>
}
<option value="8">High (8)</option>
</select>
</div>
<div class="mb-6">
<label for="gotify_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
if data.NotificationService.GotifyTitleTemplate != "" {
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.GotifyTitleTemplate }/>
} else {
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="gotify_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="gotify_message_template"
name="gotify_message_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.GotifyMessageTemplate != "" {
data.NotificationService.GotifyMessageTemplate
}</textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
<div class="space-y-2">
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="gotify_trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
} else {
<input id="gotify_trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="gotify_trigger_job_start" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Start</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="gotify_trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
} else {
<input id="gotify_trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="gotify_trigger_job_complete" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Complete</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="gotify_trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
} else {
<input id="gotify_trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="gotify_trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<!-- Test notification button for Gotify -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-gotify-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Gotify configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<!-- Pushover Fields -->
<div id="pushover_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="pushover_app_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Token/Key</label>
if data.NotificationService.PushoverAPIToken != "" {
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value={ data.NotificationService.PushoverAPIToken }/>
} else {
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your application's API token/key from <a href="https://pushover.net/apps" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
</div>
<div class="mb-6">
<label for="pushover_user_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Key</label>
if data.NotificationService.PushoverUserKey != "" {
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value={ data.NotificationService.PushoverUserKey }/>
} else {
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your user key from <a href="https://pushover.net/" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
</div>
<div class="mb-6">
<label for="pushover_device" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Name (Optional)</label>
if data.NotificationService.PushoverDevice != "" {
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushoverDevice }/>
} else {
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
}
</div>
<div class="mb-6">
<label for="pushover_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="pushover_priority" name="pushover_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="-2">Lowest (-2)</option>
<option value="-1">Low (-1)</option>
if data.NotificationService.PushoverPriority != "" && data.NotificationService.PushoverPriority == "0" {
<option value="0" selected="selected">Normal (0)</option>
} else {
<option value="0">Normal (0)</option>
}
<option value="1">High (1)</option>
<option value="2">Emergency (2)</option>
</select>
</div>
<div class="mb-6">
<label for="pushover_sound" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Sound</label>
<select id="pushover_sound" name="pushover_sound" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="pushover">Pushover (default)</option>
<option value="bike">Bike</option>
<option value="bugle">Bugle</option>
<option value="cashregister">Cash Register</option>
<option value="classical">Classical</option>
<option value="cosmic">Cosmic</option>
<option value="falling">Falling</option>
<option value="gamelan">Gamelan</option>
<option value="incoming">Incoming</option>
<option value="intermission">Intermission</option>
<option value="magic">Magic</option>
<option value="mechanical">Mechanical</option>
<option value="pianobar">Piano Bar</option>
<option value="siren">Siren</option>
<option value="spacealarm">Space Alarm</option>
<option value="tugboat">Tug Boat</option>
<option value="alien">Alien Alarm (long)</option>
<option value="climb">Climb (long)</option>
<option value="persistent">Persistent (long)</option>
<option value="echo">Echo (long)</option>
<option value="updown">Up Down (long)</option>
<option value="vibrate">Vibrate Only</option>
<option value="none">None (silent)</option>
</select>
</div>
<div class="mb-6">
<label for="pushover_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
if data.NotificationService.PushoverTitleTemplate != "" {
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushoverTitleTemplate }/>
} else {
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="'{{job.name}}' {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="pushover_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="pushover_message_template"
name="pushover_message_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.PushoverMessageTemplate != "" {
data.NotificationService.PushoverMessageTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
<div class="mb-6">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
<div class="space-y-2">
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="pushover_trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_start")) }/>
} else {
<input id="pushover_trigger_job_start" name="trigger_job_start" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="pushover_trigger_job_start" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Start</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="pushover_trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_complete")) }/>
} else {
<input id="pushover_trigger_job_complete" name="trigger_job_complete" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="pushover_trigger_job_complete" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Complete</label>
</div>
<div class="flex items-center">
if data.NotificationService.EventTriggers != nil {
<input id="pushover_trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(contains(data.NotificationService.EventTriggers, "job_error")) }/>
} else {
<input id="pushover_trigger_job_error" name="trigger_job_error" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"/>
}
<label for="pushover_trigger_job_error" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Job Error</label>
</div>
</div>
</div>
<!-- Test notification button for Pushover -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-pushover-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Pushover configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
<div class="flex items-start mb-6 hidden common-fields">
<div class="flex items-center h-5">
if data.NotificationService.IsEnabled != false {
<input id="is_enabled" name="is_enabled" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked={ boolToString(data.NotificationService.IsEnabled) }/>
} else {
<input id="is_enabled" name="is_enabled" type="checkbox" class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800" checked="true"/>
}
</div>
<div class="ml-3 text-sm">
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
</div>
</div>
<div class="hidden common-fields">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
if data.IsNew {
Add Service
} else {
Save Changes
}
</button>
</div>
</form>
</div>
<!-- Help Notice -->
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Configure your notification service to receive alerts for job events. Different notification types have different configuration options.
</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('notification-form-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('notification-form-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
-483
View File
@@ -1,483 +0,0 @@
package components
import (
"context"
"fmt"
)
// Dialog component for confirmation dialogs using Flowbite modal
templ NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) {
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
}
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/admin/settings/notifications/%d", serviceID) }
hx-target="body"
data-service-name={ serviceName }
data-service-id={ fmt.Sprint(serviceID) }
id={ fmt.Sprintf("delete-btn-%d", serviceID) }
onclick={ triggerServiceDelete(id, serviceID, serviceName) }>
{ confirmText }
</button>
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
}
script triggerServiceDelete(dialogId string, serviceID uint, serviceName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
document.getElementById(dialogId).classList.remove("flex");
// Add debugging info
console.log(`Notification service deletion triggered for: ${serviceName} (ID: ${serviceID})`);
// Store data in a way that's accessible to event handlers
window.lastDeletedService = {
id: serviceID,
name: serviceName
};
// Add custom marker to track this deletion
window.currentlyDeletingService = true;
}
templ Notifications(ctx context.Context, data SettingsNotificationsData) {
@LayoutWithContext("Notification Services", ctx) {
<!-- Status and Error Messages -->
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
<script>
// Notification system
function showToast(message, type) {
const toastContainer = document.getElementById('toast-container');
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else {
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Set inner HTML with appropriate icon and message
toast.innerHTML = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
${type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>'}
</div>
<div class="ml-3 text-sm font-normal">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay to ensure the DOM has updated
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to close button
const closeButton = toast.querySelector('button[data-dismiss-target]');
closeButton.addEventListener('click', function() {
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
// Check if this is a DELETE request for a notification service
const path = event.detail.path;
const method = event.detail.verb;
console.log(`Request path: ${path}, method: ${method}`);
// Pattern match for notification service deletions (e.g., /admin/settings/notifications/123)
if (path && method === 'DELETE' && path.match(/^\/admin\/settings\/notifications\/\d+$/)) {
console.log("Detected notification service deletion request via URL pattern");
// This is definitely a delete request - store this information
window.isServiceDeleteRequest = true;
}
});
document.addEventListener('htmx:afterRequest', function(event) {
// Check for notification service deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerServiceDelete function
window.currentlyDeletingService ||
// Check flag from beforeRequest handler
window.isServiceDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
console.log(`Is delete request: ${isDeleteRequest}`);
// If this is a successful delete request, show notification
if (isDeleteRequest && event.detail.successful) {
console.log("Delete request was successful");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
console.log(`Showing success notification for deleted service: ${serviceName}`);
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
}
});
document.addEventListener('htmx:responseError', function(event) {
console.log("HTMX response error:", event.detail);
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingService ||
window.isServiceDeleteRequest ||
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
let errorMsg = 'An error occurred';
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = event.detail.xhr.responseText;
}
if (isDeleteRequest) {
console.log("Delete request failed");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
let errorMsg = `Failed to delete notification service "${serviceName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
console.log(`Showing error notification: ${errorMsg}`);
showToast(errorMsg, 'error');
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
} else {
showToast(errorMsg, 'error');
}
});
// Handle modal hide buttons
document.addEventListener('DOMContentLoaded', function() {
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
modal.classList.add('hidden');
modal.classList.remove('flex');
});
});
// Show any success or error messages as toasts
if (document.querySelector('.success-message')) {
const successMsg = document.querySelector('.success-message').textContent.trim();
if (successMsg) {
showToast(successMsg, 'success');
}
}
if (document.querySelector('.error-message')) {
const errorMsg = document.querySelector('.error-message').textContent.trim();
if (errorMsg) {
showToast(errorMsg, 'error');
}
}
});
</script>
<div id="notifications-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="notifications-page bg-gray-50 dark:bg-gray-900">
<div class="pb-8 w-full">
<!-- Success Message (hidden, used for HTMX responses) -->
if data.SuccessMessage != "" {
<div class="hidden success-message">{ data.SuccessMessage }</div>
}
<!-- Error Message (hidden, used for HTMX responses) -->
if data.ErrorMessage != "" {
<div class="hidden error-message">{ data.ErrorMessage }</div>
}
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Notification Services
</h1>
<a href="/admin/settings/notifications/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add Notification Service
</a>
</div>
<!-- List of Notification Services -->
if len(data.NotificationServices) == 0 {
<div class="text-center py-8 bg-white dark:bg-gray-800 shadow-md rounded-lg">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Add a notification service to receive alerts for job events.</p>
<a href="/admin/settings/notifications/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add First Notification Service
</a>
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, service := range data.NotificationServices {
<li>
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
if service.Type == "email" {
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
<i class="fas fa-envelope"></i>
</div>
} else if service.Type == "webhook" {
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
<i class="fas fa-code"></i>
</div>
} else {
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
<i class="fas fa-bell"></i>
</div>
}
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ service.Name }
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{ service.Description }
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<a
href={ templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID)) }
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
>
<i class="fas fa-edit"></i>
</a>
<!-- Add notification delete dialog -->
@NotificationDialog(
fmt.Sprintf("delete-notification-dialog-%d", service.ID),
"Delete Notification Service",
fmt.Sprintf("Are you sure you want to delete the notification service '%s'? This cannot be undone.", service.Name),
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
"Delete",
"delete",
service.ID,
service.Name,
)
<button
type="button"
onclick={ showModal(fmt.Sprintf("delete-notification-dialog-%d", service.ID)) }
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<div class="flex items-center">
<span
class={ "px-2 py-1 text-xs font-medium rounded-full",
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
>
if service.IsEnabled {
Active
} else {
Disabled
}
</span>
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
{ service.Type }
</span>
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
</span>
}
if service.SuccessCount > 0 || service.FailureCount > 0 {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
</span>
}
</div>
</div>
if service.Type == "webhook" {
<div class="mt-2 md:mt-0 flex items-center space-x-4">
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Events:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if len(service.EventTriggers) == 0 {
None
} else {
for i, trigger := range service.EventTriggers {
if i > 0 {
<span>, </span>
}
{ trigger }
}
}
</span>
</div>
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if service.RetryPolicy == "" {
Default
} else {
{ service.RetryPolicy }
}
</span>
</div>
</div>
} else {
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
<p>
Last sent:
if service.SuccessCount > 0 {
"Recently"
} else {
"Never"
}
</p>
</div>
}
</div>
</div>
</div>
</li>
}
</ul>
</div>
}
<!-- Help Notice -->
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Notification services allow the system to send alerts for job events such as completion, errors, or when jobs start.
</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
if (document.documentElement.classList.contains('dark')) {
document.getElementById('notifications-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('notifications-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
});
</script>
}
}
@@ -0,0 +1,43 @@
package dialog
import (
"fmt"
// "strconv" // No longer needed here
)
// NotificationDialog component for confirmation dialogs using Flowbite modal
templ NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) {
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
if action == "delete" {
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
} else {
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
}
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/admin/settings/notifications/%d", serviceID) }
hx-target="body"
data-service-name={ serviceName }
data-service-id={ fmt.Sprint(serviceID) }
id={ fmt.Sprintf("delete-btn-%d", serviceID) }
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerServiceDelete('%s', %d, '%s')", id, serviceID, serviceName)} }>
{ confirmText }
</button>
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
}
// Scripts (triggerServiceDelete, closeModal, showModal) are now expected to be defined globally or in the calling template (e.g., list.templ).
@@ -0,0 +1,20 @@
package dialog
// DialogScripts provides the JavaScript function specific to the notification delete confirmation dialog.
templ DialogScripts() {
<script type="text/javascript">
// Called when the delete confirmation button is clicked.
// Primarily closes the modal; the actual delete is handled by hx-delete.
function triggerServiceDelete(dialogId, serviceId, serviceName) {
console.log(`Confirmed delete for service: ${serviceName} (ID: ${serviceId}). Closing modal: ${dialogId}`);
// Call the global closeModal function defined elsewhere (e.g., app.js)
if (typeof closeModal === 'function') {
closeModal(dialogId);
} else {
console.error('Global closeModal function not found.');
}
// Optional: Show a "Deleting..." toast here if desired.
// The hx-delete attribute on the button will trigger the actual backend request.
}
</script>
}
@@ -0,0 +1,32 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// No utils needed for this specific template yet
)
templ EmailFields(data types.NotificationFormData) {
<!-- TODO: Populate value attributes if editing an email service -->
<div id="email_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="smtp_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Host</label>
<input type="text" id="smtp_host" name="smtp_host" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="smtp.example.com"/>
</div>
<div class="mb-6">
<label for="smtp_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Port</label>
<input type="number" id="smtp_port" name="smtp_port" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="587"/>
</div>
<div class="mb-6">
<label for="smtp_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Username</label>
<input type="text" id="smtp_username" name="smtp_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="user@example.com"/>
</div>
<div class="mb-6">
<label for="smtp_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Password</label>
<input type="password" id="smtp_password" name="smtp_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
</div>
<div class="mb-6">
<label for="from_email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Email</label>
<input type="email" id="from_email" name="from_email" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="notifications@example.com"/>
</div>
</div>
}
@@ -0,0 +1,88 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ GotifyFields(data types.NotificationFormData) {
<div id="gotify_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="gotify_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Gotify Server URL</label>
if data.NotificationService.GotifyURL != "" {
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value={ data.NotificationService.GotifyURL }/>
} else {
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">URL of your Gotify server</p>
</div>
<div class="mb-6">
<label for="gotify_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Token</label>
if data.NotificationService.GotifyToken != "" {
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value={ data.NotificationService.GotifyToken }/>
} else {
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Find this in your Gotify application settings</p>
</div>
<div class="mb-6">
<label for="gotify_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="gotify_priority" name="gotify_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="0">Low (0)</option>
if data.NotificationService.GotifyPriority != "" && data.NotificationService.GotifyPriority == "5" {
<option value="5" selected="selected">Normal (5)</option>
} else {
<option value="5">Normal (5)</option>
}
<option value="8">High (8)</option>
</select>
</div>
<div class="mb-6">
<label for="gotify_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
if data.NotificationService.GotifyTitleTemplate != "" {
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.GotifyTitleTemplate }/>
} else {
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="gotify_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="gotify_message_template"
name="gotify_message_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.GotifyMessageTemplate != "" {
data.NotificationService.GotifyMessageTemplate
}</textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<!-- Test notification button for Gotify -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-gotify-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Gotify configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
@@ -0,0 +1,105 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ NtfyFields(data types.NotificationFormData) {
<div id="ntfy_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="ntfy_server" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Ntfy Server</label>
if data.NotificationService.NtfyServer != "" {
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value={ data.NotificationService.NtfyServer }/>
} else {
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value="https://ntfy.sh"/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The Ntfy server URL (default: ntfy.sh)</p>
</div>
<div class="mb-6">
<label for="ntfy_topic" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Topic</label>
if data.NotificationService.NtfyTopic != "" {
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value={ data.NotificationService.NtfyTopic }/>
} else {
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value="gomft"/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a unique, unguessable topic name</p>
</div>
<div class="mb-6">
<label for="ntfy_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="ntfy_priority" name="ntfy_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="1">Low (1)</option>
if data.NotificationService.NtfyPriority == "3" {
<option value="3" selected="selected">Default (3)</option>
} else {
<option value="3">Default (3)</option>
}
<option value="4">High (4)</option>
<option value="5">Urgent (5)</option>
</select>
</div>
<div class="mb-6">
<label for="ntfy_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username (Optional)</label>
if data.NotificationService.NtfyUsername != "" {
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value={ data.NotificationService.NtfyUsername }/>
} else {
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password (Optional)</label>
if data.NotificationService.NtfyPassword != "" {
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value={ data.NotificationService.NtfyPassword }/>
} else {
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
if data.NotificationService.NtfyTitleTemplate != "" {
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.NtfyTitleTemplate }/>
} else {
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="ntfy_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="ntfy_message_template"
name="ntfy_message_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.NtfyMessageTemplate != "" {
data.NotificationService.NtfyMessageTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<!-- Test notification button for Ntfy -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-ntfy-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Ntfy configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
@@ -0,0 +1,75 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ PushbulletFields(data types.NotificationFormData) {
<div id="pushbullet_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="pushbullet_api_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Key</label>
if data.NotificationService.PushbulletAPIKey != "" {
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value={ data.NotificationService.PushbulletAPIKey }/>
} else {
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get your API key from <a href="https://www.pushbullet.com/#settings/account" target="_blank" class="text-blue-500 hover:underline">Pushbullet Account Settings</a></p>
</div>
<div class="mb-6">
<label for="pushbullet_device_iden" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Identifier (Optional)</label>
if data.NotificationService.PushbulletDeviceID != "" {
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushbulletDeviceID }/>
} else {
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
}
</div>
<div class="mb-6">
<label for="pushbullet_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
if data.NotificationService.PushbulletTitleTemplate != "" {
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushbulletTitleTemplate }/>
} else {
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="pushbullet_body_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="pushbullet_body_template"
name="pushbullet_body_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.PushbulletBodyTemplate != "" {
data.NotificationService.PushbulletBodyTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<!-- Test notification button for Pushbullet -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-pushbullet-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Pushbullet configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
@@ -0,0 +1,126 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ PushoverFields(data types.NotificationFormData) {
<div id="pushover_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="pushover_app_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Token/Key</label>
if data.NotificationService.PushoverAPIToken != "" {
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value={ data.NotificationService.PushoverAPIToken }/>
} else {
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your application's API token/key from <a href="https://pushover.net/apps" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
</div>
<div class="mb-6">
<label for="pushover_user_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Key</label>
if data.NotificationService.PushoverUserKey != "" {
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value={ data.NotificationService.PushoverUserKey }/>
} else {
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value=""/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your user key from <a href="https://pushover.net/" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
</div>
<div class="mb-6">
<label for="pushover_device" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Name (Optional)</label>
if data.NotificationService.PushoverDevice != "" {
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushoverDevice }/>
} else {
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
}
</div>
<div class="mb-6">
<label for="pushover_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
<select id="pushover_priority" name="pushover_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="-2">Lowest (-2)</option>
<option value="-1">Low (-1)</option>
if data.NotificationService.PushoverPriority != "" && data.NotificationService.PushoverPriority == "0" {
<option value="0" selected="selected">Normal (0)</option>
} else {
<option value="0">Normal (0)</option>
}
<option value="1">High (1)</option>
<option value="2">Emergency (2)</option>
</select>
</div>
<div class="mb-6">
<label for="pushover_sound" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Sound</label>
<select id="pushover_sound" name="pushover_sound" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="pushover">Pushover (default)</option>
<option value="bike">Bike</option>
<option value="bugle">Bugle</option>
<option value="cashregister">Cash Register</option>
<option value="classical">Classical</option>
<option value="cosmic">Cosmic</option>
<option value="falling">Falling</option>
<option value="gamelan">Gamelan</option>
<option value="incoming">Incoming</option>
<option value="intermission">Intermission</option>
<option value="magic">Magic</option>
<option value="mechanical">Mechanical</option>
<option value="pianobar">Piano Bar</option>
<option value="siren">Siren</option>
<option value="spacealarm">Space Alarm</option>
<option value="tugboat">Tug Boat</option>
<option value="alien">Alien Alarm (long)</option>
<option value="climb">Climb (long)</option>
<option value="persistent">Persistent (long)</option>
<option value="echo">Echo (long)</option>
<option value="updown">Up Down (long)</option>
<option value="vibrate">Vibrate Only</option>
<option value="none">None (silent)</option>
</select>
</div>
<div class="mb-6">
<label for="pushover_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
if data.NotificationService.PushoverTitleTemplate != "" {
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushoverTitleTemplate }/>
} else {
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="'{{job.name}}' {{job.status}}" value=""/>
}
</div>
<div class="mb-6">
<label for="pushover_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
<textarea
id="pushover_message_template"
name="pushover_message_template"
rows="4"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
>
if data.NotificationService.PushoverMessageTemplate != "" {
data.NotificationService.PushoverMessageTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<!-- Test notification button for Pushover -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-pushover-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your Pushover configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
@@ -0,0 +1,142 @@
package fields
import (
"github.com/starfleetcptn/gomft/components/notifications/types"
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
)
templ WebhookFields(data types.NotificationFormData) {
<div id="webhook_fields" class="hidden notification-fields">
<div class="mb-6">
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
if data.NotificationService.WebhookURL != "" {
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value={ data.NotificationService.WebhookURL }/>
} else {
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value=""/>
}
</div>
<div class="mb-6">
<label for="method" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">HTTP Method</label>
<select id="method" name="method" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
if data.NotificationService.Method != "" {
if data.NotificationService.Method == "POST" {
<option value="POST" selected="selected">POST</option>
} else {
<option value="POST">POST</option>
}
if data.NotificationService.Method == "PUT" {
<option value="PUT" selected="selected">PUT</option>
} else {
<option value="PUT">PUT</option>
}
} else {
<option value="POST">POST</option>
<option value="PUT">PUT</option>
}
</select>
</div>
<div class="mb-6">
<label for="headers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Headers (JSON)</label>
if data.NotificationService.Headers != "" {
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'>{ data.NotificationService.Headers }</textarea>
} else {
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
}
</div>
<div class="mb-6">
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
<textarea
id="payload_template"
name="payload_template"
rows="5"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder='{
"event": "{{job.event}}",
"job": {
"id": "{{job.id}}",
"name": "{{job.name}}",
"status": "{{job.status}}",
"message": "{{job.message}}",
"started_at": "{{job.started_at}}",
"completed_at": "{{job.completed_at}}",
"duration_seconds": {{job.duration_seconds}},
"config_id": "{{job.config_id}}",
"config_name": "{{job.config_name}}",
"transfer_bytes": {{job.transfer_bytes}},
"file_count": {{job.file_count}}
},
"instance": {
"id": "{{instance.id}}",
"name": "{{instance.name}}",
"version": "{{instance.version}}",
"environment": "{{instance.environment}}"
},
"timestamp": "{{timestamp}}",
"notification_id": "{{notification.id}}"
}'
>
if data.NotificationService.PayloadTemplate != "" {
data.NotificationService.PayloadTemplate
} </textarea>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
</div>
// Removed duplicate Event Triggers section - now handled in form.templ
<div class="mb-6">
<label for="secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (for signature verification)</label>
if data.NotificationService.SecretKey != "" {
<input type="text" id="secret_key" name="secret_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Optional signature verification key" value={ data.NotificationService.SecretKey }/>
}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If provided, all webhooks will include an X-GoMFT-Signature header</p>
</div>
<div class="mb-6">
<label for="retry_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Retry Policy</label>
<select id="retry_policy" name="retry_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
if data.NotificationService.RetryPolicy != "" {
if data.NotificationService.RetryPolicy == "none" {
<option value="none" selected="selected">No retries</option>
} else {
<option value="none">No retries</option>
}
if data.NotificationService.RetryPolicy == "simple" {
<option value="simple" selected="selected">Simple (3 retries)</option>
} else {
<option value="simple">Simple (3 retries)</option>
}
if data.NotificationService.RetryPolicy == "exponential" {
<option value="exponential" selected="selected">Exponential backoff</option>
} else {
<option value="exponential">Exponential backoff</option>
}
} else {
<option value="none">No retries</option>
<option value="simple">Simple (3 retries)</option>
<option value="exponential">Exponential backoff</option>
}
</select>
</div>
<!-- Test notification button -->
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
<button
type="button"
id="test-webhook-btn"
hx-post="/admin/settings/notifications/test"
hx-trigger="click"
hx-target="#test-notification-result"
hx-swap="outerHTML"
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
<i class="fas fa-paper-plane mr-1"></i>
Send Test Notification
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Send a test notification to verify your configuration works correctly before saving.
</p>
<div id="test-notification-result" class="mt-3 hidden">
<!-- Result will be shown here -->
</div>
</div>
</div>
}
+219
View File
@@ -0,0 +1,219 @@
package form
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/components" // For LayoutWithContext
"github.com/starfleetcptn/gomft/components/notifications/types"
"github.com/starfleetcptn/gomft/components/notifications/form/utils"
"github.com/starfleetcptn/gomft/components/notifications/form/fields" // Import fields
)
templ NotificationForm(ctx context.Context, data types.NotificationFormData) {
@components.LayoutWithContext(utils.GetNotificationFormTitle(data.IsNew), ctx) {
<script>
// Toggle notification fields based on selection
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
// Ensure typeSelector exists before adding listener
if (!typeSelector) {
console.warn("Notification type selector not found.");
return;
}
const allFields = document.querySelectorAll('.notification-fields');
const commonFields = document.querySelectorAll('.common-fields');
function toggleFields() {
// Hide all specific fields first
allFields.forEach(field => field.classList.add('hidden'));
// Show/hide common fields based on selection
const selectedType = typeSelector.value;
if (selectedType) {
// Show common fields (name, description, is_enabled, submit)
commonFields.forEach(field => field.classList.remove('hidden'));
// Show the selected type's specific fields
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
} else {
// Hide common fields if no type selected
commonFields.forEach(field => field.classList.add('hidden'));
}
}
typeSelector.addEventListener('change', toggleFields);
// Initialize form state on load (if editing or if a type is pre-selected)
toggleFields();
});
</script>
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
<div id="notification-form-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="pb-8 w-full max-w-4xl mx-auto">
<!-- Success Message (hidden, used for HTMX responses) -->
if data.SuccessMessage != "" {
<div class="hidden success-message">{ data.SuccessMessage }</div>
}
<!-- Error Message (hidden, used for HTMX responses) -->
if data.ErrorMessage != "" {
<div class="hidden error-message">{ data.ErrorMessage }</div>
}
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
{ utils.GetNotificationFormTitle(data.IsNew) }
</h1>
<a href="/admin/settings/notifications" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
Back to Notification Services
</a>
</div>
<!-- Add Notification Service Form -->
<div class="mb-6 p-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<form id="notification-form"
if data.IsNew {
hx-post="/admin/settings/notifications"
hx-redirect="/admin/settings/notifications"
} else {
hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) }
hx-redirect="/admin/settings/notifications"
}
hx-target="body">
<div class="mb-6">
<label for="notification_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Type</label>
<select id="notification_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">Select a type</option>
if data.NotificationService != nil && data.NotificationService.Type == "webhook" {
<option value="webhook" selected="selected">Webhook</option>
} else {
<option value="webhook">Webhook</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "pushbullet" {
<option value="pushbullet" selected="selected">Pushbullet</option>
} else {
<option value="pushbullet">Pushbullet</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "ntfy" {
<option value="ntfy" selected="selected">Ntfy</option>
} else {
<option value="ntfy">Ntfy</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "gotify" {
<option value="gotify" selected="selected">Gotify</option>
} else {
<option value="gotify">Gotify</option>
}
if data.NotificationService != nil && data.NotificationService.Type == "pushover" {
<option value="pushover" selected="selected">Pushover</option>
} else {
<option value="pushover">Pushover</option>
}
<option value="email" disabled>Email (Coming Soon)</option>
</select>
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
if data.NotificationService.Name != "" {
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value={ data.NotificationService.Name }/>
} else {
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value=""/>
}
</div>
<div class="mb-6 hidden common-fields">
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
if data.NotificationService.Description != "" {
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service">{ data.NotificationService.Description }</textarea>
} else {
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service"></textarea>
}
</div>
<!-- Dynamic fields based on notification type -->
@fields.EmailFields(data)
@fields.WebhookFields(data)
@fields.PushbulletFields(data)
@fields.NtfyFields(data)
@fields.GotifyFields(data)
@fields.PushoverFields(data)
<div class="mb-6 hidden common-fields">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Select the job events that should trigger this notification.</p>
<div class="flex flex-wrap gap-4">
for _, event := range []string{"job_start", "job_complete", "job_error"} {
<div class="flex items-center">
<input
id={ "trigger_" + event }
name="event_triggers[]"
type="checkbox"
value={ event }
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
if utils.IsEventTriggerSelected(data.NotificationService, event, data.IsNew) {
checked
}
/>
<label for={ "trigger_" + event } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">{ utils.FormatEventTriggerName(event) }</label>
</div>
}
</div>
</div>
<div class="flex items-start mb-6 hidden common-fields">
<div class="flex items-center h-5">
<input type="hidden" name="is_enabled" value="false">
<input
id="is_enabled"
name="is_enabled"
type="checkbox"
value="true"
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"
if data.NotificationService != nil && data.NotificationService.IsEnabled {
checked
}
/>
</div>
<div class="ml-3 text-sm">
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
<p class="text-xs text-gray-500 dark:text-gray-400">Check this box to make the service active.</p>
</div>
</div>
<div class="hidden common-fields">
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
if data.IsNew {
Add Service
} else {
Save Changes
}
</button>
</div>
</form>
</div>
<!-- Help Notice -->
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Configure your notification service to receive alerts for job events. Different notification types have different configuration options.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Theme-specific background handled by Tailwind classes -->
}
}
@@ -0,0 +1,45 @@
package form
// FormScripts contains JavaScript specific to the notification form page.
templ FormScripts() {
<script>
// Toggle notification fields based on selection
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
// Ensure typeSelector exists before adding listener
if (!typeSelector) {
console.warn("Notification type selector not found.");
return;
}
const allFields = document.querySelectorAll('.notification-fields');
const commonFields = document.querySelectorAll('.common-fields');
function toggleFields() {
// Hide all specific fields first
allFields.forEach(field => field.classList.add('hidden'));
// Show/hide common fields based on selection
const selectedType = typeSelector.value;
if (selectedType) {
// Show common fields (name, description, is_enabled, submit)
commonFields.forEach(field => field.classList.remove('hidden'));
// Show the selected type's specific fields
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
if (fieldsToShow) {
fieldsToShow.classList.remove('hidden');
}
} else {
// Hide common fields if no type selected
commonFields.forEach(field => field.classList.add('hidden'));
}
}
typeSelector.addEventListener('change', toggleFields);
// Initialize form state on load (if editing or if a type is pre-selected)
toggleFields();
});
</script>
}
@@ -0,0 +1,88 @@
package utils
import (
"strings"
)
// GetNotificationFormTitle returns the title for the notification form page.
func GetNotificationFormTitle(isNew bool) string {
if isNew {
return "Add Notification Service"
}
return "Edit Notification Service"
}
// Contains checks if a string slice contains a specific string.
func Contains(slice []string, item string) bool {
for _, a := range slice {
if a == item {
return true
}
}
return false
}
// BoolToString converts a boolean to its string representation "true" or "false".
// Useful for setting HTML attributes that expect string values.
func BoolToString(b bool) string {
if b {
return "true"
}
return "false"
}
// IsEventTriggerSelected checks if a specific event trigger should be pre-selected.
// It now accepts the anonymous struct type defined in types.NotificationFormData.
// Defaults to checking 'job_complete' and 'job_error' when creating a new service.
func IsEventTriggerSelected(service *struct {
ID uint
Name string
Description string
Type string
IsEnabled bool
EventTriggers []string
RetryPolicy string
WebhookURL string
Method string
Headers string
PayloadTemplate string
SecretKey string
PushbulletAPIKey string
PushbulletDeviceID string
PushbulletTitleTemplate string
PushbulletBodyTemplate string
NtfyServer string
NtfyTopic string
NtfyPriority string
NtfyUsername string
NtfyPassword string
NtfyTitleTemplate string
NtfyMessageTemplate string
GotifyURL string
GotifyToken string
GotifyPriority string
GotifyTitleTemplate string
GotifyMessageTemplate string
PushoverAPIToken string
PushoverUserKey string
PushoverDevice string
PushoverPriority string
PushoverSound string
PushoverTitleTemplate string
PushoverMessageTemplate string
}, event string, isNew bool) bool {
if !isNew && service != nil {
// Use the Contains helper function
return Contains(service.EventTriggers, event)
}
// Default for new services: check complete and error
return isNew && (event == "job_complete" || event == "job_error")
}
// FormatEventTriggerName converts event trigger keys to human-readable names.
func FormatEventTriggerName(event string) string {
// Replace underscores with spaces and capitalize words
name := strings.ReplaceAll(event, "_", " ")
name = strings.Title(name) // Use strings.Title for capitalization
return name
}
+207
View File
@@ -0,0 +1,207 @@
package list
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/components/notifications/dialog"
"github.com/starfleetcptn/gomft/components/notifications/types"
)
// List renders the notification services list page.
templ List(ctx context.Context, data types.SettingsNotificationsData) {
@components.LayoutWithContext("Notification Services", ctx) {
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
<div id="notifications-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
<div class="pb-8 w-full">
<!-- Success Message (hidden, used for HTMX responses/toast trigger) -->
if data.SuccessMessage != "" {
<div class="hidden success-message">{ data.SuccessMessage }</div>
}
<!-- Error Message (hidden, used for HTMX responses/toast trigger) -->
if data.ErrorMessage != "" {
<div class="hidden error-message">{ data.ErrorMessage }</div>
}
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Notification Services
</h1>
<a href="/admin/settings/notifications/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add Notification Service
</a>
</div>
<!-- List of Notification Services -->
if len(data.NotificationServices) == 0 {
<div class="text-center py-8 bg-white dark:bg-gray-800 shadow-md rounded-lg">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Add a notification service to receive alerts for job events.</p>
<a href="/admin/settings/notifications/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Add First Notification Service
</a>
</div>
} else {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, service := range data.NotificationServices {
<li>
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
if service.Type == "email" {
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
<i class="fas fa-envelope"></i>
</div>
} else if service.Type == "webhook" {
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
<i class="fas fa-code"></i>
</div>
} else { // Default icon
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
<i class="fas fa-bell"></i>
</div>
}
<div>
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ service.Name }
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{ service.Description }
</p>
</div>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<a
href={ templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID)) }
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
>
<i class="fas fa-edit"></i>
</a>
<!-- Add notification delete dialog -->
@dialog.NotificationDialog(
fmt.Sprintf("delete-notification-dialog-%d", service.ID),
"Delete Notification Service",
fmt.Sprintf("Are you sure you want to delete the notification service '%s'? This cannot be undone.", service.Name),
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
"Delete",
"delete",
service.ID,
service.Name,
)
<button
type="button"
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-notification-dialog-%d')", service.ID)} }
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
>
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<div class="flex items-center">
<span
class={ "px-2 py-1 text-xs font-medium rounded-full",
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
>
if service.IsEnabled {
Active
} else {
Disabled
}
</span>
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
{ service.Type }
</span>
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
</span>
}
if service.SuccessCount > 0 || service.FailureCount > 0 {
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
</span>
}
</div>
</div>
if service.Type == "webhook" {
<div class="mt-2 md:mt-0 flex items-center space-x-4">
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Events:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if len(service.EventTriggers) == 0 {
None
} else {
for i, trigger := range service.EventTriggers {
if i > 0 {
<span>, </span>
}
{ trigger }
}
}
</span>
</div>
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
<span class="ml-1 text-gray-900 dark:text-gray-300">
if service.RetryPolicy == "" {
Default
} else {
{ service.RetryPolicy }
}
</span>
</div>
</div>
} else {
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
<p>
Last sent:
if service.SuccessCount > 0 {
"Recently"
} else {
"Never"
}
</p>
</div>
}
</div>
</div>
</div>
</li>
}
</ul>
</div>
}
<!-- Help Notice Placeholder -->
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Notification services allow the system to send alerts for job events such as completion, errors, or when jobs start.
</p>
</div>
</div>
</div>
</div>
</div>
@dialog.DialogScripts()
}
// Script call removed for now
}
+221
View File
@@ -0,0 +1,221 @@
package list
// ListScripts contains JavaScript specific to the notification list page.
templ ListScripts() {
<script>
// Notification system (showToast function is now in shared/toast/toast_js.templ)
// Track all HTMX events for debugging
document.addEventListener('htmx:beforeRequest', function(event) {
// Check if this is a DELETE request for a notification service
const path = event.detail.path;
const method = event.detail.verb;
console.log(`Request path: ${path}, method: ${method}`);
// Pattern match for notification service deletions (e.g., /admin/settings/notifications/123)
if (path && method === 'DELETE' && path.match(/^\/admin\/settings\/notifications\/\d+$/)) {
console.log("Detected notification service deletion request via URL pattern");
// This is definitely a delete request - store this information
window.isServiceDeleteRequest = true;
}
});
document.addEventListener('htmx:afterRequest', function(event) {
// Check for notification service deletion multiple ways
const isDeleteRequest =
// Check global flag from the triggerServiceDelete function
window.currentlyDeletingService ||
// Check flag from beforeRequest handler
window.isServiceDeleteRequest ||
// Check URL pattern directly from this event
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
console.log(`Is delete request: ${isDeleteRequest}`);
// If this is a successful delete request, show notification
if (isDeleteRequest && event.detail.successful) {
console.log("Delete request was successful");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
console.log(`Showing success notification for deleted service: ${serviceName}`);
// Ensure showToast is globally available
if (typeof showToast === 'function') {
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
} else {
console.error("showToast function not found!");
}
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
}
});
document.addEventListener('htmx:responseError', function(event) {
console.log("HTMX response error:", event.detail);
// Similar logic as success but for errors
const isDeleteRequest =
window.currentlyDeletingService ||
window.isServiceDeleteRequest ||
(event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
event.detail.verb === 'DELETE');
let errorMsg = 'An error occurred';
if (event.detail.xhr && event.detail.xhr.responseText) {
errorMsg = event.detail.xhr.responseText;
}
if (isDeleteRequest) {
console.log("Delete request failed");
let serviceName = "Unknown";
// Try multiple sources for service name
if (event.detail.elt && event.detail.elt.getAttribute) {
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
}
if (serviceName === "Unknown" && window.lastDeletedService) {
// Fallback to our stored service info
serviceName = window.lastDeletedService.name;
}
let specificErrorMsg = `Failed to delete notification service "${serviceName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
// Try to provide a more specific error from the response
specificErrorMsg = `Error deleting "${serviceName}": ${event.detail.xhr.responseText}`;
}
console.log(`Showing error notification: ${specificErrorMsg}`);
if (typeof showToast === 'function') {
showToast(specificErrorMsg, 'error');
} else {
console.error("showToast function not found!");
}
// Clear flags
window.currentlyDeletingService = false;
window.isServiceDeleteRequest = false;
window.lastDeletedService = null;
} else {
// General error toast
if (typeof showToast === 'function') {
showToast(errorMsg, 'error');
} else {
console.error("showToast function not found!");
}
}
});
// Handle modal hide buttons (This might be better placed globally or in layout if modals are used elsewhere)
document.addEventListener('DOMContentLoaded', function() {
// This listener handles closing modals via data-modal-hide attribute
// It might conflict or be redundant if Flowbite's JS handles this already.
// Consider removing if Flowbite is initialized globally.
const hideButtons = document.querySelectorAll('[data-modal-hide]');
hideButtons.forEach(button => {
button.addEventListener('click', function() {
const modalId = this.getAttribute('data-modal-hide');
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
const backdrop = document.getElementById(modalId + "-backdrop");
if (backdrop) {
backdrop.classList.add("hidden");
}
});
});
// Show any success or error messages passed via data struct as toasts
const successDiv = document.querySelector('.success-message');
if (successDiv) {
const successMsg = successDiv.textContent.trim();
if (successMsg && typeof showToast === 'function') {
showToast(successMsg, 'success');
} else if (successMsg) {
console.error("showToast function not found, cannot display success message:", successMsg);
}
}
const errorDiv = document.querySelector('.error-message');
if (errorDiv) {
const errorMsg = errorDiv.textContent.trim();
if (errorMsg && typeof showToast === 'function') {
showToast(errorMsg, 'error');
} else if (errorMsg) {
console.error("showToast function not found, cannot display error message:", errorMsg);
}
}
});
// Script for handling the service deletion trigger
function triggerServiceDelete(dialogId, serviceID, serviceName) {
// Hide the dialog first
closeModal(dialogId); // Reuse closeModal logic
// Add debugging info
console.log(`Notification service deletion triggered for: ${serviceName} (ID: ${serviceID})`);
// Store data in a way that's accessible to event handlers
window.lastDeletedService = {
id: serviceID,
name: serviceName
};
// Add custom marker to track this deletion
window.currentlyDeletingService = true;
}
// Script for closing the modal
function closeModal(id) {
const dialog = document.getElementById(id);
if (dialog) {
dialog.classList.add("hidden");
dialog.classList.remove("flex");
}
const backdrop = document.getElementById(id + "-backdrop");
if (backdrop) {
// Instead of removing, hide it to potentially reuse
backdrop.classList.add("hidden");
}
}
// Script for showing the modal
function showModal(id) {
const dialog = document.getElementById(id);
if (dialog) {
dialog.classList.remove("hidden");
dialog.classList.add("flex"); // Use flex to center content
}
const backdrop = document.getElementById(id + "-backdrop");
if (backdrop) {
backdrop.classList.remove("hidden");
}
}
</script>
}
+69
View File
@@ -0,0 +1,69 @@
package types
// SettingsNotificationsData defines the data needed for the notifications list page
type SettingsNotificationsData struct {
NotificationServices []NotificationServiceData
SuccessMessage string
ErrorMessage string
}
// NotificationServiceData defines the data for a single service in the list
type NotificationServiceData struct {
ID uint
Name string
Type string
IsEnabled bool
Config map[string]string // Keep for now, might refine later
Description string
EventTriggers []string
PayloadTemplate string
SecretKey string
RetryPolicy string
SuccessCount int
FailureCount int
}
// NotificationFormData defines the data needed for the notification add/edit form
// TODO: This will be moved from notification_form.templ later
type NotificationFormData struct {
NotificationService *struct {
ID uint
Name string
Description string
Type string
IsEnabled bool
EventTriggers []string
RetryPolicy string
WebhookURL string
Method string
Headers string
PayloadTemplate string
SecretKey string
PushbulletAPIKey string
PushbulletDeviceID string
PushbulletTitleTemplate string
PushbulletBodyTemplate string
NtfyServer string
NtfyTopic string
NtfyPriority string
NtfyUsername string
NtfyPassword string
NtfyTitleTemplate string
NtfyMessageTemplate string
GotifyURL string
GotifyToken string
GotifyPriority string
GotifyTitleTemplate string
GotifyMessageTemplate string
PushoverAPIToken string
PushoverUserKey string
PushoverDevice string
PushoverPriority string
PushoverSound string
PushoverTitleTemplate string
PushoverMessageTemplate string
}
IsNew bool
SuccessMessage string
ErrorMessage string
}
+157 -36
View File
@@ -138,21 +138,20 @@ templ ArchiveOptions() {
</div> </div>
} }
templ RcloneFlags() { templ RcloneFlags(currentCommandID uint) {
<div class="mb-6"> <div class="mb-6">
<label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label> <label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i> <i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i>
</div> </div>
@RcloneCommandOptions() @RcloneCommandOptions(currentCommandID) // Pass it down
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Select the rclone command to use for this configuration. Select the rclone command to use for this configuration.
</p> </p>
<!-- Command flags container - will be populated via HTMX --> <!-- Flag container is now rendered directly in ConfigForm -->
<div id="command-flags-container" class="mt-4"></div>
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label> <label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label>
<div class="relative"> <div class="relative">
@@ -171,20 +170,74 @@ templ RcloneFlags() {
} }
// New placeholder templ for rclone command options // New placeholder templ for rclone command options
templ RcloneCommandOptions() { templ RcloneCommandOptions(currentCommandID uint) { // Accept currentCommandID
<div <div
hx-get="/api/rclone/commands" hx-get="/api/rclone/commands"
hx-trigger="load" hx-trigger="load"
hx-target="this" hx-target="this"
hx-swap="outerHTML"> hx-swap="outerHTML"
hx-vals={ fmt.Sprintf(`{"commandId": %d}`, currentCommandID) }>
<!-- Loading placeholder --> <!-- Loading placeholder -->
<option value="">Loading commands...</option> <option value="">Loading commands...</option>
</div> </div>
} }
templ SourceSelection() { templ SourceSelection(providers []db.StorageProvider) {
<div class="mb-6"> <div class="mb-6">
<label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label> <label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label>
<!-- Provider selection -->
<div class="mb-4">
<div class="flex items-center mb-4">
<input id="use_source_provider" name="use_source_provider" type="checkbox" x-model="useSourceProvider" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="use_source_provider" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Use existing storage provider</label>
</div>
<template x-if="useSourceProvider">
<div class="mt-2 space-y-4">
<div>
<label for="source_provider_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Select provider</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<select
id="source_provider_id"
name="source_provider_id"
x-model="sourceProviderId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
hx-get="/api/storage-providers/options"
hx-trigger="load delay:500ms"
hx-target="this"
hx-swap="innerHTML">
<option value="">Select a provider...</option>
for _, provider := range providers {
<option value={ fmt.Sprintf("%d", provider.ID) }>{ provider.Name } ({ string(provider.Type) })</option>
}
</select>
</div>
</div>
<div class="flex items-center justify-between">
<a href="/storage-providers/new" target="_blank" class="text-blue-600 hover:underline flex items-center text-sm">
<i class="fas fa-plus mr-1"></i> Add new storage provider
</a>
<button type="button"
class="text-sm text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-3 py-1.5 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
hx-get="/api/storage-providers/options"
hx-target="#source_provider_id"
hx-swap="innerHTML">
<i class="fas fa-sync-alt mr-1"></i> Refresh List
</button>
</div>
</div>
</template>
</div>
<!-- Manual configuration -->
<template x-if="!useSourceProvider">
<div>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i> <i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
@@ -195,20 +248,78 @@ templ SourceSelection() {
<option value="sftp">SFTP</option> <option value="sftp">SFTP</option>
<option value="ftp">FTP</option> <option value="ftp">FTP</option>
<option value="s3">S3</option> <option value="s3">S3</option>
<option value="b2">Backblaze B2</option>
<option value="wasabi">Wasabi</option>
<option value="minio">MinIO</option> <option value="minio">MinIO</option>
<option value="smb">SMB</option> <option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option> <option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option> <option value="webdav">WebDAV</option>
<option value="gdrive">Google Drive (BETA)</option> <option value="drive">Google Drive (BETA)</option>
<option value="gphotos">Google Photos (BETA)</option> <option value="gphotos">Google Photos (BETA)</option>
<option value="hetzner">Hetzner Storage Box</option>
</select> </select>
</div> </div>
</div>
</template>
</div> </div>
} }
templ DestinationSelection() { templ DestinationSelection(providers []db.StorageProvider) {
<div class="mb-6"> <div class="mb-6">
<label for="destination_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Type</label> <label for="destination_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Type</label>
<!-- Provider selection -->
<div class="mb-4">
<div class="flex items-center mb-4">
<input id="use_destination_provider" name="use_destination_provider" type="checkbox" x-model="useDestinationProvider" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="use_destination_provider" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Use existing storage provider</label>
</div>
<template x-if="useDestinationProvider">
<div class="mt-2 space-y-4">
<div>
<label for="destination_provider_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Select provider</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<select
id="destination_provider_id"
name="destination_provider_id"
x-model="destinationProviderId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
hx-get="/api/storage-providers/options"
hx-trigger="load delay:500ms"
hx-target="this"
hx-swap="innerHTML">
<option value="">Select a provider...</option>
for _, provider := range providers {
<option value={ fmt.Sprintf("%d", provider.ID) }>{ provider.Name } ({ string(provider.Type) })</option>
}
</select>
</div>
</div>
<div class="flex items-center justify-between">
<a href="/storage-providers/new" target="_blank" class="text-blue-600 hover:underline flex items-center text-sm">
<i class="fas fa-plus mr-1"></i> Add new storage provider
</a>
<button type="button"
class="text-sm text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-3 py-1.5 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
hx-get="/api/storage-providers/options"
hx-target="#destination_provider_id"
hx-swap="innerHTML">
<i class="fas fa-sync-alt mr-1"></i> Refresh List
</button>
</div>
</div>
</template>
</div>
<!-- Manual configuration -->
<template x-if="!useDestinationProvider">
<div>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i> <i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
@@ -219,24 +330,30 @@ templ DestinationSelection() {
<option value="sftp">SFTP</option> <option value="sftp">SFTP</option>
<option value="ftp">FTP</option> <option value="ftp">FTP</option>
<option value="s3">S3</option> <option value="s3">S3</option>
<option value="b2">Backblaze B2</option>
<option value="wasabi">Wasabi</option>
<option value="minio">MinIO</option> <option value="minio">MinIO</option>
<option value="smb">SMB</option> <option value="smb">SMB</option>
<option value="nextcloud">NextCloud</option> <option value="nextcloud">NextCloud</option>
<option value="webdav">WebDAV</option> <option value="webdav">WebDAV</option>
<option value="gdrive">Google Drive (BETA)</option> <option value="drive">Google Drive (BETA)</option>
<option value="gphotos">Google Photos (BETA)</option> <option value="gphotos">Google Photos (BETA)</option>
<option value="hetzner">Hetzner Storage Box</option>
</select> </select>
</div> </div>
</div>
</template>
</div> </div>
} }
// RcloneCommandOptionsContent renders the command options organized by category // RcloneCommandOptionsContent renders the command options organized by category
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string) { templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string, currentCommandID uint, commandFlagsJSON string, commandFlagValuesJSON string) { // Add flag JSON strings
<select id="command_id" name="command_id" x-model="commandId" <select id="command_id" name="command_id" x-model="commandId"
hx-get="/api/rclone/command-flags" hx-get="/api/rclone/command-flags"
hx-target="#command-flags-container" hx-target="#command-flags-container"
hx-trigger="change" hx-trigger="change"
hx-include="[name='command_id']" hx-include="[name='command_id']"
hx-vals={ fmt.Sprintf(`{"commandFlags": %s, "commandFlagValues": %s}`, commandFlagsJSON, commandFlagValuesJSON) }
@change="updateCommandRequirements()" @change="updateCommandRequirements()"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"> class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="">Select command...</option> <option value="">Select command...</option>
@@ -244,8 +361,12 @@ templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, cat
if commands, ok := categoryMap[category]; ok && len(commands) > 0 { if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
<optgroup label={ category }> <optgroup label={ category }>
for _, cmd := range commands { for _, cmd := range commands {
if cmd.ID == currentCommandID {
<option value={ fmt.Sprintf("%d", cmd.ID) } selected>{ cmd.Name } - { cmd.Description }</option>
} else {
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option> <option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
} }
}
</optgroup> </optgroup>
} }
} }
@@ -253,7 +374,7 @@ templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, cat
} }
// RcloneCommandFlagsContent renders the command flags for a selected command // RcloneCommandFlagsContent renders the command flags for a selected command
templ RcloneCommandFlagsContent(command *db.RcloneCommand) { templ RcloneCommandFlagsContent(command *db.RcloneCommand, selectedFlagsMap map[uint]bool, selectedFlagValues map[uint]string) {
if command == nil { if command == nil {
<div class="p-4 text-red-500">Command not found</div> <div class="p-4 text-red-500">Command not found</div>
return return
@@ -277,7 +398,9 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
name="command_flags" name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) } value={ fmt.Sprintf("%d", flag.ID) }
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600" class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
/> if selectedFlagsMap[flag.ID] {
checked
} />
<div class="ml-3"> <div class="ml-3">
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white"> <label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
{ flag.Name } - { flag.Description } { flag.Name } - { flag.Description }
@@ -299,14 +422,16 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600" class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) } data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) }
onclick="toggleFlagValue(this)" onclick="toggleFlagValue(this)"
/> if selectedFlagsMap[flag.ID] {
checked
} />
<label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white"> <label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
{ flag.Name } - { flag.Description } { flag.Name } - { flag.Description }
</label> </label>
</div> </div>
<div class="w-full mt-2"> <div class="w-full mt-2">
@renderFlagInput(flag) @renderFlagInput(flag, selectedFlagValues[flag.ID], selectedFlagsMap[flag.ID]) // Pass value and enabled status
if flag.DefaultValue != "" { if flag.DefaultValue != "" {
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p>
} }
@@ -345,23 +470,7 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
} }
// Initialize all flag inputs on page load // Initialize all flag inputs on page load
document.addEventListener('DOMContentLoaded', function() { // Initialization is now handled by server-side rendering
const checkboxes = document.querySelectorAll('input[id^="flag_enable_"]');
checkboxes.forEach(function(checkbox) {
const inputId = checkbox.getAttribute('data-input-id');
const input = document.getElementById(inputId);
if (input) {
input.disabled = !checkbox.checked;
// Also initialize the hidden input
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
const hiddenInput = document.getElementById(hiddenId);
if (hiddenInput) {
hiddenInput.disabled = !checkbox.checked;
}
}
});
});
</script> </script>
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm"> <div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
@@ -402,7 +511,7 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
} }
// Helper function to render appropriate input based on flag data type // Helper function to render appropriate input based on flag data type
templ renderFlagInput(flag db.RcloneCommandFlag) { templ renderFlagInput(flag db.RcloneCommandFlag, value string, enabled bool) {
if flag.DataType == "int" { if flag.DataType == "int" {
<input <input
type="number" type="number"
@@ -410,14 +519,18 @@ templ renderFlagInput(flag db.RcloneCommandFlag) {
name={ fmt.Sprintf("flag_value_%d", flag.ID) } name={ fmt.Sprintf("flag_value_%d", flag.ID) }
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue } placeholder={ flag.DefaultValue }
value={ value }
if !enabled {
disabled disabled
/> } />
<!-- Hidden input to include this flag ID when checked --> <!-- Hidden input to include this flag ID when checked -->
<input <input
type="hidden" type="hidden"
name="command_flags" name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) } value={ fmt.Sprintf("%d", flag.ID) }
if !enabled {
disabled disabled
}
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) } id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) } data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/> />
@@ -429,14 +542,18 @@ templ renderFlagInput(flag db.RcloneCommandFlag) {
step="0.01" step="0.01"
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue } placeholder={ flag.DefaultValue }
value={ value }
if !enabled {
disabled disabled
/> } />
<!-- Hidden input to include this flag ID when checked --> <!-- Hidden input to include this flag ID when checked -->
<input <input
type="hidden" type="hidden"
name="command_flags" name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) } value={ fmt.Sprintf("%d", flag.ID) }
if !enabled {
disabled disabled
}
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) } id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) } data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/> />
@@ -448,14 +565,18 @@ templ renderFlagInput(flag db.RcloneCommandFlag) {
name={ fmt.Sprintf("flag_value_%d", flag.ID) } name={ fmt.Sprintf("flag_value_%d", flag.ID) }
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={ flag.DefaultValue } placeholder={ flag.DefaultValue }
value={ value }
if !enabled {
disabled disabled
/> } />
<!-- Hidden input to include this flag ID when checked --> <!-- Hidden input to include this flag ID when checked -->
<input <input
type="hidden" type="hidden"
name="command_flags" name="command_flags"
value={ fmt.Sprintf("%d", flag.ID) } value={ fmt.Sprintf("%d", flag.ID) }
if !enabled {
disabled disabled
}
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) } id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) } data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
/> />
+97
View File
@@ -0,0 +1,97 @@
package destination
templ B2DestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-b2-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your Backblaze B2 bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Account ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="0123456789abcdef0123456789" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Backblaze B2 Account ID
</p>
</div>
<div class="mb-6">
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="K0123456789abcdef0123456789abcdef0123456789" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Backblaze B2 Application Key
</p>
</div>
<div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="s3.us-west-000.backblazeb2.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Custom endpoint URL (only needed for non-standard regions or private endpoints)
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,128 @@
package destination
templ HetznerDestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Hetzner Storage Box details below. You'll need your server details, username, and password.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Storage Box Host</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="uXXXXXX.your-storagebox.de" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
</p>
</div>
<div class="mb-6">
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-plug text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="dest_port" name="dest_port" x-model="destPort" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="23" min="1" max="65535" value="23" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Connection port (default: 23)
</p>
</div>
<div class="mb-6">
<label for="dest_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
<div class="flex">
<div class="flex items-center me-4">
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
</div>
<div class="flex items-center me-4">
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
</div>
</div>
</div>
<div class="mb-6">
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="uXXXXXX" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box username (typically matches your Storage Box number)
</p>
</div>
<div x-show="destAuthType === 'password'" class="mb-6">
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination && destAuthType === 'password'"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box password
</p>
</div>
<div x-show="destAuthType === 'key'" class="mb-6">
<label for="dest_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destKeyFile" x-bind:required="requiresDestination && destAuthType === 'key'"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/id_rsa" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your SSH private key file (must be readable by the application)
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/backups" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path on your Storage Box (e.g., /backups, /path/to/files)
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
</div>
</div>
</div>
</div>
}
@@ -27,15 +27,5 @@ templ LocalDestinationForm() {
</div> </div>
</div> </div>
</div> </div>
<button
type="button"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
@click="checkPath(destinationPath, 'dest')"
>
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
</svg>
Check Location
</button>
</div> </div>
} }
@@ -39,6 +39,22 @@ templ MinIODestinationForm() {
</p> </p>
</div> </div>
<div class="mb-6">
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_region" name="dest_region" x-model="destRegion"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Optional: Specify the region if your MinIO setup requires it.
</p>
</div>
<div class="mb-6"> <div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label> <label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative"> <div class="relative">
@@ -10,12 +10,12 @@ templ NextCloudDestinationForm() {
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label> <label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i> <i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
</div> </div>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination" <input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://nextcloud.example.com" /> placeholder="https://nextcloud.example.com" />
</div> </div>
@@ -32,7 +32,7 @@ templ NextCloudDestinationForm() {
</div> </div>
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination" <input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="nextcloud_username" /> placeholder="username" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud username Your NextCloud username
@@ -47,7 +47,7 @@ templ NextCloudDestinationForm() {
</div> </div>
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination" <input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="NextCloud password" /> placeholder="password" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud account password Your NextCloud account password
@@ -62,10 +62,10 @@ templ NextCloudDestinationForm() {
</div> </div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination" <input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="remote.php/dav/files/username/path/to/files" /> placeholder="/path/to/files" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files in NextCloud. Usually starts with "remote.php/dav/files/username/" Path to your files in NextCloud
</p> </p>
</div> </div>
@@ -0,0 +1,112 @@
package destination
templ WasabiDestinationForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Wasabi Cloud Storage details below. You'll need your access key, secret key, region, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Wasabi Region</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-1)
</p>
</div>
<div class="mb-6">
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-wasabi-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your Wasabi bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="ABCDEFGHIJKLMNOPQRST" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Wasabi Access Key
</p>
</div>
<div class="mb-6">
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Wasabi Secret Key
</p>
</div>
<div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="s3.wasabisys.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
</p>
</div>
<div class="mb-6">
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific Wasabi bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
@@ -10,12 +10,12 @@ templ WebDAVDestinationForm() {
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label> <label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i> <i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div> </div>
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination" <input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://webdav.example.com" /> placeholder="https://webdav.example.com" />
</div> </div>
@@ -32,7 +32,7 @@ templ WebDAVDestinationForm() {
</div> </div>
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination" <input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="webdav_username" /> placeholder="username" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV username Your WebDAV username
@@ -47,7 +47,7 @@ templ WebDAVDestinationForm() {
</div> </div>
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination" <input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="WebDAV password" /> placeholder="password" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV account password Your WebDAV account password
-191
View File
@@ -1,191 +0,0 @@
package providers
import (
"fmt"
"strings"
"github.com/starfleetcptn/gomft/components/providers/common"
"github.com/starfleetcptn/gomft/components/providers/source"
"github.com/starfleetcptn/gomft/components/providers/destination"
)
// Returns the form ID based on the form type and whether it's a source or destination
func formID(formType string, isSource bool) string {
if isSource {
return "source_config_form"
}
return "destination_config_form"
}
// Returns a user-friendly display name for the provider
func providerDisplayName(provider string) string {
switch provider {
case "sftp":
return "SFTP"
case "local":
return "Local Filesystem"
case "s3":
return "Amazon S3"
case "ftp":
return "FTP"
case "azure":
return "Azure Blob Storage"
default:
return strings.Title(provider)
}
}
templ ProviderForm(formType string, providers []string, isSource bool) {
<form
id={formID(formType, isSource)}
x-data={fmt.Sprintf("{ %sProvider: '', showAdvanced: false }", formType)}
class="space-y-8">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4">
@common.NameField()
<div class="sm:col-span-4">
<label for={fmt.Sprintf("%s_provider", formType)} class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Provider 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={fmt.Sprintf("%s_provider", formType)}
name={fmt.Sprintf("%s_provider", formType)}
x-model={fmt.Sprintf("%sProvider", formType)}
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="" disabled selected>Select provider type</option>
for _, provider := range providers {
<option value={provider}>{providerDisplayName(provider)}</option>
}
</select>
</div>
</div>
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'sftp'", formType)}>
if isSource {
@source.SFTPSourceForm()
} else {
@destination.SFTPDestinationForm()
}
</div>
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'local'", formType)}>
if isSource {
@source.LocalSourceForm()
} else {
@destination.LocalDestinationForm()
}
</div>
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 's3'", formType)}>
if isSource {
@source.S3SourceForm()
} else {
@destination.S3DestinationForm()
}
</div>
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'ftp'", formType)}>
if isSource {
@source.FTPSourceForm()
} else {
@destination.FTPDestinationForm()
}
</div>
<div class="sm:col-span-12" x-show={fmt.Sprintf("%sProvider", formType)}>
<div class="mt-6">
<label for="show_advanced" class="flex items-center cursor-pointer">
<div class="relative">
<input id="show_advanced" type="checkbox" x-model="showAdvanced" class="sr-only" />
<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="showAdvanced ? 'transform translate-x-6 bg-primary-500' : ''"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">
Show Advanced Options
</div>
</label>
</div>
<div x-show="showAdvanced">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4 mt-6">
@common.FilePatternFields()
if isSource {
@common.ArchiveOptions()
}
</div>
</div>
</div>
</div>
</form>
}
script formAlpineInit() {
return {
initProviderForm() {
// Initialize with values if editing existing config
if (window.editData && window.editData.configs) {
const config = window.editData.configs.find(c =>
isSource ? (c.id === window.editData.source_config_id) : (c.id === window.editData.destination_config_id)
);
if (config) {
this[formType + 'Provider'] = config.provider;
this.name = config.name;
// Provider-specific fields
if (config.provider === 'sftp') {
this.host = config.host;
this.port = config.port;
this.username = config.username;
this.path = config.path;
if (config.key_file && config.key_file !== '') {
this.authType = 'key_file';
this.keyFile = config.key_file;
} else {
this.authType = 'password';
// Password is not included in edit data for security
}
} else if (config.provider === 'local') {
this.path = config.path;
} else if (config.provider === 's3') {
this.bucket = config.bucket;
this.region = config.region;
this.path = config.path;
this.accessKey = config.access_key;
if (config.endpoint && config.endpoint !== '') {
this.useCustomEndpoint = true;
this.endpoint = config.endpoint;
} else {
this.useCustomEndpoint = false;
}
} else if (config.provider === 'ftp') {
this.host = config.host;
this.port = config.port;
this.username = config.username;
this.path = config.path;
this.useFTPS = config.use_ftps;
}
// Advanced options
if (config.include_pattern) this.filePattern = config.include_pattern;
if (config.exclude_pattern) this.excludePattern = config.exclude_pattern;
if (isSource && config.extract_archives) {
this.extractArchives = true;
this.deleteArchives = config.delete_archives;
}
}
}
},
providerChanged() {
console.log("Provider changed to: " + this[formType + 'Provider']);
}
};
}
+97
View File
@@ -0,0 +1,97 @@
package source
templ B2SourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-b2-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your Backblaze B2 bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Account ID</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="0123456789abcdef0123456789" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Backblaze B2 Account ID
</p>
</div>
<div class="mb-6">
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="K0123456789abcdef0123456789abcdef0123456789" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Backblaze B2 Application Key
</p>
</div>
<div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="s3.us-west-000.backblazeb2.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Custom endpoint URL (only needed for non-standard regions or private endpoints)
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
+128
View File
@@ -0,0 +1,128 @@
package source
templ HetznerSourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Hetzner Storage Box details below. You'll need your server details, username, and password.</span>
</div>
</div>
<div class="mb-6">
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Storage Box Host</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="uXXXXXX.your-storagebox.de" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
</p>
</div>
<div class="mb-6">
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-plug text-gray-400 dark:text-gray-500"></i>
</div>
<input type="number" id="source_port" name="source_port" x-model="sourcePort" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="23" min="1" max="65535" value="23" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Connection port (default: 23)
</p>
</div>
<div class="mb-6">
<label for="source_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
<div class="flex">
<div class="flex items-center me-4">
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
</div>
<div class="flex items-center me-4">
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
</div>
</div>
</div>
<div class="mb-6">
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="uXXXXXX" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box username (typically matches your Storage Box number)
</p>
</div>
<div x-show="sourceAuthType === 'password'" class="mb-6">
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" x-bind:required="sourceAuthType === 'password'"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your password" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Hetzner Storage Box password
</p>
</div>
<div x-show="sourceAuthType === 'key'" class="mb-6">
<label for="source_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile" x-bind:required="sourceAuthType === 'key'"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/path/to/id_rsa" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your SSH private key file (must be readable by the application)
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="/backups" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path on your Storage Box (e.g., /backups, /path/to/files)
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
</div>
</div>
</div>
</div>
}
-10
View File
@@ -27,15 +27,5 @@ templ LocalSourceForm() {
</div> </div>
</div> </div>
</div> </div>
<button
type="button"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
@click="checkPath(sourcePath, 'source')"
>
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
</svg>
Check Location
</button>
</div> </div>
} }
+16
View File
@@ -39,6 +39,22 @@ templ MinIOSourceForm() {
</p> </p>
</div> </div>
<div class="mb-6">
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_region" name="source_region" x-model="sourceRegion"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Optional: Specify the region if your MinIO setup requires it.
</p>
</div>
<div class="mb-6"> <div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label> <label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative"> <div class="relative">
+6 -6
View File
@@ -10,12 +10,12 @@ templ NextCloudSourceForm() {
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label> <label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i> <i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
</div> </div>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required <input type="text" id="source_host" name="source_host" x-model="sourceHost" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://nextcloud.example.com" /> placeholder="https://nextcloud.example.com" />
</div> </div>
@@ -32,7 +32,7 @@ templ NextCloudSourceForm() {
</div> </div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required <input type="text" id="source_user" name="source_user" x-model="sourceUser" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="nextcloud_username" /> placeholder="username" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud username Your NextCloud username
@@ -47,7 +47,7 @@ templ NextCloudSourceForm() {
</div> </div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required <input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="NextCloud password" /> placeholder="password" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your NextCloud account password Your NextCloud account password
@@ -62,10 +62,10 @@ templ NextCloudSourceForm() {
</div> </div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required <input type="text" id="source_path" name="source_path" x-model="sourcePath" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="remote.php/dav/files/username/path/to/files" /> placeholder="/path/to/files" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path to your files in NextCloud. Usually starts with "remote.php/dav/files/username/" Path to your files in NextCloud
</p> </p>
</div> </div>
+112
View File
@@ -0,0 +1,112 @@
package source
templ WasabiSourceForm() {
<div class="space-y-6 mt-4">
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
<div class="flex items-center">
<i class="fas fa-info-circle mr-2"></i>
<span>Configure your Wasabi Cloud Storage details below. You'll need your access key, secret key, region, and bucket information.</span>
</div>
</div>
<div class="mb-6">
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Wasabi Region</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_region" name="source_region" x-model="sourceRegion" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="us-east-1" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-1)
</p>
</div>
<div class="mb-6">
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="my-wasabi-bucket" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your Wasabi bucket (case-sensitive)
</p>
</div>
<div class="mb-6">
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="ABCDEFGHIJKLMNOPQRST" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Wasabi Access Key
</p>
</div>
<div class="mb-6">
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
</div>
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your Wasabi Secret Key
</p>
</div>
<div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="s3.wasabisys.com" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
</p>
</div>
<div class="mb-6">
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
</div>
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="path/to/files/" />
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
</p>
</div>
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
<div class="flex">
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
<div>
<h3 class="font-medium">Security Note</h3>
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific Wasabi bucket and operations needed.</p>
</div>
</div>
</div>
</div>
}
+4 -4
View File
@@ -10,12 +10,12 @@ templ WebDAVSourceForm() {
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label> <label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i> <i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
</div> </div>
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required <input type="text" id="source_host" name="source_host" x-model="sourceHost" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="https://webdav.example.com" /> placeholder="https://webdav.example.com" />
</div> </div>
@@ -32,7 +32,7 @@ templ WebDAVSourceForm() {
</div> </div>
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required <input type="text" id="source_user" name="source_user" x-model="sourceUser" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="webdav_username" /> placeholder="username" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV username Your WebDAV username
@@ -47,7 +47,7 @@ templ WebDAVSourceForm() {
</div> </div>
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required <input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="WebDAV password" /> placeholder="password" />
</div> </div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your WebDAV account password Your WebDAV account password
+13
View File
@@ -0,0 +1,13 @@
package components
type RcloneImportPreview struct {
Remotes []RcloneRemotePreview
Error string
}
type RcloneRemotePreview struct {
Name string
Type string
Fields map[string]string
Import bool // Should import
}
+5
View File
@@ -0,0 +1,5 @@
package toast
templ Container() {
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
}
+114
View File
@@ -0,0 +1,114 @@
package toast
templ ShowToastJS() {
<script>
// Notification system
// Global tracking of shown messages to prevent duplicates
window.shownToastMessages = window.shownToastMessages || [];
function showToast(message, type) {
// Check if this exact message has been shown in the last 500ms
const messageKey = `${message}-${type}`;
if (window.shownToastMessages.includes(messageKey)) {
console.log(`Preventing duplicate toast: ${messageKey}`);
return;
}
// Add to shown messages
window.shownToastMessages.push(messageKey);
// Remove from tracking after 500ms to allow the same message later if needed
setTimeout(() => {
const index = window.shownToastMessages.indexOf(messageKey);
if (index > -1) {
window.shownToastMessages.splice(index, 1);
}
}, 500);
const toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
console.error("Toast container not found!");
return;
}
// Create toast element
const toast = document.createElement('div');
toast.id = 'toast-' + type + '-' + Date.now();
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
toast.role = 'alert';
// Set toast content based on type
let iconClass, bgColorClass, textColorClass;
if (type === 'success') {
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
bgColorClass = 'text-green-500 dark:text-green-200';
textColorClass = 'text-green-500 dark:text-green-200';
} else if (type === 'error') {
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
bgColorClass = 'text-red-500 dark:text-red-200';
textColorClass = 'text-red-500 dark:text-red-200';
} else { // Default to info
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
bgColorClass = 'text-blue-500 dark:text-blue-200';
textColorClass = 'text-blue-500 dark:text-blue-200';
}
// Create icon div
const iconDiv = document.createElement('div');
iconDiv.className = `inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}`;
iconDiv.innerHTML = type === 'success'
? '<i class="fas fa-check"></i>'
: type === 'error'
? '<i class="fas fa-exclamation-circle"></i>'
: '<i class="fas fa-info-circle"></i>';
// Create message div and set text content safely
const messageDiv = document.createElement('div');
messageDiv.className = 'ml-3 text-sm font-normal';
messageDiv.textContent = message; // Use textContent for safety
// Create close button
const closeButton = document.createElement('button'); // Keep this declaration
closeButton.type = 'button';
closeButton.className = 'ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700';
closeButton.setAttribute('data-dismiss-target', `#${toast.id}`);
closeButton.setAttribute('aria-label', 'Close');
closeButton.innerHTML = `
<span class="sr-only">Close</span>
<i class="fas fa-times"></i>
`;
// Append elements to the toast
toast.appendChild(iconDiv);
toast.appendChild(messageDiv);
toast.appendChild(closeButton);
// Add toast to container
toastContainer.appendChild(toast);
// Trigger animation after a small delay
setTimeout(() => {
toast.classList.remove('translate-y-16', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
}, 10);
// Add event listener to the close button we created earlier
closeButton.addEventListener('click', function() { // Use the existing closeButton variable
// Animate out before removing
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
});
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => {
toast.remove();
}, 300);
}, 5000);
}
</script>
}
+954
View File
@@ -0,0 +1,954 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/internal/db"
)
type StorageProviderFormData struct {
Provider *db.StorageProvider
IsEdit bool
Error string
}
// getTitle returns the appropriate title based on whether we're editing or creating
func getTitle(isEdit bool) string {
if isEdit {
return "Edit Storage Provider"
}
return "New Storage Provider"
}
// Main template for Storage Provider form
templ StorageProviderForm(ctx context.Context, data StorageProviderFormData) {
@LayoutWithContext(getTitle(data.IsEdit), ctx) {
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-server w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
if data.IsEdit {
Edit Storage Provider
} else {
New Storage Provider
}
</h1>
<a href="/storage-providers" class="text-blue-600 dark:text-blue-400 hover:underline flex items-center">
<i class="fas fa-arrow-left mr-1.5"></i>
Back to Providers
</a>
</div>
if data.Error != "" {
<div class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400">
<div class="flex items-center">
<i class="fas fa-exclamation-circle mr-2"></i>
<span>{ data.Error }</span>
</div>
</div>
}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-6">
if data.IsEdit {
<form action={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d", data.Provider.ID)) } method="POST">
<input type="hidden" name="_method" value="PUT" />
@formFields(data)
</form>
} else {
<form action="/storage-providers" method="POST">
@formFields(data)
</form>
}
</div>
</div>
</div>
<!-- Include the provider form scripts -->
@providerFormScript()
}
}
// Form fields template
templ formFields(data StorageProviderFormData) {
<!-- Basic Information -->
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
<!-- Provider Name -->
<div class="mb-4">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Provider Name <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" value={ data.Provider.Name } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Storage Provider Name" required />
</div>
<!-- Provider Type -->
<div>
<label for="type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Provider Type <span class="text-red-500">*</span></label>
<select id="type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required onchange="toggleProviderFields()">
<option value="" disabled
if data.Provider.Type == "" {
selected="selected"
}
>
Select provider type
</option>
<!-- Server-based providers -->
<optgroup label="Server-based">
<option value="sftp"
if data.Provider.Type == db.ProviderTypeSFTP {
selected="selected"
}
>SFTP</option>
<option value="hetzner"
if data.Provider.Type == db.ProviderTypeHetzner {
selected="selected"
}
>Hetzner Storage Box</option>
<option value="ftp"
if data.Provider.Type == db.ProviderTypeFTP {
selected="selected"
}
>FTP</option>
<option value="smb"
if data.Provider.Type == db.ProviderTypeSMB {
selected="selected"
}
>SMB/CIFS</option>
</optgroup>
<!-- Object Storage -->
<optgroup label="Object Storage">
<option value="s3"
if data.Provider.Type == db.ProviderTypeS3 {
selected="selected"
}
>Amazon S3</option>
<option value="wasabi"
if data.Provider.Type == "wasabi" {
selected="selected"
}
>Wasabi</option>
<option value="minio"
if data.Provider.Type == "minio" {
selected="selected"
}
>MinIO</option>
<option value="b2"
if data.Provider.Type == "b2" {
selected="selected"
}
>Backblaze B2</option>
</optgroup>
<!-- Web-based storage -->
<optgroup label="Web Storage">
<option value="webdav"
if data.Provider.Type == "webdav" {
selected="selected"
}
>WebDAV</option>
<option value="nextcloud"
if data.Provider.Type == "nextcloud" {
selected="selected"
}
>Nextcloud</option>
</optgroup>
<!-- Cloud providers -->
<optgroup label="Cloud Storage">
<option value="onedrive"
if data.Provider.Type == db.ProviderTypeOneDrive {
selected="selected"
}
>OneDrive</option>
<option value="drive"
if data.Provider.Type == db.ProviderTypeGoogleDrive {
selected="selected"
}
>Google Drive</option>
<option value="gphotos"
if data.Provider.Type == db.ProviderTypeGooglePhoto {
selected="selected"
}
>Google Photos</option>
</optgroup>
<!-- Local -->
<optgroup label="Local">
<option value="local"
if data.Provider.Type == db.ProviderTypeLocal {
selected="selected"
}
>Local Filesystem</option>
</optgroup>
</select>
</div>
</div>
<!-- Connection Details - SFTP/FTP/SMB -->
<div id="sftp-ftp-fields" class="mb-6 provider-fields hidden">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connection Details</h2>
<!-- Host -->
<div class="mb-4">
<label for="host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Host <span class="text-red-500">*</span></label>
<input type="text" id="host" name="host" value={ data.Provider.Host } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., sftp.example.com or 192.168.1.10" />
</div>
<!-- Port -->
<div id="port-field" class="mb-4">
<label for="port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
<input type="number" id="port" name="port" value={ fmt.Sprint(data.Provider.Port) } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="22" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty for default (SFTP: 22, FTP: 21, SMB: 445, WebDAV: 80/443)</p>
</div>
<!-- Username -->
<div class="mb-4">
<label for="username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username <span class="text-red-500">*</span></label>
<input type="text" id="username" name="username" value={ data.Provider.Username } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Your login username" />
</div>
<!-- Password -->
<div class="mb-4">
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
<input type="password" id="password" name="password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Your login password" />
if data.IsEdit {
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current password</p>
}
</div>
<!-- Key File (SFTP only) -->
<div id="key-file-field" class="mb-4 hidden">
<label for="keyFile" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Key File Path</label>
<input type="text" id="keyFile" name="keyFile" value={ data.Provider.KeyFile } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., /home/user/.ssh/id_rsa" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Absolute path to private key file (if using key-based authentication)</p>
</div>
<!-- Domain (SMB only) -->
<div id="domain-field" class="mb-4 hidden">
<label for="domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain</label>
<input type="text" id="domain" name="domain" value={ data.Provider.Domain } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., WORKGROUP or domain.local" />
</div>
<!-- Passive Mode (FTP only) -->
<div id="passive-mode-field" class="mb-4 hidden">
<div class="flex items-center">
<input id="passiveMode" name="passiveMode" type="checkbox" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
if data.Provider.PassiveMode != nil && *data.Provider.PassiveMode {
checked="checked"
}
/>
<label for="passiveMode" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</label>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Recommended for most FTP connections through firewalls</p>
</div>
</div>
<!-- S3 Fields -->
<div id="s3-fields" class="mb-6 provider-fields hidden">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">S3 Connection Details</h2>
<!-- Endpoint -->
<div class="mb-4">
<label for="endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Endpoint
<span id="endpoint-required" class="text-red-500">*</span>
</label>
<input type="text" id="endpoint" name="endpoint" value={ data.Provider.Endpoint }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="e.g., s3.amazonaws.com, s3.us-west-1.wasabisys.com" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Custom endpoint URL (only needed for non-standard regions or non-AWS S3-compatible services). Optional for B2.
</p>
</div>
<!-- Region -->
<div class="mb-4">
<label for="region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Region
<span id="region-required" class="text-red-500">*</span>
</label>
<input type="text" id="region" name="region" value={ data.Provider.Region }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="e.g., us-east-1, eu-central-1" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1). Optional for B2 and Wasabi.
</p>
</div>
<!-- Bucket -->
<div class="mb-4">
<label for="bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket <span class="text-red-500">*</span></label>
<input type="text" id="bucket" name="bucket" value={ data.Provider.Bucket }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your bucket name" />
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Name of your S3 bucket (case-sensitive)
</p>
</div>
<!-- Access Key -->
<div class="mb-4">
<label for="accessKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key <span class="text-red-500">*</span></label>
<input type="text" id="accessKey" name="accessKey" value={ data.Provider.AccessKey }
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your access key/key ID" />
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Your AWS Access Key ID
</p>
</div>
<!-- Secret Key -->
<div class="mb-4">
<label for="secretKey" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key <span class="text-red-500">*</span></label>
<input type="password" id="secretKey" name="secretKey"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Your secret access key" />
if data.IsEdit {
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current secret key</p>
}
</div>
</div>
<!-- Cloud Storage Fields (OneDrive, Google Drive, Google Photos) -->
<div id="cloud-fields" class="mb-6 provider-fields hidden">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Cloud Storage Details</h2>
<!-- Client ID -->
<div class="mb-4">
<label for="clientID" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID <span class="text-red-500">*</span></label>
<input type="text" id="clientID" name="clientID" value={ data.Provider.ClientID } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="OAuth client ID from developer console" />
</div>
<!-- Client Secret -->
<div class="mb-4">
<label for="clientSecret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret <span class="text-red-500">*</span></label>
<input type="password" id="clientSecret" name="clientSecret" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="OAuth client secret from developer console" />
if data.IsEdit {
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave empty to keep the current client secret</p>
}
</div>
<!-- Google Drive - Drive ID -->
<div id="drive-id-field" class="mb-4 hidden">
<label for="driveID" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID</label>
<input type="text" id="driveID" name="driveID" value={ data.Provider.DriveID } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="ID of shared/team drive (from Drive URL)" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Only required for shared drives</p>
</div>
<!-- Google Drive - Team Drive -->
<div id="team-drive-field" class="mb-4 hidden">
<label for="teamDrive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive</label>
<input type="text" id="teamDrive" name="teamDrive" value={ data.Provider.TeamDrive } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Team drive identifier" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Only required for team drives</p>
</div>
<!-- Google Photos - Read Only -->
<div id="readonly-field" class="mb-4 hidden">
<div class="flex items-center">
<input id="readOnly" name="readOnly" type="checkbox" value="true" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
if data.Provider.ReadOnly != nil && *data.Provider.ReadOnly {
checked="checked"
}
/>
<label for="readOnly" class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Read Only Mode</label>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Google Photos has limited write capabilities</p>
</div>
</div>
<!-- Local File System Fields -->
<div id="local-fields" class="mb-6 provider-fields hidden">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Local File System</h2>
<div class="mb-4">
<label for="localPath" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Base Path <span class="text-red-500">*</span></label>
<input type="text" id="localPath" name="localPath" value={ data.Provider.Host } class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="e.g., /data/files or C:\transfer\data" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Absolute path on the server's file system</p>
</div>
</div>
<!-- Submit Buttons -->
<div class="flex items-center justify-between mt-8">
<a href="/storage-providers" class="text-gray-500 bg-gray-50 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</a>
<div class="flex space-x-2">
<button type="submit" name="test" value="true" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700">
Save & Test
</button>
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
Save Provider
</button>
</div>
</div>
<!-- Hidden fields for S3 form data to ensure it gets submitted correctly -->
<input type="hidden" id="hidden_endpoint" name="endpoint" value={ data.Provider.Endpoint } />
<input type="hidden" id="hidden_region" name="region" value={ data.Provider.Region } />
<input type="hidden" id="hidden_bucket" name="bucket" value={ data.Provider.Bucket } />
<input type="hidden" id="hidden_accessKey" name="accessKey" value={ data.Provider.AccessKey } />
<input type="hidden" id="hidden_secretKey" name="secretKey" />
<!-- Hidden fields for Google Drive and Google Photos -->
<input type="hidden" id="hidden_clientID" name="clientID" value={ data.Provider.ClientID } />
<input type="hidden" id="hidden_clientSecret" name="clientSecret" />
}
// JavaScript helper for toggling provider fields
templ providerFormScript() {
<script>
// Set current active fields on page load
document.addEventListener('DOMContentLoaded', function() {
toggleProviderFields();
// Add event listener to track if user manually changes the port
const portField = document.getElementById('port');
portField.addEventListener('input', function() {
// Mark the field as user-modified
this.dataset.userModified = 'true';
});
// Add event listeners for S3 fields
document.getElementById('endpoint').addEventListener('input', function() {
document.getElementById('hidden_endpoint').value = this.value;
});
document.getElementById('region').addEventListener('input', function() {
document.getElementById('hidden_region').value = this.value;
});
document.getElementById('bucket').addEventListener('input', function() {
document.getElementById('hidden_bucket').value = this.value;
});
document.getElementById('accessKey').addEventListener('input', function() {
document.getElementById('hidden_accessKey').value = this.value;
});
document.getElementById('secretKey').addEventListener('input', function() {
document.getElementById('hidden_secretKey').value = this.value;
});
// Add event listeners for Google Drive/Photos fields
document.getElementById('clientID').addEventListener('input', function() {
document.getElementById('hidden_clientID').value = this.value;
});
document.getElementById('clientSecret').addEventListener('input', function() {
document.getElementById('hidden_clientSecret').value = this.value;
});
// Add form submit listener to ensure all hidden fields are populated
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
// Find active provider type
const providerType = document.getElementById('type').value;
// If S3-compatible, update hidden fields
if (['s3', 'wasabi', 'minio', 'b2'].includes(providerType)) {
document.getElementById('hidden_endpoint').value = document.getElementById('endpoint').value;
document.getElementById('hidden_region').value = document.getElementById('region').value;
document.getElementById('hidden_bucket').value = document.getElementById('bucket').value;
document.getElementById('hidden_accessKey').value = document.getElementById('accessKey').value;
// Make sure secretKey is always copied to the hidden field
// This is especially important for B2 which uses this as Application Key
const secretKeyValue = document.getElementById('secretKey').value;
document.getElementById('hidden_secretKey').value = secretKeyValue;
// For validation - ensure we have appropriate fields for each provider type
if (providerType === 'b2') {
// B2 does not require region or endpoint
if (!document.getElementById('hidden_bucket').value) {
alert('Bucket name is required');
e.preventDefault();
return false;
}
if (!document.getElementById('hidden_accessKey').value) {
alert('Account ID is required');
e.preventDefault();
return false;
}
if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
alert('Application Key is required');
e.preventDefault();
return false;
}
} else if (providerType === 'wasabi') {
// Wasabi does not require region
if (!document.getElementById('hidden_endpoint').value) {
alert('Endpoint is required for Wasabi');
e.preventDefault();
return false;
}
if (!document.getElementById('hidden_bucket').value) {
alert('Bucket name is required');
e.preventDefault();
return false;
}
if (!document.getElementById('hidden_accessKey').value) {
alert('Access key is required');
e.preventDefault();
return false;
}
if (!secretKeyValue && !document.getElementById('hidden_secretKey').value) {
alert('Secret key is required');
e.preventDefault();
return false;
}
}
console.log('Submitting S3-compatible form with:', {
provider: providerType,
endpoint: document.getElementById('hidden_endpoint').value,
region: document.getElementById('hidden_region').value,
bucket: document.getElementById('hidden_bucket').value,
accessKey: document.getElementById('hidden_accessKey').value,
secretKey: document.getElementById('hidden_secretKey').value ? '[PRESENT]' : '[EMPTY]',
secretKeyLength: document.getElementById('hidden_secretKey').value.length
});
}
// If Google Drive or Google Photos, update hidden fields
if (['drive', 'gphotos', 'onedrive'].includes(providerType)) {
document.getElementById('hidden_clientID').value = document.getElementById('clientID').value;
// Make sure clientSecret is always copied to the hidden field
const clientSecretValue = document.getElementById('clientSecret').value;
document.getElementById('hidden_clientSecret').value = clientSecretValue;
// Validate required fields
if (!document.getElementById('hidden_clientID').value) {
alert('Client ID is required');
e.preventDefault();
return false;
}
console.log('Submitting cloud storage form with:', {
provider: providerType,
clientID: document.getElementById('hidden_clientID').value,
clientSecret: document.getElementById('hidden_clientSecret').value ? '[PRESENT]' : '[EMPTY]',
clientSecretLength: document.getElementById('hidden_clientSecret').value.length
});
}
// Debug WebDAV form submission
if (['webdav', 'nextcloud'].includes(providerType)) {
console.log('Submitting WebDAV form with:', {
provider: providerType,
host: document.getElementById('host').value,
username: document.getElementById('username').value,
password: document.getElementById('password').value ? '[PRESENT]' : '[EMPTY]',
passwordLength: document.getElementById('password').value.length
});
}
});
});
});
function toggleProviderFields() {
const provider = document.getElementById('type').value;
// Hide all provider fields first
const providerFieldsets = document.querySelectorAll('.provider-fields');
providerFieldsets.forEach(fieldset => {
fieldset.classList.add('hidden');
});
// Auto-populate port based on provider type
const portField = document.getElementById('port');
const isPortEmpty = portField.value === '';
const userModified = portField.dataset.userModified === 'true';
// Only set default port if field is empty or hasn't been modified by user
if (isPortEmpty || !userModified) {
if (provider === 'sftp') {
portField.value = '22';
} else if (provider === 'hetzner') {
portField.value = '23';
} else if (provider === 'ftp') {
portField.value = '21';
} else if (provider === 'smb') {
portField.value = '445';
} else if (provider === 'webdav' || provider === 'nextcloud') {
portField.value = '443';
} else {
portField.value = '';
}
// Reset user modified flag if we're setting it programmatically
portField.dataset.userModified = 'false';
}
// Show the appropriate fields based on the selected provider type
console.log("Provider type selected:", provider);
// Server-based connection fields (SFTP, FTP, Hetzner)
if (['sftp', 'ftp', 'hetzner'].includes(provider)) {
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
// SFTP/Hetzner-specific fields
if (provider === 'sftp' || provider === 'hetzner') {
document.getElementById('key-file-field').classList.remove('hidden');
// Update placeholders for Hetzner
if (provider === 'hetzner') {
// Update host field
const hostField = document.getElementById('host');
if (hostField) {
hostField.placeholder = "uXXXXXX.your-storagebox.de";
// Update host label and description
const hostLabel = document.querySelector('label[for="host"]');
if (hostLabel) {
hostLabel.textContent = "Storage Box Host";
}
const hostDescription = hostField.nextElementSibling;
if (hostDescription?.tagName === 'P') {
hostDescription.textContent = "Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)";
}
}
// Update username field
const usernameField = document.getElementById('username');
if (usernameField) {
usernameField.placeholder = "uXXXXXX";
// Update username description
const usernameDescription = usernameField.nextElementSibling;
if (usernameDescription?.tagName === 'P') {
usernameDescription.textContent = "Your Hetzner Storage Box username (typically matches your Storage Box number)";
}
}
// Update key file field
const keyFileField = document.getElementById('keyFile');
if (keyFileField) {
keyFileField.placeholder = "/path/to/id_rsa";
// Update key file description
const keyFileDescription = keyFileField.nextElementSibling;
if (keyFileDescription?.tagName === 'P') {
keyFileDescription.textContent = "Path to your SSH private key file for Hetzner Storage Box authentication";
}
}
// Update port field description
const portField = document.getElementById('port');
if (portField) {
const portDescription = portField.nextElementSibling;
if (portDescription?.tagName === 'P') {
portDescription.textContent = "Connection port for Hetzner Storage Box (default: 23)";
}
}
}
}
// FTP-specific fields
if (provider === 'ftp') {
document.getElementById('passive-mode-field').classList.remove('hidden');
}
}
// WebDAV-based fields
if (['webdav', 'nextcloud'].includes(provider)) {
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
// Hide fields that WebDAV doesn't use
document.getElementById('port-field').classList.add('hidden');
// Update placeholders for WebDAV
const hostField = document.getElementById('host');
if (hostField) {
hostField.placeholder = "https://webdav.example.com";
}
// Update label for host field
const hostLabel = document.querySelector('label[for="host"]');
if (hostLabel) {
hostLabel.textContent = "WebDAV URL";
}
// Update description for host field
const hostDescription = hostField?.nextElementSibling;
if (hostDescription?.tagName === 'P') {
hostDescription.textContent = provider === 'webdav' ?
"Full URL to your WebDAV server including protocol (https://)" :
"Full URL to your Nextcloud WebDAV endpoint (e.g., https://nextcloud.example.com/remote.php/dav/files/username/)";
}
// Update username field
const usernameField = document.getElementById('username');
if (usernameField) {
usernameField.placeholder = provider === 'nextcloud' ? "nextcloud_username" : "webdav_username";
// Update username description
const usernameDescription = usernameField.nextElementSibling;
if (usernameDescription?.tagName === 'P') {
usernameDescription.textContent = provider === 'nextcloud' ?
"Your Nextcloud username for authentication" :
"Your WebDAV username for authentication";
}
}
// Update password field
const passwordField = document.getElementById('password');
if (passwordField) {
passwordField.name = "password"; // Ensure name is set correctly
// Update password description
const passwordDescription = passwordField.nextElementSibling;
if (passwordDescription?.tagName === 'P') {
// Check if we're in edit mode
const editMode = passwordDescription.textContent.includes("Leave empty to keep");
if (editMode) {
// Edit mode - tell user they can leave password empty to keep current one
passwordDescription.textContent = provider === 'nextcloud' ?
"Leave empty to keep the current Nextcloud password" :
"Leave empty to keep the current WebDAV password";
} else {
// New provider - show regular password help text
passwordDescription.textContent = provider === 'nextcloud' ?
"Your Nextcloud password or app-specific password" :
"Your WebDAV password for authentication";
}
}
}
if (document.getElementById('key-file-field')) {
document.getElementById('key-file-field').classList.add('hidden');
}
if (document.getElementById('domain-field')) {
document.getElementById('domain-field').classList.add('hidden');
}
} else {
// Reset placeholders for other providers
const hostField = document.getElementById('host');
if (hostField && ['sftp', 'ftp', 'smb'].includes(provider)) {
hostField.placeholder = provider === 'sftp' ? "sftp.example.com" :
provider === 'ftp' ? "ftp.example.com" :
"192.168.1.10";
}
// Reset label for host field
const hostLabel = document.querySelector('label[for="host"]');
if (hostLabel) {
hostLabel.textContent = "Host";
}
// Reset username field
const usernameField = document.getElementById('username');
if (usernameField) {
usernameField.placeholder = provider === 'sftp' ? "sftp_username" :
provider === 'ftp' ? "ftp_username" :
provider === 'smb' ? "smb_username" : "username";
// Reset username description
const usernameDescription = usernameField.nextElementSibling;
if (usernameDescription?.tagName === 'P') {
usernameDescription.textContent = "Your login username";
}
}
// Reset password field
const passwordField = document.getElementById('password');
if (passwordField) {
// Reset password description
const passwordDescription = passwordField.nextElementSibling;
if (passwordDescription?.tagName === 'P') {
// Check if we're in edit mode
const editMode = passwordDescription.textContent.includes("Leave empty to keep");
if (editMode) {
// Edit mode - tell user they can leave password empty to keep current one
if (provider === 'sftp') {
passwordDescription.textContent = "Leave empty to keep the current SFTP password";
} else if (provider === 'ftp') {
passwordDescription.textContent = "Leave empty to keep the current FTP password";
} else if (provider === 'smb') {
passwordDescription.textContent = "Leave empty to keep the current SMB password";
} else if (provider === 'hetzner') {
passwordDescription.textContent = "Leave empty to keep the current Hetzner Storage Box password";
} else {
passwordDescription.textContent = "Leave empty to keep the current password";
}
} else {
// New provider - show regular password help text
if (provider === 'sftp') {
passwordDescription.textContent = "Your SFTP server password";
} else if (provider === 'ftp') {
passwordDescription.textContent = "Your FTP server password";
} else if (provider === 'smb') {
passwordDescription.textContent = "Your SMB/CIFS share password";
} else if (provider === 'hetzner') {
passwordDescription.textContent = "Your Hetzner Storage Box password";
} else {
passwordDescription.textContent = "Your login password";
}
}
}
}
}
// SMB-specific fields
if (provider === 'smb') {
document.getElementById('sftp-ftp-fields').classList.remove('hidden');
document.getElementById('domain-field').classList.remove('hidden');
}
// S3-compatible storage
if (['s3', 'wasabi', 'minio', 'b2'].includes(provider)) {
document.getElementById('s3-fields').classList.remove('hidden');
// Set region and endpoint requirements based on provider type
const regionRequired = document.getElementById('region-required');
const endpointRequired = document.getElementById('endpoint-required');
const regionInput = document.getElementById('region');
const endpointInput = document.getElementById('endpoint');
// Update Secret Key field description based on provider type
const secretKeyField = document.getElementById('secretKey');
if (secretKeyField) {
const secretKeyDescription = secretKeyField.nextElementSibling;
if (secretKeyDescription?.tagName === 'P') {
// Check if in edit mode
const editMode = secretKeyDescription.textContent.includes("Leave empty to keep");
if (editMode) {
// Edit mode - provider specific text
if (provider === 'b2') {
secretKeyDescription.textContent = "Leave empty to keep the current B2 Application Key";
} else if (provider === 'wasabi') {
secretKeyDescription.textContent = "Leave empty to keep the current Wasabi Secret Key";
} else if (provider === 'minio') {
secretKeyDescription.textContent = "Leave empty to keep the current MinIO Secret Key";
} else {
secretKeyDescription.textContent = "Leave empty to keep the current AWS Secret Access Key";
}
} else {
// New provider - provider specific text
if (provider === 'b2') {
secretKeyDescription.textContent = "Your Backblaze B2 Application Key";
} else if (provider === 'wasabi') {
secretKeyDescription.textContent = "Your Wasabi Secret Key";
} else if (provider === 'minio') {
secretKeyDescription.textContent = "Your MinIO Secret Key";
} else {
secretKeyDescription.textContent = "Your AWS Secret Access Key";
}
}
}
}
// For B2: endpoint and region are optional
if (provider === 'b2') {
regionRequired.style.display = 'none';
endpointRequired.style.display = 'none';
regionInput.removeAttribute('required');
endpointInput.removeAttribute('required');
}
// For Wasabi: region is optional
else if (provider === 'wasabi') {
regionRequired.style.display = 'none';
endpointRequired.style.display = 'inline';
regionInput.removeAttribute('required');
endpointInput.setAttribute('required', 'required');
}
// For S3 and MinIO: both are required
else {
regionRequired.style.display = 'inline';
endpointRequired.style.display = 'inline';
regionInput.setAttribute('required', 'required');
endpointInput.setAttribute('required', 'required');
}
}
// Google services
if (['drive', 'gphotos'].includes(provider)) {
document.getElementById('cloud-fields').classList.remove('hidden');
document.getElementById('built-in-auth-field').classList.remove('hidden');
// Update client secret field description
const clientSecretField = document.getElementById('clientSecret');
if (clientSecretField) {
const clientSecretDescription = clientSecretField.nextElementSibling;
if (clientSecretDescription?.tagName === 'P') {
// Check if in edit mode
const editMode = clientSecretDescription.textContent.includes("Leave empty to keep");
if (editMode) {
// Edit mode - provider specific text
if (provider === 'drive') {
clientSecretDescription.textContent = "Leave empty to keep the current Google Drive client secret";
} else if (provider === 'gphotos') {
clientSecretDescription.textContent = "Leave empty to keep the current Google Photos client secret";
}
}
}
}
// Google Drive specific fields
if (provider === 'drive') {
document.getElementById('drive-id-field').classList.remove('hidden');
document.getElementById('team-drive-field').classList.remove('hidden');
}
// Google Photos specific fields
if (provider === 'gphotos') {
document.getElementById('gphotos-options-field').classList.remove('hidden');
}
}
// OneDrive
if (provider === 'onedrive') {
document.getElementById('cloud-fields').classList.remove('hidden');
// Update client secret field description
const clientSecretField = document.getElementById('clientSecret');
if (clientSecretField) {
const clientSecretDescription = clientSecretField.nextElementSibling;
if (clientSecretDescription?.tagName === 'P') {
// Check if in edit mode
const editMode = clientSecretDescription.textContent.includes("Leave empty to keep");
if (editMode) {
clientSecretDescription.textContent = "Leave empty to keep the current OneDrive client secret";
} else {
clientSecretDescription.textContent = "Your Microsoft Azure OAuth client secret";
}
}
}
}
// Local filesystem fields
if (provider === 'local') {
document.getElementById('local-fields').classList.remove('hidden');
}
}
</script>
}
// Test result dialog
templ ConnectionTestResult(success bool, message string, errorCode string) {
<div class="text-center">
if success {
<i class="fas fa-check-circle text-green-500 text-5xl mb-4"></i>
<h3 class="mb-2 text-lg font-semibold text-green-500 dark:text-green-400">Connection Successful</h3>
} else {
<i class="fas fa-times-circle text-red-500 text-5xl mb-4"></i>
<h3 class="mb-2 text-lg font-semibold text-red-500 dark:text-red-400">Connection Failed</h3>
}
<p class="text-gray-500 dark:text-gray-400 mb-4">
{ message }
</p>
if errorCode != "" {
<div class="text-sm bg-gray-100 dark:bg-gray-800 p-2 rounded">
<p class="text-gray-700 dark:text-gray-300">Error code: { errorCode }</p>
</div>
}
</div>
}
@@ -0,0 +1,169 @@
package components
import (
"context"
)
// StorageProviderGDriveHeadlessAuthData contains data needed for rendering the headless auth page for storage providers
type StorageProviderGDriveHeadlessAuthData struct {
AuthCommand string
ProviderID string
}
// StorageProviderGDriveHeadlessAuth renders the headless authentication page for Google Drive/Photos for storage providers
templ StorageProviderGDriveHeadlessAuth(ctx context.Context, data StorageProviderGDriveHeadlessAuthData) {
// Force the layout to display as authenticated content
@LayoutWithContext("Google Authentication - Headless Mode", ctx) {
<style>
/* Ensure proper styling for the headless auth page */
body.dark .auth-page {
background-color: #111827 !important;
}
</style>
<div id="auth-container" class="auth-page w-full pb-8 bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; background-color: rgb(249, 250, 251);">
<div class="max-w-4xl mx-auto">
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Headless Google Authentication
</h1>
<a href="/storage-providers" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
Back to Storage Providers
</a>
</div>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
<div class="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0 mt-0.5">
<i class="fas fa-info-circle h-5 w-5 text-blue-500"></i>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-300">
You need to authenticate with Google using a web browser. Since you're running GoMFT behind a reverse proxy or in a headless environment, you'll need to complete authentication on a machine with a web browser.
</p>
</div>
</div>
</div>
<div class="mb-8">
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 1: Run the following command on a machine with a web browser</h2>
<div class="relative mb-4">
<pre id="auth-command-text" class="bg-gray-50 dark:bg-gray-900 rounded-md p-4 overflow-x-auto text-sm font-mono">{ data.AuthCommand }</pre>
<button id="copy-command" class="absolute top-2 right-2 bg-gray-200 dark:bg-gray-700 p-1.5 rounded hover:bg-gray-300 dark:hover:bg-gray-600" title="Copy to clipboard">
<i class="fas fa-copy h-5 w-5 text-gray-700 dark:text-gray-300"></i>
</button>
</div>
<div>
<h3 class="text-md font-medium mb-2 text-gray-900 dark:text-white">What this command does:</h3>
<ul class="list-disc ml-6 text-sm text-gray-700 dark:text-gray-300 space-y-1">
<li>Opens a browser window on the machine where you run it</li>
<li>Allows you to authenticate with Google</li>
<li>Generates an authentication token</li>
</ul>
</div>
</div>
<div class="mb-6">
<h2 class="text-lg font-medium mb-3 text-gray-900 dark:text-white">Step 2: Paste the authentication token below</h2>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
After completing authentication in the browser, you'll receive a token. Copy and paste that token here:
</p>
<form action="/storage-providers/gdrive-headless-token" method="POST" class="space-y-4">
<input type="hidden" name="provider_id" value={ data.ProviderID } />
<div>
<label for="auth_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication Token</label>
<textarea
id="auth_token"
name="auth_token"
rows="5"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400"
placeholder="Paste your authentication token here..."
required
></textarea>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">The token will look like a long JSON string containing access credentials.</p>
</div>
<div class="flex items-center justify-end mt-6">
<a href="/storage-providers" class="mr-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400">
Cancel
</a>
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Submit Token
</button>
</div>
</form>
</div>
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">This authentication process is necessary for GoMFT to access your Google Drive or Google Photos account.</p>
<p class="mt-1 text-gray-600 dark:text-gray-400">The token is only used for authentication and is stored securely. You'll only need to complete this process once for each storage provider.</p>
</div>
</div>
</div>
</div>
</div>
<script>
// Set dark background color if in dark mode
document.addEventListener('DOMContentLoaded', function() {
if (document.documentElement.classList.contains('dark')) {
document.getElementById('auth-container').style.backgroundColor = '#111827';
}
// Add event listener for theme changes
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function() {
setTimeout(function() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('auth-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
}, 50);
});
}
// Store the actual command text
const actualCommand = document.getElementById('auth-command-text').textContent.trim();
// Add click handler to copy button
document.getElementById('copy-command').addEventListener('click', function() {
// Copy the actual command text, not the template variable
navigator.clipboard.writeText(actualCommand).then(function() {
// Show a success message
const button = document.getElementById('copy-command');
const originalTitle = button.getAttribute('title');
button.setAttribute('title', 'Copied!');
// Also show visual feedback
button.classList.add('bg-green-200', 'dark:bg-green-700');
button.classList.remove('bg-gray-200', 'dark:bg-gray-700');
setTimeout(function() {
button.setAttribute('title', originalTitle);
button.classList.remove('bg-green-200', 'dark:bg-green-700');
button.classList.add('bg-gray-200', 'dark:bg-gray-700');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy command. Please select and copy it manually.');
});
});
});
</script>
}
}
+728
View File
@@ -0,0 +1,728 @@
package components
import (
"context"
"fmt"
"github.com/starfleetcptn/gomft/components/shared/toast"
"github.com/starfleetcptn/gomft/internal/db"
)
type StorageProvidersData struct {
Providers []db.StorageProvider
Error string
Status string
}
// Main template for Storage Providers page
templ StorageProviders(ctx context.Context, data StorageProvidersData) {
@LayoutWithContext("Storage Providers", ctx) {
@toast.Container()
@toast.ShowToastJS()
<script>
// Handle test provider button clicks
window.testProvider = function(button) {
const providerId = button.getAttribute('data-provider-id');
const providerName = button.getAttribute('data-provider-name') || `Provider #${providerId}`;
showToast(`Testing connection to "${providerName}"...`, 'info');
button.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
showToast(`Connection to "${providerName}" successful!`, 'success');
} else {
let errorMsg = `Failed to connect to "${providerName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = error.error ? `Connection error: ${error.error}` : `Connection error: ${event.detail.xhr.responseText}`;
} catch (e) {
errorMsg = `Connection error: ${event.detail.xhr.responseText}`;
}
}
showToast(errorMsg, 'error');
}
}, { once: true });
};
// --- Duplicate Provider HTMX Event Handling (configs.templ style) ---
// Track all HTMX events for duplicate provider
document.addEventListener('htmx:beforeRequest', function(event) {
const path = event.detail.path;
const method = event.detail.verb;
if (path && method === 'POST' && path.match(/^\/storage-providers\/\d+\/duplicate$/)) {
window.isProviderDuplicateRequest = true;
// Store the provider name for toast
const providerId = path.match(/^\/storage-providers\/(\d+)\/duplicate$/)[1];
const btn = document.querySelector(`button[hx-post="/storage-providers/${providerId}/duplicate"]`);
if (btn) {
window.duplicatingProviderName = btn.getAttribute('data-provider-name') || `Provider #${providerId}`;
}
}
});
document.addEventListener('htmx:afterRequest', function(event) {
// Handle delete provider events (existing logic)
if (event.detail.pathInfo && event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/storage-providers\/\d+$/) &&
event.detail.verb === 'DELETE') {
const providerName = event.detail.elt.getAttribute('data-provider-name') || 'Provider';
if (event.detail.successful) {
showToast(`Provider "${providerName}" deleted successfully`, 'success');
} else {
let errorMsg = `Failed to delete provider "${providerName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = error.error ? error.error : `Error: ${event.detail.xhr.responseText}`;
} catch (e) {
errorMsg = `Error: ${event.detail.xhr.responseText}`;
}
}
showToast(errorMsg, 'error');
}
}
// Handle duplicate provider success
const isDuplicateRequest = window.isProviderDuplicateRequest &&
event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/storage-providers\/\d+\/duplicate$/);
if (isDuplicateRequest && event.detail.successful) {
const providerName = window.duplicatingProviderName || 'provider';
showToast(`Provider "${providerName}" duplicated successfully`, 'success');
window.isProviderDuplicateRequest = false;
window.duplicatingProviderName = null;
}
});
document.addEventListener('htmx:responseError', function(event) {
// Handle any HTMX response error
let errorMsg = "An error occurred";
let entityName = "Operation";
// Get more context about the operation that failed
if (event.detail.elt && event.detail.elt.getAttribute('data-provider-name')) {
entityName = event.detail.elt.getAttribute('data-provider-name');
}
// Handle duplicate provider errors
const isDuplicateRequest = window.isProviderDuplicateRequest &&
event.detail.pathInfo &&
event.detail.pathInfo.requestPath &&
event.detail.pathInfo.requestPath.match(/^\/storage-providers\/\d+\/duplicate$/);
if (isDuplicateRequest) {
const providerName = window.duplicatingProviderName || 'provider';
errorMsg = `Failed to duplicate provider "${providerName}"`;
if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = error.error ? error.error : errorMsg;
} catch (e) {
if (event.detail.xhr.responseText.trim()) {
errorMsg = event.detail.xhr.responseText;
}
}
}
showToast(errorMsg, 'error');
window.isProviderDuplicateRequest = false;
window.duplicatingProviderName = null;
}
// Handle other HTMX errors
else if (event.detail.xhr && event.detail.xhr.responseText) {
try {
const error = JSON.parse(event.detail.xhr.responseText);
errorMsg = error.error ? error.error : `Error during ${entityName} operation`;
} catch (e) {
if (event.detail.xhr.responseText.trim()) {
errorMsg = event.detail.xhr.responseText;
} else {
errorMsg = `Error during ${entityName} operation`;
}
}
showToast(errorMsg, 'error');
}
// Update the visual error alert for all error types
const errorAlert = document.getElementById('htmx-error-alert');
const errorMessageSpan = document.getElementById('htmx-error-message');
if (errorAlert && errorMessageSpan) {
errorMessageSpan.textContent = errorMsg;
errorAlert.classList.remove('hidden');
// Auto-hide after 10 seconds
setTimeout(() => {
errorAlert.classList.add('hidden');
}, 10000);
}
});
// Set a flag before duplicate request to show toast after reload
document.addEventListener('click', function(e) {
const btn = e.target.closest('button[data-provider-id][hx-post*="/duplicate"]');
if (btn) {
localStorage.setItem('showProviderDuplicateToast', '1');
}
});
document.addEventListener('DOMContentLoaded', function() {
// Display error from data.Error if present
const errorElement = document.getElementById('provider-error-message');
if (errorElement && errorElement.textContent.trim()) {
showToast(errorElement.textContent.trim(), 'error');
}
// Display status messages programmatically
const statusElement = document.getElementById('provider-status-message');
if (statusElement && statusElement.textContent.trim()) {
const status = statusElement.textContent.trim();
let message = '';
let type = 'info';
// Convert status to appropriate toast message
switch(status) {
case 'created':
message = 'Provider created successfully';
type = 'success';
break;
case 'updated':
message = 'Provider updated successfully';
type = 'success';
break;
case 'deleted':
message = 'Provider deleted successfully';
type = 'success';
break;
case 'duplicated':
message = 'Provider duplicated successfully';
type = 'success';
break;
default:
if (status) {
message = `Provider ${status}`;
type = 'info';
}
}
if (message) {
showToast(message, type);
}
}
// Show toasts based on localStorage or URL parameters
if (localStorage.getItem('showProviderDuplicateToast')) {
showToast('Provider duplicated successfully', 'success');
localStorage.removeItem('showProviderDuplicateToast');
}
// Process URL parameters for toast notifications
const urlParams = new URLSearchParams(window.location.search);
// Handle combined status and test_status parameters
if (urlParams.get('status') === 'created') {
const testStatus = urlParams.get('test_status');
const errorMsg = urlParams.get('error');
if (testStatus === 'success') {
showToast('Provider created and tested successfully', 'success');
} else if (testStatus === 'failed' && errorMsg) {
showToast(`Provider created but test failed: ${errorMsg}`, 'error');
} else {
showToast('Provider created successfully', 'success');
}
} else if (urlParams.get('status') === 'updated') {
const testStatus = urlParams.get('test_status');
const errorMsg = urlParams.get('error');
if (testStatus === 'success') {
showToast('Provider updated and tested successfully', 'success');
} else if (testStatus === 'failed' && errorMsg) {
showToast(`Provider updated but test failed: ${errorMsg}`, 'error');
} else {
showToast('Provider updated successfully', 'success');
}
} else if (urlParams.get('error')) {
showToast(urlParams.get('error'), 'error');
}
});
</script>
<!-- Error message holder (hidden, used to pass server-side errors to JS) -->
if data.Error != "" {
<div id="provider-error-message" class="hidden">{ data.Error }</div>
}
<!-- Status message holder (hidden, used to pass server-side status to JS) -->
if data.Status != "" {
<div id="provider-status-message" class="hidden">{ data.Status }</div>
}
<div id="providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="providers-page bg-gray-50 dark:bg-gray-900">
<!-- Providers list follows; import button is above -->
<div class="pb-8 w-full">
<!-- Display error alert if data.Error is not empty -->
if data.Error != "" {
<div class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{ data.Error }</span>
</div>
</div>
}
<!-- Dynamic error alert container for HTMX errors -->
// <div id="htmx-error-alert" class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 hidden" role="alert">
// <div class="flex items-center">
// <i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
// <span id="htmx-error-message"></span>
// <button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-red-50 text-red-500 rounded-lg focus:ring-2 focus:ring-red-400 p-1.5 hover:bg-red-200 inline-flex items-center justify-center h-8 w-8 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700" onclick="document.getElementById('htmx-error-alert').classList.add('hidden')">
// <span class="sr-only">Dismiss</span>
// <i class="fas fa-times"></i>
// </button>
// </div>
// </div>
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-server w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Storage Providers
</h1>
<!-- Import rclone config and New Provider links side by side -->
<div class="flex gap-x-4">
<a href="/storage-providers/import" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-lg shadow flex items-center">
<i class="fas fa-file-import mr-2"></i>
Import rclone config
</a>
<a href="/storage-providers/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
New Provider
</a>
</div>
</div>
<div class="mt-6">
if len(data.Providers) == 0 {
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
<i class="fas fa-server text-gray-400 dark:text-gray-500 text-3xl"></i>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No storage providers</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating a new storage provider.</p>
<a href="/storage-providers/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<i class="fas fa-plus w-4 h-4 mr-2"></i>
Create First Provider
</a>
</div>
} else {
@StorageProviders_ProvidersList(data.Providers)
}
</div>
<!-- Help Section -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-start mb-2">
<div class="flex items-center h-5">
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Storage providers define connection details to different storage systems.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-shield-alt w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Your credentials are encrypted for security. You can test connections before using them in transfers.</p>
<p class="mt-1 text-gray-600 dark:text-gray-400">Google Drive and Google Photos providers require authentication. Click the "Authenticate" button to complete setup.</p>
</div>
</div>
<div class="flex items-start mt-4">
<div class="flex items-center h-5">
<i class="fas fa-link w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
</div>
<div class="ml-2 text-sm">
<p class="text-gray-700 dark:text-gray-300">Providers can be used in multiple transfer configurations. Deleting a provider will affect any transfer that uses it.</p>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/storage-providers.js"></script>
<script>
// Function to initialize all auth dropdowns
function initAllAuthDropdowns() {
// Get all dropdown buttons
const dropdownButtons = document.querySelectorAll('[id^="auth-dropdown-button-"]');
dropdownButtons.forEach(button => {
const providerId = button.getAttribute('data-provider-id');
const menu = document.getElementById(`auth-dropdown-menu-${providerId}`);
if (button && menu) {
// Add click listener
button.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Position dropdown based on available space
const buttonRect = button.getBoundingClientRect();
const spaceRight = window.innerWidth - buttonRect.right;
const spaceLeft = buttonRect.left;
// Check if there's more space on the left or right side
if (spaceLeft > spaceRight) {
menu.classList.add('right-0');
menu.classList.remove('left-0');
} else {
menu.classList.add('left-0');
menu.classList.remove('right-0');
}
// Toggle visibility
menu.classList.toggle('hidden');
console.log(`Toggled dropdown for provider ${providerId}`);
});
console.log(`Initialized dropdown for provider ${providerId}`);
} else {
console.error(`Could not find dropdown elements for provider ${providerId}`);
}
});
// Close dropdowns when clicking elsewhere
document.addEventListener('click', function(e) {
dropdownButtons.forEach(button => {
const providerId = button.getAttribute('data-provider-id');
const menu = document.getElementById(`auth-dropdown-menu-${providerId}`);
if (menu && !button.contains(e.target) && !menu.contains(e.target)) {
menu.classList.add('hidden');
}
});
});
}
// Initialize dropdowns when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
// Check for status messages based on URL parameters
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('status') === 'gdrive_auth_success') {
showToast("Google Drive authentication completed successfully", 'success');
}
// Initialize all authentication dropdowns
initAllAuthDropdowns();
});
</script>
}
}
// Partial for just the providers-list div
// Usage: @StorageProviders_ProvidersList(providers)
templ StorageProviders_ProvidersList(providers []db.StorageProvider) {
<div id="providers-list" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
for _, provider := range providers {
<li>
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ provider.Name }
</p>
<span class="ml-2 bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
{ string(provider.Type) }
</span>
</div>
<div class="ml-2 flex-shrink-0 flex space-x-2">
<!-- Test Connection Button -->
<button
type="button"
hx-post={ fmt.Sprintf("/storage-providers/%d/test", provider.ID) }
hx-swap="none"
data-provider-id={ fmt.Sprint(provider.ID) }
data-provider-name={ provider.Name }
onclick="window.testProvider(this)"
class="test-provider-btn text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800">
<i class="fas fa-plug w-3.5 h-3.5 mr-1.5"></i>
Test
</button>
<!-- Duplicate Button -->
<button
type="button"
hx-post={ fmt.Sprintf("/storage-providers/%d/duplicate", provider.ID) }
hx-swap="outerHTML"
hx-target="#providers-list"
data-provider-id={ fmt.Sprint(provider.ID) }
data-provider-name={ provider.Name }
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800">
<i class="fas fa-clone w-3.5 h-3.5 mr-1.5"></i>
Duplicate
</button>
<!-- Edit Button -->
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d", provider.ID)) } class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
Edit
</a>
<!-- Delete Button -->
@StorageProviderDialog(
fmt.Sprintf("delete-provider-dialog-%d", provider.ID),
"Delete Provider",
fmt.Sprintf("Are you sure you want to delete the provider '%s'? This cannot be undone.", provider.Name),
"text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center",
"Delete",
"delete",
provider.ID,
provider.Name,
)
<button
type="button"
onclick={ showProviderModal(fmt.Sprintf("delete-provider-dialog-%d", provider.ID)) }
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800">
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
Delete
</button>
</div>
</div>
<div class="mt-3 sm:flex sm:justify-between">
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
<!-- Show different details based on provider type -->
if provider.Type == "local" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-folder w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Path: { provider.Host }
</p>
} else if provider.Type == "s3" || provider.Type == "wasabi" || provider.Type == "minio" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-server w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Endpoint: { provider.Host }
</p>
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-box w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Bucket: { provider.Bucket }
</p>
if provider.Region != "" {
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-globe w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Region: { provider.Region }
</p>
}
} else if provider.Type == "drive" || provider.Type == "gphotos" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fab fa-google-drive w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Google
if provider.Type == "drive" {
Drive
} else {
Photos
}
if provider.DriveID != "" {
: { provider.DriveID }
}
</p>
if provider.Authenticated != nil && *provider.Authenticated {
<span class="mt-2 md:mt-0 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
Authenticated
</span>
} else {
<div class="flex flex-col md:flex-row items-start md:items-center mt-2 md:mt-0">
<!-- Google Authentication Dropdown -->
<div class="relative inline-block text-left mt-2 md:mt-0">
<button
id={ fmt.Sprintf("auth-dropdown-button-%d", provider.ID) }
data-provider-id={ fmt.Sprintf("%d", provider.ID) }
type="button"
class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800"
aria-expanded="false"
aria-haspopup="true">
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
Authenticate with Google
<i class="fas fa-chevron-down w-3.5 h-3.5 ml-1.5"></i>
</button>
<div id={ fmt.Sprintf("auth-dropdown-menu-%d", provider.ID) } class="origin-top-right absolute left-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-700 ring-1 ring-black ring-opacity-5 focus:outline-none z-50 hidden" style="max-height: 200px; overflow-y: auto;" role="menu" aria-orientation="vertical" aria-labelledby={ fmt.Sprintf("auth-dropdown-button-%d", provider.ID) }>
<div class="py-1" role="none">
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 group flex items-center px-4 py-2 text-sm" role="menuitem">
<i class="fas fa-globe w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"></i>
Standard Authentication
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 group flex items-center px-4 py-2 text-sm" role="menuitem">
<i class="fas fa-terminal w-4 h-4 mr-3 text-gray-500 dark:text-gray-400"></i>
Headless Authentication
</a>
</div>
</div>
</div>
<!-- Hidden fallback links - only shown when JavaScript is disabled -->
<noscript>
<div class="flex flex-col text-xs text-gray-500 dark:text-gray-400 mt-1 ml-1">
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="hover:underline hover:text-blue-500">
Direct Standard Auth
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="hover:underline hover:text-blue-500">
Direct Headless Auth
</a>
</div>
</noscript>
</div>
}
} else if provider.Type == "webdav" || provider.Type == "nextcloud" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-cloud w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Server: { provider.Host }
</p>
if provider.Username != "" {
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-user w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
User: { provider.Username }
</p>
}
} else if provider.Type == "sftp" || provider.Type == "hetzner" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-server w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Host: { provider.Host }
if provider.Port > 0 {
:{ fmt.Sprint(provider.Port) }
}
</p>
if provider.Username != "" {
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-user w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
User: { provider.Username }
</p>
}
} else if provider.Type == "b2" {
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-cloud w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Backblaze B2
</p>
if provider.Bucket != "" {
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-box w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Bucket: { provider.Bucket }
</p>
}
} else {
<!-- Default display for other provider types -->
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="fas fa-server w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
Host: { provider.Host }
if provider.Port > 0 {
:{ fmt.Sprint(provider.Port) }
}
</p>
}
</div>
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
<p>
Updated: { provider.UpdatedAt.Format("2006-01-02 15:04:05") }
</p>
</div>
</div>
</div>
<!-- Authentication Notice (More Visible) -->
if (provider.Type == "drive" || provider.Type == "gphotos") && (provider.Authenticated == nil || !*provider.Authenticated) {
<div class="mt-3 flex items-center justify-between bg-yellow-50 dark:bg-yellow-900/30 rounded-lg p-3 border border-yellow-200 dark:border-yellow-800">
<div class="flex items-center">
<i class="fas fa-exclamation-triangle text-yellow-500 w-5 h-5 mr-2"></i>
<span class="text-sm text-yellow-700 dark:text-yellow-300">
Authentication required for Google
if provider.Type == "drive" {
Drive
} else {
Photos
}
</span>
</div>
<div class="flex gap-2">
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-auth", provider.ID)) } class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-lg">
<i class="fas fa-globe w-3.5 h-3.5 mr-1.5"></i>
Standard Auth
</a>
<a href={ templ.SafeURL(fmt.Sprintf("/storage-providers/%d/gdrive-headless-auth", provider.ID)) } class="text-sm font-medium text-white bg-green-600 hover:bg-green-700 px-3 py-1.5 rounded-lg">
<i class="fas fa-terminal w-3.5 h-3.5 mr-1.5"></i>
Headless Auth
</a>
</div>
</div>
}
</div>
</li>
}
</ul>
</div>
}
// Dialog component for confirmation dialogs for storage providers
// Usage: @StorageProviderDialog(id, title, message, confirmClass, confirmText, action, providerID, providerName)
templ StorageProviderDialog(id string, title string, message string, confirmClass string, confirmText string, action string, providerID uint, providerName string) {
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
<!-- Backdrop -->
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
<!-- Modal content -->
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<div class="p-6 text-center">
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
<button
type="button"
class={ confirmClass }
hx-delete={ fmt.Sprintf("/storage-providers/%d", providerID) }
hx-target="closest li"
hx-swap="delete"
data-provider-name={ providerName }
data-provider-id={ fmt.Sprint(providerID) }
id={ fmt.Sprintf("delete-provider-btn-%d", providerID) }
onclick={ triggerProviderDelete(id, providerID, providerName) }>
{ confirmText }
</button>
<button type="button" onclick={ closeProviderModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
Cancel
</button>
</div>
</div>
</div>
</div>
}
script closeProviderModal(id string) {
const modal = document.getElementById(id);
const backdrop = document.getElementById(id + '-backdrop');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
if (backdrop) {
backdrop.remove();
}
document.body.style.overflow = '';
}
script showProviderModal(id string) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
}
}
script triggerProviderDelete(dialogId string, providerID uint, providerName string) {
// Hide the dialog
document.getElementById(dialogId).classList.add("hidden");
document.getElementById(dialogId).classList.remove("flex");
// Store data for event handlers
window.lastDeletedProvider = {
id: providerID,
name: providerName
};
window.currentlyDeletingProvider = true;
}
+437
View File
@@ -0,0 +1,437 @@
package components
import (
"context"
"fmt"
)
// Storage Providers Import Page
// User uploads a config, previews/edit remotes, selects which to import
// On submit, POSTs selected remotes to /storage-providers/import/confirm
// Main import page template
templ StorageProvidersImport(ctx context.Context, preview RcloneImportPreview) {
<div id="providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="providers-page bg-gray-50 dark:bg-gray-900 pb-8 w-full">
<!-- Header with back button -->
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-import w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
Import rclone Config
</h1>
<a href="/storage-providers" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-white focus:outline-none dark:focus:ring-gray-800">
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
Back to Providers
</a>
</div>
<!-- Main content -->
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-3">Import from rclone Config</h2>
<p class="text-gray-700 dark:text-gray-300 mb-4">
Upload your rclone configuration file to import storage providers. You'll be able to preview and select which remotes to import, and add any missing credentials.
</p>
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-gray-800 dark:text-blue-400" role="alert">
<div class="flex">
<i class="fas fa-info-circle flex-shrink-0 inline w-5 h-5 mr-3 mt-0.5"></i>
<div>
<span class="font-medium">Instructions:</span>
<ul class="mt-1.5 ml-4 list-disc">
<li>Upload your <code>rclone.conf</code> file (usually found in <code>~/.config/rclone/</code> or <code>%USERPROFILE%\.config\rclone\</code>)</li>
<li>Review the detected remotes and select which ones to import</li>
<li>Add any missing credentials that may not be in your config file</li>
<li>Use the "Add Custom Field" button to add any provider-specific options</li>
</ul>
<div class="mt-2">
<a href="https://rclone.org/docs/" target="_blank" class="text-blue-600 dark:text-blue-500 underline hover:no-underline">rclone documentation</a> |
<button type="button" id="show-common-values" class="text-blue-600 dark:text-blue-500 underline hover:no-underline">Show common configuration values</button>
</div>
</div>
</div>
</div>
<!-- Common values reference (hidden by default) -->
<div id="common-values-reference" class="hidden p-4 mb-4 text-sm text-gray-800 rounded-lg bg-gray-50 dark:bg-gray-800 dark:text-gray-300 border border-gray-200 dark:border-gray-700">
<h3 class="font-medium text-base mb-2">Common Configuration Values by Provider Type</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="font-medium mb-1">SFTP</h4>
<ul class="ml-4 list-disc">
<li><code>host</code>: Server hostname or IP</li>
<li><code>user</code>: Username</li>
<li><code>pass</code>: Password (if not using key)</li>
<li><code>port</code>: SSH port (default: 22)</li>
<li><code>key_file</code>: Path to private key</li>
</ul>
</div>
<div>
<h4 class="font-medium mb-1">S3 / Wasabi / Minio</h4>
<ul class="ml-4 list-disc">
<li><code>access_key_id</code>: Access key</li>
<li><code>secret_access_key</code>: Secret key</li>
<li><code>region</code>: Region name</li>
<li><code>endpoint</code>: Custom endpoint URL</li>
<li><code>bucket</code>: Bucket name</li>
</ul>
</div>
<div>
<h4 class="font-medium mb-1">FTP</h4>
<ul class="ml-4 list-disc">
<li><code>host</code>: Server hostname or IP</li>
<li><code>user</code>: Username</li>
<li><code>pass</code>: Password</li>
<li><code>port</code>: FTP port (default: 21)</li>
<li><code>tls</code>: Use FTPS (true/false)</li>
</ul>
</div>
<div>
<h4 class="font-medium mb-1">Google Drive</h4>
<ul class="ml-4 list-disc">
<li><code>client_id</code>: OAuth client ID</li>
<li><code>client_secret</code>: OAuth client secret</li>
<li><code>refresh_token</code>: OAuth refresh token</li>
<li><code>team_drive</code>: Team Drive ID (optional)</li>
</ul>
</div>
<div>
<h4 class="font-medium mb-1">OneDrive</h4>
<ul class="ml-4 list-disc">
<li><code>client_id</code>: OAuth client ID</li>
<li><code>client_secret</code>: OAuth client secret</li>
<li><code>refresh_token</code>: OAuth refresh token</li>
<li><code>drive_id</code>: Drive ID (optional)</li>
</ul>
</div>
<div>
<h4 class="font-medium mb-1">WebDAV / Nextcloud</h4>
<ul class="ml-4 list-disc">
<li><code>url</code>: WebDAV URL</li>
<li><code>user</code>: Username</li>
<li><code>pass</code>: Password</li>
<li><code>vendor</code>: nextcloud/owncloud/etc</li>
</ul>
</div>
</div>
<button type="button" id="hide-common-values" class="mt-3 text-blue-600 dark:text-blue-500 underline hover:no-underline">Hide reference</button>
</div>
<form id="rclone-upload-form" enctype="multipart/form-data" method="POST" action="/storage-providers/import/preview" hx-post="/storage-providers/import/preview" hx-target="#import-preview" hx-swap="innerHTML" class="mt-4">
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Select rclone config file:</label>
<input type="file" name="rclone_config" accept=".conf,.txt,.ini,.cfg" required class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400" />
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Accepted formats: .conf, .txt, .ini, .cfg</p>
</div>
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
<i class="fas fa-search mr-2"></i>
Preview Remotes
</button>
</form>
</div>
<div id="import-preview" class="mt-6">
@RcloneImportPreviewContent(ctx, preview)
</div>
<div id="import-result" class="mt-6"></div>
</div>
</div>
}
// Just the preview content for HTMX updates
templ RcloneImportPreviewContent(ctx context.Context, preview RcloneImportPreview) {
if preview.Error != "" {
<div class="mb-4 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400" role="alert">
<div class="flex items-center">
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
<span>{preview.Error}</span>
</div>
</div>
} else if preview.Remotes != nil && len(preview.Remotes) > 0 {
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Found {fmt.Sprintf("%d", len(preview.Remotes))} remotes</h3>
<p class="text-gray-700 dark:text-gray-300 mb-4">Select which remotes to import and edit their details if needed.</p>
</div>
<form id="confirm-import-form" method="POST" action="/storage-providers/import/confirm" hx-post="/storage-providers/import/confirm" hx-target="#import-result" hx-swap="innerHTML">
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
<div class="flex items-center">
<input type="checkbox" checked class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" id="select-all-checkbox" onclick="toggleAllCheckboxes(this)" />
<label for="select-all-checkbox" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">Import?</label>
</div>
</th>
<th scope="col" class="px-6 py-3">Name</th>
<th scope="col" class="px-6 py-3">Type</th>
<th scope="col" class="px-6 py-3">Fields</th>
</tr>
</thead>
<tbody>
if len(preview.Remotes) > 0 {
for _, remote := range preview.Remotes {
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4">
<input type="checkbox" name={"import_" + remote.Name} checked class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
</td>
<td class="px-6 py-4">
<input type="text" name={"name_" + remote.Name} value={remote.Name} class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</td>
<td class="px-6 py-4">
<input type="text" name={"type_" + remote.Name} value={remote.Type} class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</td>
<td class="px-6 py-4">
<div class="space-y-2">
<!-- Existing fields from rclone config -->
for k, v := range remote.Fields {
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">{k}:</span>
<input type="text" name={"field_" + remote.Name + "_" + k} value={v} class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
}
<!-- Add missing credentials section -->
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="text-sm font-medium text-gray-900 dark:text-white mb-2">Add or Update Credentials</div>
<!-- Username field (if not present) -->
if _, exists := remote.Fields["user"]; !exists && (remote.Type == "sftp" || remote.Type == "ftp" || remote.Type == "smb" || remote.Type == "webdav") {
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">user:</span>
<input type="text" name={"field_" + remote.Name + "_user"} placeholder="Username" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
}
<!-- Password field (if not present) -->
if _, exists := remote.Fields["pass"]; !exists && (remote.Type == "sftp" || remote.Type == "ftp" || remote.Type == "smb" || remote.Type == "webdav") {
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">pass:</span>
<input type="password" name={"field_" + remote.Name + "_pass"} placeholder="Password" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
}
<!-- S3 credentials -->
if remote.Type == "s3" || remote.Type == "wasabi" || remote.Type == "minio" || remote.Type == "b2" {
<!-- Access Key -->
if _, exists := remote.Fields["access_key_id"]; !exists {
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">access_key_id:</span>
<input type="text" name={"field_" + remote.Name + "_access_key_id"} placeholder="Access Key" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
}
<!-- Secret Key -->
if _, exists := remote.Fields["secret_access_key"]; !exists {
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">secret_access_key:</span>
<input type="password" name={"field_" + remote.Name + "_secret_access_key"} placeholder="Secret Key" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
}
}
<!-- OAuth credentials -->
if remote.Type == "drive" || remote.Type == "onedrive" || remote.Type == "gphotos" {
<!-- Client ID -->
if _, exists := remote.Fields["client_id"]; !exists {
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">client_id:</span>
<input type="text" name={"field_" + remote.Name + "_client_id"} placeholder="Client ID" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
}
<!-- Client Secret -->
if _, exists := remote.Fields["client_secret"]; !exists {
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300 min-w-[80px]">client_secret:</span>
<input type="password" name={"field_" + remote.Name + "_client_secret"} placeholder="Client Secret" class="bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
</div>
}
}
<!-- Add custom field button -->
<div class="mt-2">
<button type="button" id={"add-field-btn-" + remote.Name} class="text-xs text-blue-700 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
<i class="fas fa-plus mr-1"></i> Add Custom Field
</button>
</div>
<!-- Container for dynamically added custom fields -->
<div id={"custom-fields-" + remote.Name} class="mt-2"></div>
</div>
</div>
</td>
</tr>
}
} else {
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">No remotes found in config.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="mt-6">
<button type="submit" class="text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-green-600 dark:hover:bg-green-700 focus:outline-none dark:focus:ring-green-800">
<i class="fas fa-file-import mr-2"></i>
Import Selected Remotes
</button>
<a href="/storage-providers" class="ml-2 text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">Cancel</a>
</div>
<script>
function toggleAllCheckboxes(source) {
const checkboxes = document.querySelectorAll('input[type="checkbox"][name^="import_"]');
for (let i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = source.checked;
}
}
function addCustomField(remoteName) {
console.log('Adding custom field for remote:', remoteName);
const container = document.getElementById('custom-fields-' + remoteName);
if (!container) {
console.error('Container not found for remote:', remoteName);
return;
}
const fieldCount = container.children.length;
const fieldId = 'custom-field-' + remoteName + '-' + fieldCount;
const fieldRow = document.createElement('div');
fieldRow.className = 'flex items-center gap-2 mt-2';
fieldRow.id = fieldId;
// Create key input
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.className = 'bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 w-1/3 p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500';
keyInput.placeholder = 'Field name';
keyInput.id = 'custom-key-' + remoteName + '-' + fieldCount;
keyInput.onchange = function() {
const valueInput = document.getElementById('custom-value-' + remoteName + '-' + fieldCount);
if (valueInput && this.value) {
valueInput.name = 'field_' + remoteName + '_' + this.value;
}
};
// Create value input
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.className = 'bg-gray-50 border border-gray-300 text-gray-900 text-xs rounded-lg focus:ring-blue-500 focus:border-blue-500 w-2/3 p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500';
valueInput.placeholder = 'Value';
valueInput.id = 'custom-value-' + remoteName + '-' + fieldCount;
// Name will be set when key changes
// Create remove button
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300';
removeBtn.innerHTML = '<i class="fas fa-times"></i>';
removeBtn.onclick = function() {
document.getElementById(fieldId).remove();
};
// Add elements to the row
fieldRow.appendChild(keyInput);
fieldRow.appendChild(valueInput);
fieldRow.appendChild(removeBtn);
// Add row to container
container.appendChild(fieldRow);
// Focus on the new key input
keyInput.focus();
}
// Initialize the buttons directly instead of using DOMContentLoaded
function initializeCustomFieldButtons() {
console.log('Initializing custom field buttons');
const addFieldButtons = document.querySelectorAll('[id^="add-field-btn-"]');
console.log('Found buttons:', addFieldButtons.length);
// Add click event listeners to each button
addFieldButtons.forEach(function(button) {
const remoteName = button.id.replace('add-field-btn-', '');
console.log('Adding listener for remote:', remoteName);
button.addEventListener('click', function() {
console.log('Button clicked for remote:', remoteName);
addCustomField(remoteName);
});
});
}
// Try both approaches for maximum compatibility
// 1. Initialize immediately if document is already loaded
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(initializeCustomFieldButtons, 1);
}
// 2. Also listen for DOMContentLoaded
document.addEventListener('DOMContentLoaded', initializeCustomFieldButtons);
// 3. Also initialize when the form is loaded via HTMX
document.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'import-preview') {
console.log('HTMX content loaded, initializing buttons');
setTimeout(initializeCustomFieldButtons, 1);
}
});
// 4. Toggle common values reference
function initializeReferenceToggle() {
console.log('Initializing reference toggle buttons');
const showBtn = document.getElementById('show-common-values');
const hideBtn = document.getElementById('hide-common-values');
const reference = document.getElementById('common-values-reference');
if (showBtn && hideBtn && reference) {
console.log('Found reference toggle elements');
// Remove any existing listeners to prevent duplicates
showBtn.removeEventListener('click', showReference);
hideBtn.removeEventListener('click', hideReference);
// Add new listeners
showBtn.addEventListener('click', showReference);
hideBtn.addEventListener('click', hideReference);
// Define the functions
function showReference() {
console.log('Showing reference');
reference.classList.remove('hidden');
}
function hideReference() {
console.log('Hiding reference');
reference.classList.add('hidden');
}
} else {
console.log('Reference toggle elements not found');
}
}
// Initialize on DOMContentLoaded
document.addEventListener('DOMContentLoaded', initializeReferenceToggle);
// Also initialize on page load
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(initializeReferenceToggle, 1);
}
// Also initialize when content is loaded via HTMX
document.addEventListener('htmx:afterSwap', function(event) {
console.log('HTMX content swapped, target:', event.detail.target.id);
setTimeout(initializeReferenceToggle, 1);
});
</script>
</form>
} else {
<div class="flex p-4 mb-4 text-sm text-gray-800 border border-gray-300 rounded-lg bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600" role="alert">
<i class="fas fa-info-circle flex-shrink-0 inline w-5 h-5 mr-3"></i>
<span class="sr-only">Info</span>
<div>
Upload a config file to preview rclone remotes for import.
</div>
</div>
}
}
@@ -0,0 +1,12 @@
package components
import (
"context"
)
// StorageProvidersImportPage wraps the import component with the layout
templ StorageProvidersImportPage(ctx context.Context, preview RcloneImportPreview) {
@LayoutWithContext("Import rclone Config", ctx) {
@StorageProvidersImport(ctx, preview)
}
}
+15
View File
@@ -0,0 +1,15 @@
package components
templ TestResult(success bool, message string) {
if success {
<div class="text-green-600 dark:text-green-400 flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span>{ message }</span>
</div>
} else {
<div class="text-red-600 dark:text-red-400 flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>
<span>{ message }</span>
</div>
}
}
+23
View File
@@ -0,0 +1,23 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Keep the screenshots directory README
!/static/screenshots/README.md
+47
View File
@@ -0,0 +1,47 @@
# GoMFT Documentation
This repository contains the documentation for [GoMFT](https://github.com/StarFleetCPTN/GoMFT), a modern, web-based managed file transfer solution written in Go.
[![Discord](https://img.shields.io/discord/1351354052654403675?color=7289da&logo=discord&logoColor=white&label=Discord)](https://discord.gg/f9dwtM3j)
## Getting Started
### Installation
```bash
# Install dependencies
npm install
# Start the development server
npm run start
```
### Build
```bash
# Build the static site
npm run build
```
The built files will be in the `build` directory.
## Documentation Structure
- **Introduction**: Overview and features of GoMFT
- **Getting Started**: Installation guides and quick start
- **Core Concepts**: Detailed information about transfers, connections, and schedules
- **Advanced Features**: Webhooks, email notifications, and admin tools
- **Security**: Security best practices and configuration
- **Development**: Project structure and contributing guidelines
## Contributing
Contributions to the documentation are welcome! Please submit a PR with your changes.
## Community
Join our [Discord community](https://discord.gg/f9dwtM3j) for support, discussions, and updates about GoMFT.
## License
This documentation is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+176
View File
@@ -0,0 +1,176 @@
---
sidebar_position: 3
title: Admin Tools
---
# Admin Tools
GoMFT provides a comprehensive set of administrative tools for system management, monitoring, and maintenance. These tools help administrators maintain the system, troubleshoot issues, and ensure optimal performance.
## Log Viewer
The Admin Tools panel includes an integrated log viewer with the following features:
### Features
- **Log File Browser**: View a list of all available log files in the system
- **Real-time Log Viewing**: View log file contents directly in the web interface
- **Refresh Function**: Update the log list and content with the latest information
- **User-friendly Interface**: Clean, readable presentation with custom scrolling
- **Dark Mode Support**: Consistent theming with the rest of the application
- **Navigation**: Easily switch between different log files
### Available Logs
- **Application Logs**: General application logs
- **Transfer Logs**: Detailed logs of file transfer operations
- **Authentication Logs**: Login attempts and authentication events
- **Scheduler Logs**: Information about scheduled job execution
- **API Logs**: API usage and requests
- **Webhook Logs**: Records of webhook delivery attempts
- **Email Logs**: Email sending attempts and errors
### Using the Log Viewer
1. Select a log category from the dropdown menu
2. Choose a specific log file from the list
3. View the log content in the main panel
4. Use the search function to find specific text
5. Click **Refresh** to update with the latest entries
## Database Management
The Admin Tools interface also includes database management capabilities:
### Backup Management
- **Create Backup**: Generate a backup of the GoMFT database
- **Schedule Backups**: Configure automatic backup schedules
- **View Backups**: List all available backups with their dates and sizes
- **Download Backup**: Download a backup file for safekeeping
- **Restore Backup**: Restore the system from a previous backup
### Database Operations
- **Optimize Database**: Run maintenance tasks to optimize performance
- **Check Integrity**: Verify database integrity and identify issues
- **Vacuum Database**: Reclaim unused space in the database
- **View Statistics**: Get database size and table statistics
## System Information
The System Information panel provides a comprehensive overview of your GoMFT installation:
### System Stats
- **Version Information**: Current GoMFT version and build details
- **System Resources**: CPU, memory, and disk usage
- **Uptime**: System uptime and start time
- **Active Transfers**: Currently running transfers
- **Queued Transfers**: Transfers waiting to be executed
- **Database Size**: Current size of the database
### Health Checks
- **Service Status**: Status of all system services
- **Storage Space**: Available space in data directories
- **Connection Tests**: Tests for external services like SMTP
- **Rclone Status**: Verify rclone availability and version
## User Management
Administrators can manage user accounts and permissions:
### User Operations
- **Create User**: Add new users to the system
- **Edit User**: Modify existing user details and permissions
- **Deactivate User**: Temporarily disable user accounts
- **Delete User**: Permanently remove a user account
- **Reset Password**: Force password reset for a user
### Role Management
- **View Roles**: List all available roles and their permissions
- **Create Role**: Define custom roles with specific permissions
- **Edit Role**: Modify permissions for existing roles
- **Assign Roles**: Change role assignments for users
## System Settings
The System Settings section allows customization of various system parameters:
### General Settings
- **System Name**: Customize the application name
- **Base URL**: Set the base URL for the application
- **Time Zone**: Configure the system time zone
- **Date Format**: Set the preferred date and time format
- **Default Language**: Set the default interface language
### Security Settings
- **Password Policy**: Configure password complexity requirements
- **Session Timeout**: Set the inactive session timeout period
- **Failed Login Limit**: Set thresholds for account lockouts
- **API Token Management**: Configure API token policies
### Email Settings
- **SMTP Configuration**: Set up the mail server for notifications
- **Email Templates**: Customize notification email templates
- **Notification Rules**: Configure default notification settings
### Transfer Settings
- **Concurrency Limits**: Set maximum simultaneous transfers
- **Bandwidth Limits**: Configure default bandwidth limitations
- **Temporary Storage**: Configure temp directory for transfers
- **Transfer Timeouts**: Set default timeouts for transfers
## Maintenance Mode
Administrators can put the system into maintenance mode when needed:
<!-- ### Maintenance Options
- **Enable Maintenance Mode**: Temporarily restrict access to admin users
- **Scheduled Maintenance**: Schedule maintenance windows
- **Maintenance Message**: Customize the message shown to users
- **Allow Specific IPs**: Allow specific IP addresses during maintenance -->
## Import/Export
The system provides facilities for importing and exporting configuration:
### Import/Export Features
- **Export Configurations**: Export transfer configurations as JSON
- **Import Configurations**: Import configurations from JSON files
- **Migrate Settings**: Move settings between GoMFT instances
- **Bulk Operations**: Perform operations on multiple items
## Audit Logs
For security and compliance, GoMFT maintains comprehensive audit logs:
### Audit Log Features
- **User Actions**: Records of all user-initiated actions
- **System Events**: Important system-level events
- **Authentication Events**: Login, logout, and access attempts
- **Configuration Changes**: Changes to system configuration
- **Filtering**: Filter logs by user, action type, and date range
- **Export**: Export audit logs for compliance reporting
<!-- ## Troubleshooting Tools
The Admin Tools includes several utilities for troubleshooting:
### Troubleshooting Features
- **Test Connections**: Verify connectivity to remote systems
- **Check File Permissions**: Test access to file systems
- **Debug Mode**: Enable additional logging for troubleshooting
- **Transfer Simulation**: Test transfers without moving data
- **System Check**: Run a comprehensive system check -->
+278
View File
@@ -0,0 +1,278 @@
---
sidebar_position: 8
title: Command Line Tools
---
GoMFT provides a command line tool called `gomftctl` that allows administrators to perform various management tasks without using the web interface. This tool is particularly useful for automation, scripting, and performing administrative tasks in environments where the web UI is not accessible.
## Installation
The `gomftctl` tool is included with your GoMFT installation. You can find it in the root directory of your GoMFT installation.
If you need to build it manually, you can do so with:
```bash
cd /path/to/gomft
go build -o gomftctl ./cmd/gomftctl
```
### Using with Docker
If you're running GoMFT in a Docker container, the `gomftctl` tool is already included in the container. You can run it using the `docker exec` command:
```bash
# Replace gomft-container with your actual container name
docker exec -it gomft-container /app/gomftctl [command] [options]
```
For example, to view the version information:
```bash
docker exec -it gomft-container /app/gomftctl version
```
For commands that require stopping the application first (like key rotation), you'll need to:
1. Stop the container
2. Run the command in a new container using the same volumes
3. Restart the original container
```bash
# Stop the container
docker stop gomft-container
# Run a command using the same volumes
docker run --rm -v gomft_data:/app/data -v gomft_backups:/app/backups gomft/gomft:latest /app/gomftctl [command] [options]
# Restart the container
docker start gomft-container
```
## Available Commands
The `gomftctl` tool provides the following commands:
### Provider Data Migration
Migrate provider data from older versions of GoMFT to the new storage provider model:
```bash
./gomftctl migrate-providers [--dry-run] [--validate-only] [--force] [--backup-dir PATH] [--debug] [--auto-fill]
```
Options:
- `--dry-run`: Simulate migration without making changes
- `--validate-only`: Only validate if migration is possible without making changes
- `--force`: Force migration even if validation fails
- `--backup-dir`: Directory to store backup data (defaults to config backup_dir)
- `--debug`: Enable debug mode with more detailed error messages
- `--auto-fill`: Automatically fill missing required fields with placeholder values
### Security Key Rotation
Generate new security keys for the application:
```bash
./gomftctl rotate-key --type [jwt|totp|encryption] [--write]
```
Options:
- `--type`: Type of key to rotate (required)
- `jwt`: JSON Web Token signing key
- `totp`: TOTP encryption key
- `encryption`: General encryption key used for sensitive data
- `--write`: Write the new key directly to .env file (otherwise just displays the key)
### Encryption Key Rotation
Rotate encryption keys for sensitive data stored in the database:
```bash
./gomftctl rotate-encryption-key [--dry-run] [--batch-size SIZE] [--max-errors NUM] [--backup-dir PATH] [--skip-backup] [--old-key-env VAR] [--models MODE]
```
This command will:
1. Create a backup of your database (unless `--skip-backup` is specified)
2. Re-encrypt all sensitive data with a new encryption key
3. Provide instructions for updating your configuration
**Important**: The application must be stopped before running this command to prevent data corruption.
Options:
- `--dry-run`: Simulate key rotation without making changes
- `--batch-size`: Number of records to process in each batch (default 100)
- `--max-errors`: Maximum number of errors before aborting (default 50)
- `--backup-dir`: Directory to store backup data (defaults to config backup_dir)
- `--skip-backup`: Skip database backup (not recommended)
- `--old-key-env`: Environment variable containing the old encryption key (defaults to GOMFT_ENCRYPTION_KEY)
- `--models`: Models to process (use 'auto' for automatic detection, default 'auto')
### Database Backup
Create a backup of the GoMFT database and configuration:
```bash
./gomftctl backup [--output-dir PATH]
```
Options:
- `--output-dir`: Directory to store backup files (defaults to config backup_dir)
### User Management
Commands for managing GoMFT users:
#### Create a new user
```bash
./gomftctl user create --email EMAIL --password PASSWORD [--admin]
```
Options:
- `--email`: User email address (required)
- `--password`: User password (required)
- `--admin`: Grant admin privileges to the user
#### Reset a user's password
```bash
./gomftctl user reset-password --email EMAIL --password PASSWORD
```
Options:
- `--email`: User email address (required)
- `--password`: New password (required)
#### List all users
```bash
./gomftctl user list
```
### Version Information
Display version information:
```bash
./gomftctl version
```
## Examples
### Migrating Provider Data
To migrate provider data with a dry run first:
```bash
# First do a dry run to see what would happen
./gomftctl migrate-providers --dry-run
# Then run the actual migration
./gomftctl migrate-providers
```
If you encounter errors due to missing required fields, you can use the auto-fill option:
```bash
# Migrate with auto-fill to handle missing required fields
./gomftctl migrate-providers --auto-fill
# For more detailed error information, add the debug flag
./gomftctl migrate-providers --auto-fill --debug
```
When using `--auto-fill`, the system will:
1. Automatically supply placeholder values for missing required fields
2. Mark providers with "[AUTO-FILLED]" in their names
3. Log warnings about which fields were auto-filled
4. Allow you to update the correct values after migration
### Rotating JWT Secret Key
To rotate the JWT secret key and update the .env file:
```bash
./gomftctl rotate-key --type jwt --write
```
### Rotating Encryption Key for Sensitive Data
To rotate the encryption key used for sensitive data in the database:
```bash
# First stop the GoMFT application
systemctl stop gomft
# Run a dry run to see what would be affected
./gomftctl rotate-encryption-key --dry-run
# Perform the actual key rotation
./gomftctl rotate-encryption-key
# Update your environment variable or .env file with the new key
# Then restart the application
systemctl start gomft
```
#### With Docker
To rotate encryption keys when running GoMFT in Docker:
```bash
# Stop the container
docker stop gomft-container
# Run a dry run to see what would be affected
docker run --rm -v gomft_data:/app/data -v gomft_backups:/app/backups gomft/gomft:latest /app/gomftctl rotate-encryption-key --dry-run
# Perform the actual key rotation
docker run --rm -v gomft_data:/app/data -v gomft_backups:/app/backups gomft/gomft:latest /app/gomftctl rotate-encryption-key
# Update your environment variables in your docker-compose.yml or run command
# Then restart the container
docker start gomft-container
```
### Creating an Admin User
To create a new admin user:
```bash
./gomftctl user create --email admin@example.com --password secure_password --admin
```
### Backing Up the Database
To create a backup of the database:
```bash
./gomftctl backup --output-dir /path/to/backup/directory
```
## Using in Scripts
The `gomftctl` tool is designed to be used in scripts and automation. For example, you could create a cron job to backup the database daily:
```bash
# Add to crontab
0 2 * * * /path/to/gomft/gomftctl backup --output-dir /path/to/backup/directory
```
Or you could create a script to rotate security keys periodically:
```bash
#!/bin/bash
# Stop the GoMFT service
systemctl stop gomft
# Rotate all security keys
/path/to/gomft/gomftctl rotate-key --type jwt --write
/path/to/gomft/gomftctl rotate-key --type totp --write
/path/to/gomft/gomftctl rotate-key --type encryption --write
# Rotate encryption key for sensitive data in the database
/path/to/gomft/gomftctl rotate-encryption-key
# Restart the GoMFT service to apply changes
systemctl restart gomft
```
+102
View File
@@ -0,0 +1,102 @@
---
sidebar_position: 3
title: Gotify Notifications
---
# Gotify Notifications
Gotify is a simple server for sending and receiving push notifications. GoMFT integrates with Gotify to provide real-time notifications for transfer events and system alerts.
## Overview
Gotify integration allows GoMFT to:
- Send push notifications to your self-hosted Gotify server
- Customize notification priority based on event importance
- Include detailed transfer information in notifications
- Support private and secure notification delivery
## Prerequisites
Before setting up Gotify notifications in GoMFT, you need:
1. A running Gotify server (self-hosted)
2. An application token from your Gotify server
3. Network connectivity between GoMFT and the Gotify server
## Configuration
### Global Gotify Settings
To configure Gotify notifications:
1. Navigate to **Settings** > **Notification Services** > **Add New** > **Gotify**
2. Configure the following settings:
- **Gotify Server URL**: The URL of your Gotify server (e.g., `https://gotify.example.com`)
- **Application Token**: The token for your GoMFT application in Gotify
- **Default Priority**: The default priority level for notifications (1-10)
- **Verify SSL**: Whether to verify SSL certificates (recommended for production)
### Testing Gotify Connection
After configuring your Gotify settings:
1. Click **Test Connection** to verify connectivity with your Gotify server
2. Click **Send Test Notification** to send a test message
## Notification Content
### Priority Levels
Gotify uses numeric priority levels that GoMFT leverages for different event types:
| Priority | Usage in GoMFT |
|----------|----------------|
| 1-3 | Low priority: successful transfers, routine events |
| 4-7 | Medium priority: warnings, transfers with issues |
| 8-10 | High priority: failed transfers, critical system issues |
### Example Notifications
GoMFT sends structured notifications with helpful information:
#### Successful Transfer
```
Title: Transfer Completed: Daily Backup
Message: Successfully transferred 123 files (1.45 GB) in 2:15
Priority: 3
```
#### Failed Transfer
```
Title: Transfer Failed: Daily Backup
Message: Error: Connection refused to destination server
Files processed: 45/123
Size transferred: 0.5/1.45 GB
Priority: 8
```
## Troubleshooting
### Common Issues
- **Connection Refused**: Ensure the Gotify server URL is correct and accessible
- **Authentication Failed**: Verify the Application Token is correct
- **SSL Certificate Errors**: Check the Verify SSL setting and certificate validity
### Gotify Logs
To troubleshoot notification issues:
1. Check the GoMFT logs: **Admin Tools** > **Logs** > filter for "gotify"
2. Review the Gotify server logs for any errors
3. Verify network connectivity between GoMFT and the Gotify server
## Best Practices
- **Use HTTPS** for your Gotify server to ensure secure communication
- **Set Appropriate Priorities** to differentiate between routine and critical notifications
- **Use Client Applications** on your devices to receive Gotify notifications
- **Set Up Multiple Notification Methods** for critical events
@@ -0,0 +1,128 @@
---
sidebar_position: 1
title: Notifications Overview
---
# Notifications Overview
GoMFT provides a comprehensive notification system to keep you informed about important events in your file transfer workflows. This page provides an overview of the notification system and the different notification types available.
## Notification System
The notification system in GoMFT is designed to be:
- **Flexible**: Choose from multiple notification channels
- **Configurable**: Set up different notifications for different events
- **Reliable**: Ensure critical events are always reported
- **Secure**: Protect sensitive information in notifications
## Notification Triggers
Notifications can be triggered by various events in GoMFT:
### Transfer-Related Events
- **Transfer Completion**: When a file transfer is successfully completed
- **Transfer Failure**: When a file transfer fails for any reason
- **Transfer Start**: When a file transfer begins
- **Transfer Threshold**: When a transfer exceeds a defined duration threshold
### Schedule-Related Events
- **Schedule Execution**: When a scheduled task runs
- **Schedule Failure**: When a scheduled task fails to run
- **Schedule Creation/Modification**: When schedules are created or modified
<!-- ### System Events
- **System Warnings**: Alerts about system resource usage (disk space, memory, etc.)
- **Service Status Changes**: When system services change state
- **Authentication Events**: Failed login attempts or other security events
- **Database Events**: Database backup completion, migration, or issues -->
## Notification Types
GoMFT supports multiple notification types to ensure you can receive alerts through your preferred channels:
### Webhook Notifications
Send HTTP requests to external systems or services when events occur. Features include:
- Configurable HTTP methods (POST, PUT, PATCH)
- JSON or XML payload formats
- Support for authentication
- Customizable retry strategy for improved reliability
[Learn more about Webhook Notifications](./webhook-notifications)
### Mobile Push Notifications
Receive notifications directly on your mobile devices:
#### Ntfy Notifications
- Simple HTTP-based push notifications to phones and desktops
- Customizable priority levels and notification tags
- Support for self-hosted or cloud-based ntfy servers
[Learn more about Ntfy Notifications](./ntfy-notifications)
#### Pushover Notifications
- Real-time push notifications to all your devices
- Priority levels for urgent notifications
- Custom sounds and delivery options
[Learn more about Pushover Notifications](./pushover-notifications)
#### Pushbullet Notifications
- Cross-platform notifications across all your devices
- Optional end-to-end encryption
- Support for notification mirroring
[Learn more about Pushbullet Notifications](./pushbullet-notifications)
#### Gotify Notifications
- Self-hosted push notification service
- Customizable priority levels
- Private and secure notification delivery
[Learn more about Gotify Notifications](./gotify-notifications)
## Notification Templates
Each notification type uses customizable templates to format the notification content. Templates support variables that are replaced with actual values when the notification is sent.
Common template variables include:
- `{{transfer_name}}`: Name of the transfer
- `{{transfer_status}}`: Status of the transfer (success, failure, etc.)
- `{{start_time}}`: When the transfer started
- `{{end_time}}`: When the transfer completed
- `{{duration}}`: How long the transfer took
- `{{total_files}}`: Number of files transferred
- `{{total_size}}`: Total size of transferred data
- `{{error_message}}`: Detailed error information (for failures)
## Notification Management
### Configuration
Notifications are configured at multiple levels:
1. **Global Level**: Default notification settings for all transfers
2. **Transfer Level**: Specific notification settings for individual transfers
3. **Schedule Level**: Notification settings for scheduled transfers
### Notification History
GoMFT maintains a history of sent notifications, allowing you to:
- Review past notifications
- Verify notification delivery
- Resend notifications if needed
- Audit notification patterns
## Getting Started with Notifications
To start using GoMFT notifications:
1. Navigate to **Settings** > **Notifications**
2. Configure your preferred notification channels
3. Test each notification channel
4. Apply notifications to specific transfers or schedules
For specific notification types, refer to the corresponding documentation pages in this section.
+155
View File
@@ -0,0 +1,155 @@
---
sidebar_position: 4
title: Ntfy Notifications
---
# Ntfy Notifications
Ntfy is a simple HTTP-based pub-sub notification service that allows you to send push notifications to your phone or desktop. GoMFT seamlessly integrates with Ntfy to deliver notifications about your file transfers and system events.
## Overview
Ntfy integration in GoMFT enables:
- Push notifications to mobile devices and desktops
- Choice between public ntfy.sh service or self-hosted Ntfy server
- Customizable notification topics, priorities, and tags
- Support for notification actions and attachments
## Prerequisites
Before configuring Ntfy notifications in GoMFT, you should:
1. Install the Ntfy app on your devices (available for Android, iOS, and desktop)
2. Subscribe to your chosen topic in the Ntfy app
3. Optionally set up your own Ntfy server for increased privacy
## Configuration
### Global Ntfy Settings
To configure Ntfy notifications in GoMFT:
1. Navigate to **Settings** > **Notification Services** > **Add New** > **Ntfy**
2. Configure the following settings:
- **Ntfy Server URL**: The URL of the Ntfy server (default: `https://ntfy.sh`)
- **Default Topic**: The notification topic your devices are subscribed to
- **Default Priority**: Priority level for notifications (1-5)
- **Authentication**: Access token or username/password if required
- **Default Tags**: Icon tags for different notification types
### Testing Ntfy Connection
After configuring your Ntfy settings:
1. Click **Send Test Notification** to send a test notification to your devices
## Notification Content
### Priority Levels
Ntfy supports five priority levels that GoMFT uses effectively:
| Priority | Level | Usage in GoMFT |
|----------|-------|----------------|
| 1 | Min | Background information, debug notifications |
| 2 | Low | Successful transfers, routine events |
| 3 | Default | Standard notifications, warnings |
| 4 | High | Transfer failures, important alerts |
| 5 | Max | Critical system issues, emergency alerts |
### Notification Tags
GoMFT uses meaningful tags in Ntfy notifications to provide visual cues:
| Tag | Usage |
|-----|-------|
| `✅` | Successful transfers |
| `❌` | Failed transfers |
| `⚠️` | Warnings or transfers with issues |
| `🔄` | Transfer in progress |
| `🔍` | Monitoring events |
| `⚙️` | System events |
### Example Notifications
GoMFT sends structured notifications with helpful information:
#### Successful Transfer
```
Title: Transfer Completed: Daily Backup
Message: Successfully transferred 123 files (1.45 GB) in 2:15
Priority: 2 (Low)
Tags: ✅,📁
```
#### Failed Transfer
```
Title: Transfer Failed: Daily Backup
Message: Error: Connection refused to destination server
Files processed: 45/123
Size transferred: 0.5/1.45 GB
Priority: 4 (High)
Tags: ❌,📁
Click action: Open GoMFT
```
## Advanced Features
### Custom Templates
Customize notification content with templates:
```
Title: {{event_type}}: {{transfer_name}}
Message: {{status}} - {{files_transferred}} files ({{total_size}}) in {{duration}}
Priority: {% if status == "failed" %}4{% else %}2{% endif %}
Tags: {% if status == "success" %}✅{% else %}❌{% endif %},📁
```
## Self-Hosting Ntfy
For enhanced privacy and control, you can self-host your own Ntfy server:
1. Follow the [Ntfy self-hosting guide](https://docs.ntfy.sh/install/)
2. Update your GoMFT configuration to point to your self-hosted server
3. Configure authentication as needed
Example configuration for self-hosted Ntfy:
```yaml
ntfy:
server_url: https://ntfy.example.com
default_topic: gomft
authentication:
type: basic
username: ${NTFY_USERNAME}
password: ${NTFY_PASSWORD}
```
## Troubleshooting
### Common Issues
- **Notifications Not Arriving**: Verify you've subscribed to the correct topic
- **Authentication Errors**: Check credentials and authentication method
- **Connection Issues**: Ensure the Ntfy server is accessible from GoMFT
- **App Configuration**: Verify notification settings in your Ntfy app
### Ntfy Logs
To troubleshoot notification issues:
1. Check the GoMFT logs: **Administration** > **Log Viewer** > filter for "ntfy"
2. If self-hosting, check your Ntfy server logs
3. Verify your device has properly functioning notifications
## Best Practices
- **Use Unique Topics** to prevent unauthorized notifications
- **Set Appropriate Priorities** based on event importance
- **Consider Self-Hosting** for sensitive environments
- **Keep Topic Names Secret** as they act like passwords
- **Set Up Multiple Notification Methods** for critical systems
@@ -0,0 +1,131 @@
---
sidebar_position: 5
title: Pushbullet Notifications
---
# Pushbullet Notifications
Pushbullet is a cross-platform notification service that allows you to receive notifications on multiple devices. GoMFT integrates with Pushbullet to deliver timely notifications about your file transfers and system events.
## Overview
Pushbullet integration in GoMFT offers:
- Cross-platform notifications across your devices (Android, iOS, Chrome, Firefox, etc.)
- Option to send to all devices or specific devices
- Rich notification content with transfer details
- Support for notification mirroring between devices
## Prerequisites
Before configuring Pushbullet notifications in GoMFT, you need:
1. A Pushbullet account
2. Pushbullet API access token
3. Pushbullet app installed on your devices
## Configuration
### Global Pushbullet Settings
To configure Pushbullet notifications:
1. Navigate to **Settings** > **Notification Services** > **Add New** > **Pushbullet**
2. Configure the following settings:
- **API Token**: Your Pushbullet access token
- **Default Device**: The device identifier to send notifications to (optional)
- **Default Type**: "Note" (default) or "Link"
### Getting Your Pushbullet API Token
1. Log in to your Pushbullet account at [pushbullet.com](https://www.pushbullet.com/)
2. Go to **Settings** > **Account**
3. In the **Access Tokens** section, click **Create Access Token**
4. Copy the generated token and paste it into GoMFT
### Testing Pushbullet Connection
After configuring your Pushbullet settings:
1. Click **Send Test Notification** to send a test notification to your devices
## Notification Content
### Notification Types
GoMFT supports two types of Pushbullet notifications:
#### Note Type
Simple notifications with a title and body:
```
Title: Transfer Complete: Daily Backup
Body: Successfully transferred 123 files (1.45 GB) in 2:15
```
#### Link Type
Notifications that include a link to the GoMFT interface:
```
Title: Transfer Failed: Daily Backup
Body: Error: Connection refused to destination server
URL: https://gomft.example.com/transfers/123
```
### Example Notifications
#### Successful Transfer
```
Title: Transfer Complete: Daily Backup
Body: Transfer completed successfully at 2023-09-15 14:22:33
Files: 123
Size: 1.45 GB
Duration: 00:02:15
```
#### Failed Transfer
```
Title: Transfer Failed: Daily Backup
Body: Transfer failed with error: Connection refused
Files Processed: 45/123
Size Transferred: 0.5/1.45 GB
Duration: 00:01:05
Error: Failed to connect to destination server
URL: https://gomft.example.com/transfers/123
```
## Troubleshooting
### Common Issues
- **API Token Errors**: Verify your Pushbullet API token is correct
- **No Notifications Arriving**: Check device connectivity and Pushbullet app settings
- **Rate Limiting**: Pushbullet has API rate limits; spread out notification frequency
- **Device Selection Issues**: Verify device identifiers if targeting specific devices
### Pushbullet Logs
To troubleshoot notification issues:
1. Check the GoMFT logs: **Administration** > **Log Viewer** > filter for "pushbullet"
2. Review the Pushbullet account activity in your Pushbullet account
## Best Practices
- **Secure Your API Token**: Treat your Pushbullet API token as sensitive information
- **Group Related Notifications** to avoid notification fatigue
- **Include Action Links** for quick access to relevant GoMFT pages
- **Set Up Multiple Notification Methods** for critical systems
## Pushbullet Alternatives
If you encounter limitations with Pushbullet, GoMFT also supports:
- [Email Notifications](./email-notifications)
- [Gotify](./gotify-notifications)
- [Ntfy](./ntfy-notifications)
- [Pushover](./pushover-notifications)
@@ -0,0 +1,137 @@
---
sidebar_position: 6
title: Pushover Notifications
---
# Pushover Notifications
Pushover is a simple notification service that makes it easy to send real-time notifications to your Android and iOS devices, as well as desktop computers. GoMFT integrates with Pushover to deliver instant notifications about your file transfers and system events.
## Overview
Pushover integration in GoMFT provides:
- Real-time push notifications to mobile devices and desktops
- Prioritized notifications with different sounds and attention levels
- Customizable notification content with detailed transfer information
- Support for notification grouping by device or application
## Prerequisites
Before configuring Pushover notifications in GoMFT, you need:
1. A [Pushover account](https://pushover.net/)
2. Your Pushover user key
3. A registered Pushover application (API token)
4. Pushover app installed on your devices
## Configuration
### Registering a GoMFT Application in Pushover
1. Log in to your Pushover account at [pushover.net](https://pushover.net/)
2. Go to [Your Applications](https://pushover.net/apps/build)
3. Create a new application:
- **Name**: GoMFT
- **Type**: Application
- **Description**: GoMFT File Transfer Notifications
- **URL**: Your GoMFT instance URL (optional)
- **Icon**: Upload a custom icon (optional)
4. After creation, you'll receive an **API Token/Key** for your application
### Global Pushover Settings
To configure Pushover notifications in GoMFT:
1. Navigate to **Settings** > **Notification Services** > **Add New** > **Pushover**
2. Configure the following settings:
- **User Key**: Your Pushover user key
- **API Token**: Your GoMFT application's API token
- **Default Priority**: Default priority level (-2 to 2)
- **Default Sound**: Sound for notifications
- **Default Device**: Specific device or blank for all devices
### Testing Pushover Connection
After configuring your Pushover settings:
1. Click **Send Test Notification** to send a test notification to your devices
## Notification Content
### Priority Levels
Pushover supports different priority levels that GoMFT uses effectively:
| Priority | Level | Usage in GoMFT |
|----------|-------|----------------|
| -2 | Lowest | Silent transfer logs, debugging info |
| -1 | Low | Successful transfers, routine events |
| 0 | Normal | Standard notifications |
| 1 | High | Transfer failures, important alerts |
| 2 | Emergency | Critical system issues |
Note: Emergency priority (2) notifications will repeat until acknowledged by the user.
### Example Notifications
#### Successful Transfer
```
Title: Transfer Complete: Daily Backup
Message: Successfully transferred 123 files (1.45 GB) in 2:15
Priority: Normal (0)
Sound: pushover
```
#### Failed Transfer
```
Title: Transfer Failed: Daily Backup
Message: Error: Connection refused to destination server
Files: 45/123 processed
Size: 0.5/1.45 GB transferred
Error: Failed to connect to destination server
Priority: High (1)
Sound: siren
URL: https://gomft.example.com/transfers/123
URL Title: View Transfer Details
```
## Troubleshooting
### Common Issues
- **Incorrect API Token/User Key**: Verify your Pushover credentials
- **Message Rate Limiting**: Pushover has monthly message limits for free accounts
- **Device Not Receiving**: Check device registration and network connectivity
- **Emergency Notifications**: Verify retry/expire settings for emergency priority
### Pushover Logs
To troubleshoot notification issues:
1. Check the GoMFT logs: **Admin Tools** > **Logs** > filter for "pushover"
2. Review your Pushover account's message history
3. Check your device's Pushover app settings
## Best Practices
- **Use Appropriate Priority Levels** based on event importance
- **Reserve Emergency Priority** for truly critical issues
- **Group Related Notifications** when possible
- **Include Action URLs** for immediate access to relevant information
- **Consider Sound Selection** based on notification importance
- **Set Up Multiple Notification Methods** for critical systems
## Pushover vs. Other Notification Systems
| Feature | Pushover | Email | Gotify | Ntfy | Pushbullet |
|---------|----------|-------|--------|------|------------|
| Cost | Paid (one-time) | Free | Free | Free | Free/Paid |
| Self-hosting | No | Varies | Yes | Yes | No |
| Priority levels | Yes | No | Yes | Yes | No |
| Acknowledgment | Yes | No | No | No | No |
| Sound options | Yes | No | No | Limited | No |
| Delivery guarantee | High | Varies | Good | Good | Good |
| Device support | iOS, Android, Desktop | All | All | All | iOS, Android, Desktop |
+147
View File
@@ -0,0 +1,147 @@
---
sidebar_position: 3
title: Webhook Notifications
---
# Webhook Notifications
Webhook notifications in GoMFT provide a powerful way to integrate with external systems by sending HTTP requests when events occur. This enables automation workflows and integration with your existing tools and services.
## Overview
Webhooks allow GoMFT to:
- Send real-time notifications to external systems
- Trigger automated workflows in third-party applications
- Integrate with custom applications or services
- Provide machine-readable event data (JSON or XML)
## Configuration
### Global Webhook Settings
To configure webhook notifications:
1. Navigate to **Settings** > **Notification Services** > **Webhooks**
2. Configure the following settings:
- **Default Webhook URL**: The base URL for webhook requests
- **HTTP Method**: POST (default), PUT, or PATCH
- **Content Type**: application/json (default), application/xml, or custom
- **Authentication**: None, Basic Auth, API Key, or Bearer Token
- **Retry Strategy**: Number of retries and delay between retries
- **Timeout**: Maximum wait time for responses
### Testing Webhook Connectivity
After configuring your webhook settings:
1. Click **Test Connection** to send a test webhook request
2. Review the response status and body from the server
## Webhook Payload
### Default JSON Payload
By default, GoMFT sends a JSON payload with information about the event:
```json
{
"event_type": "transfer_completed",
"timestamp": "2023-09-15T14:22:33Z",
"transfer": {
"id": "transfer-123",
"name": "Daily Backup",
"source": "local-server",
"destination": "cloud-storage",
"status": "success",
"start_time": "2023-09-15T14:20:01Z",
"end_time": "2023-09-15T14:22:33Z",
"duration_seconds": 152,
"files_transferred": 258,
"total_size_bytes": 1073741824,
"transfer_rate_bytes_per_second": 7064091
},
"schedule": {
"id": "schedule-456",
"name": "Daily Backup Schedule"
}
}
```
For failure events, additional error information is included:
```json
{
"event_type": "transfer_failed",
"timestamp": "2023-09-15T14:22:33Z",
"transfer": {
"id": "transfer-123",
"name": "Daily Backup",
"source": "local-server",
"destination": "cloud-storage",
"status": "failed",
"start_time": "2023-09-15T14:20:01Z",
"end_time": "2023-09-15T14:22:33Z",
"duration_seconds": 152,
"error": {
"code": "CONNECTION_ERROR",
"message": "Failed to connect to destination: Connection timed out",
"details": "TCP connection to cloud-storage:22 timed out after 60 seconds"
}
}
}
```
### Custom Payload Templates
You can customize the webhook payload using templates:
1. Navigate to the webhook configuration
2. Switch from **Default Payload** to **Custom Payload**
3. Edit the JSON or XML template
Example custom template:
```json
{
"alert": {
"type": "{{event_type}}",
"system": "GoMFT",
"environment": "{{environment}}",
"details": {
"transfer_name": "{{transfer_name}}",
"status": "{{status}}",
"time": "{{timestamp}}",
"size_mb": "{{total_size_mb}}"
}{% if status == "failed" %},
"error": "{{error_message}}"{% endif %}
}
}
```
## Troubleshooting
### Common Issues
- **Connection Refused**: Check network connectivity and firewall rules
- **Authentication Failed**: Verify credentials and authentication method
- **Timeout Issues**: Increase timeout settings for slow endpoints
- **Invalid Payload**: Validate your custom template syntax
- **HTTP Error Codes**: Check destination service logs for details
### Webhook Logs
GoMFT logs all webhook attempts:
1. Navigate to **Administartion** > **Log Viewer**
2. Filter for "webhook" to see relevant log entries
3. Review request and response details for troubleshooting
## Best Practices
- **Use HTTPS** for all webhook endpoints
- **Implement Retries** for important notifications
- **Monitor Webhook Deliveries** to ensure reliability
- **Set Up Fallback Notification Methods** for critical transfers
- **Validate Webhook Payloads** on the receiving end
- **Keep Webhook Processing Fast** to avoid timeouts
+144
View File
@@ -0,0 +1,144 @@
---
sidebar_position: 2
title: Connections
---
# Connection Management
Connections in GoMFT are configurations that define how to access different storage systems. Before you can transfer files, you need to set up connections for your source and destination systems.
> **Note**: GoMFT now supports the Storage Provider feature, which allows you to create reusable connection profiles with securely stored credentials. For detailed information, see the [Storage Providers](/docs/user-guides/storage-provider-guide) guide.
## Supported Connection Types
GoMFT leverages rclone as its transfer engine, supporting a wide range of storage systems:
### Cloud Storage
- **Amazon S3**: Amazon's object storage service
- **Google Cloud Storage**: Google's object storage service
- **Backblaze B2**: Affordable cloud object storage
- **Wasabi**: Hot cloud storage
### File Transfer Protocols
- **FTP**: File Transfer Protocol
- **SFTP**: SSH File Transfer Protocol
- **WebDAV**: Web Distributed Authoring and Versioning
### Local Storage
- **Local Disk**: Files on the server running GoMFT
- **SMB/CIFS**: Windows file sharing
## Creating a Connection
To create a new connection:
1. Navigate to the **Transfer Configuration** section in the sidebar
2. Click **+ New Configuration**
3. Select the connection type from the dropdown menu
4. Fill in the required details for your selected type
5. Click **Test Source/Destination** to verify the connection works
6. Click **Create Configuration** to store the configuration
## Connection Configuration Fields
Different connection types require different configuration fields. Here are some common examples:
### Amazon S3 Connection
- **Name**: A descriptive name for the connection
- **Access Key ID**: AWS access key
- **Secret Access Key**: AWS secret key
- **Region**: AWS region (e.g., us-east-1)
- **Endpoint**: Optional custom endpoint for S3-compatible services
- **Bucket**: Default bucket to use (optional)
- **Path Prefix**: Default path prefix within the bucket (optional)
### SFTP Connection
- **Name**: A descriptive name for the connection
- **Host**: Server hostname or IP address
- **Port**: Server port (usually 22)
- **Username**: SFTP username
- **Authentication Method**: Password or SSH Key
- **Password**: User password (if using password authentication)
- **SSH Key**: Private SSH key (if using key authentication)
- **SSH Key Passphrase**: Passphrase for SSH key (if applicable)
### Local Storage Connection
- **Name**: A descriptive name for the connection
- **Path**: Base path on the local filesystem
## Connection Security
GoMFT follows best practices for handling connection credentials:
- **Encryption**: All sensitive credentials are encrypted at rest using AES-256 encryption
- **Access Control**: Connections are protected by user permissions
- **Masked Values**: Passwords and secret keys are masked in the UI
- **Key Management**: SSH keys and other credentials are securely stored
- **Centralized Management**: With the Storage Provider feature, credentials can be managed in one place and reused across multiple transfers
## Managing Connections
You can manage connections either through traditional transfer configurations or using the new Storage Provider feature.
### Viewing Connections
#### Traditional Connections
The **Transfer Configurations** page displays all configured connections with:
- Configuration name
- Configuration type
- Last updated date
#### Storage Providers
Alternatively, you can use the new Storage Provider feature to manage your connections:
1. Navigate to the **Storage Providers** section in the left sidebar
2. View a list of all storage providers you have created
3. Each provider shows name, type, and creation date
### Editing Transfer Confirgurations
To edit an existing connection:
1. Navigate to the **Transfer Confiruations** section
2. Find the config you want to edit
3. Click the **Edit** button
4. Modify the config details
5. Test the updated configuration
6. Save your changes
### Deleting Transfer Configurations
To delete a connection:
1. Navigate to the **Transfer Configurations** section
2. Find the config you want to delete
3. Click the **Delete** button
4. Confirm the deletion
**Note**: You cannot delete connections that are in use by active transfers or schedules.
## Testing Transfer Configurations
GoMFT includes a configuration testing feature to verify connectivity:
1. After entering details for a configuration, click **Test Source/Destination**
2. GoMFT will attempt to authenticate with the remote system
3. For file storage, it will also verify read/write permissions
4. Results will display showing success or failure details
## Best Practices
- **Use descriptive names** for connections to easily identify them
- **Test connections regularly** to ensure they still work
- **Rotate credentials** periodically for enhanced security
- **Use service accounts** rather than personal accounts when possible
- **Document connection details** in the description field
- **Use the minimal required permissions** for enhanced security
- **Organize connections** using consistent naming conventions
- **Use Storage Providers** for reusable connections across multiple transfers
- **Update credentials in one place** by using Storage Providers instead of updating each transfer individually
+112
View File
@@ -0,0 +1,112 @@
---
sidebar_position: 4
title: Monitoring
---
# Monitoring and Reporting
GoMFT provides comprehensive monitoring and reporting features to help you track transfer activities, analyze performance, and ensure reliable operation. This page explains the monitoring tools available in GoMFT.
## Dashboard
The GoMFT dashboard provides a real-time overview of your file transfer system:
### Dashboard Components
- **Transfer Status**: Overview of currently running, completed, and failed transfers
- **Recent Transfers**: List of the most recent transfer executions
- **System Health**: Indicators for system health and resource usage
- **Quick Actions**: Buttons for common tasks like creating transfers or checking logs
To access the dashboard:
1. Log in to GoMFT
2. The dashboard is the default landing page
3. You can return to it anytime by clicking **Dashboard** in the sidebar
## Transfer History
The transfer history section provides detailed information about all transfer executions:
### Transfer History Features
- **Comprehensive Logs**: Complete transfer history with filtering options
- **Status Tracking**: Visual indicators for transfer status (successful, failed, in progress)
- **Performance Metrics**: Data on transfer speed, file counts, and total bytes
- **Time Tracking**: Start time, end time, and duration for all transfers
- **Filter and Search**: Find specific transfers by name, status, date, or other criteria
To access transfer history:
1. Navigate to **Transfer History** in the sidebar
2. Use filters to narrow down the list of transfers
3. Click on any transfer to see detailed information
## Real-Time Monitoring
GoMFT provides real-time monitoring of active transfers:
### Active Transfers
- **Live Progress**: See transfer progress as it happens
- **File Counters**: Track files transferred, remaining, and skipped
- **Bandwidth Usage**: Monitor current transfer speeds
- **Cancel Option**: Ability to cancel running transfers
- **Log Streaming**: View logs as they're generated
To monitor active transfers:
1. Navigate to **Transfer History** in the sidebar
2. View all currently running transfers
3. Click on any transfer to see detailed progress
## Detailed Transfer Logs
For each transfer execution, GoMFT maintains detailed logs:
### Log Information
- **File Details**: Information about each transferred file
- **Error Messages**: Detailed error information for failed transfers
- **Warning Messages**: Warnings that occurred during transfer
- **Transfer Summary**: Overall summary of the transfer operation
- **Performance Data**: Transfer rates and timing information
To access detailed logs:
1. Navigate to **Transfer History** in the sidebar
2. Find the transfer of interest
3. Click on **View Details** to open details
## System Monitoring
GoMFT monitors the health and performance of the system itself:
## Alerts and Notifications
GoMFT can alert you to important events:
### Alert Types
- **Transfer Failures**: Notifications when transfers fail
- **Transfer Completion**: Alerts when transfers complete
To configure alerts:
1. Navigate to **Notification Providers**
2. Set up notification methods (email, webhook, gotify, ntfy, pushover, pushbullet)
4. Configure alert severity levels
## Export and API Access
GoMFT allows you to export monitoring data:
### Export Options
- **CSV Export**: Download transfer history in CSV format
- **JSON Export**: Export data in JSON format for further processing
## Best Practices
- **Review the dashboard daily** to stay informed of transfer status
- **Set up alerts** for critical transfers to be notified of failures
- **Generate regular reports** for compliance and performance tracking
- **Monitor system health** to prevent resource issues
- **Archive logs** for long-term storage and compliance
- **Use filters** to focus on the most important information
- **Export data** for backup and external analysis
+221
View File
@@ -0,0 +1,221 @@
---
sidebar_position: 3
title: Schedules
---
# Schedule Management
GoMFT's scheduling system allows you to automate file transfers to run at specific times or on a recurring basis. This section explains how to create, manage, and monitor scheduled transfers.
## Schedule Types
GoMFT supports several types of schedules:
### One-Time Schedules
Run a transfer once at a specific date and time.
### Recurring Schedules
Run a transfer repeatedly according to a defined pattern:
- **Hourly**: Run every hour at a specific minute
- **Daily**: Run every day at a specific time
- **Weekly**: Run on specific days of the week
- **Monthly**: Run on specific days of the month
- **Custom**: Define a custom schedule using cron syntax
## Creating a Schedule
To create a new schedule:
1. Navigate to the **Schedules** section in the sidebar
2. Click **Create New Schedule**
3. Select the transfer to schedule
4. Choose the schedule type
5. Configure the schedule details
6. Set additional options
7. Click **Save Schedule**
## Schedule Configuration
### Basic Configuration
- **Name**: A descriptive name for the schedule
- **Transfer**: The transfer configuration to run
- **Enabled**: Toggle to enable or disable the schedule
- **Schedule Type**: One-time or recurring
### One-Time Schedule Options
- **Date**: The date to run the transfer
- **Time**: The time to run the transfer
### Recurring Schedule Options
#### Simple Options
- **Frequency**: Hourly, Daily, Weekly, Monthly, or Custom
- **Time**: The time to run (for Daily, Weekly, Monthly)
- **Days**: The days to run (for Weekly, Monthly)
- **Minutes**: The minute to run (for Hourly)
#### Advanced Options (Cron Syntax)
For more complex scheduling needs, you can use cron syntax:
```
┌─────────── minute (0 - 59)
│ ┌──────── hour (0 - 23)
│ │ ┌────── day of month (1 - 31)
│ │ │ ┌──── month (1 - 12)
│ │ │ │ ┌── day of week (0 - 6) (Sunday to Saturday)
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │
* * * * *
```
Examples:
- `0 2 * * *`: Every day at 2:00 AM
- `0 9-17 * * 1-5`: Every hour from 9 AM to 5 PM, Monday to Friday
- `*/15 * * * *`: Every 15 minutes
- `0 0 1,15 * *`: 1st and 15th of every month at midnight
### Additional Options
- **Timeout**: Maximum duration for the transfer (after which it will be terminated)
- **Retry Count**: Number of times to retry on failure
- **Retry Delay**: Time to wait between retry attempts
- **Priority**: Schedule priority (higher priority schedules run first when multiple are due)
- **Description**: Additional notes about the schedule
## Schedule Groups
GoMFT allows you to organize schedules into logical groups:
1. Navigate to **Schedule Groups** in the Schedules section
2. Create a new group with a name and description
3. Assign schedules to the group
4. View and manage grouped schedules together
Benefits of groups:
- Organize related schedules
- Apply batch operations to multiple schedules
- Monitor group-level statistics
## Managing Schedules
### Viewing Schedules
The **Schedules** page displays all configured schedules with:
- Schedule name
- Associated transfer
- Next run time
- Last run status
- Enabled/disabled status
### Editing Scheduled Jobs
To edit an existing schedule:
1. Navigate to the **Scheduled Jobs** section
2. Find the schedule you want to edit
3. Click the **Edit** button
4. Modify the schedule details
5. Save your changes
### Enabling/Disabling Scheduled Jobs
To temporarily disable a schedule without deleting it:
1. Navigate to the **Scheduled Job** section
2. Find the schedule you want to disable
3. Click edit
3. Toggle the **Enabled** switch to Off and save
4. The schedule will remain configured but won't run until re-enabled
### Deleting Schedules
To delete a schedule:
1. Navigate to the **Scheduled Job** section
2. Find the schedule you want to delete
3. Click the **Delete** button
4. Confirm the deletion
## Schedule Execution
When a schedule runs, GoMFT performs these actions:
1. Identifies schedules due for execution
2. Prioritizes schedules based on priority setting
3. Creates execution jobs for the associated transfers
4. Monitors job execution
5. Records results in the history
6. Handles retries if configured and needed
7. Updates next run time for recurring schedules
## Monitoring Schedules
GoMFT provides several ways to monitor your scheduled transfers:
### Transfer Calendar
The Transfer Calendar provides a visual overview of all your scheduled transfers:
1. Navigate to **Transfer Calendar** in the sidebar
2. View all scheduled transfers in a monthly, weekly, or daily view
3. Color-coded events indicate different transfer types or statuses
4. Hover over any event to see a summary of the transfer details
5. Click on any scheduled transfer to see full details or edit it
#### Calendar Views
- **Month View**: See all scheduled transfers for the entire month
- **Week View**: Focus on transfers scheduled for the current week
- **Day View**: Detailed timeline of transfers for a specific day
- **Agenda View**: List-based view of upcoming transfers
#### Calendar Features
- **Filtering**: Filter transfers by type, status, or associated connection
<!-- - **Search**: Find specific transfers by name or description -->
<!-- - **Export**: Export calendar events to iCal or CSV format -->
<!-- - **Drag and Drop**: Reschedule transfers by dragging them to a new time slot (requires appropriate permissions) -->
<!-- - **Conflict Detection**: Visual indicators for potentially overlapping transfers -->
<!-- #### Calendar Integration
You can subscribe to the transfer calendar using external calendar applications:
1. Click the **Calendar Subscription** button
2. Copy the provided iCal URL
3. Add the URL as a calendar subscription in applications like Google Calendar, Outlook, or Apple Calendar
4. Set the refresh frequency in your calendar application
> **Note**: The calendar subscription is read-only and requires authentication. Calendar subscriptions will only show transfers that the authenticated user has permission to view. -->
### Transfer History
View the execution history of your schedules:
1. Navigate to **Transfer History** on the sidebar
2. See when schedules ran, their status, and execution details
3. Filter by date range, status, or schedule name
## Schedule Notifications
Configure notifications for scheduled transfers:
1. Edit a schedule
2. Navigate to the **Notifications** tab
3. Configure email notifications or webhooks
4. Specify notification conditions (success, failure, or both)
## Best Practices
- **Use descriptive names** for schedules to easily identify them
- **Set appropriate timeouts** based on expected transfer duration
- **Configure retries** for critical transfers
- **Use schedule groups** to organize related schedules
- **Stagger schedules** to avoid resource contention
- **Set up notifications** for critical schedules
- **Review schedule history** regularly to identify issues
- **Disable schedules** instead of deleting them for temporary pauses
+186
View File
@@ -0,0 +1,186 @@
---
sidebar_position: 1
title: Transfers
---
# Transfer Operations
GoMFT's primary function is to manage file transfers between different storage systems. This page explains the transfer operations available in GoMFT and how to configure them.
> **Note**: GoMFT now supports the Storage Provider feature, which allows you to create reusable connection profiles for your transfers. For detailed information, see the [Storage Providers](/docs/user-guides/storage-provider-guide) guide.
## Transfer Types
GoMFT supports several types of transfer operations, each with different behaviors:
### Copy
The **Copy** operation copies files from the source to the destination. Files are only copied if they don't exist at the destination or if they've been modified at the source.
```
Source → Destination
```
### Sync
The **Sync** operation makes the destination identical to the source, adding, removing, and updating files as necessary.
```
Source → Destination (with deletions)
```
### Move
The **Move** operation copies files from the source to the destination and then deletes the source files after a successful transfer.
```
Source → Destination → Delete Source
```
### Bidirectional Sync
The **Bidirectional Sync** operation synchronizes files in both directions, ensuring that the newest version of each file is present in both locations.
```
Source ⟷ Destination
```
## Transfer Configuration
When creating a transfer in GoMFT, you need to configure the following elements:
### Basic Configuration
- **Name**: A descriptive name for the transfer
- **Description**: Optional details about the transfer's purpose
- **Source**: Either a direct connection configuration or a Storage Provider
- **Destination**: Either a direct connection configuration or a Storage Provider
- **Transfer Type**: Copy, Sync, Move, or Bidirectional Sync
#### Using Storage Providers
When creating a transfer, you can now select a Storage Provider instead of entering connection details directly:
1. In the Source or Destination section, select **Provider** from the dropdown
2. Choose from your available Storage Providers
3. Enter the path within the selected provider
This approach offers several benefits:
- Reuse the same provider across multiple transfers
- Update credentials in one place
- Enhanced security with AES-256 encryption for credentials
### Advanced Options
#### File Selection
- **Include Patterns**: Patterns for files to include (e.g., `*.txt`, `data/**/*.csv`)
- **Exclude Patterns**: Patterns for files to exclude (e.g., `*.tmp`, `**/._*`)
- **Min Size**: Minimum file size to transfer
- **Max Size**: Maximum file size to transfer
- **Min Age**: Only transfer files older than this
- **Max Age**: Only transfer files newer than this
#### Transfer Behavior
- **Checksum**: Compare files using checksums instead of size/date
- **Delete Before**: Delete destination files before transferring
- **Delete During**: Delete destination files during transfer
- **Delete After**: Delete destination files not in source after transfer
- **Update Existing**: Update existing files at destination
- **Skip New**: Skip new files not present at destination
- **Skip Newer**: Skip files that are newer at the destination
#### Performance Options
- **Transfers**: Number of concurrent file transfers
- **Checkers**: Number of concurrent file checkers
- **Bandwidth Limit**: Maximum bandwidth to use in bytes/s
- **Buffer Size**: Size of transfer buffer (default: 16MB)
- **Chunk Size**: Upload chunk size for chunked uploads
## Transfer Execution
### Manual Execution
Transfers can be run on-demand:
1. Navigate to the **Scheduled Jobs** section
2. Find your job in the list
3. Click **Run Now**
4. Monitor the job progress in real-time
### Scheduled Execution
Transfers can be scheduled to run automatically:
1. Navigate to the **Scheduled Jobs** section
2. Create a new schedule linked to your transfer configuration
3. Set up the schedule using cron syntax or the schedule builder
4. The transfer will run automatically according to the schedule
## Transfer Monitoring
### Status Indicators
- **Pending**: Transfer is waiting to start
- **Running**: Transfer is in progress
- **Completed**: Transfer finished successfully
- **Failed**: Transfer encountered an error
- **Canceled**: Transfer was manually canceled
### Transfer Details
For each transfer execution, GoMFT records:
- Start and end times
- Duration
- Number of files transferred
- Total bytes transferred
- Files skipped
- Errors encountered
- Detailed logs
## Transfer Logs
GoMFT provides detailed logs for each transfer:
1. Navigate to **Transfer History**
2. Click on a **View Details** button on the transfer entry
The logs include information about:
- Each file transferred
- Skipped files
- Errors
- Performance metrics
- Overall transfer summary
## Troubleshooting Failed Transfers
When a transfer fails, GoMFT provides information to help identify the cause:
1. Check the error message in the transfer history
2. Review the detailed logs for the specific error
3. For transfers using Storage Providers, you can test the provider connection directly from the Storage Providers section
4. Common issues include:
- Permission problems
- Network connectivity
- Invalid credentials
- Path not found
- Disk space issues
- Expired tokens (for OAuth providers like OneDrive or Google Drive)
## Best Practices
- **Use meaningful names** for your transfers to easily identify them
- **Start small** when testing new configurations
- **Use include/exclude patterns** to limit scope when working with large directories
- **Set appropriate concurrency** based on network conditions and system resources
- **Use checksumming** for critical data to ensure integrity
- **Set bandwidth limits** to avoid network congestion during peak hours
- **Schedule large transfers** during off-peak times
- **Use notifications** to stay informed about transfer results
- **Regularly review logs** to identify potential issues
- **Use Storage Providers** for reusable connections across multiple transfers
- **Convert existing transfers** to use Storage Providers for easier credential management
+221
View File
@@ -0,0 +1,221 @@
---
sidebar_position: 4
title: Code Review Guidelines
---
# Code Review Guidelines
This document outlines the code review process and expectations for the GoMFT project.
## Purpose of Code Reviews
Code reviews serve several important purposes:
- Ensuring code quality and consistency
- Identifying bugs, edge cases, and potential issues early
- Sharing knowledge among team members
- Ensuring adherence to project standards and best practices
- Validating that the implementation meets requirements
## Code Review Process
### 1. Before Requesting a Review
Before requesting a review, ensure your code:
- Passes all automated tests
- Follows the project's coding standards
- Is well-documented
- Includes appropriate tests
- Has a clear, descriptive PR title and description
### 2. Requesting a Review
- Create a pull request against the appropriate branch
- Fill out the PR template completely
- Tag appropriate reviewers based on the code being changed
- Respond to any automated CI/CD feedback
### 3. Conducting a Review
When reviewing code, focus on:
#### Code Quality
- Is the code readable and maintainable?
- Does it follow project conventions and patterns?
- Is the implementation efficient?
- Are edge cases handled appropriately?
#### Functionality
- Does the code meet the requirements?
- Does it handle error conditions properly?
- Is the user experience considered?
#### Testing
- Are there sufficient tests?
- Do tests cover edge cases?
- Are tests reliable (not flaky)?
#### Security
- Are there potential security issues?
- Is user input properly validated?
- Are credentials or sensitive data handled securely?
#### Documentation
- Is the code well-documented?
- Are public APIs clearly documented?
- Is the documentation accurate and up-to-date?
### 4. Providing Feedback
When providing feedback:
- Be specific and clear
- Offer suggestions for improvement
- Differentiate between required changes and optional suggestions
- Provide context or reasoning for requested changes
- Be constructive and respectful
Use these comment prefixes to indicate the severity of feedback:
- **Blocker:** Must be addressed before merging
- **Suggestion:** Recommended improvement, but not required
- **Question:** Request for clarification
- **Nitpick:** Minor style or formatting issue
- **Praise:** Highlight particularly good code
### 5. Responding to Feedback
When receiving review feedback:
- Address all comments
- Explain your reasoning if you disagree with a suggestion
- Ask for clarification if needed
- Thank reviewers for their input
- Mark resolved comments as such
### 6. Approving and Merging
A PR can be merged when:
- It has received approval from at least one reviewer
- All "Blocker" issues are resolved
- All automated checks are passing
- The PR has been rebased on the latest target branch
## Best Practices for Reviewers
### Focus on the Important Things
- Prioritize correctness, security, and maintainability
- Don't get too caught up in style issues that could be automated
- Consider the big picture and overall architecture
### Be Timely
- Try to review PRs within 1-2 business days
- If you can't review promptly, let the author know or reassign
- For urgent fixes, prioritize those reviews
### Be Thorough
- Take the time to understand the code
- Test the code locally if necessary
- Consider edge cases and failure modes
### Be Respectful
- Focus on the code, not the person
- Phrase feedback as suggestions or questions
- Acknowledge good work and improvements
## Best Practices for Authors
### Keep PRs Focused
- Each PR should address a single concern
- Large changes should be broken into smaller, logical PRs
- Avoid unrelated changes in a PR
### Provide Context
- Explain the purpose and approach in the PR description
- Link to relevant issues or documentation
- Point out areas where you're uncertain or would like specific feedback
### Respond Promptly
- Address feedback in a timely manner
- Ask questions if feedback is unclear
- Be open to suggestions and alternatives
### Test Thoroughly
- Test your changes locally before requesting review
- Consider edge cases and error scenarios
- Update tests to cover new functionality
## Special Considerations
### Security-Related Changes
- Security-focused changes require extra scrutiny
- At least one reviewer should have security expertise
- Consider potential attack vectors and edge cases
### API Changes
- Changes to public APIs require careful review
- Consider backward compatibility
- Ensure API changes are well-documented
### Database Changes
- Review for potential performance issues
- Consider migration strategy and backward compatibility
- Validate data integrity considerations
### UI Changes
- Consider accessibility implications
- Review for consistency with design standards
- Test on different devices and screen sizes
## Learning from Code Reviews
Code reviews are a learning opportunity:
- Take note of recurring feedback to improve future code
- Share knowledge gained from reviews with the team
- Use reviews to identify areas where documentation or guides could be improved
## Code Review Checklist
### General
- [ ] Code is well-structured and follows project patterns
- [ ] Variables and functions have clear, descriptive names
- [ ] Comments explain "why" not just "what"
- [ ] Unnecessary code is removed (commented code, debug logs)
- [ ] No hardcoded values that should be configurable
### Go-Specific
- [ ] Error handling is appropriate and consistent
- [ ] Follows Go idioms and best practices
- [ ] Concurrent code is safe and efficient
- [ ] Uses appropriate Go standard library functions
- [ ] Properly handles resources (file handles, connections)
### Frontend-Specific
- [ ] UI is responsive and accessible
- [ ] HTMX usage follows project patterns
- [ ] Templ templates are clean and maintainable
- [ ] JavaScript is minimal and follows best practices
- [ ] CSS follows project conventions
### Testing
- [ ] Tests are included for new functionality
- [ ] Tests cover edge cases and error paths
- [ ] Tests are clear and maintainable
- [ ] Mocks and fixtures are used appropriately
### Security
- [ ] Input validation is thorough
- [ ] No SQL injection vulnerabilities
- [ ] Authentication and authorization are properly implemented
- [ ] Sensitive data is handled securely
### Performance
- [ ] Code is efficient for expected scale
- [ ] Database queries are optimized
- [ ] Appropriate caching is used
- [ ] Resources are used efficiently
Remember that code reviews are a collaborative process aimed at improving the overall quality of the codebase. Both reviewers and authors should approach the process with a growth mindset and mutual respect.
+278
View File
@@ -0,0 +1,278 @@
---
sidebar_position: 2
title: Contributing
---
# Contributing to GoMFT
Thank you for your interest in contributing to GoMFT! This guide will help you get started with contributing to the project.
## Code of Conduct
By participating in this project, you agree to abide by our Code of Conduct. Please read it before contributing.
## Getting Started
### Prerequisites
Before you begin, ensure you have the following installed:
- **Go** (version 1.20 or later)
- **Node.js** (version 18 or later)
- **Git**
- **Docker** (optional, for container-based development)
### Setting Up the Development Environment
1. Fork the repository on GitHub.
2. Clone your forked repository:
```bash
git clone https://github.com/YOUR_USERNAME/GoMFT.git
cd GoMFT
```
3. Add the original repository as an upstream remote:
```bash
git remote add upstream https://github.com/StarFleetCPTN/GoMFT.git
```
4. Install Go dependencies:
```bash
go mod download
```
5. Install Node.js dependencies:
```bash
npm install
```
6. Install the Templ compiler:
```bash
go install github.com/a-h/templ/cmd/templ@latest
```
7. Install Air for live reloading during development:
```bash
go install github.com/cosmtrek/air@latest
```
### Development Workflow
1. Create a new branch for your feature or bug fix:
```bash
git checkout -b feature/your-feature-name
```
2. Make your changes to the codebase.
3. Compile the Templ templates:
```bash
templ generate
```
4. Run the development server with Air:
```bash
air
```
This will start the application with hot reloading enabled, so changes to Go files will trigger a rebuild.
5. For frontend development, compile the Tailwind CSS and watch for changes:
```bash
npm run dev
```
6. Access the application at `http://localhost:8080`.
## Project Structure
See the [Project Structure](/docs/development/project-structure) page for a detailed overview of the codebase organization.
## Coding Guidelines
### Go Code
- Follow the [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments) and [Effective Go](https://golang.org/doc/effective_go) guidelines.
- Format your code with `gofmt` or `go fmt`.
- Ensure your code passes `golint` and `go vet`.
- Write tests for your functionality.
- Add comments to exported functions, types, and packages.
### Frontend Code
- Follow the [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript) for JavaScript code.
- Use Tailwind CSS for styling.
- Ensure your UI components are responsive.
- Test your UI changes in different browsers.
### Commit Messages
- Use clear and meaningful commit messages.
- Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
- `style`: Changes that do not affect the meaning of the code
- `refactor`: A code change that neither fixes a bug nor adds a feature
- `test`: Adding missing tests or correcting existing tests
- `chore`: Changes to the build process or auxiliary tools
- `perf`: Performance improvements
Example: `feat: add email notification for failed transfers`
## Testing
### Running Tests
Run the Go tests:
```bash
go test ./...
```
Run specific tests:
```bash
go test ./internal/api/...
```
### Writing Tests
- Write unit tests for your functions and methods.
- Write integration tests for API endpoints.
- Aim for high test coverage, especially for critical functionality.
- Use table-driven tests where appropriate.
## Pull Request Process
1. Update your branch with the latest changes from upstream:
```bash
git fetch upstream
git rebase upstream/main
```
2. Push your branch to your forked repository:
```bash
git push origin feature/your-feature-name
```
3. Create a pull request from your branch to the main repository.
4. Ensure your PR description clearly describes the changes you've made.
5. Link any relevant issues in your PR description.
6. Wait for code review and address any feedback.
### PR Review Checklist
Before submitting your PR, please ensure:
- [ ] Your code builds without errors or warnings
- [ ] You've added tests for your changes
- [ ] All tests pass
- [ ] Your code follows the project's coding guidelines
- [ ] You've updated documentation as needed
- [ ] You've added appropriate logging
- [ ] You've considered security implications
- [ ] Your changes don't introduce performance regressions
## Development Tips
### Working with Templ
[Templ](https://github.com/a-h/templ) is used for HTML templating in GoMFT. After making changes to `.templ` files, you need to regenerate the Go code:
```bash
templ generate
```
### Working with HTMX
[HTMX](https://htmx.org/) is used for dynamic UI updates. Familiarize yourself with its concepts before making UI changes.
### Working with SQLite
GoMFT uses SQLite for data storage. The database file is located at `data/gomft.db` by default. You can use a tool like [SQLite Browser](https://sqlitebrowser.org/) to inspect the database.
### Debugging
For debugging Go code, you can use:
- `fmt.Printf()` statements for simple debugging
- [Delve](https://github.com/go-delve/delve) for more complex debugging scenarios
- VSCode's integrated Go debugger
## Documentation
### Updating Documentation
Documentation is written in Markdown and stored in the `docs/` directory. To update the documentation:
1. Edit the relevant Markdown files.
2. If you're adding new pages, update the sidebar configuration in `sidebars.ts`.
3. Preview your changes using the documentation development server:
```bash
cd documentation
npm install
npm start
```
4. Access the documentation at `http://localhost:3000`.
### API Documentation
API documentation is generated from Go comments using [Swaggo](https://github.com/swaggo/swag). To update the API documentation:
1. Update the API comments following the Swagger/OpenAPI format.
2. Regenerate the API documentation:
```bash
swag init -g cmd/api/main.go
```
## Release Process
GoMFT follows [Semantic Versioning](https://semver.org/).
### Creating a Release
1. Update the version number in relevant files:
- `VERSION` file
- `package.json`
- `internal/version/version.go`
2. Update the CHANGELOG.md file with the new version and its changes.
3. Create a new tag:
```bash
git tag -a v1.2.3 -m "Release v1.2.3"
git push origin v1.2.3
```
4. The CI/CD pipeline will build and publish the release artifacts.
## Getting Help
If you need help with contributing to GoMFT, you can:
- Open an issue on GitHub with questions
- Discuss in the GitHub Discussions section
- Reach out to the maintainers
Thank you for contributing to GoMFT!
+216
View File
@@ -0,0 +1,216 @@
---
sidebar_position: 1
title: Project Structure
---
# Project Structure
This page explains the structure of the GoMFT codebase to help developers understand how the application is organized.
## Overview
GoMFT is built using a combination of Go for the backend and web technologies for the frontend. The application follows a modular architecture to maintain separation of concerns and facilitate testing and maintenance.
## Directory Structure
Here's the high-level directory structure of the GoMFT project:
```
.
├── components/ # Templ components for UI
├── internal/
│ ├── api/ # REST API handlers
│ ├── auth/ # Authentication/authorization
│ ├── config/ # Configuration management
│ ├── db/ # Database models and operations
│ ├── email/ # Email service for notifications and password resets
│ ├── scheduler/ # Job scheduling and execution
│ └── web/ # Web interface handlers
├── static/ # Static assets
│ ├── css/
│ └── js/
└── main.go # Application entry point
```
## Core Components
### Backend (Go)
GoMFT's backend is written in Go and structured around several key packages:
#### `main.go`
The entry point for the application. It initializes the application, loads configuration, sets up the database, and starts the web server.
#### `internal/`
Contains all internal packages that are not intended to be imported by other applications.
- **`api/`**: REST API implementation
- `handlers/`: API request handlers
- `middleware/`: API middleware (authentication, logging, etc.)
- `routes.go`: API route definitions
- **`auth/`**: Authentication and authorization
- `providers/`: Authentication providers (local, LDAP, OAuth)
- `middleware/`: Authentication middleware
- `rbac/`: Role-based access control
- **`config/`**: Configuration management
- `config.go`: Application configuration structure
- `env.go`: Environment variable loading
- `file.go`: Configuration file loading
- **`db/`**: Database layer
- `models/`: Database model definitions
- `migrations/`: Database schema migrations
- `repositories/`: Data access methods
- **`email/`**: Email functionality
- `templates/`: Email templates
- `sender.go`: Email sending service
- **`scheduler/`**: Job scheduling and execution
- `cron.go`: Cron-based job scheduler
- `executor.go`: Transfer job executor
- `queue.go`: Job queue management
- **`web/`**: Web interface
- `handlers/`: Web request handlers
- `middleware/`: Web middleware
- `routes.go`: Web route definitions
#### `components/`
Contains [templ](https://github.com/a-h/templ) components that define the UI. Templ is a Go HTML templating library that provides type-safe templates.
```
components/
├── layouts/ # Page layouts
├── partials/ # Reusable UI components
├── pages/ # Page templates
│ ├── dashboard/
│ ├── transfers/
│ ├── connections/
│ ├── schedules/
│ └── admin/
└── htmx/ # HTMX-specific components
```
### Frontend
The frontend uses a combination of Tailwind CSS for styling and HTMX for dynamic interactions.
#### `static/`
Contains static assets for the web interface:
- **`css/`**: CSS files
- `main.css`: Main stylesheet (compiled from Tailwind)
- **`js/`**: JavaScript files
- `htmx.min.js`: HTMX library
- `alpine.min.js`: Alpine.js for lightweight interactivity
- `app.js`: Application-specific JavaScript
- **`img/`**: Images and icons
## Build System
GoMFT uses several tools to build and bundle the application:
- **Go Build**: Compiles the Go code
- **Templ**: Compiles templ templates to Go code
- **esbuild**: Bundles JavaScript files
- **Tailwind CSS**: Compiles CSS
The build process is orchestrated by a combination of Go commands and npm scripts defined in `package.json`.
## Configuration Files
- **`.air.toml`**: Configuration for Air, a live reload tool for Go
- **`.env.example`**: Example environment variables configuration
- **`go.mod`**: Go module definition
- **`go.sum`**: Go module checksums
- **`package.json`**: npm package definition for frontend dependencies
- **`Dockerfile`**: Docker container definition
- **`docker-compose.yaml`**: Docker Compose configuration
## Database Structure
GoMFT uses GORM (Go Object Relational Mapper) with SQLite as the default database. The main database models include:
- **`User`**: User account information
- **`Role`**: User roles for RBAC
- **`Permission`**: Individual permissions
- **`Connection`**: File transfer connection configurations
- **`Transfer`**: Transfer definitions
- **`Schedule`**: Transfer schedules
- **`History`**: Transfer execution history
- **`Setting`**: Application settings
## API Structure
The REST API follows a RESTful design with these main endpoints:
- **`/api/auth`**: Authentication endpoints
- **`/api/users`**: User management
- **`/api/connections`**: Connection management
- **`/api/transfers`**: Transfer management
- **`/api/schedules`**: Schedule management
- **`/api/history`**: Transfer history
Each endpoint typically supports standard CRUD operations.
## Web Routes
The web interface is organized around these main routes:
- **`/`**: Dashboard
- **`/connections`**: Connection management
- **`/transfers`**: Transfer management
- **`/schedules`**: Schedule management
- **`/history`**: Transfer history
- **`/admin`**: Administrative functions
## Authentication Flow
The authentication flow in GoMFT works like this:
1. User submits credentials via the login form or API
2. Credentials are validated against the configured authentication provider(s)
3. On success, a session is created for web users or a JWT token is issued for API users
4. The user's permissions are loaded based on their role
5. Requests are then authenticated via session cookie or JWT token
## Transfer Execution Flow
The transfer execution flow is as follows:
1. Transfer job is initiated (manually or via scheduler)
2. Job is added to the execution queue
3. Executor picks up the job and prepares the transfer
4. rclone is invoked with the appropriate parameters
5. Progress is monitored and logged
6. Results are recorded in the history
7. Notifications are sent if configured
## Testing Structure
GoMFT includes several types of tests:
- **Unit Tests**: Test individual functions and methods
- **Integration Tests**: Test interactions between components
- **API Tests**: Test API endpoints
- **End-to-End Tests**: Test complete user flows
Tests are organized alongside the code they're testing, following Go conventions.
## Documentation
Documentation is provided in several formats:
- **Code Comments**: Go doc comments for packages and functions
- **API Documentation**: OpenAPI/Swagger documentation for the REST API
- **User Documentation**: User guides and tutorials (this documentation site)
- **README**: Project overview and quick start instructions
+222
View File
@@ -0,0 +1,222 @@
---
sidebar_position: 3
title: Release Process
---
# Release Process
This document outlines the process for creating and publishing new releases of GoMFT.
## Version Numbering
GoMFT follows [Semantic Versioning](https://semver.org/) (SemVer) for version numbering:
- **Major version** (X.0.0): Incompatible API changes or significant architectural changes
- **Minor version** (0.X.0): New features added in a backward-compatible manner
- **Patch version** (0.0.X): Backward-compatible bug fixes and minor improvements
## Release Cycle
GoMFT follows a time-based release cycle:
- **Major releases**: Approximately once per year
- **Minor releases**: Every 2-3 months
- **Patch releases**: As needed for bug fixes and security updates
## Release Preparation
### 1. Feature Freeze
One week before a planned release:
- No new features are merged into the main branch
- Only bug fixes, documentation updates, and release preparation are allowed
- All tests must pass on the main branch
### 2. Update Documentation
- Ensure all new features are properly documented
- Update the changelog with all changes since the last release
- Review and update installation and upgrade instructions
### 3. Version Update
Update version numbers in:
- `VERSION` file in the project root
- `package.json` for frontend dependencies
- `internal/version/version.go` for the Go application
### 4. Create Release Branch
For minor and major releases, create a release branch:
```bash
git checkout -b release/vX.Y.Z
```
This branch will be used for final testing and preparation.
### 5. Update Changelog
Update the `CHANGELOG.md` file with all changes since the last release:
```markdown
# Changelog
## [X.Y.Z] - YYYY-MM-DD
### Added
- New feature 1
- New feature 2
### Changed
- Change 1
- Change 2
### Fixed
- Bug fix 1
- Bug fix 2
### Security
- Security fix 1
```
## Release Process
### 1. Final Testing
Perform the following tests on the release branch:
- Run the full test suite
- Test installation from scratch
- Test upgrading from the previous version
- Test all major features manually
- Test on different platforms (Linux, macOS, Windows)
### 2. Create Release Commit
Once testing is complete, commit the version updates:
```bash
git add VERSION package.json internal/version/version.go CHANGELOG.md
git commit -m "Release vX.Y.Z"
```
### 3. Tag the Release
Create an annotated Git tag for the release:
```bash
git tag -a vX.Y.Z -m "Release vX.Y.Z"
```
### 4. Merge to Main
If using a release branch, merge it back to main:
```bash
git checkout main
git merge release/vX.Y.Z
git push origin main
```
### 5. Push the Tag
Push the tag to trigger the CI/CD release pipeline:
```bash
git push origin vX.Y.Z
```
## Release Artifacts
The CI/CD pipeline automatically builds the following artifacts upon tagging:
1. **Docker Images**:
- `gomft/gomft:vX.Y.Z` - Specific version
- `gomft/gomft:latest` - Updated for stable releases only
2. **Binary Distributions**:
- Linux (amd64, arm64)
- macOS (amd64, arm64)
- Windows (amd64)
3. **Documentation**:
- Updated documentation website with the new version
## Post-Release Tasks
### 1. Create GitHub Release
Create a new release on GitHub:
1. Navigate to the repository's "Releases" page
2. Click "Draft a new release"
3. Select the tag you just pushed
4. Title the release "GoMFT vX.Y.Z"
5. Copy the changelog entry for this version
6. Attach the built artifacts
7. Publish the release
### 2. Announce the Release
Announce the new release through:
- Project website
- GitHub Discussions
- Relevant community forums or mailing lists
### 3. Update Demo Environment
Update the demo/staging environment to the new version to showcase the latest features.
### 4. Version Bump for Development
Create a commit on the main branch that bumps the version to the next anticipated version with a `-dev` suffix:
```bash
# Update versions in files to X.Y.(Z+1)-dev
git add VERSION package.json internal/version/version.go
git commit -m "Bump version to vX.Y.(Z+1)-dev"
git push origin main
```
## Hotfix Releases
For critical issues that need immediate fixes:
1. Create a hotfix branch from the release tag:
```bash
git checkout -b hotfix/vX.Y.(Z+1) vX.Y.Z
```
2. Make the necessary fixes
3. Update version numbers and changelog
4. Follow the standard release process from the "Final Testing" step
## Long-Term Support (LTS)
- Major versions may be designated as LTS releases
- LTS releases receive security updates and critical bug fixes for 12 months after the next major version is released
- Only the most recent major version receives new features
## Release Checklist
Use this checklist for each release:
- [ ] All tests pass on the main branch
- [ ] Documentation is up-to-date
- [ ] CHANGELOG.md is updated
- [ ] Version numbers are updated in all files
- [ ] Release branch created (for minor/major versions)
- [ ] Final testing completed successfully
- [ ] Release committed and tagged
- [ ] Tag pushed to trigger build pipeline
- [ ] GitHub release created with changelog and artifacts
- [ ] Release announced to the community
- [ ] Demo environment updated
- [ ] Development version bumped on main branch
+173
View File
@@ -0,0 +1,173 @@
---
sidebar_position: 4
title: Configuration
---
# Configuration
GoMFT can be customized through environment variables. This document provides a complete list of configuration options available in GoMFT.
## Environment Variables
Environment variables are the primary way to configure GoMFT, especially when running in Docker. These variables can be set in your Docker Compose file, `.env` file, or directly in your system environment.
### Core Configuration
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| SERVER_ADDRESS | Server address and port | :8080 | `SERVER_ADDRESS=:8080` |
| DATA_DIR | Main data directory | ./data | `DATA_DIR=/app/data` |
| BACKUP_DIR | Directory for backups | ./backups | `BACKUP_DIR=/app/backups` |
| JWT_SECRET | Secret for JWT tokens | change_this_to_a_secure_random_string | `JWT_SECRET=your-secure-secret-key` |
| GOMFT_ENCRYPTION_KEY | Key used to encrypt sensitive data in the database | change_this_to_a_secure_random_string | `GOMFT_ENCRYPTION_KEY=your-secure-encryption-key` |
| BASE_URL | Base URL for GoMFT (used in email links) | http://localhost:8080 | `BASE_URL=https://gomft.example.com` |
| SKIP_SSL_VERIFY | Skip SSL verification for outgoing webhooks/notifications | false | `SKIP_SSL_VERIFY=false` |
### Authentication Configuration
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| TOTP_ENCRYPTION_KEY | Encryption key for TOTP secrets | this-is-a-dev-key-not-for-production! | `TOTP_ENCRYPTION_KEY=your-secure-key` |
| PUID | User ID to run as (Docker only) | | `PUID=1000` |
| PGID | Group ID to run as (Docker only) | | `PGID=1000` |
### Email Configuration
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| EMAIL_ENABLED | Enable email functionality | false | `EMAIL_ENABLED=true` |
| EMAIL_HOST | SMTP server hostname | smtp.example.com | `EMAIL_HOST=smtp.gmail.com` |
| EMAIL_PORT | SMTP server port | 587 | `EMAIL_PORT=587` |
| EMAIL_USERNAME | SMTP username | user@example.com | `EMAIL_USERNAME=your-email@example.com` |
| EMAIL_PASSWORD | SMTP password | your-password | `EMAIL_PASSWORD=your-smtp-password` |
| EMAIL_FROM_EMAIL | From email address | gomft@example.com | `EMAIL_FROM_EMAIL=gomft@example.com` |
| EMAIL_FROM_NAME | From name | GoMFT | `EMAIL_FROM_NAME=GoMFT Notifications` |
| EMAIL_REPLY_TO | Reply-to email address | | `EMAIL_REPLY_TO=support@example.com` |
| EMAIL_ENABLE_TLS | Use TLS for SMTP connection | true | `EMAIL_ENABLE_TLS=true` |
| EMAIL_REQUIRE_AUTH | Require authentication for SMTP | true | `EMAIL_REQUIRE_AUTH=true` |
### OAuth Configuration (Optional)
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| GOOGLE_CLIENT_ID | Google OAuth client ID | | `GOOGLE_CLIENT_ID=your-client-id` |
| GOOGLE_CLIENT_SECRET | Google OAuth client secret | | `GOOGLE_CLIENT_SECRET=your-client-secret` |
## Configuration File
In addition to setting environment variables directly, GoMFT can also be configured using a `.env` file. This file should be placed in the root directory of your GoMFT installation.
Example `.env` file:
```
# Server Configuration
SERVER_ADDRESS=:8080
DATA_DIR=./data
BACKUP_DIR=./backups
JWT_SECRET=change_this_to_a_secure_random_string
GOMFT_ENCRYPTION_KEY=change_this_to_a_secure_random_string
BASE_URL=http://localhost:8080
SKIP_SSL_VERIFY=false
# Two-Factor Authentication configuration
TOTP_ENCRYPTION_KEY=this-is-a-dev-key-not-for-production!
# OAuth Configuration (optional)
# GOOGLE_CLIENT_ID=your_google_client_id
# GOOGLE_CLIENT_SECRET=your_google_client_secret
# Email Configuration
EMAIL_ENABLED=true
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_FROM_EMAIL=gomft@example.com
EMAIL_FROM_NAME=GoMFT
EMAIL_REPLY_TO=
EMAIL_ENABLE_TLS=true
EMAIL_REQUIRE_AUTH=true
EMAIL_USERNAME=your-email@example.com
EMAIL_PASSWORD=your-smtp-password
```
## Priority Order
GoMFT uses the following priority order for configuration:
1. Environment variables set directly
2. Variables in the `.env` file
3. Default values
This means that environment variables set directly will override settings in the `.env` file, which in turn override the default values.
## Docker Configuration
When running GoMFT in Docker, you can configure the application in several ways:
### Using Environment Variables
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-e SERVER_ADDRESS=:8080 \
-e JWT_SECRET=your-secure-secret \
-e GOMFT_ENCRYPTION_KEY=your-secure-encryption-key \
-e EMAIL_ENABLED=true \
-e EMAIL_HOST=smtp.example.com \
-e PUID=1000 \
-e PGID=1000 \
starfleetcptn/gomft:latest
```
### Using .env File
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-v /path/to/.env:/app/.env \
starfleetcptn/gomft:latest
```
### Docker Compose Example
```yaml
version: '3'
services:
gomft:
image: starfleetcptn/gomft:latest
container_name: gomft
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./backups:/app/backups
- ./.env:/app/.env # Mount .env file (optional)
environment:
- PUID=1000
- PGID=1000
restart: unless-stopped
```
## Applying Configuration Changes
Most configuration changes require a restart of the GoMFT service to take effect. After modifying environment variables or the `.env` file, restart your container or service:
```bash
# For Docker
docker restart gomft
# For Docker Compose
docker-compose restart gomft
```
## Next Steps
- [Docker Deployment](/docs/getting-started/docker) - Advanced Docker deployment options
- [Non-Root Operation](/docs/security/non-root) - Running GoMFT as a non-root user
- [Best Practices](/docs/security/best-practices) - Security best practices
+348
View File
@@ -0,0 +1,348 @@
---
sidebar_position: 3
title: Docker Deployment
---
# Docker Deployment Guide
This guide provides detailed instructions for deploying GoMFT using Docker and Docker Compose, including advanced configuration options and best practices.
## Docker Image Information
GoMFT is available as a Docker image on Docker Hub:
- **Image Name**: `starfleetcptn/gomft`
- **Tags**:
- `latest` - Latest stable release
- `edge` - Latest development build
- `v0.1.0`, `v0.2.0`, etc. - Specific version releases
## Basic Docker Run Command
The simplest way to run GoMFT with Docker:
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-v $(pwd)/backups:/app/backups \
starfleetcptn/gomft:latest
```
## Docker Compose Setup
For a more complete and production-ready setup, use Docker Compose:
```yaml
version: '3'
services:
gomft:
image: starfleetcptn/gomft:latest
container_name: gomft
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./backups:/app/backups
environment:
- TZ=UTC
- PUID=1000
- PGID=1000
restart: unless-stopped
```
Save this to a file named `docker-compose.yml` and run:
```bash
docker-compose up -d
```
## Persisting Data
GoMFT stores data in specific directories that should be mounted as volumes:
- **/app/data**: Contains the SQLite database, rclone configurations, and logs
- **/app/backups**: Contains database backups
Example with more specific volume mapping:
```yaml
volumes:
- ./data/db:/app/data/db # Database files
- ./data/configs:/app/data/configs # Rclone config files
- ./data/logs:/app/data/logs # Log files
- ./backups:/app/backups # Backup files
```
## File Transfer Volumes
In addition to the application data, you'll need to mount volumes for the files you want to transfer. These volumes provide GoMFT access to your source files and destination directories.
### Common File Volume Mounts
```yaml
volumes:
# Application data volumes
- ./data:/app/data
- ./backups:/app/backups
# File transfer volumes
- /path/to/source/files:/sftp/files # Source files for transfer
- /path/to/destination:/mft/destination # Destination for transferred files
- /path/to/temp:/mft/temp # Temporary processing directory
```
### Docker Run Example with File Volumes
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-v $(pwd)/backups:/app/backups \
-v /path/to/source/files:/sftp/files \
-v /path/to/destination:/mft/destination \
-v /path/to/temp:/mft/temp \
starfleetcptn/gomft:latest
```
### Docker Compose Example with File Volumes
```yaml
services:
gomft:
image: starfleetcptn/gomft:latest
container_name: gomft
ports:
- "8080:8080"
volumes:
# Application data
- ./data:/app/data
- ./backups:/app/backups
# File transfer directories
- ./source_files:/sftp/files
- ./destination:/mft/destination
- ./temp:/mft/temp
environment:
- TZ=UTC
restart: unless-stopped
```
### Volume Permissions
When mounting file volumes, ensure the container has appropriate permissions to access these directories:
1. If using `PUID` and `PGID` environment variables:
```bash
# Set correct ownership on host directories
chown -R 1000:1000 /path/to/source/files
chown -R 1000:1000 /path/to/destination
chown -R 1000:1000 /path/to/temp
```
2. Or set appropriate permissions:
```bash
# Make directories accessible to the container
chmod -R 755 /path/to/source/files
chmod -R 755 /path/to/destination
chmod -R 755 /path/to/temp
```
## Environment Variables
GoMFT can be configured using environment variables:
### Basic Configuration
```yaml
environment:
- PORT=8080 # Web UI port
- BASE_URL=https://gomft.example.com # Base URL for email links
- TZ=America/New_York # Timezone
```
### Data Directory Configuration
```yaml
environment:
- DATA_DIR=/app/data # Main data directory
- LOGS_DIR=/app/data/logs # Logs directory
- BACKUP_DIR=/app/backups # Backup directory
```
### Email Notification Configuration
```yaml
environment:
- EMAIL_ENABLED=true # Enable email notifications
- EMAIL_HOST=smtp.example.com # SMTP server host
- EMAIL_PORT=587 # SMTP server port
- EMAIL_USER=user@example.com # SMTP username
- EMAIL_PASSWORD=password # SMTP password
- EMAIL_FROM=gomft@example.com # From address for emails
```
### Security Configuration
```yaml
environment:
- JWT_SECRET=your-secret-key # Secret for JWT tokens
- ENCRYPT_KEY=32-char-key # Key for encrypting sensitive data
```
## Running as Non-Root User
For enhanced security, run GoMFT as a non-root user:
```yaml
environment:
- PUID=1000 # User ID to run as
- PGID=1000 # Group ID to run as
```
Make sure your mounted volumes have the appropriate permissions for this user.
## Exposing GoMFT Behind a Reverse Proxy
It's recommended to run GoMFT behind a reverse proxy like Nginx or Traefik for SSL termination and security.
### Nginx Example
```nginx
server {
listen 80;
server_name gomft.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name gomft.example.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
location / {
proxy_pass http://gomft:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Docker Compose with Traefik Example
```yaml
version: '3'
services:
traefik:
image: traefik:v2.5
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
- "--certificatesresolvers.myresolver.acme.email=your@email.com"
- "--certificatesresolvers.myresolver.acme.storage=/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./acme.json:/acme.json
restart: unless-stopped
gomft:
image: starfleetcptn/gomft:latest
volumes:
- ./data:/app/data
- ./backups:/app/backups
environment:
- PUID=1000
- PGID=1000
- BASE_URL=https://gomft.example.com
labels:
- "traefik.enable=true"
- "traefik.http.routers.gomft.rule=Host(`gomft.example.com`)"
- "traefik.http.routers.gomft.entrypoints=websecure"
- "traefik.http.routers.gomft.tls.certresolver=myresolver"
restart: unless-stopped
```
## Health Checks
You can configure a health check to monitor the container's health:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 1m
timeout: 10s
retries: 3
start_period: 30s
```
## Resource Limits
Set resource limits to prevent the container from consuming too many resources:
```yaml
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 512M
```
## Logging Configuration
Configure container logging:
```yaml
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
## Troubleshooting Docker Deployment
If you encounter issues with your Docker deployment:
1. Check container logs:
```
docker logs gomft
```
2. Check container status:
```
docker ps -a | grep gomft
```
3. Verify volume permissions:
```
ls -la ./data
```
4. Check container environment:
```
docker exec gomft env
```
5. Inspect the container:
```
docker inspect gomft
```
For more help, refer to the [GitHub repository](https://github.com/StarFleetCPTN/GoMFT) or open an issue.
+203
View File
@@ -0,0 +1,203 @@
---
sidebar_position: 1
title: Installation
---
# Installing GoMFT
GoMFT can be installed using Docker (recommended) or through a traditional installation. This guide covers both methods.
## System Requirements
- **CPU**: 1+ cores (2+ recommended for production)
- **RAM**: 512MB minimum (1GB+ recommended for production)
- **Disk Space**: 100MB for the application plus space for your transfer data and logs
- **Operating System**: Linux, macOS, or Windows with Docker support
## Docker Installation (Recommended)
The easiest way to deploy GoMFT is using Docker. This method handles all dependencies and provides an isolated environment.
### Using Docker Run
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
starfleetcptn/gomft:latest
```
Replace `/path/to/data` and `/path/to/backups` with your desired local paths for persistent storage.
### Using Docker Compose
Create a `docker-compose.yaml` file:
```yaml
version: '3'
services:
gomft:
image: starfleetcptn/gomft:latest
container_name: gomft
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./backups:/app/backups
environment:
- TZ=UTC
restart: unless-stopped
```
Then run:
```bash
docker-compose up -d
```
### Environment Variables
You can customize your GoMFT installation using environment variables:
```yaml
environment:
- TZ=America/New_York
- PORT=8080
- DATA_DIR=/app/data
- BACKUP_DIR=/app/backups
- LOGS_DIR=/app/data/logs
- EMAIL_ENABLED=false
- BASE_URL=http://localhost:8080
```
See the [Configuration](/docs/getting-started/configuration) section for a complete list of environment variables.
### File Volume Mounts
When running GoMFT in Docker, you'll need to mount volumes to provide access to the files you want to transfer. Here are common volume mount scenarios:
#### For SFTP/FTP Source Files
```bash
-v /path/to/local/files:/sftp/files
```
#### For Destination Directories
```bash
-v /path/to/destination:/mft/destination
```
#### For Processing Temporary Files
```bash
-v /path/to/temp:/mft/temp
```
Example using Docker Run with file volumes:
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-v /path/to/local/files:/sftp/files \
-v /path/to/destination:/mft/destination \
starfleetcptn/gomft:latest
```
Example Docker Compose configuration with file volumes:
```yaml
services:
gomft:
image: starfleetcptn/gomft:latest
container_name: gomft
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./backups:/app/backups
- ./source_files:/sftp/files
- ./destination:/mft/destination
- ./temp:/mft/temp
environment:
- TZ=UTC
restart: unless-stopped
```
> **Note**: Ensure the container has appropriate permissions to access the mounted directories. You may need to adjust host-side permissions accordingly.
## Traditional Installation
For environments where Docker is not available or preferred, you can install GoMFT directly.
### Prerequisites
- Go 1.20 or later
- Node.js 18 or later
- gcc (for building SQLite dependencies)
- templ (for generating template code)
### Building from Source
1. Clone the repository:
```bash
git clone https://github.com/StarFleetCPTN/GoMFT.git
cd GoMFT
```
2. Install Node.js dependencies:
```bash
npm install
```
3. Build the frontend assets:
```bash
npm run build
```
4. Install templ if you haven't already:
```bash
go install github.com/a-h/templ/cmd/templ@latest
```
5. Generate templ templates:
```bash
templ generate
```
6. Build the Go application:
```bash
go build -o gomft
```
7. Run the application:
```bash
./gomft
```
## Verifying the Installation
After installation, access the GoMFT web interface by navigating to:
```
http://localhost:8080
```
The default login credentials are:
- **Username**: admin
- **Password**: admin
**Important**: Change the default password immediately after the first login for security reasons.
## Next Steps
Once GoMFT is installed, proceed to the [Quick Start](/docs/getting-started/quick-start) guide to begin configuring your file transfers.
+88
View File
@@ -0,0 +1,88 @@
---
sidebar_position: 2
title: Quick Start
---
# GoMFT Quick Start Guide
This guide will help you get up and running with GoMFT quickly. We'll cover logging in, creating your first connection configuration, and setting up a file transfer.
## Accessing the Web Interface
After installation, access the GoMFT web interface at `http://your-server:8080` (or the appropriate port if you've modified it).
1. Log in with the default credentials:
- **Username**: admin@example.com
- **Password**: admin
## Initial Dashboard
The dashboard provides an overview of:
- Recent transfer jobs
- Upcoming scheduled transfers
- System status
- Quick action buttons
![GoMFT Dashboard](../../static/img/dashboard.gomft.png)
## Creating Your First Transfer Configuration
1. Navigate to **Transfer Configurations** in the sidebar menu
2. Click **+ New Configuration**
3. Configure the transfer:
- Select source and destination configurations
- Specify source and destination paths
- Choose the transfer type (Copy, Sync, Move, etc.)
- Configure transfer options (file filtering, bandwidth limits, etc.)
4. Click **Save Transfer**
![Create Transfer](../../static/img/transfer.config.gomft.png)
## Create Your First Scheduled Job
1. Naviagate to **Scheduled Jobs** in the sidebar menu
2. Click **+ New Job**
3. Configure the job:
- Specifiy schedule
- Select Job(s) to run this can be 1 or more
- Change job run order if needed
## Running a Transfer
Once you've created a transfer configuration, you can:
### Run On-Demand
1. Navigate to **Secheduled Jobs**
2. Find your transfer in the list
3. Click the **Run Now** button
4. The transfer will execute immediately
### Schedule a Transfer
1. Navigate to **Schedules**
2. Click **Create New Schedule**
3. Select your transfer configuration
4. Set the schedule using cron syntax or the schedule builder
5. Set additional options (timeout, max retries, etc.)
6. Click **Save Schedule**
## Monitoring Transfers
1. Navigate to **Transfer History** to view all past and ongoing transfers
2. Click on a specific transfer to view detailed information:
- Transfer status
- Start and end times
- Files transferred
- Bytes transferred
- Errors (if any)
- Transfer log
## Next Steps
Now that you've set up your first transfer, explore these additional features:
- [Docker Deployment](/docs/getting-started/docker) - For containerized deployment
- [Traditional Installation](/docs/getting-started/traditional) - For non-Docker environments
- [Transfer Concepts](/docs/core-concepts/transfers) - Learn more about transfer operations
- [Scheduling](/docs/core-concepts/schedules) - Advanced scheduling options
- [Monitoring](/docs/core-concepts/monitoring) - Advanced monitoring capabilities
+325
View File
@@ -0,0 +1,325 @@
---
sidebar_position: 4
title: Traditional Installation
---
# Traditional Installation Guide
This guide covers installing GoMFT directly on your system without using Docker. This approach is useful for environments where containers aren't available or when you need more direct control over the installation.
## System Requirements
- **Operating System**: Linux, macOS, or Windows
- **Go**: Version 1.20 or later
- **Node.js**: Version 18 or later
- **Build Tools**: gcc and related build tools (for SQLite compilation)
## Prerequisites Installation
### On Debian/Ubuntu Linux
```bash
# Install Go
wget https://go.dev/dl/go1.20.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.20.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile
source ~/.profile
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install build tools
sudo apt-get install -y build-essential
```
### On macOS
```bash
# Using Homebrew
brew install go
brew install node
brew install gcc
```
### On Windows
1. Install Go from [https://golang.org/dl/](https://golang.org/dl/)
2. Install Node.js from [https://nodejs.org/](https://nodejs.org/)
3. Install Build Tools for Visual Studio
## Building GoMFT from Source
1. Clone the repository:
```bash
git clone https://github.com/StarFleetCPTN/GoMFT.git
cd GoMFT
```
2. Install Node.js dependencies and build the frontend:
```bash
npm install
npm run build
```
3. Compile the Go application:
```bash
go build -o gomft main.go
```
## Installation Options
### Option 1: Run Directly
After building, you can run the application directly:
```bash
./gomft
```
### Option 2: Install as a System Service
#### On Linux (systemd)
Create a systemd service file:
```bash
sudo nano /etc/systemd/system/gomft.service
```
Add the following content:
```ini
[Unit]
Description=GoMFT - Go Managed File Transfer
After=network.target
[Service]
Type=simple
User=gomft
Group=gomft
WorkingDirectory=/opt/gomft
ExecStart=/opt/gomft/gomft
Restart=on-failure
RestartSec=5s
Environment="PORT=8080"
Environment="DATA_DIR=/var/lib/gomft/data"
Environment="BACKUP_DIR=/var/lib/gomft/backups"
Environment="LOGS_DIR=/var/log/gomft"
[Install]
WantedBy=multi-user.target
```
Create a dedicated user and set up directories:
```bash
# Create user
sudo useradd -r -s /bin/false gomft
# Create directories
sudo mkdir -p /opt/gomft /var/lib/gomft/data /var/lib/gomft/backups /var/log/gomft
# Copy application
sudo cp -r * /opt/gomft/
# Set permissions
sudo chown -R gomft:gomft /opt/gomft /var/lib/gomft /var/log/gomft
```
Enable and start the service:
```bash
sudo systemctl enable gomft
sudo systemctl start gomft
```
#### On macOS (launchd)
Create a launchd plist file:
```bash
sudo nano /Library/LaunchDaemons/com.gomft.plist
```
Add the following content:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.gomft</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/gomft/gomft</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>/usr/local/gomft</string>
<key>EnvironmentVariables</key>
<dict>
<key>PORT</key>
<string>8080</string>
<key>DATA_DIR</key>
<string>/var/lib/gomft/data</string>
<key>BACKUP_DIR</key>
<string>/var/lib/gomft/backups</string>
<key>LOGS_DIR</key>
<string>/var/log/gomft</string>
</dict>
</dict>
</plist>
```
Set up directories and install:
```bash
# Create directories
sudo mkdir -p /usr/local/gomft /var/lib/gomft/data /var/lib/gomft/backups /var/log/gomft
# Copy application
sudo cp -r * /usr/local/gomft/
# Set permissions
sudo chown -R $(whoami):staff /usr/local/gomft /var/lib/gomft /var/log/gomft
# Load service
sudo launchctl load /Library/LaunchDaemons/com.gomft.plist
```
#### On Windows (Windows Service)
1. Install [NSSM (Non-Sucking Service Manager)](https://nssm.cc/download)
2. Open Command Prompt as Administrator
3. Create the service:
```bat
nssm install GoMFT C:\path\to\gomft.exe
nssm set GoMFT AppDirectory C:\path\to\gomft\directory
nssm set GoMFT AppEnvironmentExtra PORT=8080 DATA_DIR=C:\ProgramData\GoMFT\data BACKUP_DIR=C:\ProgramData\GoMFT\backups LOGS_DIR=C:\ProgramData\GoMFT\logs
nssm start GoMFT
```
## Configuration
### Environment Variables
Create a `.env` file in the application directory or set system environment variables:
```
PORT=8080
DATA_DIR=/var/lib/gomft/data
BACKUP_DIR=/var/lib/gomft/backups
LOGS_DIR=/var/log/gomft
BASE_URL=http://localhost:8080
EMAIL_ENABLED=false
JWT_SECRET=your-secret-key
ENCRYPT_KEY=32-character-encryption-key
```
### Web Server Setup
For production use, it's recommended to run GoMFT behind a web server like Nginx:
#### Nginx Configuration
```nginx
server {
listen 80;
server_name your-gomft-server.com;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Updating GoMFT
To update a traditionally installed GoMFT:
1. Stop the service:
```bash
sudo systemctl stop gomft # For Linux
sudo launchctl unload /Library/LaunchDaemons/com.gomft.plist # For macOS
nssm stop GoMFT # For Windows
```
2. Back up your data:
```bash
cp -r /var/lib/gomft/data /var/lib/gomft/data.backup
```
3. Get the latest code:
```bash
cd /path/to/gomft/source
git pull
```
4. Rebuild:
```bash
npm install
npm run build
go build -o gomft main.go
```
5. Update the installation:
```bash
sudo cp gomft /opt/gomft/ # For Linux
sudo cp gomft /usr/local/gomft/ # For macOS
copy gomft.exe C:\path\to\gomft.exe # For Windows
```
6. Restart the service:
```bash
sudo systemctl start gomft # For Linux
sudo launchctl load /Library/LaunchDaemons/com.gomft.plist # For macOS
nssm start GoMFT # For Windows
```
## Troubleshooting
### Common Issues
1. **Permission Errors**:
- Check that the user running GoMFT has write permissions to the data, backup, and logs directories.
2. **Database Errors**:
- Ensure the SQLite database path is writeable.
- Check database integrity: `sqlite3 /var/lib/gomft/data/gomft.db "PRAGMA integrity_check;"`
3. **Port Already in Use**:
- Change the port in the configuration.
- Check what's using port 8080: `sudo lsof -i :8080`
4. **Missing Dependencies**:
- Make sure all required Go and Node.js dependencies are installed.
### Viewing Logs
- **Application Logs**: Check `/var/log/gomft/` or your configured logs directory
- **System Service Logs**:
```bash
# For Linux
journalctl -u gomft
# For macOS
log show --predicate 'senderImagePath contains "gomft"'
# For Windows
Get-EventLog -LogName Application -Source GoMFT
```
For more help, refer to the [GitHub repository](https://github.com/StarFleetCPTN/GoMFT) or open an issue.
+78
View File
@@ -0,0 +1,78 @@
---
sidebar_position: 2
title: Features
---
# GoMFT Features
GoMFT offers a comprehensive set of features that make it a powerful solution for managed file transfers. Here's a detailed breakdown of what GoMFT offers:
## Core Features
### Multi-Protocol Support
GoMFT leverages rclone to support a wide range of storage providers and protocols:
- **Cloud Storage**: Amazon S3, Google Cloud Storage
- **Object Storage**: MinIO, Backblaze B2, Wasabi
- **FTP/SFTP**: FTP, FTPS, SFTP servers
- **WebDAV**: WebDAV servers and services
- **Local Storage**: Local disk, SMB/CIFS shares
- **And many more**: Over 40 storage systems supported
### Intuitive Web Interface
- **Clean, Modern UI**: Easy-to-use web interface built with Tailwind CSS and HTMX
- **Dashboard**: Overview of recent transfers, scheduled jobs, and system status
- **Configuration Manager**: Visual interface for creating and editing transfer configurations
- **Job Scheduler**: Interface for creating and managing scheduled jobs
- **Transfer Logs**: Detailed logs of all transfer operations
- **Dark Mode**: Support for light and dark themes
### Powerful Scheduling
- **Cron-style Scheduling**: Set up transfers using familiar cron syntax
- **Recurring Transfers**: Schedule transfers to run on a regular basis
- **One-time Transfers**: Run transfers immediately or at a specific time
- **Schedule Grouping**: Organize schedules into logical groups
- **Priority Control**: Set priority levels for scheduled tasks
## Advanced Features
### Transfer Options
- **Bidirectional Sync**: Synchronize files in both directions
- **File Filtering**: Include or exclude files based on patterns
- **Bandwidth Limiting**: Restrict bandwidth usage for transfers
- **Parallel Transfers**: Configure the number of simultaneous transfers
- **Delta Transfers**: Transfer only changed parts of files
- **Checksumming**: Verify file integrity during transfers
### Notification System
- **Notifications**: Receive alerts when transfers complete or fail
- **Custom Templates**: Customize notification content and format
- **Notification Rules**: Configure which events trigger notifications
- **Notification Providers**: Webhooks, Ntfy, Gotify, Pushover, Pushbullet
### Admin Tools
- **User Management**: Create and manage users with different roles
- **Role-Based Access Control**: Control access to different parts of the application
- **Audit Logging**: Track user actions for security and compliance
- **System Monitoring**: Monitor system performance and resource usage
- **Database Backup/Restore**: Back up and restore the application database
- **Log Viewer**: Browse and search through application logs
### Security Features
- **Authentication**: Secure login with optional MFA support
- **Encryption**: Encrypt data in transit and at rest
- **Secure Credential Storage**: Safely store connection credentials
- **Non-Root Container Support**: Run containers as non-root users for enhanced security
## Integration Capabilities
- **Docker Support**: Easy deployment with Docker containers
- **Docker Compose**: Multi-container deployment using Docker Compose
- **Reverse Proxy Compatible**: Works behind reverse proxies like Nginx or Traefik
+39
View File
@@ -0,0 +1,39 @@
---
sidebar_position: 1
title: Overview
---
# GoMFT Overview
GoMFT is a modern, web-based managed file transfer solution written in Go. It provides an intuitive interface for setting up, scheduling, and monitoring file transfers across various storage backends.
## What is GoMFT?
GoMFT (Go Managed File Transfer) is an open-source file transfer platform that enables reliable, secure, and automated file transfers. Built on top of the powerful [rclone](https://rclone.org/) engine, GoMFT provides a user-friendly web interface that makes it easy to configure and manage complex file transfer operations.
## Key Benefits
- **User-Friendly Interface**: Intuitive web UI for configuring and monitoring file transfers
- **Multi-Protocol Support**: Transfer files using SFTP, S3, Google Drive, and many more protocols
- **Automated Scheduling**: Set up recurring transfers with flexible scheduling options
- **Comprehensive Logging**: Detailed logs for troubleshooting and audit purposes
- **Notifications**: Get alerts when transfers succeed or fail
- **Docker Support**: Easy deployment with Docker containers
- **Security**: Role-based access control and secure credential management
## Use Cases
- **Data Synchronization**: Keep files in sync across different storage systems
- **Backup and Archiving**: Automate backup processes to cloud or local storage
- **Secure File Exchange**: Transfer files securely between organizations
- **Cloud Migration**: Move data between different cloud providers
- **Workflow Automation**: Trigger file transfers as part of larger workflows
- **Compliance**: Maintain audit logs for regulatory compliance
GoMFT is designed to be simple to deploy and use, while providing the reliability and features needed for enterprise file transfer needs.
## Community Support
Join our Discord community for support, discussions, and updates about GoMFT:
<a href="https://discord.gg/f9dwtM3j" className="discord-badge">Join Discord Community</a>
+158
View File
@@ -0,0 +1,158 @@
---
sidebar_position: 2
title: Authentication
---
# Authentication and Authorization
GoMFT provides robust authentication and authorization mechanisms to ensure secure access to the application and its features. This page explains how to configure and manage authentication in GoMFT.
## Authentication Methods
GoMFT supports multiple authentication methods to secure access to the application:
### Local Authentication
The default authentication method using GoMFT's built-in user database:
- **Username/Password**: Traditional username and password authentication
- **Password Requirements**: Configurable password complexity rules
- **Password Expiration**: Force password changes after a configurable period
- **Account Lockout**: Temporarily lock accounts after failed login attempts
### OAuth/OpenID Connect
Support for modern identity providers:
- **Single Sign-On**: Integrate with SSO solutions
- **Identity Providers**: Support for popular providers (Google, Microsoft, Okta, etc.)
- **JWT Tokens**: Secure token-based authentication
- **Automatic Account Provisioning (COMING SOON)**: Create GoMFT accounts based on SSO information
## Setting Up Authentication
### Configuring Local Authentication
Local authentication is enabled by default and requires minimal setup:
### Configuring OAuth/OpenID Connect
To set up OAuth or OpenID Connect:
1. Navigate to **Settingss** > **Authentication Providers**
2. Select **OAuth/OIDC** as an authentication method
3. Configure provider settings:
- Provider URL
- Client ID
- Client Secret
- Scope (e.g., `openid profile email`)
- Callback URL
4. Set up attribute mappings:
- Map provider attributes to GoMFT user properties
- Configure role attribute or claim
5. Test the configuration
## Multi-Factor Authentication (MFA)
GoMFT supports multi-factor authentication for enhanced security:
### MFA Options
- **Time-based One-Time Password (TOTP)**: Compatible with apps like Google Authenticator
- **Email Verification Codes**: One-time codes sent via email
- **Recovery Codes**: Backup codes for emergency access
### Enabling MFA
For users to set up MFA:
1. Log in to GoMFT
2. Navigate to **Profile** > **Security Settings**
3. Select **Enable Multi-Factor Authentication**
4. Choose the MFA method (e.g., TOTP)
5. Follow the setup instructions:
- For TOTP: Scan QR code with authenticator app
- For Email: Verify email address
6. Generate and save recovery codes
## User Management
### Creating Users
To create new users:
1. Navigate to **Administration** > **Users**
2. Click **Create New User**
3. Fill in the user details:
- Username
- Email address
- Full name
- Initial password or send password reset link
- Role assignment
4. Click **Create Users**
### Managing User Accounts
To manage existing users:
1. Navigate to **Administration** > **Users**
2. Find the user in the list
3. Available actions:
- Edit user details
- Change role assignment
- Reset password
- Enable/disable account
- Force MFA enrollment
- Delete user
### User Self-Service
GoMFT provides self-service features for users:
- **Profile Management**: Users can update their profile information
- **Password Change**: Users can change their password
- **MFA Setup**: Users can configure their MFA preferences
## Role-Based Access Control
GoMFT implements role-based access control (RBAC) to manage permissions:
### Default Roles
- **Administrator**: Full access to all system features
- **System**: Can manage transfers and connections but not admin settings
- **User**: Basic access to create and manage personal transfers
### Creating Custom Roles
To create a custom role:
1. Navigate to **Administration** > **Roles**
2. Click **Create New Role**
3. Define the role:
- Role name
- Description
- Permission assignments
4. Save the role
### Permission Categories
GoMFT organizes permissions into categories:
- **System Administration**: System-wide settings and maintenance
- **User Management**: User and role administration
- **Transfer Management**: Creating and managing transfers
- **Connection Management**: Creating and managing connections
- **Schedule Management**: Managing transfer schedules
- **Execution Control**: Running and controlling transfers
- **Monitoring**: Viewing logs and reports
## Security Best Practices
- **Enforce Strong Passwords**: Configure strong password requirements
- **Enable MFA**: Require MFA for all users, especially administrators
- **Regular Review**: Periodically review user accounts and permissions
- **Principle of Least Privilege**: Assign the minimum necessary permissions
- **Audit Authentication**: Monitor and audit authentication events
- **Secure Configuration**: Properly secure authentication configuration files
- **Account Lifecycle**: Implement processes for account creation and termination
+187
View File
@@ -0,0 +1,187 @@
---
sidebar_position: 1
title: Security Best Practices
---
# Security Best Practices
This guide provides recommendations for securing your GoMFT installation and maintaining a secure file transfer environment.
## Installation Security
### Use Docker Security Features
When deploying GoMFT with Docker:
- **Run as Non-Root**: Always run the container as a non-root user (see [Running as Non-Root](/docs/security/non-root))
- **Use Read-Only Filesystem**: Mount the filesystem as read-only except for specific data directories
- **Limit Capabilities**: Use Docker's `--cap-drop` to limit container capabilities
- **Set Resource Limits**: Prevent resource exhaustion with memory and CPU limits
- **Use Docker Secrets**: Store sensitive configuration in Docker secrets instead of environment variables
Example secure docker-compose configuration:
```yaml
services:
gomft:
image: starfleetcptn/gomft:latest
user: "1000:1000"
read_only: true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
volumes:
- ./data:/app/data
- ./backups:/app/backups
environment:
- TZ=UTC
deploy:
resources:
limits:
cpus: '1'
memory: 1G
```
### Traditional Installation Security
For traditional installations:
- **Dedicated User**: Create a dedicated system user for running GoMFT
- **Minimal Permissions**: Give the user only the permissions it needs
- **Firewall Rules**: Restrict access to only necessary ports
- **SELinux/AppArmor**: Use system security modules to limit application scope
## Network Security
### Use HTTPS
Always use HTTPS for the web interface:
- **Configure TLS**: Use a reverse proxy like Nginx or Traefik for TLS termination
- **Strong Ciphers**: Use modern, secure cipher suites
- **HSTS**: Enable HTTP Strict Transport Security
- **Valid Certificates**: Use trusted certificates from Let's Encrypt or other providers
### Access Control
- **IP Restrictions**: Limit access to trusted IP addresses where possible
- **VPN Access**: Consider placing GoMFT behind a VPN for additional security
- **Firewall Rules**: Configure firewall rules to restrict access to essential ports only
## Authentication and Authorization
### Strong Authentication
- **Password Policy**: Enforce strong password requirements
- **MFA**: Enable Multi-Factor Authentication for all users
- **Session Management**: Set appropriate session timeouts
- **Failed Login Limits**: Implement account lockouts after several failed attempts
### Role-Based Access Control
- **Principle of Least Privilege**: Grant users only the permissions they need
- **Separation of Duties**: Use roles to separate administrative functions
- **Regular Review**: Periodically review user roles and permissions
## Credential Management
### Secure Storage
- **Encrypted Credentials**: Ensure all credentials are encrypted at rest
- **Isolated Storage**: Store sensitive credentials in a separate database or secure storage
- **Key Rotation**: Regularly rotate encryption keys
### Credential Practices
- **Service Accounts**: Use service accounts instead of personal accounts for connections
- **Temporary Credentials**: Use temporary credentials where supported (e.g., AWS STS)
- **API Keys**: Regularly rotate API keys and access tokens
- **Minimal Scope**: Grant credentials the minimum required permissions
## Transfer Security
### Secure Protocols
- **Choose Secure Protocols**: Prefer SFTP, FTPS, or HTTPS over unencrypted protocols
- **Disable Legacy Protocols**: Disable insecure protocols like FTP where possible
- **Strong Ciphers**: Configure secure cipher suites for encrypted protocols
### Data Handling
- **Data Classification**: Classify data by sensitivity and apply appropriate controls
- **Data Validation**: Validate files before processing them
- **Virus Scanning**: Implement virus scanning for transferred files
- **Data Loss Prevention**: Consider DLP measures for sensitive data
## Auditing and Monitoring
### Comprehensive Logging
- **Detailed Logs**: Enable detailed logging for all operations
- **Secure Log Storage**: Store logs securely with access controls
- **Log Rotation**: Implement log rotation to manage disk space
- **Tamper Protection**: Ensure logs cannot be modified or deleted
### Monitoring and Alerting
- **Real-time Monitoring**: Monitor for suspicious activities
- **Security Alerts**: Configure alerts for security-related events
- **Performance Monitoring**: Watch for performance issues that might indicate attacks
- **Regular Review**: Establish a process for regular log review
## System Security
### Regular Updates
- **Update GoMFT**: Keep GoMFT updated to the latest version
- **Patch Host System**: Keep the host operating system patched
- **Update Dependencies**: Keep all dependencies (Docker, etc.) updated
### Backup and Recovery
- **Regular Backups**: Back up the GoMFT database and configurations regularly
- **Secure Backups**: Encrypt backups and store them securely
- **Test Restoration**: Regularly test backup restoration
- **Disaster Recovery Plan**: Create and maintain a disaster recovery plan
## Periodic Security Review
### Security Assessments
- **Vulnerability Scanning**: Regularly scan for vulnerabilities
- **Penetration Testing**: Conduct periodic penetration tests
- **Configuration Review**: Review security configurations regularly
- **Compliance Checks**: Ensure ongoing compliance with relevant standards
### Documentation
- **Security Policies**: Document security policies and procedures
- **Configuration Documentation**: Maintain documentation of secure configurations
- **Incident Response Plan**: Create and maintain an incident response plan
## Integrating with Security Tools
GoMFT can be integrated with external security tools:
- **SIEM Integration**: Forward logs to Security Information and Event Management tools
- **Vulnerability Scanners**: Include GoMFT in vulnerability scanning
- **Compliance Tools**: Integrate with compliance monitoring tools
## Best Practices for Specific Environments
### Cloud Deployment
- **Cloud Security Services**: Utilize cloud provider security services
- **Network Security Groups**: Configure appropriate network security groups
- **Private Endpoints**: Use private endpoints where possible
- **Cloud IAM**: Leverage cloud Identity and Access Management
### On-Premises Deployment
- **Network Segmentation**: Place GoMFT in an appropriate network segment
- **Physical Security**: Ensure physical security of the servers
- **Environmental Controls**: Implement appropriate environmental controls
- **Backup Power**: Ensure backup power for critical systems
+235
View File
@@ -0,0 +1,235 @@
---
sidebar_position: 3
title: Running as Non-Root
---
# Running GoMFT as a Non-Root User
By default, Docker containers run as the root user, which can pose security risks. GoMFT fully supports running as a non-root user, which is recommended for production environments.
## Benefits of Running as Non-Root
- **Improved Security**: Limits the potential damage if the container is compromised
- **Better File Permissions**: Files created by the container will match your host user permissions
- **Compliance**: Many security policies and best practices require containers to run as non-root
## Methods to Run GoMFT as Non-Root
GoMFT supports several methods for running as a non-root user, each with its own advantages.
### Method 1: Using PUID/PGID Environment Variables (Recommended)
This method allows changing the user at runtime without rebuilding the image:
```bash
# Using current user's ID
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
```
Or in docker-compose.yml:
```yaml
services:
gomft:
image: starfleetcptn/gomft:latest
environment:
- PUID=1000 # Your user ID
- PGID=1000 # Your group ID
volumes:
- ./data:/app/data
- ./backups:/app/backups
```
### Method 2: Using the `--user` Flag with Docker Run
This method is simple but doesn't support some advanced features like permission fixing:
```bash
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
```
### Method 3: Using Docker Compose with Environment Variables
This approach uses environment variables from the host for the user directive:
```yaml
services:
gomft:
image: starfleetcptn/gomft:latest
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./data:/app/data
- ./backups:/app/backups
```
Run with:
```bash
UID=$(id -u) GID=$(id -g) docker-compose up -d
```
### Method 4: Building a Custom Image with Specified UID/GID
This method builds a custom image with your specified user ID:
```dockerfile
FROM starfleetcptn/gomft:latest
ARG UID=1000
ARG GID=1000
RUN usermod -u $UID gomft && groupmod -g $GID gomft
```
In docker-compose.yml:
```yaml
services:
gomft:
build:
context: .
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
```
## Environment Variables for User Management
| Variable | Description | Default |
| -------- | ------------------ | ------------------------ |
| PUID | User ID to run as | Built-in user ID (1000) |
| PGID | Group ID to run as | Built-in group ID (1000) |
| USERNAME | Username to use | gomft |
## Volume Permissions
When running as a non-root user, ensure that the directories on the host have appropriate permissions for the container user:
### Option 1: Create Directories with Correct Ownership (Recommended)
```bash
# Create directories
mkdir -p data backups
# Set ownership to match the PUID/PGID you'll use
chown -R 1000:1000 data backups
```
### Option 2: Adjust Permissions (Less Secure, but Easier for Testing)
```bash
mkdir -p data backups
chmod -R 777 data backups
```
## Verifying Non-Root Operation
To verify that GoMFT is running as a non-root user:
```bash
docker exec gomft id
```
You should see output showing the UID and GID you specified.
## Troubleshooting Permission Issues
### Common Issues
1. **Volume Mount Permission Denied**: The container user doesn't have permission to access mounted volumes
**Solution**:
```bash
chown -R <PUID>:<PGID> ./data ./backups
```
2. **Cannot Write to Log Files**: Permission issues with log files
**Solution**:
```bash
# Ensure log directory exists and has correct permissions
mkdir -p ./data/logs
chown -R <PUID>:<PGID> ./data/logs
```
3. **Database Permission Errors**: SQLite database permissions
**Solution**:
```bash
# Check and fix database file permissions
chown <PUID>:<PGID> ./data/gomft.db
chmod 644 ./data/gomft.db
```
### Checking Container Logs for Permission Issues
```bash
docker logs gomft | grep -i "permission denied"
```
### Volume Permission Script
You can use this script to fix permissions on your data volumes:
```bash
#!/bin/bash
# Fix permissions for GoMFT volumes
# Set your PUID and PGID here
PUID=1000
PGID=1000
# Create directories if they don't exist
mkdir -p ./data ./backups
# Fix ownership
chown -R $PUID:$PGID ./data ./backups
echo "Permissions fixed for GoMFT volumes"
```
## Security Considerations
When running as non-root, there are still some security considerations:
- Avoid using `chmod 777` in production environments
- Use volume binding with caution, especially for sensitive data
- Consider using Docker secrets for sensitive credentials
- Regularly update your GoMFT image to get the latest security fixes
- Implement network segmentation to limit the container's access
## Example: Complete Docker Compose Setup with Non-Root User
```yaml
version: '3.8'
services:
gomft:
image: starfleetcptn/gomft:latest
container_name: gomft
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
- BASE_URL=http://localhost:8080
volumes:
- ./data:/app/data
- ./backups:/app/backups
ports:
- "8080:8080"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 1m
timeout: 10s
retries: 3
start_period: 30s
```
## Best Practices Summary
1. **Always run GoMFT as a non-root user in production**
2. **Use PUID/PGID environment variables for flexible user mapping**
3. **Set appropriate permissions on volume mounts**
4. **Verify the container is running as the expected user**
5. **Follow least privilege principles for the container user**

Some files were not shown because too many files have changed in this diff Show More