Merge pull request #86 from StarFleetCPTN/development

New UI Schedule Builder and Bug Fixes
This commit is contained in:
StarFleetCPTN
2025-04-11 20:50:34 -05:00
committed by GitHub
13 changed files with 1735 additions and 1696 deletions
+22 -94
View File
@@ -6,6 +6,12 @@
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>
</p>
> [!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.
@@ -363,8 +369,13 @@ Log files contain detailed information about file transfers, job execution, and
5. Create jobs using your configurations: 5. Create jobs using your configurations:
- Navigate to "Jobs" section - Navigate to "Jobs" section
- Select an existing transfer config - Select an existing transfer config
- Set up a schedule using cron expressions or run manually - Use the visual schedule builder to set your timing preferences:
- Enable/disable as needed - Choose from common presets (hourly, daily, weekly, monthly)
- Customize with specific days, times, or intervals
- See a plain-language description of your schedule
- View upcoming run times on the interactive calendar
- Switch to advanced mode for direct cron expression input if needed
- Enable/disable jobs with a single click
6. Monitor transfers: 6. Monitor transfers:
- View active and completed transfers on the Dashboard - View active and completed transfers on the Dashboard
@@ -575,9 +586,14 @@ The following fields have been added to the `users` table:
- Adaptive processing based on source/destination capabilities - Adaptive processing based on source/destination capabilities
7. **Schedule Options**: 7. **Schedule Options**:
- Cron expressions for flexible scheduling - **Visual Schedule Builder**: Intuitive interface for setting schedule preferences
- Manual execution - **Natural Language Description**: Plain-language description of schedule
- Enable/disable schedules - **Interactive Calendar**: Visual representation of upcoming runs
- **Common Presets**: Hourly, daily, weekly, monthly schedules
- **Advanced Mode**: Manual cron expression input for complex schedules
- **Schedule Validation**: Preview and confirm schedule
- **Enable/Disable**: One-click enable/disable
- **Time Zone Support**: Accurate scheduling based on user's time zone
8. **Notification Options**: 8. **Notification Options**:
- **Email Notifications**: Receive job status updates via email - **Email Notifications**: Receive job status updates via email
@@ -771,92 +787,4 @@ volumes:
- /host/path/backups:/app/backups # For database backups - /host/path/backups:/app/backups # For database backups
``` ```
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`. These paths can be customized using the environment variables `
---
## 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
[MIT License](LICENSE) - see the full license terms
The GoMFT logo is licensed under the Creative Commons Attribution 4.0 International Public License.
The gopher design is from https://github.com/egonelbre/gophers.
The original Go gopher was designed by Renee French (http://reneefrench.blogspot.com/).
+234 -1
View File
@@ -167,6 +167,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
@@ -377,6 +380,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"
@@ -413,6 +417,11 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
} }
} }
// Ensure maxConcurrentTransfers is at least 1
if (!maxConcurrentTransfers || maxConcurrentTransfers < 1) {
maxConcurrentTransfers = 1;
}
// Initialize command requirements // Initialize command requirements
updateCommandRequirements(); updateCommandRequirements();
})" })"
@@ -435,8 +444,18 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
destPort = 23; destPort = 23;
console.log('Updating destination port to 23 for Hetzner'); console.log('Updating destination port to 23 for Hetzner');
}" }"
@formvalidation
> >
<!-- 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">
@@ -450,13 +469,16 @@ 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"
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"
/> />
@@ -703,4 +725,215 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
</div> </div>
</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;
}
// Source path validation
// Note: Most source and destination forms already have HTML5 validation
// with the required attribute, but we do additional JS validation here
// to provide a better user experience with a centralized error display
const sourcePath = document.getElementById('source_path')?.value;
if (!sourcePath || sourcePath.trim() === '') {
errors.push('Source path is required');
hasErrors = true;
}
// Get source type
const sourceType = document.querySelector('input[name="source_type"]').value;
// For remote source, validate credentials
if (sourceType !== 'local') {
// Host validation for remote sources
const sourceHost = document.getElementById('source_host')?.value;
if (!sourceHost || sourceHost.trim() === '') {
errors.push('Source host is required for remote connections');
hasErrors = true;
}
// Port validation
const sourcePort = document.getElementById('source_port')?.value;
if (!sourcePort || isNaN(parseInt(sourcePort)) || parseInt(sourcePort) <= 0 || parseInt(sourcePort) > 65535) {
errors.push('Source port must be a valid port number (1-65535)');
hasErrors = true;
}
// Username validation for SFTP/FTP/etc.
if (['sftp', 'ftp', 'hetzner'].includes(sourceType)) {
const sourceUser = document.getElementById('source_username')?.value;
if (!sourceUser || sourceUser.trim() === '') {
errors.push('Source username is required');
hasErrors = true;
}
// Check auth type
const sourceAuthType = document.querySelector('input[name="source_auth_type"]:checked')?.value;
// Password validation if using password auth
if (sourceAuthType === 'password') {
const sourcePassword = document.getElementById('source_password')?.value;
if (!sourcePassword || sourcePassword.trim() === '') {
errors.push('Source password is required when using password authentication');
hasErrors = true;
}
} else if (sourceAuthType === 'key') {
// Key file validation if using key auth
const sourceKeyFile = document.getElementById('source_key_file')?.value;
if (!sourceKeyFile || sourceKeyFile.trim() === '') {
errors.push('Source key file path is required when using key authentication');
hasErrors = true;
}
}
}
// S3/B2/Wasabi specific validations
if (['s3', 'b2', 'wasabi', 'minio'].includes(sourceType)) {
const sourceAccessKey = document.getElementById('source_access_key')?.value;
const sourceSecretKey = document.getElementById('source_secret_key')?.value;
if (!sourceAccessKey || sourceAccessKey.trim() === '') {
errors.push('Source access key is required');
hasErrors = true;
}
if (!sourceSecretKey || sourceSecretKey.trim() === '') {
errors.push('Source secret key is required');
hasErrors = true;
}
// Bucket validation
const sourceBucket = document.getElementById('source_bucket')?.value;
if (!sourceBucket || sourceBucket.trim() === '') {
errors.push('Source bucket is required');
hasErrors = true;
}
}
}
// Validate destination if it's a required field
const requiresDestination = document.querySelector('form').__x.$data.requiresDestination;
if (requiresDestination) {
// Destination path validation
const destPath = document.getElementById('destination_path')?.value;
if (!destPath || destPath.trim() === '') {
errors.push('Destination path is required');
hasErrors = true;
}
// Get destination type
const destType = document.querySelector('input[name="destination_type"]').value;
// For remote destination, validate credentials
if (destType !== 'local') {
// Host validation for remote destinations
const destHost = document.getElementById('destination_host')?.value;
if (!destHost || destHost.trim() === '') {
errors.push('Destination host is required for remote connections');
hasErrors = true;
}
// Port validation
const destPort = document.getElementById('destination_port')?.value;
if (!destPort || isNaN(parseInt(destPort)) || parseInt(destPort) <= 0 || parseInt(destPort) > 65535) {
errors.push('Destination port must be a valid port number (1-65535)');
hasErrors = true;
}
// Username validation for SFTP/FTP/etc.
if (['sftp', 'ftp', 'hetzner'].includes(destType)) {
const destUser = document.getElementById('destination_username')?.value;
if (!destUser || destUser.trim() === '') {
errors.push('Destination username is required');
hasErrors = true;
}
// Check auth type
const destAuthType = document.querySelector('input[name="destination_auth_type"]:checked')?.value;
// Password validation if using password auth
if (destAuthType === 'password') {
const destPassword = document.getElementById('destination_password')?.value;
if (!destPassword || destPassword.trim() === '') {
errors.push('Destination password is required when using password authentication');
hasErrors = true;
}
} else if (destAuthType === 'key') {
// Key file validation if using key auth
const destKeyFile = document.getElementById('destination_key_file')?.value;
if (!destKeyFile || destKeyFile.trim() === '') {
errors.push('Destination key file path is required when using key authentication');
hasErrors = true;
}
}
}
// S3/B2/Wasabi specific validations
if (['s3', 'b2', 'wasabi', 'minio'].includes(destType)) {
const destAccessKey = document.getElementById('destination_access_key')?.value;
const destSecretKey = document.getElementById('destination_secret_key')?.value;
if (!destAccessKey || destAccessKey.trim() === '') {
errors.push('Destination access key is required');
hasErrors = true;
}
if (!destSecretKey || destSecretKey.trim() === '') {
errors.push('Destination secret key is required');
hasErrors = true;
}
// Bucket validation
const destBucket = document.getElementById('destination_bucket')?.value;
if (!destBucket || destBucket.trim() === '') {
errors.push('Destination bucket is required');
hasErrors = true;
}
}
}
}
// Check for concurrent transfers
const maxTransfers = document.getElementById('max_concurrent_transfers')?.value;
if (maxTransfers && (isNaN(parseInt(maxTransfers)) || parseInt(maxTransfers) < 1)) {
errors.push('Maximum concurrent transfers must be at least 1');
hasErrors = true;
}
// If errors exist, prevent form submission and display errors
if (hasErrors) {
evt.preventDefault();
// Display errors
errors.forEach(error => {
const li = document.createElement('li');
li.textContent = error;
errorList.appendChild(li);
});
formErrors.classList.remove('hidden');
// Scroll to the errors
formErrors.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
});
</script>
} }
+74
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,53 @@ 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
}
// 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, ".")
// 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 +302,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="https://github.com/starfleetcptn/gomft/releases/tag/{ 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>
} }
} }
+1168 -14
View File
File diff suppressed because it is too large Load Diff
+59 -52
View File
@@ -151,27 +151,27 @@ 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="/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>
<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>
@@ -183,11 +183,11 @@ 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>
@@ -250,8 +250,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>
@@ -262,20 +262,20 @@ 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="/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>
<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>
@@ -287,11 +287,11 @@ 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>
@@ -340,7 +340,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">
@@ -368,40 +368,41 @@ templ LayoutWithContext(title string, ctx context.Context) {
> >
<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>
<!-- Notification dropdown --> <!-- Place the notification count container outside but still in the relative container -->
<div <div id="notification-count-container" class="absolute top-0 right-0" hx-get="/notifications/count" hx-trigger="load, notification-updated from:body"></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" </div>
id="notification-dropdown" <!-- Notification dropdown -->
style="min-width: 320px; width: 100%;" <div
data-dropdown-placement="bottom-end" 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"
> id="notification-dropdown"
<div id="notification-dropdown-content"> style="min-width: 320px; width: 100%;"
<!-- Content will be loaded dynamically via HTMX --> data-dropdown-placement="bottom-end"
<div class="block py-2 px-4 text-base font-medium text-center text-gray-700 bg-gray-50 dark:bg-gray-600 dark:text-gray-300"> >
Notifications <div id="notification-dropdown-content">
</div> <!-- Content will be loaded dynamically via HTMX -->
<div class="py-4 px-4 text-center text-gray-500 dark:text-gray-400"> <div class="block py-2 px-4 text-base font-medium text-center text-gray-700 bg-gray-50 dark:bg-gray-600 dark:text-gray-300">
<div class="animate-pulse flex flex-col items-center"> Notifications
<div class="rounded-full bg-gray-200 dark:bg-gray-700 h-12 w-12 mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
</div>
</div>
<a href="/notifications" class="block py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:underline">
<div class="inline-flex items-center">
<svg aria-hidden="true" class="mr-2 w-4 h-4 text-gray-500 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
View all
</div>
</a>
</div> </div>
<div class="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
<div class="animate-pulse flex flex-col items-center">
<div class="rounded-full bg-gray-200 dark:bg-gray-700 h-12 w-12 mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
</div>
</div>
<a href="/notifications" class="block py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:underline">
<div class="inline-flex items-center">
<svg aria-hidden="true" class="mr-2 w-4 h-4 text-gray-500 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
View all
</div>
</a>
</div> </div>
</div> </div>
<!-- User menu dropdown --> <!-- User menu dropdown -->
@@ -474,6 +475,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>
@@ -497,6 +501,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>
-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>
}
}
+44 -2
View File
@@ -10,8 +10,48 @@ import (
) )
templ NotificationForm(ctx context.Context, data types.NotificationFormData) { templ NotificationForm(ctx context.Context, data types.NotificationFormData) {
@FormScripts() // Include the form-specific scripts
@components.LayoutWithContext(utils.GetNotificationFormTitle(data.IsNew), ctx) { @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) --> <!-- 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 id="notification-form-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
@@ -41,10 +81,12 @@ templ NotificationForm(ctx context.Context, data types.NotificationFormData) {
<form id="notification-form" <form id="notification-form"
if data.IsNew { if data.IsNew {
hx-post="/admin/settings/notifications" hx-post="/admin/settings/notifications"
hx-redirect="/admin/settings/notifications"
} else { } else {
hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) } hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) }
hx-redirect="/admin/settings/notifications"
} }
hx-target="#notification-form-container"> hx-target="body">
<div class="mb-6"> <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> <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"> <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">
+4 -4
View File
@@ -114,13 +114,13 @@ func (l *Logger) Write(p []byte) (n int, err error) {
level, source, message := parseLogEntry(logLine) level, source, message := parseLogEntry(logLine)
// *** DEBUG: Print parsed result to stderr *** // *** DEBUG: Print parsed result to stderr ***
fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER-V2] Parsed: Level='%s', Source='%s', Message='%s'\n", level, source, strings.TrimSpace(message)) fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER] Parsed: Level='%s', Source='%s', Message='%s'\n", level, source, strings.TrimSpace(message))
// --- TEMPORARILY DISABLED FILTER --- // --- TEMPORARILY DISABLED FILTER ---
/* /*
// Don't broadcast logs about WebSocket activity to avoid potential loops // Don't broadcast logs about WebSocket activity to avoid potential loops
if source == "handler" || source == "admin_handlers" || strings.Contains(message, "WebSocket") || strings.Contains(message, "Broadcasting log") || source == "routes" { if source == "handler" || source == "admin_handlers" || strings.Contains(message, "WebSocket") || strings.Contains(message, "Broadcasting log") || source == "routes" {
fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER-V2] Filtered out log from source '%s'\n", source) fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER] Filtered out log from source '%s'\n", source)
return n, nil return n, nil
} }
*/ */
@@ -128,10 +128,10 @@ func (l *Logger) Write(p []byte) (n int, err error) {
// Broadcast to WebSocket clients if handlers are initialized // Broadcast to WebSocket clients if handlers are initialized
if handlers, ok := web.GetHandlersInstance(); ok && handlers != nil { if handlers, ok := web.GetHandlersInstance(); ok && handlers != nil {
fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER-V2] Broadcasting: Level='%s', Source='%s'\n", level, source) fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER] Broadcasting: Level='%s', Source='%s'\n", level, source)
handlers.BroadcastLog(level, message, source) // Pass parsed values handlers.BroadcastLog(level, message, source) // Pass parsed values
} else { } else {
fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER-V2] Skipped broadcast: handlers not ready\n") fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER] Skipped broadcast: handlers not ready\n")
} }
return n, nil // Return the number of bytes written and no error return n, nil // Return the number of bytes written and no error
+6 -3
View File
@@ -915,8 +915,8 @@ func (n *Notifier) sendNtfyNotification(service *db.NotificationService, job *db
title := replaceVariables(titleTemplate, variables) // Use package-level helper title := replaceVariables(titleTemplate, variables) // Use package-level helper
message := replaceVariables(messageTemplate, variables) // Use package-level helper message := replaceVariables(messageTemplate, variables) // Use package-level helper
// Create the URL for the notification // Create the URL for the notification - do not append topic
ntfyURL := fmt.Sprintf("%s/%s", strings.TrimRight(server, "/"), topic) ntfyURL := strings.TrimRight(server, "/")
// Create the notification data // Create the notification data
ntfyData := map[string]interface{}{ ntfyData := map[string]interface{}{
@@ -926,7 +926,7 @@ func (n *Notifier) sendNtfyNotification(service *db.NotificationService, job *db
"priority": priority, "priority": priority,
} }
// Add username and password if provided // Get username and password if provided
username := service.Config["username"] username := service.Config["username"]
password := service.Config["password"] password := service.Config["password"]
@@ -936,6 +936,9 @@ func (n *Notifier) sendNtfyNotification(service *db.NotificationService, job *db
return fmt.Errorf("failed to marshal ntfy notification data: %v", err) return fmt.Errorf("failed to marshal ntfy notification data: %v", err)
} }
// Debug log the payload
n.logger.LogDebug("ntfy URL: %s, payload: %s", ntfyURL, string(jsonData))
// Create HTTP request // Create HTTP request
req, err := http.NewRequest("POST", ntfyURL, bytes.NewBuffer(jsonData)) req, err := http.NewRequest("POST", ntfyURL, bytes.NewBuffer(jsonData))
if err != nil { if err != nil {
+12 -12
View File
@@ -59,7 +59,7 @@ func NewHandlers(database *db.DB, scheduler scheduler.SchedulerInterface, jwtSec
// StartLogBroadcaster starts a goroutine that broadcasts logs to all connected WebSocket clients // StartLogBroadcaster starts a goroutine that broadcasts logs to all connected WebSocket clients
func StartLogBroadcaster() { func StartLogBroadcaster() {
go func() { go func() {
fmt.Fprintln(os.Stderr, "[DEBUG-BROADCASTER-V4] Broadcaster goroutine started.") fmt.Fprintln(os.Stderr, "[DEBUG-BROADCASTER] Broadcaster goroutine started.")
for { for {
logEntry := <-LogChannel // Wait for a log entry logEntry := <-LogChannel // Wait for a log entry
@@ -76,7 +76,7 @@ func StartLogBroadcaster() {
continue // Skip if no clients continue // Skip if no clients
} }
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Received log. Broadcasting to %d clients. Level='%s', Src='%s'\n", fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Received log. Broadcasting to %d clients. Level='%s', Src='%s'\n",
len(clientsToSend), logEntry.Level, logEntry.Source) len(clientsToSend), logEntry.Level, logEntry.Source)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -85,7 +85,7 @@ func StartLogBroadcaster() {
go func(c *websocket.Conn, m *sync.Mutex, entry components.LogEntry) { go func(c *websocket.Conn, m *sync.Mutex, entry components.LogEntry) {
defer wg.Done() defer wg.Done()
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Attempting send to client %v\n", c.RemoteAddr()) fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Attempting send to client %v\n", c.RemoteAddr())
// Lock only for this specific client's write // Lock only for this specific client's write
m.Lock() m.Lock()
@@ -93,7 +93,7 @@ func StartLogBroadcaster() {
deadline := time.Now().Add(5 * time.Second) // 5-second deadline deadline := time.Now().Add(5 * time.Second) // 5-second deadline
err := c.SetWriteDeadline(deadline) err := c.SetWriteDeadline(deadline)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Error setting write deadline for client %v: %v\n", c.RemoteAddr(), err) fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Error setting write deadline for client %v: %v\n", c.RemoteAddr(), err)
// Don't unlock yet, proceed to cleanup // Don't unlock yet, proceed to cleanup
} else { } else {
err = c.WriteJSON(entry) err = c.WriteJSON(entry)
@@ -101,19 +101,19 @@ func StartLogBroadcaster() {
m.Unlock() // Unlock after write attempt (or deadline error) m.Unlock() // Unlock after write attempt (or deadline error)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Error writing to client %v: %v. Initiating removal.\n", c.RemoteAddr(), err) fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Error writing to client %v: %v. Initiating removal.\n", c.RemoteAddr(), err)
WebSocketClientsMutex.Lock() WebSocketClientsMutex.Lock()
if _, stillExists := WebSocketClients[c]; stillExists { if _, stillExists := WebSocketClients[c]; stillExists {
delete(WebSocketClients, c) delete(WebSocketClients, c)
delete(WebSocketClientWriteMutexes, c) delete(WebSocketClientWriteMutexes, c)
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Removed client %v from maps.\n", c.RemoteAddr()) fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Removed client %v from maps.\n", c.RemoteAddr())
} else { } else {
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Client %v already removed by another process.\n", c.RemoteAddr()) fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Client %v already removed by another process.\n", c.RemoteAddr())
} }
WebSocketClientsMutex.Unlock() WebSocketClientsMutex.Unlock()
c.Close() // Close the connection outside the lock c.Close() // Close the connection outside the lock
} else { } else {
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER-V4] Successfully sent to client %v\n", c.RemoteAddr()) fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTER] Successfully sent to client %v\n", c.RemoteAddr())
} }
}(client, mutex, logEntry) }(client, mutex, logEntry)
} }
@@ -143,18 +143,18 @@ func (h *Handlers) BroadcastLog(level, message, source string) {
// Only attempt to write to channel if there are clients // Only attempt to write to channel if there are clients
if numClients > 0 { if numClients > 0 {
// *** DEBUG: Print channel send attempt *** // *** DEBUG: Print channel send attempt ***
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTLOG-V4] Attempting to send to LogChannel: Level='%s', Source='%s'\n", level, source) fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTLOG] Attempting to send to LogChannel: Level='%s', Source='%s'\n", level, source)
// Try to send the log entry to the channel with a timeout // Try to send the log entry to the channel with a timeout
select { select {
case LogChannel <- logEntry: case LogChannel <- logEntry:
// Successfully sent // Successfully sent
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTLOG-V4] Successfully sent to LogChannel.\n") fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTLOG] Successfully sent to LogChannel.\n")
case <-time.After(100 * time.Millisecond): case <-time.After(100 * time.Millisecond):
// Channel is full or blocked, log and continue // Channel is full or blocked, log and continue
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTLOG-V4] Log channel timeout, discarding log entry: %s\n", message) fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTLOG] Log channel timeout, discarding log entry: %s\n", message)
} }
} else { } else {
fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTLOG-V4] No clients connected, skipping send to LogChannel.\n") fmt.Fprintf(os.Stderr, "[DEBUG-BROADCASTLOG] No clients connected, skipping send to LogChannel.\n")
} }
} }
+108 -45
View File
@@ -162,59 +162,90 @@ func (h *Handlers) HandleCreateNotificationService(c *gin.Context) {
config["priority"] = c.PostForm("ntfy_priority") config["priority"] = c.PostForm("ntfy_priority")
config["username"] = c.PostForm("ntfy_username") config["username"] = c.PostForm("ntfy_username")
config["password"] = c.PostForm("ntfy_password") config["password"] = c.PostForm("ntfy_password")
config["title_template"] = c.PostForm("ntfy_title_template") config["title"] = c.PostForm("ntfy_title")
config["message_template"] = c.PostForm("ntfy_message_template")
// Event triggers are handled above
// Validate required fields // Validate required fields
if config["server"] == "" || config["topic"] == "" { if config["topic"] == "" {
h.handleNotificationsWithError(c, "Ntfy Server and Topic are required.") c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Ntfy Topic is required"})
return return
} }
// Create new notification service // Set default server if not provided
service := db.NotificationService{ if config["server"] == "" {
Name: name, config["server"] = "https://ntfy.sh"
Type: serviceType,
// IsEnabled will be set using the helper method below
Config: config,
Description: description,
EventTriggers: eventTriggers,
CreatedBy: c.GetUint("userID"),
} }
service.SetIsEnabled(isEnabled) // Use helper method
// Save to database // Create the URL for the notification - no need to include topic in the URL
if err := h.DB.Create(&service).Error; err != nil { ntfyURL := strings.TrimRight(config["server"], "/")
log.Printf("Error creating notification service: %v", err)
h.handleNotificationsWithError(c, "Failed to create notification service: "+err.Error()) // Create the notification data
ntfyData := map[string]interface{}{
"topic": config["topic"],
"title": "GoMFT Test Notification",
"message": "This is a test notification from GoMFT",
}
// Add priority if provided
if config["priority"] != "" {
priority, err := strconv.Atoi(config["priority"])
if err == nil {
ntfyData["priority"] = priority
} else {
ntfyData["priority"] = config["priority"]
}
}
// Add title if provided and not empty
if config["title"] != "" {
ntfyData["title"] = config["title"]
}
// Marshal to JSON
jsonData, err := json.Marshal(ntfyData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to prepare test notification: " + err.Error()})
return return
} }
// Create audit log // Create request
auditDetails := map[string]interface{}{ req, err := http.NewRequest("POST", ntfyURL, bytes.NewBuffer(jsonData))
"name": service.Name, if err != nil {
"type": service.Type, c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to create request: " + err.Error()})
"is_enabled": service.GetIsEnabled(), // Use getter return
"description": service.Description,
"event_triggers": eventTriggers,
} }
auditLog := db.AuditLog{ // Set the Content-Type header
Action: "create", req.Header.Set("Content-Type", "application/json")
EntityType: "notification_service",
EntityID: service.ID, // Add authentication if provided
UserID: c.GetUint("userID"), if config["username"] != "" && config["password"] != "" {
Details: auditDetails, req.SetBasicAuth(config["username"], config["password"])
} }
if err := h.DB.Create(&auditLog).Error; err != nil { // Debug info
log.Printf("Error creating audit log: %v", err) fmt.Printf("ntfy URL: %s\n", ntfyURL)
} fmt.Printf("ntfy payload: %s\n", string(jsonData))
// Redirect back to notifications page with success message // Send the request
h.handleNotificationsWithSuccess(c, "Ntfy notification service created successfully.") client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to send test notification: " + err.Error()})
return
}
defer resp.Body.Close()
// Read response body for error details if needed
respBody, _ := io.ReadAll(resp.Body)
fmt.Printf("ntfy response status: %s\n", resp.Status)
fmt.Printf("ntfy response body: %s\n", string(respBody))
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Test notification sent successfully"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Test notification returned error: " + resp.Status + " - " + string(respBody)})
}
return return
case "gotify": case "gotify":
@@ -636,8 +667,8 @@ func (h *Handlers) HandleTestNotification(c *gin.Context) {
config["server"] = "https://ntfy.sh" config["server"] = "https://ntfy.sh"
} }
// Create the URL for the notification // Create the URL for the notification - no need to include topic in the URL
ntfyURL := fmt.Sprintf("%s/%s", strings.TrimRight(config["server"], "/"), config["topic"]) ntfyURL := strings.TrimRight(config["server"], "/")
// Create the notification data // Create the notification data
ntfyData := map[string]interface{}{ ntfyData := map[string]interface{}{
@@ -648,32 +679,64 @@ func (h *Handlers) HandleTestNotification(c *gin.Context) {
// Add priority if provided // Add priority if provided
if config["priority"] != "" { if config["priority"] != "" {
ntfyData["priority"] = config["priority"] priority, err := strconv.Atoi(config["priority"])
if err == nil {
ntfyData["priority"] = priority
} else {
ntfyData["priority"] = config["priority"]
}
} }
// Add title if provided // Add title if provided and not empty
if config["title"] != "" { if config["title"] != "" {
ntfyData["title"] = config["title"] ntfyData["title"] = config["title"]
} }
// Marshal to JSON
jsonData, err := json.Marshal(ntfyData) jsonData, err := json.Marshal(ntfyData)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to prepare test notification: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to prepare test notification: " + err.Error()})
return return
} }
// Send the notification // Create request
resp, err := http.Post(ntfyURL, "application/json", bytes.NewBuffer(jsonData)) req, err := http.NewRequest("POST", ntfyURL, bytes.NewBuffer(jsonData))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to create request: " + err.Error()})
return
}
// Set the Content-Type header
req.Header.Set("Content-Type", "application/json")
// Add authentication if provided
if config["username"] != "" && config["password"] != "" {
req.SetBasicAuth(config["username"], config["password"])
}
// Debug info
fmt.Printf("ntfy URL: %s\n", ntfyURL)
fmt.Printf("ntfy payload: %s\n", string(jsonData))
// Send the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to send test notification: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Failed to send test notification: " + err.Error()})
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
// Read response body for error details if needed
respBody, _ := io.ReadAll(resp.Body)
fmt.Printf("ntfy response status: %s\n", resp.Status)
fmt.Printf("ntfy response body: %s\n", string(respBody))
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Test notification sent successfully"}) c.JSON(http.StatusOK, gin.H{"success": true, "message": "Test notification sent successfully"})
} else { } else {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Test notification returned non-success status code: " + resp.Status}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "Test notification returned error: " + resp.Status + " - " + string(respBody)})
} }
return return