mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-11 00:50:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82f73cfe60 | ||
|
|
032e188a24 | ||
|
|
2dbf8de089 | ||
|
|
1c0459683b | ||
|
|
856a8403b3 | ||
|
|
dc6209dbc1 | ||
|
|
4b540faec4 | ||
|
|
2f6c95dd51 | ||
|
|
beb77d3fe9 | ||
|
|
152f137cad | ||
|
|
1358cb0e6d | ||
|
|
ca54b450de | ||
|
|
2221e6a29c | ||
|
|
db44823ecf | ||
|
|
66623693df | ||
|
|
e2ab2eb603 | ||
|
|
87bdfc5e91 | ||
|
|
78fe589fcf | ||
|
|
bcd09af2d6 | ||
|
|
de97ad6b50 | ||
|
|
417841b3d2 | ||
|
|
11f8631929 | ||
|
|
d650247817 | ||
|
|
46df27e8c6 | ||
|
|
bb75bc9400 | ||
|
|
8acdfc216d | ||
|
|
16e7b7e6e5 | ||
|
|
0f46b20fc4 | ||
|
|
3654161245 | ||
|
|
d691fc837e | ||
|
|
b9af8fc051 | ||
|
|
e14d90e37e | ||
|
|
71ec298db5 | ||
|
|
53cf4bc3c5 | ||
|
|
0f9a4cffd2 | ||
|
|
105d223be4 | ||
|
|
7a54a83c8d | ||
|
|
c52267bbb6 | ||
|
|
e93556e537 | ||
|
|
391cfbc50c | ||
|
|
608da9ce1c | ||
|
|
b35174f857 | ||
|
|
ae18cd1a12 | ||
|
|
68dd9fc173 | ||
|
|
f95720758e | ||
|
|
d4c07fdc8e | ||
|
|
28fa65df9d | ||
|
|
c52edb75df | ||
|
|
11fe75dd57 | ||
|
|
ec107ca0ce | ||
|
|
7172d4da90 | ||
|
|
badcbfdb17 | ||
|
|
748d5c0939 |
+1
-14
@@ -43,20 +43,7 @@ node_modules/
|
||||
Thumbs.db
|
||||
|
||||
# Ignore all Go files in the components directory
|
||||
components/*.go
|
||||
!components/components.go
|
||||
|
||||
components/providers/*.go
|
||||
!components/providers/providers.go
|
||||
|
||||
components/providers/source/*.go
|
||||
!components/providers/source/source.go
|
||||
|
||||
components/providers/destination/*.go
|
||||
!components/providers/destination/destination.go
|
||||
|
||||
components/providers/common/*.go
|
||||
!components/providers/common/common.go
|
||||
*_templ.go
|
||||
|
||||
# Ignore the data directory
|
||||
data/
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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]
|
||||
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
|
||||
|
||||
@@ -44,6 +50,9 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
||||
- SFTP
|
||||
- FTP
|
||||
- SMB/CIFS shares
|
||||
- Hetzner Storage Box
|
||||
- Backblaze B2
|
||||
- Wasabi
|
||||
- Local filesystem
|
||||
- And more via rclone
|
||||
- **Webhook Notifications**: Receive real-time notifications of job events:
|
||||
@@ -311,6 +320,10 @@ PGID=1000
|
||||
- If longer than 32 bytes, it will be truncated to 32 bytes
|
||||
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456`
|
||||
|
||||
|
||||
- SSL/TLS Verification Control:
|
||||
- `SKIP_SSL_VERIFY`: Set to `true` to disable SSL/TLS certificate verification for outgoing connections (e.g., webhooks, email). Use with caution, as this can expose connections to man-in-the-middle attacks. Defaults to `false` (verification enabled).
|
||||
- Example: `SKIP_SSL_VERIFY=true`
|
||||
### Logging Configuration
|
||||
|
||||
GoMFT provides configurable logging with rotation support through the following environment variables:
|
||||
@@ -356,8 +369,13 @@ Log files contain detailed information about file transfers, job execution, and
|
||||
5. Create jobs using your configurations:
|
||||
- Navigate to "Jobs" section
|
||||
- Select an existing transfer config
|
||||
- Set up a schedule using cron expressions or run manually
|
||||
- Enable/disable as needed
|
||||
- Use the visual schedule builder to set your timing preferences:
|
||||
- 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:
|
||||
- View active and completed transfers on the Dashboard
|
||||
@@ -521,6 +539,10 @@ The following fields have been added to the `users` table:
|
||||
- Local filesystem
|
||||
- Amazon S3
|
||||
- MinIO (S3-compatible storage)
|
||||
- NextCloud
|
||||
- Backblaze B2
|
||||
- Wasabi
|
||||
- Hetzner Storage Box
|
||||
- SFTP
|
||||
- FTP
|
||||
- SMB/CIFS shares
|
||||
@@ -564,9 +586,14 @@ The following fields have been added to the `users` table:
|
||||
- Adaptive processing based on source/destination capabilities
|
||||
|
||||
7. **Schedule Options**:
|
||||
- Cron expressions for flexible scheduling
|
||||
- Manual execution
|
||||
- Enable/disable schedules
|
||||
- **Visual Schedule Builder**: Intuitive interface for setting schedule preferences
|
||||
- **Natural Language Description**: Plain-language description of schedule
|
||||
- **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**:
|
||||
- **Email Notifications**: Receive job status updates via email
|
||||
@@ -760,92 +787,4 @@ volumes:
|
||||
- /host/path/backups:/app/backups # For database backups
|
||||
```
|
||||
|
||||
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Running as a Non-Root User
|
||||
|
||||
By default, Docker containers run as the root user, which can pose security risks. GoMFT supports running as a non-root user, which is recommended for production environments.
|
||||
|
||||
#### Benefits of Running as Non-Root
|
||||
|
||||
- **Improved Security**: Limits the potential damage if the container is compromised
|
||||
- **Better File Permissions**: Files created by the container will match your host user permissions
|
||||
- **Compliance**: Many security policies and best practices require containers to run as non-root
|
||||
|
||||
#### Methods to Run as Non-Root
|
||||
|
||||
1. **Using PUID/PGID environment variables (recommended)**:
|
||||
```bash
|
||||
# Using current user's ID
|
||||
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
|
||||
|
||||
# Or in docker-compose.yml
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
```
|
||||
This is the most flexible method as it allows changing the user at runtime without rebuilding the image.
|
||||
|
||||
2. **Using the `--user` flag with Docker run**:
|
||||
```bash
|
||||
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
3. **Using Docker Compose with environment variables for `user` directive**:
|
||||
```yaml
|
||||
services:
|
||||
gomft:
|
||||
image: starfleetcptn/gomft:latest
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
```
|
||||
|
||||
4. **Building a custom image with specified UID/GID**:
|
||||
```yaml
|
||||
services:
|
||||
gomft:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
```
|
||||
|
||||
#### Environment Variables for User Management
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PUID` | User ID to run as | Built-in user ID (1000) |
|
||||
| `PGID` | Group ID to run as | Built-in group ID (1000) |
|
||||
| `USERNAME` | Username to use | `gomft` |
|
||||
|
||||
These environment variables allow you to change the user/group IDs at runtime without rebuilding the image.
|
||||
|
||||
#### Volume Permissions
|
||||
|
||||
When mounting volumes, ensure that the directories on the host have appropriate permissions for the container user:
|
||||
|
||||
```bash
|
||||
# Create directories with correct ownership
|
||||
mkdir -p data backups
|
||||
chown -R $(id -u):$(id -g) data backups
|
||||
|
||||
# Or adjust permissions to allow the container user to write
|
||||
mkdir -p data backups
|
||||
chmod -R 777 data backups # Less secure, but easier for testing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[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/).
|
||||
These paths can be customized using the environment variables `
|
||||
@@ -0,0 +1,538 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry represents a log entry for display
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time
|
||||
Level string
|
||||
Message string
|
||||
Source string
|
||||
Details map[string]interface{}
|
||||
}
|
||||
|
||||
// LogViewerData represents the data for the log viewer component
|
||||
type LogViewerData struct {
|
||||
Logs []LogEntry
|
||||
CurrentFilter string
|
||||
LogFilePath string
|
||||
}
|
||||
|
||||
// Helper function to get the appropriate CSS class for log levels
|
||||
func getLogLevelClass(level string) string {
|
||||
baseClass := "px-4 py-2 text-sm font-medium whitespace-nowrap "
|
||||
|
||||
switch level {
|
||||
case "debug":
|
||||
return baseClass + "text-purple-500 dark:text-purple-400"
|
||||
case "info":
|
||||
return baseClass + "text-blue-500 dark:text-blue-400"
|
||||
case "warn":
|
||||
return baseClass + "text-yellow-500 dark:text-yellow-400"
|
||||
case "error":
|
||||
return baseClass + "text-red-500 dark:text-red-400"
|
||||
case "fatal":
|
||||
return baseClass + "text-red-700 dark:text-red-600 font-bold"
|
||||
default:
|
||||
return baseClass + "text-gray-500 dark:text-gray-400"
|
||||
}
|
||||
}
|
||||
|
||||
// AdminLogs renders the log viewer page
|
||||
templ AdminLogs(ctx context.Context, data LogViewerData) {
|
||||
@LayoutWithContext("Log Viewer", ctx) {
|
||||
<div class="log-viewer-page">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-stream w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i> Log Viewer
|
||||
</h1>
|
||||
<div class="flex gap-2">
|
||||
<button id="pause-logs" class="flex items-center justify-center text-white bg-yellow-500 hover:bg-yellow-600 focus:ring-4 focus:ring-yellow-300 font-medium rounded-lg px-4 py-2 dark:bg-yellow-600 dark:hover:bg-yellow-700 focus:outline-none dark:focus:ring-yellow-800">
|
||||
<i class="fas fa-pause w-4 h-4 mr-2"></i> Pause
|
||||
</button>
|
||||
<button id="resume-logs" class="hidden flex items-center justify-center text-white bg-green-500 hover:bg-green-600 focus:ring-4 focus:ring-green-300 font-medium rounded-lg px-4 py-2 dark:bg-green-600 dark:hover:bg-green-700 focus:outline-none dark:focus:ring-green-800">
|
||||
<i class="fas fa-play w-4 h-4 mr-2"></i> Resume
|
||||
</button>
|
||||
<button id="clear-logs" class="flex items-center justify-center text-white bg-red-500 hover:bg-red-600 focus:ring-4 focus:ring-red-300 font-medium rounded-lg px-4 py-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
|
||||
<i class="fas fa-trash w-4 h-4 mr-2"></i> Clear
|
||||
</button>
|
||||
<button id="download-logs" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-download w-4 h-4 mr-2"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Information -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-4 p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<p><i class="fas fa-info-circle mr-2 text-blue-500 dark:text-blue-400"></i> Viewing logs from: <span class="font-mono">{ data.LogFilePath }</span></p>
|
||||
<p><i class="fas fa-circle text-green-500 dark:text-green-400 mr-2"></i> Real-time log streaming is active, logs are automatically captured and displayed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 mb-6">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Filter Logs</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- Log Level Filter -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label for="filter-level" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Log Level</label>
|
||||
<select id="filter-level" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">All Levels</option>
|
||||
<option value="debug">Debug</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="fatal">Fatal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Source Filter -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label for="filter-source" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source</label>
|
||||
<select id="filter-source" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">All Sources</option>
|
||||
<option value="api">API</option>
|
||||
<option value="web">Web</option>
|
||||
<option value="scheduler">Scheduler</option>
|
||||
<option value="auth">Authentication</option>
|
||||
<option value="database">Database</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Search Filter -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label for="filter-search" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Search</label>
|
||||
<input type="text" id="filter-search" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Search logs...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Table -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Live Logs</h3>
|
||||
<div class="flex items-center">
|
||||
<span id="connection-status" class="flex items-center text-sm text-green-500 dark:text-green-400">
|
||||
<span class="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-2"></span>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto" style="max-height: 60vh; overflow-y: auto;">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase w-[100px]">Level</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase w-[120px]">Source</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="log-entries" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<!-- Log entries will be inserted here dynamically -->
|
||||
if len(data.Logs) == 0 {
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">Waiting for logs...</td>
|
||||
</tr>
|
||||
} else {
|
||||
for _, log := range data.Logs {
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{ log.Timestamp.Format("2006-01-02 15:04:05.000") }</td>
|
||||
<td class={ getLogLevelClass(log.Level) }>{ log.Level }</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{ log.Source }</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-white font-mono">{ log.Message }</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const logEntries = document.getElementById('log-entries');
|
||||
const pauseButton = document.getElementById('pause-logs');
|
||||
const resumeButton = document.getElementById('resume-logs');
|
||||
const clearButton = document.getElementById('clear-logs');
|
||||
const downloadButton = document.getElementById('download-logs');
|
||||
const connectionStatus = document.getElementById('connection-status');
|
||||
const filterLevel = document.getElementById('filter-level');
|
||||
const filterSource = document.getElementById('filter-source');
|
||||
const filterSearch = document.getElementById('filter-search');
|
||||
|
||||
let isPaused = false;
|
||||
let logs = [];
|
||||
let filteredLogs = [];
|
||||
let ws;
|
||||
let knownSources = new Set();
|
||||
let reconnectTimer = null;
|
||||
let pingInterval = null;
|
||||
|
||||
// Connect to WebSocket
|
||||
function connectWebSocket() {
|
||||
// Clear any existing reconnect timer
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
// Clear any existing ping interval
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = protocol + '//' + window.location.host + '/admin/logs/ws';
|
||||
|
||||
console.log("Connecting to WebSocket:", wsUrl);
|
||||
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-yellow-500 dark:bg-yellow-400 rounded-full mr-2"></span>Connecting...';
|
||||
connectionStatus.className = 'flex items-center text-sm text-yellow-500 dark:text-yellow-400';
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = function() {
|
||||
console.log("WebSocket connection established");
|
||||
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-2"></span>Connected';
|
||||
connectionStatus.className = 'flex items-center text-sm text-green-500 dark:text-green-400';
|
||||
|
||||
// Set up ping interval to keep connection alive
|
||||
pingInterval = setInterval(function() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.debug("Sending ping to server");
|
||||
// Send a simple ping message
|
||||
ws.send(JSON.stringify({type: "ping"}));
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
};
|
||||
|
||||
ws.onclose = function(event) {
|
||||
console.log("WebSocket connection closed", event);
|
||||
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Disconnected';
|
||||
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
|
||||
|
||||
// Clear the ping interval
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
// Attempt to reconnect after 5 seconds
|
||||
console.log("Scheduling reconnect in 5 seconds...");
|
||||
reconnectTimer = setTimeout(connectWebSocket, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = function(error) {
|
||||
console.error("WebSocket error:", error);
|
||||
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Error';
|
||||
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
|
||||
|
||||
// Don't set up reconnect here, let onclose handle it
|
||||
};
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
// Debug log the received data
|
||||
console.debug("Raw log entry received:", event.data);
|
||||
|
||||
try {
|
||||
const logEntry = JSON.parse(event.data);
|
||||
|
||||
// Debug log the parsed entry
|
||||
console.debug("Parsed log entry:", logEntry);
|
||||
|
||||
// Extract source and add to known sources for filtering
|
||||
const source = logEntry.Source || logEntry.source || '';
|
||||
if (source && !knownSources.has(source)) {
|
||||
knownSources.add(source);
|
||||
updateSourceFilter();
|
||||
}
|
||||
|
||||
// Handle potential log prefixes in the message
|
||||
const message = logEntry.Message || logEntry.message || '';
|
||||
if (message.startsWith("DEBUG:")) {
|
||||
logEntry.Level = "debug";
|
||||
logEntry.Message = message.substring(7).trim();
|
||||
} else if (message.startsWith("INFO:")) {
|
||||
logEntry.Level = "info";
|
||||
logEntry.Message = message.substring(6).trim();
|
||||
} else if (message.startsWith("ERROR:")) {
|
||||
logEntry.Level = "error";
|
||||
logEntry.Message = message.substring(7).trim();
|
||||
} else if (message.startsWith("WARN:")) {
|
||||
logEntry.Level = "warn";
|
||||
logEntry.Message = message.substring(6).trim();
|
||||
} else if (message.startsWith("WARNING:")) {
|
||||
logEntry.Level = "warn";
|
||||
logEntry.Message = message.substring(9).trim();
|
||||
} else if (message.startsWith("FATAL:")) {
|
||||
logEntry.Level = "fatal";
|
||||
logEntry.Message = message.substring(7).trim();
|
||||
}
|
||||
|
||||
// Add to logs array
|
||||
logs.push(logEntry);
|
||||
|
||||
// Apply filters and update display if not paused
|
||||
if (!isPaused) {
|
||||
applyFilters();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing log entry:", error);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating WebSocket:", error);
|
||||
connectionStatus.innerHTML = '<span class="inline-block w-2 h-2 bg-red-500 dark:bg-red-400 rounded-full mr-2"></span>Connection Failed';
|
||||
connectionStatus.className = 'flex items-center text-sm text-red-500 dark:text-red-400';
|
||||
|
||||
// Retry connection after 5 seconds
|
||||
reconnectTimer = setTimeout(connectWebSocket, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the source filter dropdown with dynamically discovered sources
|
||||
function updateSourceFilter() {
|
||||
// Remember the current selection
|
||||
const currentValue = filterSource.value;
|
||||
|
||||
// Clear existing options except the first "All Sources" option
|
||||
while (filterSource.options.length > 1) {
|
||||
filterSource.remove(1);
|
||||
}
|
||||
|
||||
// Add sorted sources to dropdown
|
||||
Array.from(knownSources).sort().forEach(source => {
|
||||
const option = document.createElement('option');
|
||||
option.value = source.toLowerCase();
|
||||
option.textContent = source;
|
||||
filterSource.appendChild(option);
|
||||
});
|
||||
|
||||
// Restore previous selection if it still exists
|
||||
if (currentValue) {
|
||||
for (let i = 0; i < filterSource.options.length; i++) {
|
||||
if (filterSource.options[i].value === currentValue) {
|
||||
filterSource.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters to logs
|
||||
function applyFilters() {
|
||||
const levelFilter = filterLevel.value.toLowerCase();
|
||||
const sourceFilter = filterSource.value.toLowerCase();
|
||||
const searchFilter = filterSearch.value.toLowerCase();
|
||||
|
||||
filteredLogs = logs.filter(log => {
|
||||
// Handle capitalized properties from the server
|
||||
const level = (log.Level || log.level || '').toLowerCase();
|
||||
const source = (log.Source || log.source || '').toLowerCase();
|
||||
const message = (log.Message || log.message || '').toLowerCase();
|
||||
|
||||
return (levelFilter === '' || level === levelFilter) &&
|
||||
(sourceFilter === '' || source === sourceFilter) &&
|
||||
(searchFilter === '' || message.includes(searchFilter));
|
||||
});
|
||||
|
||||
renderLogs();
|
||||
}
|
||||
|
||||
// Render logs to the table
|
||||
function renderLogs() {
|
||||
// Clear existing logs
|
||||
logEntries.innerHTML = '';
|
||||
|
||||
if (filteredLogs.length === 0) {
|
||||
const emptyRow = document.createElement('tr');
|
||||
emptyRow.innerHTML = `<td colspan="4" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">No logs found</td>`;
|
||||
logEntries.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add filtered logs
|
||||
filteredLogs.forEach(log => {
|
||||
// Handle capitalized property names from the server
|
||||
const timestamp = log.Timestamp || log.timestamp;
|
||||
const level = log.Level || log.level || 'unknown';
|
||||
const source = log.Source || log.source || 'unknown';
|
||||
const message = log.Message || log.message || '';
|
||||
|
||||
let formattedTime;
|
||||
try {
|
||||
// Convert to date object
|
||||
const date = new Date(timestamp);
|
||||
|
||||
// Format in local time with milliseconds
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
};
|
||||
|
||||
// Format main part of the timestamp
|
||||
formattedTime = date.toLocaleString(undefined, options);
|
||||
|
||||
// Add milliseconds
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
formattedTime += "." + ms;
|
||||
} catch (e) {
|
||||
console.error("Error formatting timestamp:", e);
|
||||
formattedTime = String(timestamp);
|
||||
}
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
|
||||
let levelClass = 'px-4 py-2 text-sm font-medium whitespace-nowrap ';
|
||||
|
||||
switch(level.toLowerCase()) {
|
||||
case 'debug':
|
||||
levelClass += 'text-purple-500 dark:text-purple-400';
|
||||
break;
|
||||
case 'info':
|
||||
levelClass += 'text-blue-500 dark:text-blue-400';
|
||||
break;
|
||||
case 'warn':
|
||||
levelClass += 'text-yellow-500 dark:text-yellow-400';
|
||||
break;
|
||||
case 'error':
|
||||
levelClass += 'text-red-500 dark:text-red-400';
|
||||
break;
|
||||
case 'fatal':
|
||||
levelClass += 'text-red-700 dark:text-red-600 font-bold';
|
||||
break;
|
||||
default:
|
||||
levelClass += 'text-gray-500 dark:text-gray-400';
|
||||
}
|
||||
|
||||
row.innerHTML =
|
||||
'<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">' + formattedTime + '</td>' +
|
||||
'<td class="' + levelClass + '">' + level + '</td>' +
|
||||
'<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">' + source + '</td>' +
|
||||
'<td class="px-4 py-2 text-sm text-gray-900 dark:text-white font-mono">' + message + '</td>';
|
||||
|
||||
logEntries.appendChild(row);
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom unless user has scrolled up
|
||||
const container = logEntries.parentElement;
|
||||
if (container.scrollTop + container.clientHeight >= container.scrollHeight - 100) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Pause button click
|
||||
pauseButton.addEventListener('click', function() {
|
||||
isPaused = true;
|
||||
pauseButton.classList.add('hidden');
|
||||
resumeButton.classList.remove('hidden');
|
||||
});
|
||||
|
||||
// Resume button click
|
||||
resumeButton.addEventListener('click', function() {
|
||||
isPaused = false;
|
||||
resumeButton.classList.add('hidden');
|
||||
pauseButton.classList.remove('hidden');
|
||||
applyFilters(); // Re-apply filters and update
|
||||
});
|
||||
|
||||
// Clear button click
|
||||
clearButton.addEventListener('click', function() {
|
||||
logs = [];
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
// Download button click
|
||||
downloadButton.addEventListener('click', function() {
|
||||
// Create CSV from logs
|
||||
let csv = 'Timestamp,Level,Source,Message\n';
|
||||
|
||||
logs.forEach(log => {
|
||||
// Handle capitalized property names from the server
|
||||
const timestamp = log.Timestamp || log.timestamp;
|
||||
const level = log.Level || log.level || 'unknown';
|
||||
const source = log.Source || log.source || 'unknown';
|
||||
const message = log.Message || log.message || '';
|
||||
|
||||
let formattedTime;
|
||||
try {
|
||||
// Convert to date object
|
||||
const date = new Date(timestamp);
|
||||
|
||||
// Format in local time with milliseconds
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
};
|
||||
|
||||
// Format main part of the timestamp
|
||||
formattedTime = date.toLocaleString(undefined, options);
|
||||
|
||||
// Add milliseconds
|
||||
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
||||
formattedTime += "." + ms;
|
||||
} catch (e) {
|
||||
formattedTime = String(timestamp);
|
||||
}
|
||||
|
||||
// Properly escape CSV fields
|
||||
let escapedMessage = '';
|
||||
if (message) {
|
||||
escapedMessage = message.split('"').join('""');
|
||||
}
|
||||
|
||||
csv += '"' + formattedTime + '","' + level + '","' + source + '","' + escapedMessage + '"\n';
|
||||
});
|
||||
|
||||
// Create and trigger download
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const date = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
||||
|
||||
a.setAttribute('href', url);
|
||||
a.setAttribute('download', 'gomft-logs-' + date + '.csv');
|
||||
a.click();
|
||||
});
|
||||
|
||||
// Filter change handlers
|
||||
filterLevel.addEventListener('change', applyFilters);
|
||||
filterSource.addEventListener('change', applyFilters);
|
||||
|
||||
// Debounce search input
|
||||
let searchTimeout;
|
||||
filterSearch.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(applyFilters, 300);
|
||||
});
|
||||
|
||||
// Initial connection
|
||||
connectWebSocket();
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ templ formContent(provider *db.AuthProvider, isNew bool) {
|
||||
<!-- Enabled -->
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
if provider == nil || provider.Enabled {
|
||||
if provider == nil || provider.GetEnabled() {
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
|
||||
@@ -80,7 +80,7 @@ templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
|
||||
{ string(provider.Type) }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
if provider.Enabled {
|
||||
if provider.GetEnabled() {
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Active
|
||||
</span>
|
||||
|
||||
@@ -35,9 +35,9 @@ templ AuthProviderButtons(providers []db.AuthProvider) {
|
||||
} else {
|
||||
<div class="space-y-2 w-full">
|
||||
for _, provider := range providers {
|
||||
if provider.Enabled {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
|
||||
if provider.GetEnabled() {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
|
||||
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<span class="flex-shrink-0 w-5 h-5 mr-2.5">
|
||||
|
||||
+378
-37
@@ -12,6 +12,10 @@ import (
|
||||
type ConfigFormData struct {
|
||||
Config *db.TransferConfig
|
||||
IsNew bool
|
||||
// Fields for pre-rendering flags on edit
|
||||
InitialCommand *db.RcloneCommand
|
||||
SelectedFlagsMap map[uint]bool
|
||||
SelectedFlagValues map[uint]string
|
||||
}
|
||||
|
||||
func getConfigFormTitle(isNew bool) string {
|
||||
@@ -27,7 +31,7 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
sourceType := "local"
|
||||
sourcePath := ""
|
||||
sourceHost := ""
|
||||
sourcePort := 22
|
||||
sourcePort := 0 // Initialize to 0 to trigger default setting
|
||||
sourceUser := ""
|
||||
sourcePassword := ""
|
||||
sourceKeyFile := ""
|
||||
@@ -55,7 +59,7 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
destinationType := "local"
|
||||
destinationPath := ""
|
||||
destHost := ""
|
||||
destPort := 22
|
||||
destPort := 0 // Initialize to 0 to trigger default setting
|
||||
destUser := ""
|
||||
destPassword := ""
|
||||
destKeyFile := ""
|
||||
@@ -163,6 +167,9 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
deleteAfterTransfer = config.GetDeleteAfterTransfer()
|
||||
skipProcessedFiles = config.GetSkipProcessedFiles()
|
||||
maxConcurrentTransfers = config.MaxConcurrentTransfers
|
||||
if maxConcurrentTransfers <= 0 {
|
||||
maxConcurrentTransfers = 1 // Ensure at least 1 concurrent transfer
|
||||
}
|
||||
rcloneFlags = config.RcloneFlags
|
||||
commandId = config.CommandID
|
||||
commandFlags = config.CommandFlags
|
||||
@@ -286,33 +293,34 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
// This will need coordination with your backend to ensure the IDs match the commands
|
||||
let commandName = '';
|
||||
switch(parseInt(this.commandId)) {
|
||||
// Map command IDs to command names - adjust these based on your actual command IDs
|
||||
// Correct mapping based on internal/db/migrations/009_add_rclone_tables.go
|
||||
case 1: commandName = 'copy'; break;
|
||||
case 2: commandName = 'move'; break;
|
||||
case 3: commandName = 'sync'; break;
|
||||
case 4: commandName = 'ls'; break;
|
||||
case 5: commandName = 'lsd'; break;
|
||||
case 6: commandName = 'lsl'; break;
|
||||
case 7: commandName = 'lsf'; break;
|
||||
case 8: commandName = 'lsjson'; break;
|
||||
case 9: commandName = 'md5sum'; break;
|
||||
case 10: commandName = 'sha1sum'; break;
|
||||
case 11: commandName = 'size'; break;
|
||||
case 12: commandName = 'delete'; break;
|
||||
case 13: commandName = 'purge'; break;
|
||||
case 14: commandName = 'mkdir'; break;
|
||||
case 15: commandName = 'rmdir'; break;
|
||||
case 16: commandName = 'rmdirs'; break;
|
||||
case 17: commandName = 'check'; break;
|
||||
case 18: commandName = 'cleanup'; break;
|
||||
case 19: commandName = 'dedupe'; break;
|
||||
case 20: commandName = 'version'; break;
|
||||
case 21: commandName = 'listremotes'; break;
|
||||
case 22: commandName = 'cryptcheck'; break;
|
||||
case 23: commandName = 'bisync'; break;
|
||||
case 24: commandName = 'copyto'; break;
|
||||
case 25: commandName = 'moveto'; break;
|
||||
default: commandName = 'copy'; // Default to copy
|
||||
case 2: commandName = 'sync'; break;
|
||||
case 3: commandName = 'bisync'; break;
|
||||
case 4: commandName = 'move'; break;
|
||||
case 5: commandName = 'delete'; break;
|
||||
case 6: commandName = 'purge'; break;
|
||||
case 7: commandName = 'mkdir'; break;
|
||||
case 8: commandName = 'rmdir'; break;
|
||||
case 9: commandName = 'rmdirs'; break;
|
||||
case 10: commandName = 'check'; break;
|
||||
case 11: commandName = 'ls'; break;
|
||||
case 12: commandName = 'lsd'; break;
|
||||
case 13: commandName = 'lsl'; break;
|
||||
case 14: commandName = 'lsf'; break;
|
||||
case 15: commandName = 'lsjson'; break;
|
||||
case 16: commandName = 'md5sum'; break;
|
||||
case 17: commandName = 'sha1sum'; break;
|
||||
case 18: commandName = 'size'; break;
|
||||
case 19: commandName = 'version'; break;
|
||||
case 20: commandName = 'cleanup'; break;
|
||||
case 21: commandName = 'dedupe'; break;
|
||||
case 22: commandName = 'copyto'; break;
|
||||
case 23: commandName = 'moveto'; break;
|
||||
case 24: commandName = 'listremotes'; break;
|
||||
case 25: commandName = 'obscure'; break;
|
||||
case 26: commandName = 'cryptcheck'; break;
|
||||
default: commandName = 'copy'; // Default to copy if ID is unknown
|
||||
}
|
||||
|
||||
console.log('Command ID:', this.commandId, 'Command Name:', commandName);
|
||||
@@ -372,6 +380,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
|
||||
<!-- Main Form -->
|
||||
<form
|
||||
id="config-form"
|
||||
class="space-y-6"
|
||||
if data.IsNew {
|
||||
hx-post="/configs"
|
||||
@@ -386,13 +395,67 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
// Ensure initial form state displays correctly on load
|
||||
sourceType = sourceType || 'local';
|
||||
destinationType = destinationType || 'local';
|
||||
sourcePort = sourcePort || 22;
|
||||
destPort = destPort || 22;
|
||||
|
||||
// Set default ports based on connection type
|
||||
if (sourcePort === 0 || !sourcePort) {
|
||||
if (sourceType === 'sftp') {
|
||||
sourcePort = 22;
|
||||
} else if (sourceType === 'ftp') {
|
||||
sourcePort = 21;
|
||||
} else if (sourceType === 'hetzner') {
|
||||
sourcePort = 23;
|
||||
}
|
||||
}
|
||||
|
||||
if (destPort === 0 || !destPort) {
|
||||
if (destinationType === 'sftp') {
|
||||
destPort = 22;
|
||||
} else if (destinationType === 'ftp') {
|
||||
destPort = 21;
|
||||
} else if (destinationType === 'hetzner') {
|
||||
destPort = 23;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure maxConcurrentTransfers is at least 1
|
||||
if (!maxConcurrentTransfers || maxConcurrentTransfers < 1) {
|
||||
maxConcurrentTransfers = 1;
|
||||
}
|
||||
|
||||
// Initialize command requirements
|
||||
updateCommandRequirements();
|
||||
})"
|
||||
x-effect="if (sourceType === 'sftp' && (sourcePort === 0 || sourcePort === 21 || sourcePort === 23)) {
|
||||
sourcePort = 22;
|
||||
console.log('Updating source port to 22 for SFTP');
|
||||
} else if (sourceType === 'ftp' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 23)) {
|
||||
sourcePort = 21;
|
||||
console.log('Updating source port to 21 for FTP');
|
||||
} else if (sourceType === 'hetzner' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 21)) {
|
||||
sourcePort = 23;
|
||||
console.log('Updating source port to 23 for Hetzner');
|
||||
} else if (destinationType === 'sftp' && (destPort === 0 || destPort === 21 || destPort === 23)) {
|
||||
destPort = 22;
|
||||
console.log('Updating destination port to 22 for SFTP');
|
||||
} else if (destinationType === 'ftp' && (destPort === 0 || destPort === 22 || destPort === 23)) {
|
||||
destPort = 21;
|
||||
console.log('Updating destination port to 21 for FTP');
|
||||
} else if (destinationType === 'hetzner' && (destPort === 0 || destPort === 22 || destPort === 21)) {
|
||||
destPort = 23;
|
||||
console.log('Updating destination port to 23 for Hetzner');
|
||||
}"
|
||||
@formvalidation
|
||||
>
|
||||
|
||||
<!-- 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 -->
|
||||
<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">
|
||||
@@ -406,13 +469,16 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
|
||||
<!-- Name field -->
|
||||
<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
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
x-model="name"
|
||||
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"
|
||||
placeholder="My Transfer Configuration"
|
||||
/>
|
||||
@@ -425,8 +491,18 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
<i class="fas fa-terminal mr-2 text-blue-500 dark:text-blue-400"></i>Command Configuration
|
||||
</h3>
|
||||
|
||||
<!-- Use the RcloneFlags component from common package -->
|
||||
@common.RcloneFlags()
|
||||
<!-- Additonal Rclone Flags -->
|
||||
@common.RcloneFlags(data.Config.CommandID) // Pass current command ID
|
||||
|
||||
<!-- Container for flags, pre-rendered on edit, loaded via HTMX on new/change -->
|
||||
<div id="command-flags-container" class="mt-4">
|
||||
if !data.IsNew && data.InitialCommand != nil {
|
||||
// Pre-render flags if editing and command data is available
|
||||
@common.RcloneCommandFlagsContent(data.InitialCommand, data.SelectedFlagsMap, data.SelectedFlagValues)
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Source Configuration Section -->
|
||||
@@ -438,6 +514,20 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
<!-- Source selection -->
|
||||
@common.SourceSelection()
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="button"
|
||||
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
|
||||
hx-post="/configs/test-connection"
|
||||
hx-include="closest form"
|
||||
hx-vals='{"providerType": "source"}'
|
||||
hx-swap="none"
|
||||
hx-indicator="#source-test-spinner">
|
||||
<i class="fas fa-plug mr-1"></i> Test Source
|
||||
<span id="source-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
</button>
|
||||
<!-- Removed target div, result shown via toast -->
|
||||
</div>
|
||||
|
||||
<!-- Source type specific forms -->
|
||||
<template x-if="sourceType === 'local'">
|
||||
@source.LocalSourceForm()
|
||||
@@ -450,15 +540,23 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
<template x-if="sourceType === 'ftp'">
|
||||
@source.FTPSourceForm()
|
||||
</template>
|
||||
|
||||
|
||||
<template x-if="sourceType === 's3'">
|
||||
@source.S3SourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'b2'">
|
||||
@source.B2SourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'wasabi'">
|
||||
@source.WasabiSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'minio'">
|
||||
@source.MinIOSourceForm()
|
||||
</template>
|
||||
|
||||
|
||||
<template x-if="sourceType === 'smb'">
|
||||
@source.SMBSourceForm()
|
||||
</template>
|
||||
@@ -466,7 +564,7 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
<template x-if="sourceType === 'webdav'">
|
||||
@source.WebDAVSourceForm()
|
||||
</template>
|
||||
|
||||
|
||||
<template x-if="sourceType === 'nextcloud'">
|
||||
@source.NextCloudSourceForm()
|
||||
</template>
|
||||
@@ -474,10 +572,14 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
<template x-if="sourceType === 'gdrive'">
|
||||
@source.GoogleDriveSourceForm()
|
||||
</template>
|
||||
|
||||
|
||||
<template x-if="sourceType === 'gphotos'">
|
||||
@source.GooglePhotosSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'hetzner'">
|
||||
@source.HetznerSourceForm()
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- File Pattern Section -->
|
||||
@@ -499,6 +601,20 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
<!-- Destination selection -->
|
||||
@common.DestinationSelection()
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="button"
|
||||
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
|
||||
hx-post="/configs/test-connection"
|
||||
hx-include="closest form"
|
||||
hx-vals='{"providerType": "destination"}'
|
||||
hx-swap="none"
|
||||
hx-indicator="#dest-test-spinner">
|
||||
<i class="fas fa-plug mr-1"></i> Test Destination
|
||||
<span id="dest-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
</button>
|
||||
<!-- Removed target div, result shown via toast -->
|
||||
</div>
|
||||
|
||||
<!-- Destination type specific forms -->
|
||||
<template x-if="destinationType === 'local'">
|
||||
@destination.LocalDestinationForm()
|
||||
@@ -516,6 +632,14 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
@destination.S3DestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'b2'">
|
||||
@destination.B2DestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'wasabi'">
|
||||
@destination.WasabiDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'minio'">
|
||||
@destination.MinIODestinationForm()
|
||||
</template>
|
||||
@@ -539,6 +663,10 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
<template x-if="destinationType === 'gphotos'">
|
||||
@destination.GooglePhotosDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'hetzner'">
|
||||
@destination.HetznerDestinationForm()
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options Section -->
|
||||
@@ -554,6 +682,8 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
@@ -595,4 +725,215 @@ templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
@@ -16,6 +16,8 @@ type DashboardData struct {
|
||||
FailedTransfers int
|
||||
Configs map[uint]db.TransferConfig
|
||||
RcloneVersion string
|
||||
LatestVersion string
|
||||
CurrentVersion string
|
||||
}
|
||||
|
||||
// GetRcloneVersion executes the rclone --version command and returns the version string
|
||||
@@ -41,6 +43,53 @@ func GetRcloneVersion() string {
|
||||
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) {
|
||||
@LayoutWithContext("Dashboard", ctx) {
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -177,12 +177,15 @@ templ NotificationDropdown(data NotificationsData) {
|
||||
|
||||
templ NotificationCount(count int64) {
|
||||
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 {
|
||||
99+
|
||||
} else {
|
||||
{ fmt.Sprintf("%d", count) }
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<!-- Empty element to ensure proper swap when count is zero -->
|
||||
<span class="hidden"></span>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package details
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/dialog" // Import dialog package
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils" // Import utils package
|
||||
)
|
||||
|
||||
// FileMetadataDetails renders the details view for a file metadata, matching original structure
|
||||
templ FileMetadataDetails(ctx context.Context, data file_metadata.FileMetadataDetailsData) {
|
||||
@components.LayoutWithContext("File Details", ctx) {
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
@utils.FileMetadataJS() // Include JS for toasts, etc.
|
||||
|
||||
<div id="file-details-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
File Details: { data.File.FileName }
|
||||
</h1>
|
||||
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<!-- Card header -->
|
||||
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||
{ data.File.FileName }
|
||||
</h5>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="p-4 md:p-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- File Information -->
|
||||
<div>
|
||||
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
|
||||
</h6>
|
||||
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<tbody>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Filename
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.FileName }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Size
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ utils.FormatFileSize(data.File.FileSize) }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Hash
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
if data.File.FileHash != "" {
|
||||
<span class="font-mono">{ data.File.FileHash }</span>
|
||||
} else {
|
||||
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Status
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(data.File.Status) }>
|
||||
{ data.File.Status }
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Original Path
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-folder mr-2 text-yellow-500"></i>
|
||||
<span>{ data.File.OriginalPath }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Destination Path
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
|
||||
<span>{ data.File.DestinationPath }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Information -->
|
||||
<div>
|
||||
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
|
||||
</h6>
|
||||
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<tbody>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Job
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
{ data.File.Job.Name }
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Processed Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2 text-gray-500"></i>
|
||||
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Creation Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
|
||||
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Modification Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
|
||||
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
if data.File.Status == "error" && data.File.ErrorMessage != "" {
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Error
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
|
||||
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Record Created
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Record Updated
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete dialog component call -->
|
||||
@dialog.FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
|
||||
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800", // Use correct classes
|
||||
"Delete",
|
||||
"delete",
|
||||
data.File.ID,
|
||||
data.File.FileName,
|
||||
"details", // Indicate this is from the details view
|
||||
)
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="mt-6 flex flex-wrap justify-end gap-3">
|
||||
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-list mr-2"></i> Back to Files
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", data.File.ID)} }
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
|
||||
<i class="fas fa-trash mr-2"></i> Delete Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('file-details-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('file-details-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||
)
|
||||
|
||||
// FileMetadataDialog renders a confirmation dialog for file metadata actions
|
||||
templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) {
|
||||
@utils.FileMetadataJS()
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<!-- Backdrop -->
|
||||
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||
<!-- Modal content -->
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div class="p-6 text-center">
|
||||
if action == "delete" {
|
||||
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||
} else {
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||
}
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||
if section == "list" {
|
||||
<button
|
||||
type="button"
|
||||
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
|
||||
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
|
||||
hx-target={ fmt.Sprintf("#file-row-%d", fileID) }
|
||||
hx-swap="delete"
|
||||
data-file-name={ fileName }
|
||||
data-file-id={ fmt.Sprint(fileID) }
|
||||
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
type="button"
|
||||
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
|
||||
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
|
||||
hx-redirect="/files"
|
||||
data-file-name={ fileName }
|
||||
data-file-id={ fmt.Sprint(fileID) }
|
||||
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
}
|
||||
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||
"github.com/starfleetcptn/gomft/components" // Import the main components package
|
||||
)
|
||||
|
||||
// FileMetadataList renders the list of file metadata
|
||||
templ FileMetadataList(ctx context.Context, data file_metadata.FileMetadataListData) {
|
||||
@components.LayoutWithContext("Files", ctx) { // Call using components package
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
@utils.FileMetadataJS() // Use capitalized function name
|
||||
|
||||
<div id="list-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Files
|
||||
</h1>
|
||||
<div class="flex gap-3">
|
||||
<a href="/files/search" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-search mr-2"></i> Advanced Search
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Form -->
|
||||
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Filter Files</h5>
|
||||
<form
|
||||
hx-get="/files/partial"
|
||||
hx-target="#file-list-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#filter-loading"
|
||||
hx-headers='{"X-HX-Request": "true"}'
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
if data.Job == nil {
|
||||
<div>
|
||||
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job</label>
|
||||
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div class="md:col-span-3 flex justify-end items-center">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-filter mr-2"></i> Apply Filters
|
||||
</button>
|
||||
<div id="filter-loading" class="htmx-indicator ml-2 flex items-center">
|
||||
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<!-- Card header -->
|
||||
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||
File List
|
||||
</h5>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing { strconv.FormatInt(int64((data.Page - 1) * data.Limit + 1), 10) } to { strconv.FormatInt(int64(min(data.Page * data.Limit, int(data.TotalCount))), 10) } of { strconv.FormatInt(data.TotalCount, 10) } files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div id="file-list-container" class="p-4 md:p-5">
|
||||
@FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('list-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('list-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/dialog"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||
)
|
||||
|
||||
// Helper function to generate sorting links
|
||||
func sortLink(currentSortBy, currentSortDir, targetSortBy, basePath string, filter file_metadata.FileMetadataFilter, limit int) string {
|
||||
nextSortDir := "asc"
|
||||
if currentSortBy == targetSortBy && currentSortDir == "asc" {
|
||||
nextSortDir = "desc"
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("page", "1")
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
q.Set("sort_by", targetSortBy)
|
||||
q.Set("sort_dir", nextSortDir)
|
||||
if filter.Status != "" {
|
||||
q.Set("status", filter.Status)
|
||||
}
|
||||
if filter.FileName != "" {
|
||||
q.Set("filename", filter.FileName)
|
||||
}
|
||||
if filter.JobID != "" {
|
||||
q.Set("job_id", filter.JobID)
|
||||
}
|
||||
if filter.Hash != "" {
|
||||
q.Set("hash", filter.Hash)
|
||||
}
|
||||
if filter.StartDate != "" {
|
||||
q.Set("start_date", filter.StartDate)
|
||||
}
|
||||
if filter.EndDate != "" {
|
||||
q.Set("end_date", filter.EndDate)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s?%s", basePath, q.Encode())
|
||||
}
|
||||
|
||||
// Helper function to get sort icon class
|
||||
func sortIconClass(currentSortBy, currentSortDir, targetSortBy string) string {
|
||||
if currentSortBy == targetSortBy {
|
||||
if currentSortDir == "asc" {
|
||||
return "fas fa-sort-up ml-1"
|
||||
}
|
||||
return "fas fa-sort-down ml-1"
|
||||
}
|
||||
return "fas fa-sort text-gray-400 ml-1"
|
||||
}
|
||||
|
||||
// FileMetadataListPartial renders the list of file metadata in a table format
|
||||
// Added basePath and targetContainerID parameters
|
||||
templ FileMetadataListPartial(ctx context.Context, data file_metadata.FileMetadataListData, basePath string, targetContainerID string) {
|
||||
|
||||
<!-- Container for dynamically generated dialogs -->
|
||||
<div id="dialog-container">
|
||||
for _, file := range data.Files {
|
||||
@dialog.FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", file.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
|
||||
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
|
||||
"Delete",
|
||||
"delete",
|
||||
file.ID,
|
||||
file.FileName,
|
||||
"list",
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- File List Table -->
|
||||
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "id", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
ID <i class={ sortIconClass(data.SortBy, data.SortDir, "id") }></i>
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "filename", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
Filename <i class={ sortIconClass(data.SortBy, data.SortDir, "filename") }></i>
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "size", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
Size <i class={ sortIconClass(data.SortBy, data.SortDir, "size") }></i>
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "processed_time", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
Processed time <i class={ sortIconClass(data.SortBy, data.SortDir, "processed_time") }></i>
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "status", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
Status <i class={ sortIconClass(data.SortBy, data.SortDir, "status") }></i>
|
||||
</a>
|
||||
</th>
|
||||
if data.Job == nil {
|
||||
<th scope="col" class="px-6 py-3">Job</th>
|
||||
}
|
||||
<th scope="col" class="px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, file := range data.Files {
|
||||
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white">
|
||||
{ strconv.FormatUint(uint64(file.ID), 10) }
|
||||
</td>
|
||||
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
||||
{ file.FileName }
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{ utils.FormatFileSize(file.FileSize) }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{ file.ProcessedTime.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(file.Status) }>
|
||||
{ file.Status }
|
||||
</span>
|
||||
</td>
|
||||
if data.Job == nil {
|
||||
<td class="px-6 py-4">
|
||||
if file.Job.ID > 0 {
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
{ file.Job.Name }
|
||||
</a>
|
||||
} else {
|
||||
<span class="text-gray-400 dark:text-gray-500 italic">N/A</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex space-x-3">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", file.ID)} }
|
||||
data-file-id={ strconv.FormatUint(uint64(file.ID), 10) }
|
||||
data-file-name={ file.FileName }
|
||||
class="font-medium text-red-600 dark:text-red-500 hover:underline">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination with HTMX (Update links to include sorting and targetContainerID) -->
|
||||
if data.TotalPages > 1 {
|
||||
<nav class="flex items-center flex-column flex-wrap md:flex-row justify-between p-4" aria-label="Table navigation">
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{ strconv.Itoa((data.Page-1)*data.Limit+1) }-{ strconv.Itoa(func() int {
|
||||
end := data.Page*data.Limit
|
||||
if int64(end) > data.TotalCount {
|
||||
return int(data.TotalCount)
|
||||
}
|
||||
return end
|
||||
}()) }</span> of <span class="font-semibold text-gray-900 dark:text-white">{ strconv.FormatInt(data.TotalCount, 10) }</span>
|
||||
</span>
|
||||
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
|
||||
<li>
|
||||
if data.Page == 1 {
|
||||
<span class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||
Previous
|
||||
</span>
|
||||
} else {
|
||||
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page - 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
Previous
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
|
||||
for i := 1; i <= data.TotalPages; i++ {
|
||||
if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
||||
<li>
|
||||
if i == data.Page {
|
||||
<span aria-current="page" class="flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white">
|
||||
{ strconv.Itoa(i) }
|
||||
</span>
|
||||
} else {
|
||||
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, i, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
{ strconv.Itoa(i) }
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
} else if (i == 2 && data.Page > 4) || (i == data.TotalPages-1 && data.Page < data.TotalPages-3) {
|
||||
<li>
|
||||
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||
...
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
<li>
|
||||
if data.Page == data.TotalPages {
|
||||
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||
Next
|
||||
</span>
|
||||
} else {
|
||||
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page + 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
Next
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||
"github.com/starfleetcptn/gomft/components" // Import the main components package
|
||||
)
|
||||
|
||||
// FileMetadataSearch renders the search interface for file metadata
|
||||
templ FileMetadataSearch(ctx context.Context, data file_metadata.FileMetadataSearchData) {
|
||||
@components.LayoutWithContext("Search Files", ctx) {
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
@utils.FileMetadataJS() // Include JS for toasts, etc.
|
||||
|
||||
<div id="search-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-search w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Search Files
|
||||
</h1>
|
||||
<div class="flex gap-3">
|
||||
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Search Form -->
|
||||
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
|
||||
<form
|
||||
hx-get="/files/search/partial"
|
||||
hx-target="#search-results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#search-form-loading"
|
||||
hx-headers='{"X-HX-Request": "true"}'
|
||||
hx-boost="false"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
|
||||
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
|
||||
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
|
||||
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
|
||||
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-search mr-2"></i> Search Files
|
||||
</button>
|
||||
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
|
||||
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results Container (Initially empty, populated by HTMX) -->
|
||||
<div id="search-results-container" class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full mt-6">
|
||||
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||
Search Results
|
||||
</h5>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Enter search criteria above and click "Search Files".
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 md:p-5">
|
||||
<!-- Content will be loaded here by HTMX -->
|
||||
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
No results yet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('search-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('search-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/list"
|
||||
)
|
||||
|
||||
// FileMetadataSearchContent renders only the search results table and pagination
|
||||
templ FileMetadataSearchContent(ctx context.Context, data file_metadata.FileMetadataSearchData) {
|
||||
<!-- Search Results -->
|
||||
<div id="search-results">
|
||||
if len(data.Files) > 0 {
|
||||
@list.FileMetadataListPartial(ctx, file_metadata.FileMetadataListData{
|
||||
Files: data.Files,
|
||||
Page: data.Page,
|
||||
Limit: data.Limit,
|
||||
TotalCount: data.TotalCount,
|
||||
TotalPages: data.TotalPages,
|
||||
Filter: data.Filter, // Pass filter data for pagination links
|
||||
SortBy: data.SortBy,
|
||||
SortDir: data.SortDir,
|
||||
}, "/files/search/partial", "#search-results-container") // Pass correct base path and target ID
|
||||
} else {
|
||||
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto mb-4 w-12 h-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<p>No files found matching your search criteria.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package file_metadata
|
||||
|
||||
import "github.com/starfleetcptn/gomft/internal/db"
|
||||
|
||||
// FileMetadataFilter represents filter parameters for file metadata queries
|
||||
type FileMetadataFilter struct {
|
||||
Status string
|
||||
JobID string
|
||||
FileName string
|
||||
Hash string
|
||||
StartDate string
|
||||
EndDate string
|
||||
}
|
||||
|
||||
// FileMetadataListData contains data for the file metadata list template
|
||||
type FileMetadataListData struct {
|
||||
Files []db.FileMetadata
|
||||
TotalCount int64
|
||||
Page int
|
||||
Limit int
|
||||
TotalPages int
|
||||
Job *db.Job // Optional: if viewing files for a specific job
|
||||
Filter FileMetadataFilter
|
||||
SortBy string // Added for sorting
|
||||
SortDir string // Added for sorting ("asc" or "desc")
|
||||
}
|
||||
|
||||
// FileMetadataDetailsData contains data for the file metadata details template
|
||||
type FileMetadataDetailsData struct {
|
||||
File db.FileMetadata
|
||||
}
|
||||
|
||||
// FileMetadataSearchData contains data for the file metadata search template
|
||||
type FileMetadataSearchData struct {
|
||||
Files []db.FileMetadata
|
||||
TotalCount int64
|
||||
Page int
|
||||
Limit int
|
||||
TotalPages int
|
||||
Filter FileMetadataFilter
|
||||
SortBy string // Added for sorting
|
||||
SortDir string // Added for sorting ("asc" or "desc")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package utils
|
||||
|
||||
// FileMetadataJS provides common JavaScript functions for file metadata components,
|
||||
// including HTMX event listeners for delete toasts.
|
||||
templ FileMetadataJS() {
|
||||
<script>
|
||||
// Function to create and show a toast (Defined locally for guaranteed availability)
|
||||
function showToast(message, type = 'info') {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
if (!toastContainer) {
|
||||
console.error("Toast container not found!"); // Keep this error log
|
||||
return;
|
||||
}
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
// Use classes similar to the original file_metadata.templ for consistency
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
let iconClass;
|
||||
if (type === 'success') {
|
||||
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||
} else { // Default to info
|
||||
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||
}
|
||||
|
||||
// Set inner HTML with appropriate icon and message
|
||||
toast.innerHTML = `
|
||||
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||
${type === 'success'
|
||||
? '<i class="fas fa-check"></i>'
|
||||
: type === 'error'
|
||||
? '<i class="fas fa-exclamation-circle"></i>'
|
||||
: '<i class="fas fa-info-circle"></i>'}
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to close button
|
||||
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||
closeButton.addEventListener('click', function() {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => { toast.remove(); }, 300);
|
||||
});
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => { toast.remove(); }, 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// --- HTMX Event Listener for Delete Toasts ---
|
||||
|
||||
// Ensure listener is attached only once using a flag
|
||||
if (!window._gomft_fileMetadataListenerAttached) {
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
const triggerElement = event.detail.elt;
|
||||
|
||||
// Check if the element that triggered this request was the file delete button from the dialog
|
||||
if (triggerElement && triggerElement.id && triggerElement.id.startsWith('delete-file-btn-')) {
|
||||
// Get the path directly from the element's hx-delete attribute
|
||||
const path = triggerElement.getAttribute('hx-delete');
|
||||
// Ensure requestConfig exists before accessing verb (robustness)
|
||||
const method = event.detail.requestConfig ? event.detail.requestConfig.verb : null;
|
||||
|
||||
// Check if the method was delete and the path from the attribute matches the expected pattern
|
||||
if (method === 'delete' && path && path.match(/^\/files\/\d+$/)) {
|
||||
const fileName = triggerElement.getAttribute('data-file-name') || "Unknown"; // Get filename from the button
|
||||
|
||||
// Call the locally defined showToast function
|
||||
if (event.detail.successful) {
|
||||
showToast(`File "${fileName}" metadata deleted successfully`, 'success');
|
||||
} else {
|
||||
let errorMsg = `Failed to delete file "${fileName}" metadata`;
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
try {
|
||||
const responseJson = JSON.parse(event.detail.xhr.responseText);
|
||||
errorMsg = responseJson.error ? `Error: ${responseJson.error}` : `Error: ${event.detail.xhr.responseText}`;
|
||||
} catch (e) {
|
||||
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||
}
|
||||
}
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set the flag to true after attaching the listener
|
||||
window._gomft_fileMetadataListenerAttached = true;
|
||||
console.log("[FileMetadataJS] HTMX afterRequest listener attached."); // Log attachment once
|
||||
}
|
||||
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
// GetStatusBadgeClass returns the appropriate CSS class for a file status badge
|
||||
func GetStatusBadgeClass(status string) string {
|
||||
switch status {
|
||||
case "processed":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||
case "archived":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
|
||||
case "deleted":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
|
||||
case "archived_and_deleted":
|
||||
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300"
|
||||
case "error":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
}
|
||||
}
|
||||
|
||||
// FormatFileSize formats a file size in bytes to a human-readable string
|
||||
func FormatFileSize(size int64) string {
|
||||
if size < 1024 {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
} else if size < 1024*1024 {
|
||||
return fmt.Sprintf("%.2f KB", float64(size)/1024)
|
||||
} else if size < 1024*1024*1024 {
|
||||
return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
|
||||
} else {
|
||||
return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
|
||||
}
|
||||
}
|
||||
+1168
-14
File diff suppressed because it is too large
Load Diff
+95
-55
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"github.com/gin-gonic/gin"
|
||||
"time"
|
||||
"github.com/starfleetcptn/gomft/components/shared/toast"
|
||||
)
|
||||
|
||||
// AppVersion will be set at build time using ldflags
|
||||
@@ -141,7 +142,7 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;">
|
||||
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;" hx-on::after-swap="initFlowbite()">
|
||||
if isLoggedIn(ctx) {
|
||||
<!-- Application Shell -->
|
||||
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
@@ -150,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 items-center flex-shrink-0 px-4">
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
<nav class="flex-1 px-2 py-4 bg-white dark:bg-gray-800">
|
||||
<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">
|
||||
<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
|
||||
</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">
|
||||
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Configs
|
||||
<i class="fas fa-exchange-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Transfer Configurations
|
||||
</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">
|
||||
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Jobs
|
||||
<i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Scheduled Jobs
|
||||
</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">
|
||||
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
History
|
||||
Transfer History
|
||||
</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">
|
||||
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
@@ -182,16 +183,20 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
</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">
|
||||
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Users
|
||||
User Management
|
||||
</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">
|
||||
<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 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>
|
||||
Audit Logs
|
||||
</a>
|
||||
<a href="/admin/logs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-stream w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Log Viewer
|
||||
</a>
|
||||
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Database Tools
|
||||
@@ -245,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 class="flex items-center justify-between">
|
||||
<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
|
||||
<!-- GoMFT Logo -->
|
||||
<img src="/static/img/logo.png" alt="GoMFT" class="w-8 h-8 mr-2">
|
||||
</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">
|
||||
<i class="fas fa-times"></i>
|
||||
@@ -257,20 +262,20 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<div class="space-y-1">
|
||||
<!-- 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">
|
||||
<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
|
||||
</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">
|
||||
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Configs
|
||||
<i class="fas fa-exchange-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Transfer Configurations
|
||||
</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">
|
||||
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Jobs
|
||||
<i class="fas fa-calendar-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Scheduled Jobs
|
||||
</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">
|
||||
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
History
|
||||
Transfer History
|
||||
</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">
|
||||
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
@@ -282,29 +287,33 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
</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">
|
||||
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Users
|
||||
User Management
|
||||
</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">
|
||||
<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 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>
|
||||
Audit Logs
|
||||
</a>
|
||||
<a href="/admin/logs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-stream w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Log Viewer
|
||||
</a>
|
||||
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Database Tools
|
||||
</a>
|
||||
// Settings Dropdown
|
||||
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
|
||||
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings-mobile" data-collapse-toggle="dropdown-settings-mobile">
|
||||
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
|
||||
<ul id="dropdown-settings-mobile" class="hidden py-2 space-y-2">
|
||||
// <li>
|
||||
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
|
||||
// </li>
|
||||
@@ -331,7 +340,7 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<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 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>
|
||||
<!-- Theme toggle and user menu -->
|
||||
<div class="flex items-center space-x-4">
|
||||
@@ -359,40 +368,41 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
>
|
||||
<span class="sr-only">View notifications</span>
|
||||
<i class="fas fa-bell"></i>
|
||||
<!-- Notification badge will be loaded dynamically -->
|
||||
<div hx-get="/notifications/count" hx-trigger="load, notification-updated from:body" id="notification-count-container"></div>
|
||||
<!-- Notification count will be placed here via HTMX -->
|
||||
</button>
|
||||
<!-- Notification dropdown -->
|
||||
<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"
|
||||
id="notification-dropdown"
|
||||
style="min-width: 320px; width: 100%;"
|
||||
data-dropdown-placement="bottom-end"
|
||||
>
|
||||
<div id="notification-dropdown-content">
|
||||
<!-- Content will be loaded dynamically via HTMX -->
|
||||
<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>
|
||||
<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>
|
||||
<!-- Place the notification count container outside but still in the relative container -->
|
||||
<div id="notification-count-container" class="absolute top-0 right-0" hx-get="/notifications/count" hx-trigger="load, notification-updated from:body"></div>
|
||||
</div>
|
||||
<!-- Notification dropdown -->
|
||||
<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"
|
||||
id="notification-dropdown"
|
||||
style="min-width: 320px; width: 100%;"
|
||||
data-dropdown-placement="bottom-end"
|
||||
>
|
||||
<div id="notification-dropdown-content">
|
||||
<!-- Content will be loaded dynamically via HTMX -->
|
||||
<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>
|
||||
<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>
|
||||
<!-- User menu dropdown -->
|
||||
@@ -444,6 +454,8 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Toast Container -->
|
||||
@toast.Container()
|
||||
<!-- Page Content -->
|
||||
<main class="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="py-6 bg-gray-50 dark:bg-gray-900">
|
||||
@@ -463,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">
|
||||
<i class="fab fa-github"></i>
|
||||
</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">
|
||||
<i class="fas fa-tag"></i> { AppVersion }
|
||||
</a>
|
||||
@@ -486,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">
|
||||
<i class="fab fa-github"></i>
|
||||
</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">
|
||||
<i class="fas fa-tag"></i> { AppVersion }
|
||||
</a>
|
||||
@@ -498,9 +516,31 @@ templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<!-- Scripts -->
|
||||
<!-- Alpine.js and dependencies -->
|
||||
<script defer src="/static/dist/vendor.js"></script>
|
||||
<!-- Shared Scripts -->
|
||||
@toast.ShowToastJS()
|
||||
<!-- Application scripts -->
|
||||
<script defer src="/static/dist/app.js"></script>
|
||||
<script defer src="/static/dist/init.js"></script>
|
||||
<!-- Dropdown fix for mobile -->
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
// Initialize the mobile dropdown separately
|
||||
const mobileDropdownButton = document.querySelector('[data-collapse-toggle="dropdown-settings-mobile"]');
|
||||
const mobileDropdown = document.getElementById("dropdown-settings-mobile");
|
||||
|
||||
if (mobileDropdownButton && mobileDropdown) {
|
||||
// Show dropdown if on admin pages
|
||||
if (window.location.pathname.startsWith("/admin")) {
|
||||
// Keep it hidden by default, will be toggled by button
|
||||
mobileDropdown.classList.add("hidden");
|
||||
}
|
||||
|
||||
mobileDropdownButton.addEventListener("click", function() {
|
||||
mobileDropdown.classList.toggle("hidden");
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
+10
-8
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
templ Login(ctx context.Context, errorMessage string) {
|
||||
templ Login(ctx context.Context, errorMessage string, hasExternalProviders bool) {
|
||||
@LayoutWithContext("Login", ctx) {
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
@@ -116,15 +116,17 @@ templ Login(ctx context.Context, errorMessage string) {
|
||||
Contact an administrator to create an account
|
||||
</p>
|
||||
|
||||
<!-- External Authentication Providers -->
|
||||
<div id="external-auth-providers" class="mt-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
|
||||
<div id="provider-buttons" class="flex flex-col gap-2" hx-get="/auth/providers" hx-trigger="load" hx-target="#provider-buttons">
|
||||
<div class="animate-pulse flex justify-center">
|
||||
<div class="h-10 bg-gray-200 rounded w-full max-w-[200px] dark:bg-gray-700"></div>
|
||||
if hasExternalProviders {
|
||||
<!-- External Authentication Providers -->
|
||||
<div id="external-auth-providers" class="mt-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
|
||||
<div id="provider-buttons" class="flex flex-col gap-2" hx-get="/auth/providers" hx-trigger="load" hx-target="#provider-buttons">
|
||||
<div class="animate-pulse flex justify-center">
|
||||
<div class="h-10 bg-gray-200 rounded w-full max-w-[200px] dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Dialog component for confirmation dialogs using Flowbite modal
|
||||
templ NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) {
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<!-- Backdrop -->
|
||||
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||
<!-- Modal content -->
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div class="p-6 text-center">
|
||||
if action == "delete" {
|
||||
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||
} else {
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||
}
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
hx-delete={ fmt.Sprintf("/admin/settings/notifications/%d", serviceID) }
|
||||
hx-target="body"
|
||||
data-service-name={ serviceName }
|
||||
data-service-id={ fmt.Sprint(serviceID) }
|
||||
id={ fmt.Sprintf("delete-btn-%d", serviceID) }
|
||||
onclick={ triggerServiceDelete(id, serviceID, serviceName) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
script triggerServiceDelete(dialogId string, serviceID uint, serviceName string) {
|
||||
// Hide the dialog
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
document.getElementById(dialogId).classList.remove("flex");
|
||||
|
||||
// Add debugging info
|
||||
console.log(`Notification service deletion triggered for: ${serviceName} (ID: ${serviceID})`);
|
||||
|
||||
// Store data in a way that's accessible to event handlers
|
||||
window.lastDeletedService = {
|
||||
id: serviceID,
|
||||
name: serviceName
|
||||
};
|
||||
|
||||
// Add custom marker to track this deletion
|
||||
window.currentlyDeletingService = true;
|
||||
}
|
||||
|
||||
templ Notifications(ctx context.Context, data SettingsNotificationsData) {
|
||||
@LayoutWithContext("Notification Services", ctx) {
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
<script>
|
||||
// Notification system
|
||||
function showToast(message, type) {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
let iconClass, bgColorClass, textColorClass;
|
||||
|
||||
if (type === 'success') {
|
||||
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||
textColorClass = 'text-green-500 dark:text-green-200';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||
textColorClass = 'text-red-500 dark:text-red-200';
|
||||
} else {
|
||||
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
}
|
||||
|
||||
// Set inner HTML with appropriate icon and message
|
||||
toast.innerHTML = `
|
||||
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||
${type === 'success'
|
||||
? '<i class="fas fa-check"></i>'
|
||||
: type === 'error'
|
||||
? '<i class="fas fa-exclamation-circle"></i>'
|
||||
: '<i class="fas fa-info-circle"></i>'}
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay to ensure the DOM has updated
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to close button
|
||||
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||
closeButton.addEventListener('click', function() {
|
||||
// Animate out before removing
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
// Check if this is a DELETE request for a notification service
|
||||
const path = event.detail.path;
|
||||
const method = event.detail.verb;
|
||||
|
||||
console.log(`Request path: ${path}, method: ${method}`);
|
||||
|
||||
// Pattern match for notification service deletions (e.g., /admin/settings/notifications/123)
|
||||
if (path && method === 'DELETE' && path.match(/^\/admin\/settings\/notifications\/\d+$/)) {
|
||||
console.log("Detected notification service deletion request via URL pattern");
|
||||
|
||||
// This is definitely a delete request - store this information
|
||||
window.isServiceDeleteRequest = true;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Check for notification service deletion multiple ways
|
||||
const isDeleteRequest =
|
||||
// Check global flag from the triggerServiceDelete function
|
||||
window.currentlyDeletingService ||
|
||||
// Check flag from beforeRequest handler
|
||||
window.isServiceDeleteRequest ||
|
||||
// Check URL pattern directly from this event
|
||||
(event.detail.pathInfo &&
|
||||
event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
|
||||
event.detail.verb === 'DELETE');
|
||||
|
||||
console.log(`Is delete request: ${isDeleteRequest}`);
|
||||
|
||||
// If this is a successful delete request, show notification
|
||||
if (isDeleteRequest && event.detail.successful) {
|
||||
console.log("Delete request was successful");
|
||||
|
||||
let serviceName = "Unknown";
|
||||
|
||||
// Try multiple sources for service name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
|
||||
}
|
||||
|
||||
if (serviceName === "Unknown" && window.lastDeletedService) {
|
||||
// Fallback to our stored service info
|
||||
serviceName = window.lastDeletedService.name;
|
||||
}
|
||||
|
||||
console.log(`Showing success notification for deleted service: ${serviceName}`);
|
||||
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingService = false;
|
||||
window.isServiceDeleteRequest = false;
|
||||
window.lastDeletedService = null;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:responseError', function(event) {
|
||||
console.log("HTMX response error:", event.detail);
|
||||
|
||||
// Similar logic as success but for errors
|
||||
const isDeleteRequest =
|
||||
window.currentlyDeletingService ||
|
||||
window.isServiceDeleteRequest ||
|
||||
(event.detail.pathInfo &&
|
||||
event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
|
||||
event.detail.verb === 'DELETE');
|
||||
|
||||
let errorMsg = 'An error occurred';
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
errorMsg = event.detail.xhr.responseText;
|
||||
}
|
||||
|
||||
if (isDeleteRequest) {
|
||||
console.log("Delete request failed");
|
||||
|
||||
let serviceName = "Unknown";
|
||||
|
||||
// Try multiple sources for service name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
|
||||
}
|
||||
|
||||
if (serviceName === "Unknown" && window.lastDeletedService) {
|
||||
// Fallback to our stored service info
|
||||
serviceName = window.lastDeletedService.name;
|
||||
}
|
||||
|
||||
let errorMsg = `Failed to delete notification service "${serviceName}"`;
|
||||
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||
}
|
||||
|
||||
console.log(`Showing error notification: ${errorMsg}`);
|
||||
showToast(errorMsg, 'error');
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingService = false;
|
||||
window.isServiceDeleteRequest = false;
|
||||
window.lastDeletedService = null;
|
||||
} else {
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle modal hide buttons
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||
hideButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const modalId = this.getAttribute('data-modal-hide');
|
||||
const modal = document.getElementById(modalId);
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
});
|
||||
});
|
||||
|
||||
// Show any success or error messages as toasts
|
||||
if (document.querySelector('.success-message')) {
|
||||
const successMsg = document.querySelector('.success-message').textContent.trim();
|
||||
if (successMsg) {
|
||||
showToast(successMsg, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.querySelector('.error-message')) {
|
||||
const errorMsg = document.querySelector('.error-message').textContent.trim();
|
||||
if (errorMsg) {
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="notifications-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="notifications-page bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Success Message (hidden, used for HTMX responses) -->
|
||||
if data.SuccessMessage != "" {
|
||||
<div class="hidden success-message">{ data.SuccessMessage }</div>
|
||||
}
|
||||
<!-- Error Message (hidden, used for HTMX responses) -->
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="hidden error-message">{ data.ErrorMessage }</div>
|
||||
}
|
||||
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Notification Services
|
||||
</h1>
|
||||
<a href="/admin/settings/notifications/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Add Notification Service
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- List of Notification Services -->
|
||||
if len(data.NotificationServices) == 0 {
|
||||
<div class="text-center py-8 bg-white dark:bg-gray-800 shadow-md rounded-lg">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
|
||||
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Add a notification service to receive alerts for job events.</p>
|
||||
<a href="/admin/settings/notifications/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Add First Notification Service
|
||||
</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, service := range data.NotificationServices {
|
||||
<li>
|
||||
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
if service.Type == "email" {
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</div>
|
||||
} else if service.Type == "webhook" {
|
||||
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
|
||||
<i class="fas fa-code"></i>
|
||||
</div>
|
||||
} else {
|
||||
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
|
||||
<i class="fas fa-bell"></i>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||
{ service.Name }
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{ service.Description }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID)) }
|
||||
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<!-- Add notification delete dialog -->
|
||||
@NotificationDialog(
|
||||
fmt.Sprintf("delete-notification-dialog-%d", service.ID),
|
||||
"Delete Notification Service",
|
||||
fmt.Sprintf("Are you sure you want to delete the notification service '%s'? This cannot be undone.", service.Name),
|
||||
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
|
||||
"Delete",
|
||||
"delete",
|
||||
service.ID,
|
||||
service.Name,
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showModal(fmt.Sprintf("delete-notification-dialog-%d", service.ID)) }
|
||||
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 sm:flex sm:justify-between">
|
||||
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class={ "px-2 py-1 text-xs font-medium rounded-full",
|
||||
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
|
||||
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
|
||||
>
|
||||
if service.IsEnabled {
|
||||
Active
|
||||
} else {
|
||||
Disabled
|
||||
}
|
||||
</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
|
||||
{ service.Type }
|
||||
</span>
|
||||
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
|
||||
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
|
||||
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
|
||||
</span>
|
||||
}
|
||||
if service.SuccessCount > 0 || service.FailureCount > 0 {
|
||||
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
||||
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if service.Type == "webhook" {
|
||||
<div class="mt-2 md:mt-0 flex items-center space-x-4">
|
||||
<div class="text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Events:</span>
|
||||
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||
if len(service.EventTriggers) == 0 {
|
||||
None
|
||||
} else {
|
||||
for i, trigger := range service.EventTriggers {
|
||||
if i > 0 {
|
||||
<span>, </span>
|
||||
}
|
||||
{ trigger }
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
|
||||
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||
if service.RetryPolicy == "" {
|
||||
Default
|
||||
} else {
|
||||
{ service.RetryPolicy }
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||
<p>
|
||||
Last sent:
|
||||
if service.SuccessCount > 0 {
|
||||
"Recently"
|
||||
} else {
|
||||
"Never"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
Notification services allow the system to send alerts for job events such as completion, errors, or when jobs start.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('notifications-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('notifications-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
// "strconv" // No longer needed here
|
||||
)
|
||||
|
||||
// NotificationDialog component for confirmation dialogs using Flowbite modal
|
||||
templ NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) {
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<!-- Backdrop -->
|
||||
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||
<!-- Modal content -->
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div class="p-6 text-center">
|
||||
if action == "delete" {
|
||||
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||
} else {
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||
}
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
hx-delete={ fmt.Sprintf("/admin/settings/notifications/%d", serviceID) }
|
||||
hx-target="body"
|
||||
data-service-name={ serviceName }
|
||||
data-service-id={ fmt.Sprint(serviceID) }
|
||||
id={ fmt.Sprintf("delete-btn-%d", serviceID) }
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerServiceDelete('%s', %d, '%s')", id, serviceID, serviceName)} }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Scripts (triggerServiceDelete, closeModal, showModal) are now expected to be defined globally or in the calling template (e.g., list.templ).
|
||||
@@ -0,0 +1,20 @@
|
||||
package dialog
|
||||
|
||||
// DialogScripts provides the JavaScript function specific to the notification delete confirmation dialog.
|
||||
templ DialogScripts() {
|
||||
<script type="text/javascript">
|
||||
// Called when the delete confirmation button is clicked.
|
||||
// Primarily closes the modal; the actual delete is handled by hx-delete.
|
||||
function triggerServiceDelete(dialogId, serviceId, serviceName) {
|
||||
console.log(`Confirmed delete for service: ${serviceName} (ID: ${serviceId}). Closing modal: ${dialogId}`);
|
||||
// Call the global closeModal function defined elsewhere (e.g., app.js)
|
||||
if (typeof closeModal === 'function') {
|
||||
closeModal(dialogId);
|
||||
} else {
|
||||
console.error('Global closeModal function not found.');
|
||||
}
|
||||
// Optional: Show a "Deleting..." toast here if desired.
|
||||
// The hx-delete attribute on the button will trigger the actual backend request.
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// No utils needed for this specific template yet
|
||||
)
|
||||
|
||||
templ EmailFields(data types.NotificationFormData) {
|
||||
<!-- TODO: Populate value attributes if editing an email service -->
|
||||
<div id="email_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="smtp_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Host</label>
|
||||
<input type="text" id="smtp_host" name="smtp_host" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="smtp.example.com"/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="smtp_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Port</label>
|
||||
<input type="number" id="smtp_port" name="smtp_port" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="587"/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="smtp_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Username</label>
|
||||
<input type="text" id="smtp_username" name="smtp_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="user@example.com"/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="smtp_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Password</label>
|
||||
<input type="password" id="smtp_password" name="smtp_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="from_email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Email</label>
|
||||
<input type="email" id="from_email" name="from_email" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="notifications@example.com"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ GotifyFields(data types.NotificationFormData) {
|
||||
<div id="gotify_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="gotify_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Gotify Server URL</label>
|
||||
if data.NotificationService.GotifyURL != "" {
|
||||
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value={ data.NotificationService.GotifyURL }/>
|
||||
} else {
|
||||
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">URL of your Gotify server</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="gotify_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Token</label>
|
||||
if data.NotificationService.GotifyToken != "" {
|
||||
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value={ data.NotificationService.GotifyToken }/>
|
||||
} else {
|
||||
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Find this in your Gotify application settings</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="gotify_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
|
||||
<select id="gotify_priority" name="gotify_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="0">Low (0)</option>
|
||||
if data.NotificationService.GotifyPriority != "" && data.NotificationService.GotifyPriority == "5" {
|
||||
<option value="5" selected="selected">Normal (5)</option>
|
||||
} else {
|
||||
<option value="5">Normal (5)</option>
|
||||
}
|
||||
<option value="8">High (8)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="gotify_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
|
||||
if data.NotificationService.GotifyTitleTemplate != "" {
|
||||
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.GotifyTitleTemplate }/>
|
||||
} else {
|
||||
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="gotify_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||
<textarea
|
||||
id="gotify_message_template"
|
||||
name="gotify_message_template"
|
||||
rows="4"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||
>
|
||||
if data.NotificationService.GotifyMessageTemplate != "" {
|
||||
data.NotificationService.GotifyMessageTemplate
|
||||
}</textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<!-- Test notification button for Gotify -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-gotify-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your Gotify configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ NtfyFields(data types.NotificationFormData) {
|
||||
<div id="ntfy_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_server" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Ntfy Server</label>
|
||||
if data.NotificationService.NtfyServer != "" {
|
||||
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value={ data.NotificationService.NtfyServer }/>
|
||||
} else {
|
||||
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value="https://ntfy.sh"/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The Ntfy server URL (default: ntfy.sh)</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_topic" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Topic</label>
|
||||
if data.NotificationService.NtfyTopic != "" {
|
||||
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value={ data.NotificationService.NtfyTopic }/>
|
||||
} else {
|
||||
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value="gomft"/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a unique, unguessable topic name</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
|
||||
<select id="ntfy_priority" name="ntfy_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="1">Low (1)</option>
|
||||
if data.NotificationService.NtfyPriority == "3" {
|
||||
<option value="3" selected="selected">Default (3)</option>
|
||||
} else {
|
||||
<option value="3">Default (3)</option>
|
||||
}
|
||||
<option value="4">High (4)</option>
|
||||
<option value="5">Urgent (5)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username (Optional)</label>
|
||||
if data.NotificationService.NtfyUsername != "" {
|
||||
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value={ data.NotificationService.NtfyUsername }/>
|
||||
} else {
|
||||
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password (Optional)</label>
|
||||
if data.NotificationService.NtfyPassword != "" {
|
||||
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value={ data.NotificationService.NtfyPassword }/>
|
||||
} else {
|
||||
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
|
||||
if data.NotificationService.NtfyTitleTemplate != "" {
|
||||
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.NtfyTitleTemplate }/>
|
||||
} else {
|
||||
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||
<textarea
|
||||
id="ntfy_message_template"
|
||||
name="ntfy_message_template"
|
||||
rows="4"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||
>
|
||||
if data.NotificationService.NtfyMessageTemplate != "" {
|
||||
data.NotificationService.NtfyMessageTemplate
|
||||
} </textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<!-- Test notification button for Ntfy -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-ntfy-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your Ntfy configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ PushbulletFields(data types.NotificationFormData) {
|
||||
<div id="pushbullet_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="pushbullet_api_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Key</label>
|
||||
if data.NotificationService.PushbulletAPIKey != "" {
|
||||
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value={ data.NotificationService.PushbulletAPIKey }/>
|
||||
} else {
|
||||
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get your API key from <a href="https://www.pushbullet.com/#settings/account" target="_blank" class="text-blue-500 hover:underline">Pushbullet Account Settings</a></p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushbullet_device_iden" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Identifier (Optional)</label>
|
||||
if data.NotificationService.PushbulletDeviceID != "" {
|
||||
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushbulletDeviceID }/>
|
||||
} else {
|
||||
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushbullet_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
|
||||
if data.NotificationService.PushbulletTitleTemplate != "" {
|
||||
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushbulletTitleTemplate }/>
|
||||
} else {
|
||||
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushbullet_body_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||
<textarea
|
||||
id="pushbullet_body_template"
|
||||
name="pushbullet_body_template"
|
||||
rows="4"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||
>
|
||||
if data.NotificationService.PushbulletBodyTemplate != "" {
|
||||
data.NotificationService.PushbulletBodyTemplate
|
||||
} </textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<!-- Test notification button for Pushbullet -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-pushbullet-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your Pushbullet configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ PushoverFields(data types.NotificationFormData) {
|
||||
<div id="pushover_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="pushover_app_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Token/Key</label>
|
||||
if data.NotificationService.PushoverAPIToken != "" {
|
||||
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value={ data.NotificationService.PushoverAPIToken }/>
|
||||
} else {
|
||||
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your application's API token/key from <a href="https://pushover.net/apps" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_user_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Key</label>
|
||||
if data.NotificationService.PushoverUserKey != "" {
|
||||
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value={ data.NotificationService.PushoverUserKey }/>
|
||||
} else {
|
||||
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your user key from <a href="https://pushover.net/" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_device" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Name (Optional)</label>
|
||||
if data.NotificationService.PushoverDevice != "" {
|
||||
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushoverDevice }/>
|
||||
} else {
|
||||
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
|
||||
<select id="pushover_priority" name="pushover_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="-2">Lowest (-2)</option>
|
||||
<option value="-1">Low (-1)</option>
|
||||
if data.NotificationService.PushoverPriority != "" && data.NotificationService.PushoverPriority == "0" {
|
||||
<option value="0" selected="selected">Normal (0)</option>
|
||||
} else {
|
||||
<option value="0">Normal (0)</option>
|
||||
}
|
||||
<option value="1">High (1)</option>
|
||||
<option value="2">Emergency (2)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_sound" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Sound</label>
|
||||
<select id="pushover_sound" name="pushover_sound" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="pushover">Pushover (default)</option>
|
||||
<option value="bike">Bike</option>
|
||||
<option value="bugle">Bugle</option>
|
||||
<option value="cashregister">Cash Register</option>
|
||||
<option value="classical">Classical</option>
|
||||
<option value="cosmic">Cosmic</option>
|
||||
<option value="falling">Falling</option>
|
||||
<option value="gamelan">Gamelan</option>
|
||||
<option value="incoming">Incoming</option>
|
||||
<option value="intermission">Intermission</option>
|
||||
<option value="magic">Magic</option>
|
||||
<option value="mechanical">Mechanical</option>
|
||||
<option value="pianobar">Piano Bar</option>
|
||||
<option value="siren">Siren</option>
|
||||
<option value="spacealarm">Space Alarm</option>
|
||||
<option value="tugboat">Tug Boat</option>
|
||||
<option value="alien">Alien Alarm (long)</option>
|
||||
<option value="climb">Climb (long)</option>
|
||||
<option value="persistent">Persistent (long)</option>
|
||||
<option value="echo">Echo (long)</option>
|
||||
<option value="updown">Up Down (long)</option>
|
||||
<option value="vibrate">Vibrate Only</option>
|
||||
<option value="none">None (silent)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
|
||||
if data.NotificationService.PushoverTitleTemplate != "" {
|
||||
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushoverTitleTemplate }/>
|
||||
} else {
|
||||
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="'{{job.name}}' {{job.status}}" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||
<textarea
|
||||
id="pushover_message_template"
|
||||
name="pushover_message_template"
|
||||
rows="4"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||
>
|
||||
if data.NotificationService.PushoverMessageTemplate != "" {
|
||||
data.NotificationService.PushoverMessageTemplate
|
||||
} </textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<!-- Test notification button for Pushover -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-pushover-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your Pushover configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ WebhookFields(data types.NotificationFormData) {
|
||||
<div id="webhook_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
|
||||
if data.NotificationService.WebhookURL != "" {
|
||||
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value={ data.NotificationService.WebhookURL }/>
|
||||
} else {
|
||||
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="method" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">HTTP Method</label>
|
||||
<select id="method" name="method" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
if data.NotificationService.Method != "" {
|
||||
if data.NotificationService.Method == "POST" {
|
||||
<option value="POST" selected="selected">POST</option>
|
||||
} else {
|
||||
<option value="POST">POST</option>
|
||||
}
|
||||
if data.NotificationService.Method == "PUT" {
|
||||
<option value="PUT" selected="selected">PUT</option>
|
||||
} else {
|
||||
<option value="PUT">PUT</option>
|
||||
}
|
||||
} else {
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="headers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Headers (JSON)</label>
|
||||
if data.NotificationService.Headers != "" {
|
||||
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'>{ data.NotificationService.Headers }</textarea>
|
||||
} else {
|
||||
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
|
||||
<textarea
|
||||
id="payload_template"
|
||||
name="payload_template"
|
||||
rows="5"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder='{
|
||||
"event": "{{job.event}}",
|
||||
"job": {
|
||||
"id": "{{job.id}}",
|
||||
"name": "{{job.name}}",
|
||||
"status": "{{job.status}}",
|
||||
"message": "{{job.message}}",
|
||||
"started_at": "{{job.started_at}}",
|
||||
"completed_at": "{{job.completed_at}}",
|
||||
"duration_seconds": {{job.duration_seconds}},
|
||||
"config_id": "{{job.config_id}}",
|
||||
"config_name": "{{job.config_name}}",
|
||||
"transfer_bytes": {{job.transfer_bytes}},
|
||||
"file_count": {{job.file_count}}
|
||||
},
|
||||
"instance": {
|
||||
"id": "{{instance.id}}",
|
||||
"name": "{{instance.name}}",
|
||||
"version": "{{instance.version}}",
|
||||
"environment": "{{instance.environment}}"
|
||||
},
|
||||
"timestamp": "{{timestamp}}",
|
||||
"notification_id": "{{notification.id}}"
|
||||
}'
|
||||
>
|
||||
if data.NotificationService.PayloadTemplate != "" {
|
||||
data.NotificationService.PayloadTemplate
|
||||
} </textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<div class="mb-6">
|
||||
<label for="secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (for signature verification)</label>
|
||||
if data.NotificationService.SecretKey != "" {
|
||||
<input type="text" id="secret_key" name="secret_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Optional signature verification key" value={ data.NotificationService.SecretKey }/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If provided, all webhooks will include an X-GoMFT-Signature header</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="retry_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Retry Policy</label>
|
||||
<select id="retry_policy" name="retry_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
if data.NotificationService.RetryPolicy != "" {
|
||||
if data.NotificationService.RetryPolicy == "none" {
|
||||
<option value="none" selected="selected">No retries</option>
|
||||
} else {
|
||||
<option value="none">No retries</option>
|
||||
}
|
||||
if data.NotificationService.RetryPolicy == "simple" {
|
||||
<option value="simple" selected="selected">Simple (3 retries)</option>
|
||||
} else {
|
||||
<option value="simple">Simple (3 retries)</option>
|
||||
}
|
||||
if data.NotificationService.RetryPolicy == "exponential" {
|
||||
<option value="exponential" selected="selected">Exponential backoff</option>
|
||||
} else {
|
||||
<option value="exponential">Exponential backoff</option>
|
||||
}
|
||||
} else {
|
||||
<option value="none">No retries</option>
|
||||
<option value="simple">Simple (3 retries)</option>
|
||||
<option value="exponential">Exponential backoff</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Test notification button -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-webhook-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/components" // For LayoutWithContext
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
"github.com/starfleetcptn/gomft/components/notifications/form/utils"
|
||||
"github.com/starfleetcptn/gomft/components/notifications/form/fields" // Import fields
|
||||
)
|
||||
|
||||
templ NotificationForm(ctx context.Context, data types.NotificationFormData) {
|
||||
@components.LayoutWithContext(utils.GetNotificationFormTitle(data.IsNew), ctx) {
|
||||
<script>
|
||||
// Toggle notification fields based on selection
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const typeSelector = document.getElementById('notification_type');
|
||||
// Ensure typeSelector exists before adding listener
|
||||
if (!typeSelector) {
|
||||
console.warn("Notification type selector not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const allFields = document.querySelectorAll('.notification-fields');
|
||||
const commonFields = document.querySelectorAll('.common-fields');
|
||||
|
||||
function toggleFields() {
|
||||
// Hide all specific fields first
|
||||
allFields.forEach(field => field.classList.add('hidden'));
|
||||
|
||||
// Show/hide common fields based on selection
|
||||
const selectedType = typeSelector.value;
|
||||
if (selectedType) {
|
||||
// Show common fields (name, description, is_enabled, submit)
|
||||
commonFields.forEach(field => field.classList.remove('hidden'));
|
||||
|
||||
// Show the selected type's specific fields
|
||||
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
|
||||
if (fieldsToShow) {
|
||||
fieldsToShow.classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
// Hide common fields if no type selected
|
||||
commonFields.forEach(field => field.classList.add('hidden'));
|
||||
}
|
||||
}
|
||||
|
||||
typeSelector.addEventListener('change', toggleFields);
|
||||
|
||||
// Initialize form state on load (if editing or if a type is pre-selected)
|
||||
toggleFields();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
|
||||
|
||||
<div id="notification-form-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div class="pb-8 w-full max-w-4xl mx-auto">
|
||||
<!-- Success Message (hidden, used for HTMX responses) -->
|
||||
if data.SuccessMessage != "" {
|
||||
<div class="hidden success-message">{ data.SuccessMessage }</div>
|
||||
}
|
||||
<!-- Error Message (hidden, used for HTMX responses) -->
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="hidden error-message">{ data.ErrorMessage }</div>
|
||||
}
|
||||
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
{ utils.GetNotificationFormTitle(data.IsNew) }
|
||||
</h1>
|
||||
<a href="/admin/settings/notifications" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
|
||||
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
|
||||
Back to Notification Services
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Add Notification Service Form -->
|
||||
<div class="mb-6 p-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<form id="notification-form"
|
||||
if data.IsNew {
|
||||
hx-post="/admin/settings/notifications"
|
||||
hx-redirect="/admin/settings/notifications"
|
||||
} else {
|
||||
hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) }
|
||||
hx-redirect="/admin/settings/notifications"
|
||||
}
|
||||
hx-target="body">
|
||||
<div class="mb-6">
|
||||
<label for="notification_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Type</label>
|
||||
<select id="notification_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">Select a type</option>
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "webhook" {
|
||||
<option value="webhook" selected="selected">Webhook</option>
|
||||
} else {
|
||||
<option value="webhook">Webhook</option>
|
||||
}
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "pushbullet" {
|
||||
<option value="pushbullet" selected="selected">Pushbullet</option>
|
||||
} else {
|
||||
<option value="pushbullet">Pushbullet</option>
|
||||
}
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "ntfy" {
|
||||
<option value="ntfy" selected="selected">Ntfy</option>
|
||||
} else {
|
||||
<option value="ntfy">Ntfy</option>
|
||||
}
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "gotify" {
|
||||
<option value="gotify" selected="selected">Gotify</option>
|
||||
} else {
|
||||
<option value="gotify">Gotify</option>
|
||||
}
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "pushover" {
|
||||
<option value="pushover" selected="selected">Pushover</option>
|
||||
} else {
|
||||
<option value="pushover">Pushover</option>
|
||||
}
|
||||
<option value="email" disabled>Email (Coming Soon)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6 hidden common-fields">
|
||||
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
|
||||
if data.NotificationService.Name != "" {
|
||||
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value={ data.NotificationService.Name }/>
|
||||
} else {
|
||||
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6 hidden common-fields">
|
||||
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
|
||||
if data.NotificationService.Description != "" {
|
||||
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service">{ data.NotificationService.Description }</textarea>
|
||||
} else {
|
||||
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Dynamic fields based on notification type -->
|
||||
@fields.EmailFields(data)
|
||||
@fields.WebhookFields(data)
|
||||
@fields.PushbulletFields(data)
|
||||
@fields.NtfyFields(data)
|
||||
@fields.GotifyFields(data)
|
||||
@fields.PushoverFields(data)
|
||||
|
||||
|
||||
<div class="mb-6 hidden common-fields">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Select the job events that should trigger this notification.</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
for _, event := range []string{"job_start", "job_complete", "job_error"} {
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id={ "trigger_" + event }
|
||||
name="event_triggers[]"
|
||||
type="checkbox"
|
||||
value={ event }
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
if utils.IsEventTriggerSelected(data.NotificationService, event, data.IsNew) {
|
||||
checked
|
||||
}
|
||||
/>
|
||||
<label for={ "trigger_" + event } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">{ utils.FormatEventTriggerName(event) }</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mb-6 hidden common-fields">
|
||||
<div class="flex items-center h-5">
|
||||
<input type="hidden" name="is_enabled" value="false">
|
||||
<input
|
||||
id="is_enabled"
|
||||
name="is_enabled"
|
||||
type="checkbox"
|
||||
value="true"
|
||||
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"
|
||||
if data.NotificationService != nil && data.NotificationService.IsEnabled {
|
||||
checked
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Check this box to make the service active.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden common-fields">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
if data.IsNew {
|
||||
Add Service
|
||||
} else {
|
||||
Save Changes
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
Configure your notification service to receive alerts for job events. Different notification types have different configuration options.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme-specific background handled by Tailwind classes -->
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package form
|
||||
|
||||
// FormScripts contains JavaScript specific to the notification form page.
|
||||
templ FormScripts() {
|
||||
<script>
|
||||
// Toggle notification fields based on selection
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const typeSelector = document.getElementById('notification_type');
|
||||
// Ensure typeSelector exists before adding listener
|
||||
if (!typeSelector) {
|
||||
console.warn("Notification type selector not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const allFields = document.querySelectorAll('.notification-fields');
|
||||
const commonFields = document.querySelectorAll('.common-fields');
|
||||
|
||||
function toggleFields() {
|
||||
// Hide all specific fields first
|
||||
allFields.forEach(field => field.classList.add('hidden'));
|
||||
|
||||
// Show/hide common fields based on selection
|
||||
const selectedType = typeSelector.value;
|
||||
if (selectedType) {
|
||||
// Show common fields (name, description, is_enabled, submit)
|
||||
commonFields.forEach(field => field.classList.remove('hidden'));
|
||||
|
||||
// Show the selected type's specific fields
|
||||
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
|
||||
if (fieldsToShow) {
|
||||
fieldsToShow.classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
// Hide common fields if no type selected
|
||||
commonFields.forEach(field => field.classList.add('hidden'));
|
||||
}
|
||||
}
|
||||
|
||||
typeSelector.addEventListener('change', toggleFields);
|
||||
|
||||
// Initialize form state on load (if editing or if a type is pre-selected)
|
||||
toggleFields();
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetNotificationFormTitle returns the title for the notification form page.
|
||||
func GetNotificationFormTitle(isNew bool) string {
|
||||
if isNew {
|
||||
return "Add Notification Service"
|
||||
}
|
||||
return "Edit Notification Service"
|
||||
}
|
||||
|
||||
// Contains checks if a string slice contains a specific string.
|
||||
func Contains(slice []string, item string) bool {
|
||||
for _, a := range slice {
|
||||
if a == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BoolToString converts a boolean to its string representation "true" or "false".
|
||||
// Useful for setting HTML attributes that expect string values.
|
||||
func BoolToString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// IsEventTriggerSelected checks if a specific event trigger should be pre-selected.
|
||||
// It now accepts the anonymous struct type defined in types.NotificationFormData.
|
||||
// Defaults to checking 'job_complete' and 'job_error' when creating a new service.
|
||||
func IsEventTriggerSelected(service *struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Type string
|
||||
IsEnabled bool
|
||||
EventTriggers []string
|
||||
RetryPolicy string
|
||||
WebhookURL string
|
||||
Method string
|
||||
Headers string
|
||||
PayloadTemplate string
|
||||
SecretKey string
|
||||
PushbulletAPIKey string
|
||||
PushbulletDeviceID string
|
||||
PushbulletTitleTemplate string
|
||||
PushbulletBodyTemplate string
|
||||
NtfyServer string
|
||||
NtfyTopic string
|
||||
NtfyPriority string
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyTitleTemplate string
|
||||
NtfyMessageTemplate string
|
||||
GotifyURL string
|
||||
GotifyToken string
|
||||
GotifyPriority string
|
||||
GotifyTitleTemplate string
|
||||
GotifyMessageTemplate string
|
||||
PushoverAPIToken string
|
||||
PushoverUserKey string
|
||||
PushoverDevice string
|
||||
PushoverPriority string
|
||||
PushoverSound string
|
||||
PushoverTitleTemplate string
|
||||
PushoverMessageTemplate string
|
||||
}, event string, isNew bool) bool {
|
||||
if !isNew && service != nil {
|
||||
// Use the Contains helper function
|
||||
return Contains(service.EventTriggers, event)
|
||||
}
|
||||
// Default for new services: check complete and error
|
||||
return isNew && (event == "job_complete" || event == "job_error")
|
||||
}
|
||||
|
||||
// FormatEventTriggerName converts event trigger keys to human-readable names.
|
||||
func FormatEventTriggerName(event string) string {
|
||||
// Replace underscores with spaces and capitalize words
|
||||
name := strings.ReplaceAll(event, "_", " ")
|
||||
name = strings.Title(name) // Use strings.Title for capitalization
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
"github.com/starfleetcptn/gomft/components/notifications/dialog"
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
)
|
||||
|
||||
// List renders the notification services list page.
|
||||
templ List(ctx context.Context, data types.SettingsNotificationsData) {
|
||||
@components.LayoutWithContext("Notification Services", ctx) {
|
||||
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
|
||||
|
||||
<div id="notifications-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Success Message (hidden, used for HTMX responses/toast trigger) -->
|
||||
if data.SuccessMessage != "" {
|
||||
<div class="hidden success-message">{ data.SuccessMessage }</div>
|
||||
}
|
||||
<!-- Error Message (hidden, used for HTMX responses/toast trigger) -->
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="hidden error-message">{ data.ErrorMessage }</div>
|
||||
}
|
||||
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Notification Services
|
||||
</h1>
|
||||
<a href="/admin/settings/notifications/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Add Notification Service
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- List of Notification Services -->
|
||||
if len(data.NotificationServices) == 0 {
|
||||
<div class="text-center py-8 bg-white dark:bg-gray-800 shadow-md rounded-lg">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
|
||||
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Add a notification service to receive alerts for job events.</p>
|
||||
<a href="/admin/settings/notifications/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Add First Notification Service
|
||||
</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, service := range data.NotificationServices {
|
||||
<li>
|
||||
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
if service.Type == "email" {
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</div>
|
||||
} else if service.Type == "webhook" {
|
||||
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
|
||||
<i class="fas fa-code"></i>
|
||||
</div>
|
||||
} else { // Default icon
|
||||
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
|
||||
<i class="fas fa-bell"></i>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||
{ service.Name }
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{ service.Description }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID)) }
|
||||
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<!-- Add notification delete dialog -->
|
||||
@dialog.NotificationDialog(
|
||||
fmt.Sprintf("delete-notification-dialog-%d", service.ID),
|
||||
"Delete Notification Service",
|
||||
fmt.Sprintf("Are you sure you want to delete the notification service '%s'? This cannot be undone.", service.Name),
|
||||
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
|
||||
"Delete",
|
||||
"delete",
|
||||
service.ID,
|
||||
service.Name,
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-notification-dialog-%d')", service.ID)} }
|
||||
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 sm:flex sm:justify-between">
|
||||
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class={ "px-2 py-1 text-xs font-medium rounded-full",
|
||||
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
|
||||
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
|
||||
>
|
||||
if service.IsEnabled {
|
||||
Active
|
||||
} else {
|
||||
Disabled
|
||||
}
|
||||
</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
|
||||
{ service.Type }
|
||||
</span>
|
||||
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
|
||||
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
|
||||
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
|
||||
</span>
|
||||
}
|
||||
if service.SuccessCount > 0 || service.FailureCount > 0 {
|
||||
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
||||
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if service.Type == "webhook" {
|
||||
<div class="mt-2 md:mt-0 flex items-center space-x-4">
|
||||
<div class="text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Events:</span>
|
||||
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||
if len(service.EventTriggers) == 0 {
|
||||
None
|
||||
} else {
|
||||
for i, trigger := range service.EventTriggers {
|
||||
if i > 0 {
|
||||
<span>, </span>
|
||||
}
|
||||
{ trigger }
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
|
||||
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||
if service.RetryPolicy == "" {
|
||||
Default
|
||||
} else {
|
||||
{ service.RetryPolicy }
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||
<p>
|
||||
Last sent:
|
||||
if service.SuccessCount > 0 {
|
||||
"Recently"
|
||||
} else {
|
||||
"Never"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Help Notice Placeholder -->
|
||||
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
Notification services allow the system to send alerts for job events such as completion, errors, or when jobs start.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@dialog.DialogScripts()
|
||||
|
||||
}
|
||||
// Script call removed for now
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package list
|
||||
|
||||
// ListScripts contains JavaScript specific to the notification list page.
|
||||
templ ListScripts() {
|
||||
<script>
|
||||
// Notification system (showToast function is now in shared/toast/toast_js.templ)
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
// Check if this is a DELETE request for a notification service
|
||||
const path = event.detail.path;
|
||||
const method = event.detail.verb;
|
||||
|
||||
console.log(`Request path: ${path}, method: ${method}`);
|
||||
|
||||
// Pattern match for notification service deletions (e.g., /admin/settings/notifications/123)
|
||||
if (path && method === 'DELETE' && path.match(/^\/admin\/settings\/notifications\/\d+$/)) {
|
||||
console.log("Detected notification service deletion request via URL pattern");
|
||||
|
||||
// This is definitely a delete request - store this information
|
||||
window.isServiceDeleteRequest = true;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Check for notification service deletion multiple ways
|
||||
const isDeleteRequest =
|
||||
// Check global flag from the triggerServiceDelete function
|
||||
window.currentlyDeletingService ||
|
||||
// Check flag from beforeRequest handler
|
||||
window.isServiceDeleteRequest ||
|
||||
// Check URL pattern directly from this event
|
||||
(event.detail.pathInfo &&
|
||||
event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
|
||||
event.detail.verb === 'DELETE');
|
||||
|
||||
console.log(`Is delete request: ${isDeleteRequest}`);
|
||||
|
||||
// If this is a successful delete request, show notification
|
||||
if (isDeleteRequest && event.detail.successful) {
|
||||
console.log("Delete request was successful");
|
||||
|
||||
let serviceName = "Unknown";
|
||||
|
||||
// Try multiple sources for service name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
|
||||
}
|
||||
|
||||
if (serviceName === "Unknown" && window.lastDeletedService) {
|
||||
// Fallback to our stored service info
|
||||
serviceName = window.lastDeletedService.name;
|
||||
}
|
||||
|
||||
console.log(`Showing success notification for deleted service: ${serviceName}`);
|
||||
// Ensure showToast is globally available
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
|
||||
} else {
|
||||
console.error("showToast function not found!");
|
||||
}
|
||||
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingService = false;
|
||||
window.isServiceDeleteRequest = false;
|
||||
window.lastDeletedService = null;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:responseError', function(event) {
|
||||
console.log("HTMX response error:", event.detail);
|
||||
|
||||
// Similar logic as success but for errors
|
||||
const isDeleteRequest =
|
||||
window.currentlyDeletingService ||
|
||||
window.isServiceDeleteRequest ||
|
||||
(event.detail.pathInfo &&
|
||||
event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
|
||||
event.detail.verb === 'DELETE');
|
||||
|
||||
let errorMsg = 'An error occurred';
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
errorMsg = event.detail.xhr.responseText;
|
||||
}
|
||||
|
||||
if (isDeleteRequest) {
|
||||
console.log("Delete request failed");
|
||||
|
||||
let serviceName = "Unknown";
|
||||
|
||||
// Try multiple sources for service name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
|
||||
}
|
||||
|
||||
if (serviceName === "Unknown" && window.lastDeletedService) {
|
||||
// Fallback to our stored service info
|
||||
serviceName = window.lastDeletedService.name;
|
||||
}
|
||||
|
||||
let specificErrorMsg = `Failed to delete notification service "${serviceName}"`;
|
||||
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
// Try to provide a more specific error from the response
|
||||
specificErrorMsg = `Error deleting "${serviceName}": ${event.detail.xhr.responseText}`;
|
||||
}
|
||||
|
||||
console.log(`Showing error notification: ${specificErrorMsg}`);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(specificErrorMsg, 'error');
|
||||
} else {
|
||||
console.error("showToast function not found!");
|
||||
}
|
||||
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingService = false;
|
||||
window.isServiceDeleteRequest = false;
|
||||
window.lastDeletedService = null;
|
||||
} else {
|
||||
// General error toast
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(errorMsg, 'error');
|
||||
} else {
|
||||
console.error("showToast function not found!");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle modal hide buttons (This might be better placed globally or in layout if modals are used elsewhere)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// This listener handles closing modals via data-modal-hide attribute
|
||||
// It might conflict or be redundant if Flowbite's JS handles this already.
|
||||
// Consider removing if Flowbite is initialized globally.
|
||||
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||
hideButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const modalId = this.getAttribute('data-modal-hide');
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
const backdrop = document.getElementById(modalId + "-backdrop");
|
||||
if (backdrop) {
|
||||
backdrop.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Show any success or error messages passed via data struct as toasts
|
||||
const successDiv = document.querySelector('.success-message');
|
||||
if (successDiv) {
|
||||
const successMsg = successDiv.textContent.trim();
|
||||
if (successMsg && typeof showToast === 'function') {
|
||||
showToast(successMsg, 'success');
|
||||
} else if (successMsg) {
|
||||
console.error("showToast function not found, cannot display success message:", successMsg);
|
||||
}
|
||||
}
|
||||
|
||||
const errorDiv = document.querySelector('.error-message');
|
||||
if (errorDiv) {
|
||||
const errorMsg = errorDiv.textContent.trim();
|
||||
if (errorMsg && typeof showToast === 'function') {
|
||||
showToast(errorMsg, 'error');
|
||||
} else if (errorMsg) {
|
||||
console.error("showToast function not found, cannot display error message:", errorMsg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Script for handling the service deletion trigger
|
||||
function triggerServiceDelete(dialogId, serviceID, serviceName) {
|
||||
// Hide the dialog first
|
||||
closeModal(dialogId); // Reuse closeModal logic
|
||||
|
||||
// Add debugging info
|
||||
console.log(`Notification service deletion triggered for: ${serviceName} (ID: ${serviceID})`);
|
||||
|
||||
// Store data in a way that's accessible to event handlers
|
||||
window.lastDeletedService = {
|
||||
id: serviceID,
|
||||
name: serviceName
|
||||
};
|
||||
|
||||
// Add custom marker to track this deletion
|
||||
window.currentlyDeletingService = true;
|
||||
}
|
||||
|
||||
// Script for closing the modal
|
||||
function closeModal(id) {
|
||||
const dialog = document.getElementById(id);
|
||||
if (dialog) {
|
||||
dialog.classList.add("hidden");
|
||||
dialog.classList.remove("flex");
|
||||
}
|
||||
const backdrop = document.getElementById(id + "-backdrop");
|
||||
if (backdrop) {
|
||||
// Instead of removing, hide it to potentially reuse
|
||||
backdrop.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// Script for showing the modal
|
||||
function showModal(id) {
|
||||
const dialog = document.getElementById(id);
|
||||
if (dialog) {
|
||||
dialog.classList.remove("hidden");
|
||||
dialog.classList.add("flex"); // Use flex to center content
|
||||
}
|
||||
const backdrop = document.getElementById(id + "-backdrop");
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package types
|
||||
|
||||
// SettingsNotificationsData defines the data needed for the notifications list page
|
||||
type SettingsNotificationsData struct {
|
||||
NotificationServices []NotificationServiceData
|
||||
SuccessMessage string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
// NotificationServiceData defines the data for a single service in the list
|
||||
type NotificationServiceData struct {
|
||||
ID uint
|
||||
Name string
|
||||
Type string
|
||||
IsEnabled bool
|
||||
Config map[string]string // Keep for now, might refine later
|
||||
Description string
|
||||
EventTriggers []string
|
||||
PayloadTemplate string
|
||||
SecretKey string
|
||||
RetryPolicy string
|
||||
SuccessCount int
|
||||
FailureCount int
|
||||
}
|
||||
|
||||
// NotificationFormData defines the data needed for the notification add/edit form
|
||||
// TODO: This will be moved from notification_form.templ later
|
||||
type NotificationFormData struct {
|
||||
NotificationService *struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Type string
|
||||
IsEnabled bool
|
||||
EventTriggers []string
|
||||
RetryPolicy string
|
||||
WebhookURL string
|
||||
Method string
|
||||
Headers string
|
||||
PayloadTemplate string
|
||||
SecretKey string
|
||||
PushbulletAPIKey string
|
||||
PushbulletDeviceID string
|
||||
PushbulletTitleTemplate string
|
||||
PushbulletBodyTemplate string
|
||||
NtfyServer string
|
||||
NtfyTopic string
|
||||
NtfyPriority string
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyTitleTemplate string
|
||||
NtfyMessageTemplate string
|
||||
GotifyURL string
|
||||
GotifyToken string
|
||||
GotifyPriority string
|
||||
GotifyTitleTemplate string
|
||||
GotifyMessageTemplate string
|
||||
PushoverAPIToken string
|
||||
PushoverUserKey string
|
||||
PushoverDevice string
|
||||
PushoverPriority string
|
||||
PushoverSound string
|
||||
PushoverTitleTemplate string
|
||||
PushoverMessageTemplate string
|
||||
}
|
||||
IsNew bool
|
||||
SuccessMessage string
|
||||
ErrorMessage string
|
||||
}
|
||||
@@ -138,21 +138,20 @@ templ ArchiveOptions() {
|
||||
</div>
|
||||
}
|
||||
|
||||
templ RcloneFlags() {
|
||||
templ RcloneFlags(currentCommandID uint) {
|
||||
<div class="mb-6">
|
||||
<label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
@RcloneCommandOptions()
|
||||
@RcloneCommandOptions(currentCommandID) // Pass it down
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Select the rclone command to use for this configuration.
|
||||
</p>
|
||||
|
||||
<!-- Command flags container - will be populated via HTMX -->
|
||||
<div id="command-flags-container" class="mt-4"></div>
|
||||
<!-- Flag container is now rendered directly in ConfigForm -->
|
||||
|
||||
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label>
|
||||
<div class="relative">
|
||||
@@ -171,12 +170,13 @@ templ RcloneFlags() {
|
||||
}
|
||||
|
||||
// New placeholder templ for rclone command options
|
||||
templ RcloneCommandOptions() {
|
||||
<div
|
||||
hx-get="/api/rclone/commands"
|
||||
templ RcloneCommandOptions(currentCommandID uint) { // Accept currentCommandID
|
||||
<div
|
||||
hx-get="/api/rclone/commands"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
hx-swap="outerHTML"
|
||||
hx-vals={ fmt.Sprintf(`{"commandId": %d}`, currentCommandID) }>
|
||||
<!-- Loading placeholder -->
|
||||
<option value="">Loading commands...</option>
|
||||
</div>
|
||||
@@ -195,12 +195,15 @@ templ SourceSelection() {
|
||||
<option value="sftp">SFTP</option>
|
||||
<option value="ftp">FTP</option>
|
||||
<option value="s3">S3</option>
|
||||
<option value="b2">Backblaze B2</option>
|
||||
<option value="wasabi">Wasabi</option>
|
||||
<option value="minio">MinIO</option>
|
||||
<option value="smb">SMB</option>
|
||||
<option value="nextcloud">NextCloud</option>
|
||||
<option value="webdav">WebDAV</option>
|
||||
<option value="gdrive">Google Drive (BETA)</option>
|
||||
<option value="gphotos">Google Photos (BETA)</option>
|
||||
<option value="hetzner">Hetzner Storage Box</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,24 +222,28 @@ templ DestinationSelection() {
|
||||
<option value="sftp">SFTP</option>
|
||||
<option value="ftp">FTP</option>
|
||||
<option value="s3">S3</option>
|
||||
<option value="b2">Backblaze B2</option>
|
||||
<option value="wasabi">Wasabi</option>
|
||||
<option value="minio">MinIO</option>
|
||||
<option value="smb">SMB</option>
|
||||
<option value="nextcloud">NextCloud</option>
|
||||
<option value="webdav">WebDAV</option>
|
||||
<option value="gdrive">Google Drive (BETA)</option>
|
||||
<option value="gphotos">Google Photos (BETA)</option>
|
||||
<option value="hetzner">Hetzner Storage Box</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// RcloneCommandOptionsContent renders the command options organized by category
|
||||
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string) {
|
||||
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string, currentCommandID uint, commandFlagsJSON string, commandFlagValuesJSON string) { // Add flag JSON strings
|
||||
<select id="command_id" name="command_id" x-model="commandId"
|
||||
hx-get="/api/rclone/command-flags"
|
||||
hx-target="#command-flags-container"
|
||||
hx-trigger="change"
|
||||
hx-include="[name='command_id']"
|
||||
hx-vals={ fmt.Sprintf(`{"commandFlags": %s, "commandFlagValues": %s}`, commandFlagsJSON, commandFlagValuesJSON) }
|
||||
@change="updateCommandRequirements()"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">Select command...</option>
|
||||
@@ -244,7 +251,11 @@ templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, cat
|
||||
if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
|
||||
<optgroup label={ category }>
|
||||
for _, cmd := range commands {
|
||||
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
|
||||
if cmd.ID == currentCommandID {
|
||||
<option value={ fmt.Sprintf("%d", cmd.ID) } selected>{ cmd.Name } - { cmd.Description }</option>
|
||||
} else {
|
||||
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
|
||||
}
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
@@ -253,7 +264,7 @@ templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, cat
|
||||
}
|
||||
|
||||
// RcloneCommandFlagsContent renders the command flags for a selected command
|
||||
templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
|
||||
templ RcloneCommandFlagsContent(command *db.RcloneCommand, selectedFlagsMap map[uint]bool, selectedFlagValues map[uint]string) {
|
||||
if command == nil {
|
||||
<div class="p-4 text-red-500">Command not found</div>
|
||||
return
|
||||
@@ -277,7 +288,9 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||
/>
|
||||
if selectedFlagsMap[flag.ID] {
|
||||
checked
|
||||
} />
|
||||
<div class="ml-3">
|
||||
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||
{ flag.Name } - { flag.Description }
|
||||
@@ -299,14 +312,16 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||
data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
onclick="toggleFlagValue(this)"
|
||||
/>
|
||||
if selectedFlagsMap[flag.ID] {
|
||||
checked
|
||||
} />
|
||||
<label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||
{ flag.Name } - { flag.Description }
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-2">
|
||||
@renderFlagInput(flag)
|
||||
@renderFlagInput(flag, selectedFlagValues[flag.ID], selectedFlagsMap[flag.ID]) // Pass value and enabled status
|
||||
if flag.DefaultValue != "" {
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p>
|
||||
}
|
||||
@@ -345,23 +360,7 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
|
||||
}
|
||||
|
||||
// Initialize all flag inputs on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const checkboxes = document.querySelectorAll('input[id^="flag_enable_"]');
|
||||
checkboxes.forEach(function(checkbox) {
|
||||
const inputId = checkbox.getAttribute('data-input-id');
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.disabled = !checkbox.checked;
|
||||
|
||||
// Also initialize the hidden input
|
||||
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
|
||||
const hiddenInput = document.getElementById(hiddenId);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.disabled = !checkbox.checked;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// Initialization is now handled by server-side rendering
|
||||
</script>
|
||||
|
||||
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
|
||||
@@ -402,7 +401,7 @@ templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
|
||||
}
|
||||
|
||||
// Helper function to render appropriate input based on flag data type
|
||||
templ renderFlagInput(flag db.RcloneCommandFlag) {
|
||||
templ renderFlagInput(flag db.RcloneCommandFlag, value string, enabled bool) {
|
||||
if flag.DataType == "int" {
|
||||
<input
|
||||
type="number"
|
||||
@@ -410,14 +409,18 @@ templ renderFlagInput(flag db.RcloneCommandFlag) {
|
||||
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder={ flag.DefaultValue }
|
||||
disabled
|
||||
/>
|
||||
value={ value }
|
||||
if !enabled {
|
||||
disabled
|
||||
} />
|
||||
<!-- Hidden input to include this flag ID when checked -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
disabled
|
||||
if !enabled {
|
||||
disabled
|
||||
}
|
||||
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
/>
|
||||
@@ -429,14 +432,18 @@ templ renderFlagInput(flag db.RcloneCommandFlag) {
|
||||
step="0.01"
|
||||
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder={ flag.DefaultValue }
|
||||
disabled
|
||||
/>
|
||||
value={ value }
|
||||
if !enabled {
|
||||
disabled
|
||||
} />
|
||||
<!-- Hidden input to include this flag ID when checked -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
disabled
|
||||
if !enabled {
|
||||
disabled
|
||||
}
|
||||
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
/>
|
||||
@@ -448,14 +455,18 @@ templ renderFlagInput(flag db.RcloneCommandFlag) {
|
||||
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder={ flag.DefaultValue }
|
||||
disabled
|
||||
/>
|
||||
value={ value }
|
||||
if !enabled {
|
||||
disabled
|
||||
} />
|
||||
<!-- Hidden input to include this flag ID when checked -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
disabled
|
||||
if !enabled {
|
||||
disabled
|
||||
}
|
||||
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package destination
|
||||
|
||||
templ B2DestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-b2-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your Backblaze B2 bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Account ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="0123456789abcdef0123456789" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Backblaze B2 Account ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="K0123456789abcdef0123456789abcdef0123456789" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Backblaze B2 Application Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="s3.us-west-000.backblazeb2.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Custom endpoint URL (only needed for non-standard regions or private endpoints)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Security Note</h3>
|
||||
<p class="mt-1">It's recommended to create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -23,7 +23,9 @@ templ FTPDestinationForm() {
|
||||
</div>
|
||||
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="21" min="1" max="65535" />
|
||||
placeholder="21" min="1" max="65535"
|
||||
x-init="if (!destPort || destPort === 0) destPort = 21"
|
||||
x-effect="if (destinationType === 'ftp' && (destPort === 0 || destPort === 22)) destPort = 21" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package destination
|
||||
|
||||
templ HetznerDestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your Hetzner Storage Box details below. You'll need your server details, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Storage Box Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="uXXXXXX.your-storagebox.de" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-plug text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="dest_port" name="dest_port" x-model="destPort" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="23" min="1" max="65535" value="23" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Connection port (default: 23)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
|
||||
<div class="flex">
|
||||
<div class="flex items-center me-4">
|
||||
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
</div>
|
||||
<div class="flex items-center me-4">
|
||||
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="uXXXXXX" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Hetzner Storage Box username (typically matches your Storage Box number)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="destAuthType === 'password'" class="mb-6">
|
||||
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination && destAuthType === 'password'"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Hetzner Storage Box password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="destAuthType === 'key'" class="mb-6">
|
||||
<label for="dest_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destKeyFile" x-bind:required="requiresDestination && destAuthType === 'key'"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/id_rsa" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your SSH private key file (must be readable by the application)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/backups" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path on your Storage Box (e.g., /backups, /path/to/files)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Security Note</h3>
|
||||
<p class="mt-1">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -27,15 +27,5 @@ templ LocalDestinationForm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
@click="checkPath(destinationPath, 'dest')"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
|
||||
</svg>
|
||||
Check Location
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -39,6 +39,22 @@ templ MinIODestinationForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_region" name="dest_region" x-model="destRegion"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Optional: Specify the region if your MinIO setup requires it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||
<div class="relative">
|
||||
|
||||
@@ -10,12 +10,12 @@ templ NextCloudDestinationForm() {
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
|
||||
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://nextcloud.example.com" />
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@ templ NextCloudDestinationForm() {
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="nextcloud_username" />
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud username
|
||||
@@ -47,7 +47,7 @@ templ NextCloudDestinationForm() {
|
||||
</div>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="NextCloud password" />
|
||||
placeholder="password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud account password
|
||||
@@ -62,10 +62,10 @@ templ NextCloudDestinationForm() {
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="remote.php/dav/files/username/path/to/files" />
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your files in NextCloud. Usually starts with "remote.php/dav/files/username/"
|
||||
Path to your files in NextCloud
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ templ SFTPDestinationForm() {
|
||||
</div>
|
||||
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="22" min="1" max="65535" />
|
||||
placeholder="22" min="1" max="65535" x-init="if (!destPort || destPort === 0) destPort = 22" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
|
||||
</div>
|
||||
@@ -74,7 +74,7 @@ templ SFTPDestinationForm() {
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destinationKeyFile"
|
||||
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destKeyFile"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/id_rsa" x-bind:required="destAuthType === 'key'" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package destination
|
||||
|
||||
templ WasabiDestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your Wasabi Cloud Storage details below. You'll need your access key, secret key, region, and bucket information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Wasabi Region</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-wasabi-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your Wasabi bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="ABCDEFGHIJKLMNOPQRST" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Wasabi Access Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Wasabi Secret Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="s3.wasabisys.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Security Note</h3>
|
||||
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific Wasabi bucket and operations needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -10,12 +10,12 @@ templ WebDAVDestinationForm() {
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
|
||||
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://webdav.example.com" />
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@ templ WebDAVDestinationForm() {
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="webdav_username" />
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV username
|
||||
@@ -47,7 +47,7 @@ templ WebDAVDestinationForm() {
|
||||
</div>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="WebDAV password" />
|
||||
placeholder="password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV account password
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package source
|
||||
|
||||
templ B2SourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-b2-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your Backblaze B2 bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Account ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="0123456789abcdef0123456789" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Backblaze B2 Account ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="K0123456789abcdef0123456789abcdef0123456789" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Backblaze B2 Application Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="s3.us-west-000.backblazeb2.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Custom endpoint URL (only needed for non-standard regions or private endpoints)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Security Note</h3>
|
||||
<p class="mt-1">It's recommended to create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -23,7 +23,7 @@ templ FTPSourceForm() {
|
||||
</div>
|
||||
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="21" min="1" max="65535" />
|
||||
placeholder="21" min="1" max="65535" value="21"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package source
|
||||
|
||||
templ HetznerSourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your Hetzner Storage Box details below. You'll need your server details, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Storage Box Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="uXXXXXX.your-storagebox.de" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-plug text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="source_port" name="source_port" x-model="sourcePort" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="23" min="1" max="65535" value="23" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Connection port (default: 23)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
|
||||
<div class="flex">
|
||||
<div class="flex items-center me-4">
|
||||
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
</div>
|
||||
<div class="flex items-center me-4">
|
||||
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="uXXXXXX" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Hetzner Storage Box username (typically matches your Storage Box number)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="sourceAuthType === 'password'" class="mb-6">
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" x-bind:required="sourceAuthType === 'password'"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Hetzner Storage Box password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="sourceAuthType === 'key'" class="mb-6">
|
||||
<label for="source_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile" x-bind:required="sourceAuthType === 'key'"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/id_rsa" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your SSH private key file (must be readable by the application)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/backups" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path on your Storage Box (e.g., /backups, /path/to/files)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Security Note</h3>
|
||||
<p class="mt-1">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -27,15 +27,5 @@ templ LocalSourceForm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
@click="checkPath(sourcePath, 'source')"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
|
||||
</svg>
|
||||
Check Location
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -39,6 +39,22 @@ templ MinIOSourceForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_region" name="source_region" x-model="sourceRegion"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Optional: Specify the region if your MinIO setup requires it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||
<div class="relative">
|
||||
|
||||
@@ -10,12 +10,12 @@ templ NextCloudSourceForm() {
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required
|
||||
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://nextcloud.example.com" />
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@ templ NextCloudSourceForm() {
|
||||
</div>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="nextcloud_username" />
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud username
|
||||
@@ -47,7 +47,7 @@ templ NextCloudSourceForm() {
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="NextCloud password" />
|
||||
placeholder="password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud account password
|
||||
@@ -62,10 +62,10 @@ templ NextCloudSourceForm() {
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="remote.php/dav/files/username/path/to/files" />
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your files in NextCloud. Usually starts with "remote.php/dav/files/username/"
|
||||
Path to your files in NextCloud
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package source
|
||||
|
||||
templ WasabiSourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your Wasabi Cloud Storage details below. You'll need your access key, secret key, region, and bucket information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Wasabi Region</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_region" name="source_region" x-model="sourceRegion" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-wasabi-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your Wasabi bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="ABCDEFGHIJKLMNOPQRST" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Wasabi Access Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your Wasabi Secret Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="s3.wasabisys.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Security Note</h3>
|
||||
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific Wasabi bucket and operations needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -10,12 +10,12 @@ templ WebDAVSourceForm() {
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required
|
||||
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://webdav.example.com" />
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@ templ WebDAVSourceForm() {
|
||||
</div>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="webdav_username" />
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV username
|
||||
@@ -47,7 +47,7 @@ templ WebDAVSourceForm() {
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="WebDAV password" />
|
||||
placeholder="password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV account password
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package toast
|
||||
|
||||
templ Container() {
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package toast
|
||||
|
||||
templ ShowToastJS() {
|
||||
<script>
|
||||
// Notification system
|
||||
function showToast(message, type) {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
if (!toastContainer) {
|
||||
console.error("Toast container not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
let iconClass, bgColorClass, textColorClass;
|
||||
|
||||
if (type === 'success') {
|
||||
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||
textColorClass = 'text-green-500 dark:text-green-200';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||
textColorClass = 'text-red-500 dark:text-red-200';
|
||||
} else { // Default to info
|
||||
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
}
|
||||
|
||||
// Create icon div
|
||||
const iconDiv = document.createElement('div');
|
||||
iconDiv.className = `inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}`;
|
||||
iconDiv.innerHTML = type === 'success'
|
||||
? '<i class="fas fa-check"></i>'
|
||||
: type === 'error'
|
||||
? '<i class="fas fa-exclamation-circle"></i>'
|
||||
: '<i class="fas fa-info-circle"></i>';
|
||||
|
||||
// Create message div and set text content safely
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'ml-3 text-sm font-normal';
|
||||
messageDiv.textContent = message; // Use textContent for safety
|
||||
|
||||
// Create close button
|
||||
const closeButton = document.createElement('button'); // Keep this declaration
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700';
|
||||
closeButton.setAttribute('data-dismiss-target', `#${toast.id}`);
|
||||
closeButton.setAttribute('aria-label', 'Close');
|
||||
closeButton.innerHTML = `
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fas fa-times"></i>
|
||||
`;
|
||||
|
||||
// Append elements to the toast
|
||||
toast.appendChild(iconDiv);
|
||||
toast.appendChild(messageDiv);
|
||||
toast.appendChild(closeButton);
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to the close button we created earlier
|
||||
closeButton.addEventListener('click', function() { // Use the existing closeButton variable
|
||||
// Animate out before removing
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package components
|
||||
|
||||
templ TestResult(success bool, message string) {
|
||||
if success {
|
||||
<div class="text-green-600 dark:text-green-400 flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span>{ message }</span>
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-red-600 dark:text-red-400 flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span>{ message }</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+89
-18
@@ -16,15 +16,59 @@ if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
|
||||
echo "Detected Alpine Linux, using busybox usermod/groupmod..."
|
||||
|
||||
# Update group ID first
|
||||
if [ "$(getent group ${USERNAME} | cut -d: -f3)" != "${PGID}" ]; then
|
||||
echo "Updating GID to ${PGID}..."
|
||||
groupmod -g ${PGID} ${USERNAME} || echo "⚠️ Failed to change GID"
|
||||
fi
|
||||
|
||||
# Update user ID
|
||||
if [ "$(id -u ${USERNAME})" != "${PUID}" ]; then
|
||||
echo "Updating UID to ${PUID}..."
|
||||
usermod -u ${PUID} ${USERNAME} || echo "⚠️ Failed to change UID"
|
||||
CURRENT_GID=$(getent group ${USERNAME} | cut -d: -f3)
|
||||
CURRENT_UID=$(id -u ${USERNAME})
|
||||
|
||||
if [ "${CURRENT_GID}" != "${PGID}" ] || [ "${CURRENT_UID}" != "${PUID}" ]; then
|
||||
echo "Attempting to update UID/GID to ${PUID}:${PGID} using delete/recreate..."
|
||||
|
||||
# Delete existing user and group, ignoring errors
|
||||
deluser ${USERNAME} > /dev/null 2>&1 || true
|
||||
delgroup ${USERNAME} > /dev/null 2>&1 || true
|
||||
|
||||
# Check if a group with the target GID already exists
|
||||
EXISTING_GROUP=$(getent group ${PGID} | cut -d: -f1 || echo "")
|
||||
|
||||
if [ -n "${EXISTING_GROUP}" ]; then
|
||||
echo "Group with GID ${PGID} already exists as '${EXISTING_GROUP}', will use this group"
|
||||
# Set USERNAME_GROUP to the existing group name
|
||||
USERNAME_GROUP="${EXISTING_GROUP}"
|
||||
else
|
||||
# Add group with the specified GID
|
||||
echo "Adding group ${USERNAME} with GID ${PGID}"
|
||||
if ! addgroup -g ${PGID} ${USERNAME}; then
|
||||
echo "⚠️ Failed to add group ${USERNAME} with GID ${PGID}."
|
||||
# Exiting because user creation will likely fail
|
||||
exit 1
|
||||
fi
|
||||
USERNAME_GROUP="${USERNAME}"
|
||||
fi
|
||||
|
||||
# Add user with the specified UID and GID
|
||||
# Use -G for primary group with adduser in BusyBox
|
||||
# Use -h /app for home directory (consistent with expectations)
|
||||
# Use -s /bin/sh for shell
|
||||
# Use -D for no password (system user)
|
||||
echo "Adding user ${USERNAME} with UID ${PUID} and group ${USERNAME_GROUP}"
|
||||
if ! adduser -u ${PUID} -G ${USERNAME_GROUP} -h /app -s /bin/sh -D ${USERNAME}; then
|
||||
echo "⚠️ Failed to add user ${USERNAME} with UID ${PUID} and group ${USERNAME_GROUP}."
|
||||
# Exiting because the application cannot run as the correct user
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify the change
|
||||
FINAL_UID=$(id -u ${USERNAME} 2>/dev/null || echo "error")
|
||||
FINAL_GID=$(id -g ${USERNAME} 2>/dev/null || echo "error")
|
||||
|
||||
if [ "${FINAL_UID}" = "${PUID}" ] && [ "${FINAL_GID}" = "${PGID}" ]; then
|
||||
echo "✅ Successfully updated UID/GID to ${PUID}:${PGID}"
|
||||
else
|
||||
echo "⚠️ Verification failed after update. Target: ${PUID}:${PGID}, Actual: ${FINAL_UID}:${FINAL_GID}"
|
||||
# Exiting because the UID/GID is not correct
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "UID/GID ${PUID}:${PGID} already set."
|
||||
fi
|
||||
else
|
||||
echo "Non-Alpine system, using standard user management..."
|
||||
@@ -45,13 +89,40 @@ if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
|
||||
groupdel ${USERNAME} 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Recreate group and user in the correct order
|
||||
echo "Creating group ${USERNAME} with GID ${PGID}"
|
||||
groupadd -g ${PGID} ${USERNAME} 2>/dev/null || groupadd ${USERNAME} 2>/dev/null || true
|
||||
# Check if a group with the target GID already exists
|
||||
EXISTING_GROUP=$(getent group ${PGID} | cut -d: -f1 || echo "")
|
||||
|
||||
echo "Creating user ${USERNAME} with UID ${PUID}"
|
||||
useradd -u ${PUID} -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null ||
|
||||
useradd -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null || true
|
||||
if [ -n "${EXISTING_GROUP}" ]; then
|
||||
echo "Group with GID ${PGID} already exists as '${EXISTING_GROUP}', will use this group"
|
||||
# Set USERNAME_GROUP to the existing group name
|
||||
USERNAME_GROUP="${EXISTING_GROUP}"
|
||||
else
|
||||
# Recreate group with the specified GID
|
||||
echo "Creating group ${USERNAME} with GID ${PGID}"
|
||||
groupadd -g ${PGID} ${USERNAME} 2>/dev/null || groupadd ${USERNAME} 2>/dev/null || true
|
||||
USERNAME_GROUP="${USERNAME}"
|
||||
fi
|
||||
|
||||
# Check if a user with the target UID already exists
|
||||
EXISTING_USER=$(getent passwd ${PUID} | cut -d: -f1 || echo "")
|
||||
|
||||
if [ -n "${EXISTING_USER}" ] && [ "${EXISTING_USER}" != "${USERNAME}" ]; then
|
||||
echo "⚠️ Warning: User with UID ${PUID} already exists as '${EXISTING_USER}'. Using a different username may cause issues."
|
||||
fi
|
||||
|
||||
echo "Creating user ${USERNAME} with UID ${PUID} and group ${USERNAME_GROUP}"
|
||||
useradd -u ${PUID} -g ${USERNAME_GROUP} -s /bin/sh ${USERNAME} 2>/dev/null ||
|
||||
useradd -g ${USERNAME_GROUP} -s /bin/sh ${USERNAME} 2>/dev/null || true
|
||||
|
||||
# Verify the change
|
||||
FINAL_UID=$(id -u ${USERNAME} 2>/dev/null || echo "error")
|
||||
FINAL_GID=$(id -g ${USERNAME} 2>/dev/null || echo "error")
|
||||
|
||||
if [ "${FINAL_UID}" = "${PUID}" ] && [ "${FINAL_GID}" = "${PGID}" ]; then
|
||||
echo "✅ Successfully updated UID/GID to ${PUID}:${PGID}"
|
||||
else
|
||||
echo "⚠️ Warning: Verification failed. Target: ${PUID}:${PGID}, Actual: ${FINAL_UID}:${FINAL_GID}"
|
||||
fi
|
||||
} || {
|
||||
echo "⚠️ Warning: Failed to update UID/GID, continuing with built-in user"
|
||||
}
|
||||
@@ -59,17 +130,17 @@ if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
|
||||
|
||||
# Fix ownership of app directories
|
||||
echo "Setting ownership of app directories"
|
||||
chown -R ${USERNAME}:${USERNAME} /app/data /app/backups || echo "⚠️ Warning: Failed to change ownership"
|
||||
chown -R ${USERNAME}:${USERNAME_GROUP:-${USERNAME}} /app/data /app/backups || echo "⚠️ Warning: Failed to change ownership"
|
||||
|
||||
# Ensure .env file exists and has correct permissions
|
||||
if [ -f /app/.env ]; then
|
||||
echo "Found .env file, setting permissions..."
|
||||
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||
chown ${USERNAME}:${USERNAME_GROUP:-${USERNAME}} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
||||
else
|
||||
echo "No .env file found, creating empty one..."
|
||||
touch /app/.env
|
||||
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||
chown ${USERNAME}:${USERNAME_GROUP:-${USERNAME}} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
||||
fi
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/gorm v1.25.12
|
||||
@@ -22,6 +23,7 @@ require (
|
||||
github.com/bytedance/sonic v1.12.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
@@ -34,6 +36,7 @@ require (
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.2.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -43,6 +46,7 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
|
||||
@@ -56,6 +56,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
|
||||
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -294,6 +295,12 @@ func handleUpdateConfig(database *db.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Regenerate the rclone config file
|
||||
if err := database.GenerateRcloneConfig(&updatedConfig); err != nil {
|
||||
// Log the error but continue anyway as the config was updated in the database
|
||||
log.Printf("Warning: Failed to regenerate rclone config after API update: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updatedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
Email EmailConfig `json:"email"`
|
||||
BaseURL string `json:"base_url"` // Base URL for generating links in emails
|
||||
TOTPEncryptKey string `json:"totp_encrypt_key"` // Encryption key for TOTP secrets
|
||||
SkipSSLVerify bool `json:"skip_ssl_verify"` // Skip SSL verification for outgoing webhooks/notifications
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
@@ -40,6 +41,7 @@ func Load() (*Config, error) {
|
||||
JWTSecret: "change_this_to_a_secure_random_string",
|
||||
BaseURL: "http://localhost:8080",
|
||||
TOTPEncryptKey: "this-is-a-dev-key-not-for-production!", // Default development key
|
||||
SkipSSLVerify: false, // Default to verifying SSL
|
||||
Email: EmailConfig{
|
||||
Enabled: false,
|
||||
Host: "smtp.example.com",
|
||||
@@ -119,6 +121,13 @@ func Load() (*Config, error) {
|
||||
if emailRequireAuth := os.Getenv("EMAIL_REQUIRE_AUTH"); emailRequireAuth != "" {
|
||||
cfg.Email.RequireAuth = strings.ToLower(emailRequireAuth) == "true"
|
||||
}
|
||||
|
||||
// Skip SSL Verification configuration
|
||||
if skipSSLVerify := os.Getenv("SKIP_SSL_VERIFY"); skipSSLVerify != "" {
|
||||
// If SKIP_SSL_VERIFY is set, parse its boolean value
|
||||
cfg.SkipSSLVerify = strings.ToLower(skipSSLVerify) == "true"
|
||||
}
|
||||
// Otherwise, the default from line 44 (false) is used.
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
} else {
|
||||
@@ -148,6 +157,11 @@ func Load() (*Config, error) {
|
||||
"EMAIL_REQUIRE_AUTH=" + strconv.FormatBool(cfg.Email.RequireAuth),
|
||||
"EMAIL_USERNAME=" + cfg.Email.Username,
|
||||
"EMAIL_PASSWORD=" + cfg.Email.Password,
|
||||
"",
|
||||
"# Skip SSL Verification for outgoing notifications (webhooks, etc.)",
|
||||
"# Set to true to disable SSL certificate verification (USE WITH CAUTION)",
|
||||
"# Defaults to false (verification enabled) if not set.",
|
||||
"SKIP_SSL_VERIFY=" + strconv.FormatBool(cfg.SkipSSLVerify), // Default is false
|
||||
}
|
||||
|
||||
if err := os.WriteFile(envPath, []byte(strings.Join(envContent, "\n")), 0644); err != nil {
|
||||
|
||||
@@ -28,7 +28,7 @@ type AuthProvider struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Type ProviderType `gorm:"not null" json:"type"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Enabled *bool `gorm:"default:true" json:"enabled"`
|
||||
Description string `json:"description"`
|
||||
ProviderURL string `json:"provider_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
@@ -75,6 +75,21 @@ func (p *AuthProvider) SetConfig(data map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- AuthProvider Helper Methods ---
|
||||
|
||||
// GetEnabled returns the value of Enabled with a default if nil
|
||||
func (p *AuthProvider) GetEnabled() bool {
|
||||
if p.Enabled == nil {
|
||||
return true // Default to true if not set
|
||||
}
|
||||
return *p.Enabled
|
||||
}
|
||||
|
||||
// SetEnabled sets the Enabled field
|
||||
func (p *AuthProvider) SetEnabled(value bool) {
|
||||
p.Enabled = &value
|
||||
}
|
||||
|
||||
// ExternalUserIdentity represents a user identity from an external authentication provider
|
||||
type ExternalUserIdentity struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
|
||||
+15
-2055
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileMetadata stores information about processed files
|
||||
type FileMetadata struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
JobID uint `gorm:"not null;index"`
|
||||
Job Job `gorm:"foreignkey:JobID"`
|
||||
ConfigID uint `gorm:"default:0"` // The specific config ID this file was processed with
|
||||
FileName string `gorm:"not null"`
|
||||
OriginalPath string `gorm:"not null"`
|
||||
FileSize int64 `gorm:"not null"`
|
||||
FileHash string `gorm:"index"` // MD5 or other hash for file identity
|
||||
CreationTime time.Time
|
||||
ModTime time.Time
|
||||
ProcessedTime time.Time `gorm:"not null"`
|
||||
DestinationPath string `gorm:"not null"`
|
||||
Status string `gorm:"not null"` // processed, archived, deleted, etc.
|
||||
ErrorMessage string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package db
|
||||
|
||||
// --- FileMetadata Store Methods ---
|
||||
|
||||
// CreateFileMetadata creates a new file metadata record
|
||||
func (db *DB) CreateFileMetadata(metadata *FileMetadata) error {
|
||||
return db.Create(metadata).Error
|
||||
}
|
||||
|
||||
// GetFileMetadataByJobAndName retrieves file metadata by job ID and filename
|
||||
func (db *DB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*FileMetadata, error) {
|
||||
var metadata FileMetadata
|
||||
err := db.Where("job_id = ? AND file_name = ?", jobID, fileName).First(&metadata).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// GetFileMetadataByHash retrieves file metadata by file hash
|
||||
func (db *DB) GetFileMetadataByHash(fileHash string) (*FileMetadata, error) {
|
||||
var metadata FileMetadata
|
||||
err := db.Where("file_hash = ?", fileHash).First(&metadata).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// DeleteFileMetadata deletes file metadata by ID
|
||||
func (db *DB) DeleteFileMetadata(id uint) error {
|
||||
return db.Delete(&FileMetadata{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Job represents a scheduled transfer task
|
||||
type Job struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Name string `form:"name"`
|
||||
ConfigID uint `gorm:"not null" form:"config_id"`
|
||||
Config TransferConfig `gorm:"foreignkey:ConfigID"`
|
||||
ConfigIDs string `gorm:"column:config_ids"` // Comma-separated list of config IDs
|
||||
Schedule string `gorm:"not null" form:"schedule"`
|
||||
Enabled *bool `gorm:"default:true" form:"enabled"`
|
||||
LastRun *time.Time
|
||||
NextRun *time.Time
|
||||
// Webhook notification fields
|
||||
WebhookEnabled *bool `gorm:"default:false" form:"webhook_enabled"`
|
||||
WebhookURL string `form:"webhook_url"`
|
||||
WebhookSecret string `form:"webhook_secret"`
|
||||
WebhookHeaders string `form:"webhook_headers"` // JSON-encoded headers
|
||||
NotifyOnSuccess *bool `gorm:"default:true" form:"notify_on_success"`
|
||||
NotifyOnFailure *bool `gorm:"default:true" form:"notify_on_failure"`
|
||||
CreatedBy uint
|
||||
User User `gorm:"foreignkey:CreatedBy"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// JobHistory records the execution history of a job
|
||||
type JobHistory struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
JobID uint `gorm:"not null"`
|
||||
Job Job `gorm:"foreignkey:JobID"`
|
||||
ConfigID uint `gorm:"default:0"` // The specific config ID this history entry is for
|
||||
StartTime time.Time `gorm:"not null"`
|
||||
EndTime *time.Time
|
||||
Status string `gorm:"not null"`
|
||||
BytesTransferred int64
|
||||
FilesTransferred int
|
||||
ErrorMessage string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// --- Job Helper Methods ---
|
||||
|
||||
// GetConfigIDsList returns the list of config IDs as integers
|
||||
func (j *Job) GetConfigIDsList() []uint {
|
||||
if j.ConfigIDs == "" {
|
||||
// If ConfigIDs is empty but ConfigID is set, return that as the only ID
|
||||
if j.ConfigID > 0 {
|
||||
return []uint{j.ConfigID}
|
||||
}
|
||||
return []uint{}
|
||||
}
|
||||
|
||||
// Split the comma-separated string
|
||||
strIDs := strings.Split(j.ConfigIDs, ",")
|
||||
ids := make([]uint, 0, len(strIDs))
|
||||
|
||||
// Convert each string to uint
|
||||
for _, strID := range strIDs {
|
||||
if id, err := strconv.ParseUint(strings.TrimSpace(strID), 10, 32); err == nil {
|
||||
ids = append(ids, uint(id))
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// SetConfigIDsList sets the config IDs from a slice of uint
|
||||
func (j *Job) SetConfigIDsList(ids []uint) {
|
||||
// Convert to strings
|
||||
strIDs := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
strIDs[i] = strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// Join with commas
|
||||
j.ConfigIDs = strings.Join(strIDs, ",")
|
||||
|
||||
// Debug log the final ConfigIDs string
|
||||
log.Printf("SetConfigIDsList: Setting ConfigIDs to: %s (from %v)", j.ConfigIDs, ids)
|
||||
|
||||
// If there's at least one ID, set ConfigID to the first one for backward compatibility
|
||||
if len(ids) > 0 {
|
||||
j.ConfigID = ids[0]
|
||||
} else {
|
||||
j.ConfigID = 0 // Ensure ConfigID is cleared if the list is empty
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfigIDsAsStrings returns the list of config IDs as strings for template rendering
|
||||
func (j *Job) GetConfigIDsAsStrings() []string {
|
||||
ids := j.GetConfigIDsList()
|
||||
strIDs := make([]string, len(ids))
|
||||
|
||||
for i, id := range ids {
|
||||
strIDs[i] = fmt.Sprintf("'%d'", id)
|
||||
}
|
||||
|
||||
return strIDs
|
||||
}
|
||||
|
||||
// GetEnabled returns the value of Enabled with a default if nil
|
||||
func (j *Job) GetEnabled() bool {
|
||||
if j.Enabled == nil {
|
||||
return true // Default to true if not set
|
||||
}
|
||||
return *j.Enabled
|
||||
}
|
||||
|
||||
// SetEnabled sets the Enabled field
|
||||
func (j *Job) SetEnabled(value bool) {
|
||||
j.Enabled = &value
|
||||
}
|
||||
|
||||
// GetWebhookEnabled returns the value of WebhookEnabled with a default if nil
|
||||
func (j *Job) GetWebhookEnabled() bool {
|
||||
if j.WebhookEnabled == nil {
|
||||
return false // Default to false if not set
|
||||
}
|
||||
return *j.WebhookEnabled
|
||||
}
|
||||
|
||||
// SetWebhookEnabled sets the WebhookEnabled field
|
||||
func (j *Job) SetWebhookEnabled(value bool) {
|
||||
j.WebhookEnabled = &value
|
||||
}
|
||||
|
||||
// GetNotifyOnSuccess returns the value of NotifyOnSuccess with a default if nil
|
||||
func (j *Job) GetNotifyOnSuccess() bool {
|
||||
if j.NotifyOnSuccess == nil {
|
||||
return true // Default to true if not set
|
||||
}
|
||||
return *j.NotifyOnSuccess
|
||||
}
|
||||
|
||||
// SetNotifyOnSuccess sets the NotifyOnSuccess field
|
||||
func (j *Job) SetNotifyOnSuccess(value bool) {
|
||||
j.NotifyOnSuccess = &value
|
||||
}
|
||||
|
||||
// GetNotifyOnFailure returns the value of NotifyOnFailure with a default if nil
|
||||
func (j *Job) GetNotifyOnFailure() bool {
|
||||
if j.NotifyOnFailure == nil {
|
||||
return true // Default to true if not set
|
||||
}
|
||||
return *j.NotifyOnFailure
|
||||
}
|
||||
|
||||
// SetNotifyOnFailure sets the NotifyOnFailure field
|
||||
func (j *Job) SetNotifyOnFailure(value bool) {
|
||||
j.NotifyOnFailure = &value
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// --- Job Store Methods ---
|
||||
|
||||
// CreateJob creates a new job record
|
||||
func (db *DB) CreateJob(job *Job) error {
|
||||
// Use Omit to prevent GORM from creating a new config
|
||||
return db.Omit("Config").Create(job).Error
|
||||
}
|
||||
|
||||
// GetJobs retrieves all jobs for a user, preloading the associated config
|
||||
func (db *DB) GetJobs(userID uint) ([]Job, error) {
|
||||
var jobs []Job
|
||||
err := db.Preload("Config").Where("created_by = ?", userID).Find(&jobs).Error
|
||||
return jobs, err
|
||||
}
|
||||
|
||||
// GetJob retrieves a single job by ID, preloading the associated config
|
||||
func (db *DB) GetJob(id uint) (*Job, error) {
|
||||
var job Job
|
||||
err := db.Preload("Config").First(&job, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
// UpdateJob updates an existing job record
|
||||
func (db *DB) UpdateJob(job *Job) error {
|
||||
log.Printf("UpdateJob: Updating job ID: %d, ConfigIDs: %s", job.ID, job.ConfigIDs)
|
||||
|
||||
// Use Omit to prevent GORM from updating or creating a new config
|
||||
// Explicitly update fields that can be changed
|
||||
return db.Model(&Job{}).
|
||||
Where("id = ?", job.ID).
|
||||
Omit("Config"). // Omit the nested Config struct
|
||||
Updates(map[string]interface{}{
|
||||
"name": job.Name,
|
||||
"config_id": job.ConfigID, // Update the foreign key if needed
|
||||
"config_ids": job.ConfigIDs, // Explicitly update config_ids string
|
||||
"schedule": job.Schedule,
|
||||
"enabled": job.Enabled,
|
||||
"webhook_enabled": job.WebhookEnabled,
|
||||
"webhook_url": job.WebhookURL,
|
||||
"webhook_secret": job.WebhookSecret,
|
||||
"webhook_headers": job.WebhookHeaders,
|
||||
"notify_on_success": job.NotifyOnSuccess,
|
||||
"notify_on_failure": job.NotifyOnFailure,
|
||||
// Do not update LastRun, NextRun, CreatedBy, CreatedAt, UpdatedAt here
|
||||
// GORM handles UpdatedAt automatically
|
||||
}).Error
|
||||
}
|
||||
|
||||
// DeleteJob deletes a job and its associated history records
|
||||
func (db *DB) DeleteJob(id uint) error {
|
||||
// Start transaction
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
|
||||
// Delete associated job history records first
|
||||
if err := tx.Where("job_id = ?", id).Delete(&JobHistory{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to delete job history: %v", err)
|
||||
}
|
||||
|
||||
// Delete the job
|
||||
if err := tx.Delete(&Job{}, id).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to delete job: %v", err)
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// UpdateJobStatus updates the LastRun and NextRun fields of a job
|
||||
func (db *DB) UpdateJobStatus(job *Job) error {
|
||||
// Only update specific fields related to run status
|
||||
return db.Model(job).Updates(map[string]interface{}{
|
||||
"last_run": job.LastRun,
|
||||
"next_run": job.NextRun,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetActiveJobs returns all active (enabled) jobs
|
||||
func (db *DB) GetActiveJobs() ([]Job, error) {
|
||||
if db.DB == nil {
|
||||
return nil, fmt.Errorf("database connection is nil")
|
||||
}
|
||||
var jobs []Job
|
||||
// For boolean pointer fields, need to check either NULL (for default) or true value
|
||||
err := db.Preload("Config").Where("enabled IS NULL OR enabled = ?", true).Find(&jobs).Error
|
||||
return jobs, err
|
||||
}
|
||||
|
||||
// GetConfigsForJob returns all transfer configurations associated with a job, in the order specified by ConfigIDs
|
||||
func (db *DB) GetConfigsForJob(jobID uint) ([]TransferConfig, error) {
|
||||
var job Job
|
||||
if err := db.First(&job, jobID).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get job %d: %w", jobID, err)
|
||||
}
|
||||
|
||||
configIDs := job.GetConfigIDsList()
|
||||
if len(configIDs) == 0 {
|
||||
return []TransferConfig{}, nil // No configs associated
|
||||
}
|
||||
|
||||
var configs []TransferConfig
|
||||
if err := db.Where("id IN ?", configIDs).Find(&configs).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get configs for job %d: %w", jobID, err)
|
||||
}
|
||||
|
||||
// Order the fetched configs according to the job.ConfigIDs list
|
||||
configMap := make(map[uint]TransferConfig, len(configs))
|
||||
for _, cfg := range configs {
|
||||
configMap[cfg.ID] = cfg
|
||||
}
|
||||
|
||||
orderedConfigs := make([]TransferConfig, 0, len(configIDs))
|
||||
for _, id := range configIDs {
|
||||
if cfg, ok := configMap[id]; ok {
|
||||
orderedConfigs = append(orderedConfigs, cfg)
|
||||
} else {
|
||||
log.Printf("Warning: Config ID %d listed in job %d not found in database", id, jobID)
|
||||
}
|
||||
}
|
||||
|
||||
return orderedConfigs, nil
|
||||
}
|
||||
|
||||
// --- JobHistory Store Methods ---
|
||||
|
||||
// CreateJobHistory creates a new job history record
|
||||
func (db *DB) CreateJobHistory(history *JobHistory) error {
|
||||
return db.Create(history).Error
|
||||
}
|
||||
|
||||
// UpdateJobHistory updates an existing job history record
|
||||
func (db *DB) UpdateJobHistory(history *JobHistory) error {
|
||||
return db.Save(history).Error
|
||||
}
|
||||
|
||||
// GetJobHistory retrieves all history records for a specific job, ordered by start time descending
|
||||
func (db *DB) GetJobHistory(jobID uint) ([]JobHistory, error) {
|
||||
var histories []JobHistory
|
||||
err := db.Where("job_id = ?", jobID).Order("start_time desc").Find(&histories).Error
|
||||
return histories, err
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AlterBooleanDefaults changes boolean columns with default:true to pointers
|
||||
// using explicit table recreation with raw SQL for SQLite compatibility.
|
||||
func AlterBooleanDefaults() *gormigrate.Migration {
|
||||
|
||||
// --- Raw SQL CREATE TABLE statements for the target schema ---
|
||||
|
||||
const createNotificationServicesSQL = `
|
||||
CREATE TABLE notification_services (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
is_enabled INTEGER DEFAULT 1, -- Target: *bool, SQLite uses 0/1, default true
|
||||
config TEXT,
|
||||
description TEXT,
|
||||
event_triggers TEXT DEFAULT '[]',
|
||||
payload_template TEXT,
|
||||
secret_key TEXT,
|
||||
retry_policy TEXT DEFAULT 'simple',
|
||||
last_used timestamp,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
failure_count INTEGER DEFAULT 0,
|
||||
created_by INTEGER,
|
||||
created_at timestamp,
|
||||
updated_at timestamp
|
||||
);`
|
||||
|
||||
const createAuthProvidersSQL = `
|
||||
CREATE TABLE auth_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1, -- Target: *bool, SQLite uses 0/1, default true
|
||||
description TEXT,
|
||||
provider_url TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
redirect_url TEXT,
|
||||
scopes TEXT,
|
||||
attribute_mapping TEXT,
|
||||
config TEXT,
|
||||
icon_url TEXT,
|
||||
successful_logins INTEGER DEFAULT 0,
|
||||
last_used timestamp,
|
||||
created_at timestamp,
|
||||
updated_at timestamp
|
||||
);`
|
||||
|
||||
const createTransferConfigsSQL = `
|
||||
CREATE TABLE transfer_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL,
|
||||
source_host TEXT,
|
||||
source_port INTEGER DEFAULT 22,
|
||||
source_user TEXT,
|
||||
source_key_file TEXT,
|
||||
source_bucket TEXT,
|
||||
source_region TEXT,
|
||||
source_access_key TEXT,
|
||||
source_endpoint TEXT,
|
||||
source_share TEXT,
|
||||
source_domain TEXT,
|
||||
source_passive_mode INTEGER DEFAULT 1, -- Already *bool, keep default
|
||||
source_client_id TEXT,
|
||||
source_drive_id TEXT,
|
||||
source_team_drive TEXT,
|
||||
source_read_only INTEGER,
|
||||
source_start_year INTEGER,
|
||||
source_include_archived INTEGER,
|
||||
file_pattern TEXT DEFAULT '*',
|
||||
output_pattern TEXT,
|
||||
destination_type TEXT NOT NULL,
|
||||
destination_path TEXT NOT NULL,
|
||||
dest_host TEXT,
|
||||
dest_port INTEGER DEFAULT 22,
|
||||
dest_user TEXT,
|
||||
dest_key_file TEXT,
|
||||
dest_bucket TEXT,
|
||||
dest_region TEXT,
|
||||
dest_access_key TEXT,
|
||||
dest_endpoint TEXT,
|
||||
dest_share TEXT,
|
||||
dest_domain TEXT,
|
||||
dest_passive_mode INTEGER DEFAULT 1, -- Already *bool, keep default
|
||||
dest_client_id TEXT,
|
||||
dest_drive_id TEXT,
|
||||
dest_team_drive TEXT,
|
||||
dest_read_only INTEGER,
|
||||
dest_start_year INTEGER,
|
||||
dest_include_archived INTEGER,
|
||||
use_builtin_auth_source INTEGER,
|
||||
use_builtin_auth_dest INTEGER,
|
||||
google_drive_authenticated INTEGER,
|
||||
archive_path TEXT,
|
||||
archive_enabled INTEGER DEFAULT 0,
|
||||
rclone_flags TEXT,
|
||||
command_id INTEGER DEFAULT 1,
|
||||
command_flags TEXT,
|
||||
command_flag_values TEXT,
|
||||
delete_after_transfer INTEGER DEFAULT 0,
|
||||
skip_processed_files INTEGER DEFAULT 1, -- Target: *bool, SQLite uses 0/1, default true
|
||||
max_concurrent_transfers INTEGER DEFAULT 4,
|
||||
created_by INTEGER,
|
||||
created_at timestamp,
|
||||
updated_at timestamp
|
||||
);`
|
||||
|
||||
// --- End Raw SQL ---
|
||||
|
||||
return &gormigrate.Migration{
|
||||
ID: "012_alter_boolean_defaults",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// --- Backup Logic (copied) ---
|
||||
var count int64
|
||||
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||
}
|
||||
if count > 0 {
|
||||
sqlDB, err := tx.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||
}
|
||||
var seq int
|
||||
var name, dbPath string
|
||||
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||
return fmt.Errorf("failed to get database path: %v", err)
|
||||
}
|
||||
backupDir := os.Getenv("BACKUP_DIR")
|
||||
if backupDir == "" {
|
||||
backupDir = "/app/backups"
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
backupDir = "backups"
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||
}
|
||||
dbFileName := filepath.Base(dbPath)
|
||||
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||
backupFile := filepath.Join(backupDir, backupFileName)
|
||||
data, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to create database backup: %v", err)
|
||||
}
|
||||
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||
}
|
||||
// --- End Backup Logic ---
|
||||
|
||||
// --- Table Recreation Logic for SQLite ---
|
||||
if err := tx.Exec("PRAGMA foreign_keys = OFF").Error; err != nil {
|
||||
return fmt.Errorf("failed to disable foreign keys: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
|
||||
fmt.Printf("Warning: failed to re-enable foreign keys: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Helper function for table recreation
|
||||
recreateTable := func(tableName, createSQL string) error {
|
||||
fmt.Printf("Recreating table %s...\n", tableName)
|
||||
oldTableName := fmt.Sprintf("_%s_old", tableName)
|
||||
|
||||
// Drop the old temp table if it exists from a previous failed run
|
||||
if err := tx.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", oldTableName)).Error; err != nil {
|
||||
// Log the error but proceed, as the rename might still work or fail for the intended reason
|
||||
fmt.Printf("Warning: failed to drop potential leftover table %s: %v\n", oldTableName, err)
|
||||
}
|
||||
|
||||
// Rename old table
|
||||
if err := tx.Exec(fmt.Sprintf("ALTER TABLE %s RENAME TO %s", tableName, oldTableName)).Error; err == nil {
|
||||
fmt.Printf("Renamed %s to %s.\n", tableName, oldTableName)
|
||||
|
||||
// Create new table using raw SQL
|
||||
fmt.Printf("Creating new %s table...\n", tableName)
|
||||
if err := tx.Exec(createSQL).Error; err != nil {
|
||||
return fmt.Errorf("failed to create new %s table: %w", tableName, err)
|
||||
}
|
||||
fmt.Printf("New %s table created.\n", tableName)
|
||||
|
||||
// Copy data
|
||||
fmt.Printf("Copying data to new %s table...\n", tableName)
|
||||
// IMPORTANT: Ensure column order/names match if schema changed beyond types/defaults
|
||||
if err := tx.Exec(fmt.Sprintf("INSERT INTO %s SELECT * FROM %s", tableName, oldTableName)).Error; err != nil {
|
||||
return fmt.Errorf("failed to copy data to new %s table: %w", tableName, err)
|
||||
}
|
||||
fmt.Printf("Data copied to %s.\n", tableName)
|
||||
|
||||
// Drop old table
|
||||
if err := tx.Exec(fmt.Sprintf("DROP TABLE %s", oldTableName)).Error; err != nil {
|
||||
return fmt.Errorf("failed to drop old %s table: %w", tableName, err)
|
||||
}
|
||||
fmt.Printf("Successfully recreated %s.\n", tableName)
|
||||
} else {
|
||||
// Check if rename failed because table doesn't exist (fresh install)
|
||||
var tableExists int
|
||||
tx.Raw(fmt.Sprintf("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='%s'", tableName)).Scan(&tableExists)
|
||||
if tableExists == 0 {
|
||||
fmt.Printf("%s table does not exist, creating.\n", tableName)
|
||||
if err := tx.Exec(createSQL).Error; err != nil { // Create table directly
|
||||
return fmt.Errorf("failed to create new %s table: %w", tableName, err)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("failed to rename %s: %w", tableName, err) // Real rename error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Recreate tables
|
||||
if err := recreateTable("notification_services", createNotificationServicesSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recreateTable("auth_providers", createAuthProvidersSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recreateTable("transfer_configs", createTransferConfigsSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// --- End Table Recreation Logic ---
|
||||
|
||||
// Create audit log entry
|
||||
now := time.Now()
|
||||
details, auditErr := json.Marshal(map[string]interface{}{
|
||||
"tables_affected": []string{"notification_services", "auth_providers", "transfer_configs"},
|
||||
"columns_altered": []string{"is_enabled", "enabled", "skip_processed_files"},
|
||||
"new_type": "*bool (pointer to boolean)",
|
||||
"method": "Table recreation (SQLite - Raw SQL)",
|
||||
"message": "Changed boolean columns with default:true to pointers to handle false values correctly with GORM.",
|
||||
})
|
||||
if auditErr != nil {
|
||||
fmt.Printf("Warning: Failed to marshal audit log details: %v\n", auditErr)
|
||||
}
|
||||
|
||||
if auditErr == nil {
|
||||
if auditExecErr := tx.Exec(`
|
||||
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
|
||||
VALUES ('schema_update', 'multiple_tables', 0, 1, ?, ?, ?, ?)
|
||||
`, string(details), now, now, now).Error; auditExecErr != nil {
|
||||
fmt.Printf("Warning: Failed to insert audit log: %v\n", auditExecErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
// Rollback is complex and risky with table recreation. Log skip.
|
||||
now := time.Now()
|
||||
details, err := json.Marshal(map[string]interface{}{
|
||||
"migration_id": "012_alter_boolean_defaults",
|
||||
"message": "Skipping rollback of boolean column type changes (via table recreation) due to complexity/potential data loss.",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to marshal rollback audit log details: %v\n", err)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if auditExecErr := tx.Exec(`
|
||||
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
|
||||
VALUES ('migration_rollback', 'multiple_tables', 0, 1, ?, ?, ?, ?)
|
||||
`, string(details), now, now, now).Error; auditExecErr != nil {
|
||||
fmt.Printf("Warning: Failed to insert rollback audit log: %v\n", auditExecErr)
|
||||
}
|
||||
}
|
||||
fmt.Println("Rollback for migration 012_alter_boolean_defaults skipped for safety.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RecoverTransferConfigsRename checks for and corrects a specific inconsistent state
|
||||
// left by a potentially failed run of migration 012, where the transfer_configs
|
||||
// table might have been left renamed as _transfer_configs_old.
|
||||
func RecoverTransferConfigsRename() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "012a_recover_transfer_configs_rename",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
fmt.Println("Running migration 012a: Checking for transfer_configs rename recovery...")
|
||||
|
||||
var oldTableExists int
|
||||
tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='_transfer_configs_old'").Scan(&oldTableExists)
|
||||
|
||||
var newTableExists int
|
||||
tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='transfer_configs'").Scan(&newTableExists)
|
||||
|
||||
if oldTableExists > 0 && newTableExists == 0 {
|
||||
fmt.Println("Found _transfer_configs_old table but not transfer_configs. Attempting recovery rename...")
|
||||
if err := tx.Exec("ALTER TABLE _transfer_configs_old RENAME TO transfer_configs").Error; err != nil {
|
||||
return fmt.Errorf("failed to rename _transfer_configs_old back to transfer_configs: %w", err)
|
||||
}
|
||||
fmt.Println("Successfully renamed _transfer_configs_old to transfer_configs.")
|
||||
} else if oldTableExists > 0 && newTableExists > 0 {
|
||||
// This state shouldn't ideally happen if migration 012 followed its logic,
|
||||
// but indicates a potential issue. Maybe drop the old one? For now, just log.
|
||||
fmt.Println("Warning: Both transfer_configs and _transfer_configs_old tables exist. Manual inspection might be needed.")
|
||||
} else {
|
||||
fmt.Println("No recovery needed for transfer_configs rename.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
// Rollback doesn't make sense for a recovery step.
|
||||
fmt.Println("Rollback for migration 012a_recover_transfer_configs_rename is not applicable.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RecoverNotificationServicesRename checks for and corrects a specific inconsistent state
|
||||
// left by a potentially failed run of migration 012, where the notification_services
|
||||
// table might have been left renamed as _notification_services_old.
|
||||
func RecoverNotificationServicesRename() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "012b_recover_notification_services_rename",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
fmt.Println("Running migration 012b: Checking for notification_services rename recovery...")
|
||||
|
||||
var oldTableExists int
|
||||
tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='_notification_services_old'").Scan(&oldTableExists)
|
||||
|
||||
var newTableExists int
|
||||
tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='notification_services'").Scan(&newTableExists)
|
||||
|
||||
if oldTableExists > 0 && newTableExists == 0 {
|
||||
fmt.Println("Found _notification_services_old table but not notification_services. Attempting recovery rename...")
|
||||
if err := tx.Exec("ALTER TABLE _notification_services_old RENAME TO notification_services").Error; err != nil {
|
||||
return fmt.Errorf("failed to rename _notification_services_old back to notification_services: %w", err)
|
||||
}
|
||||
fmt.Println("Successfully renamed _notification_services_old to notification_services.")
|
||||
} else if oldTableExists > 0 && newTableExists > 0 {
|
||||
fmt.Println("Warning: Both notification_services and _notification_services_old tables exist. Manual inspection might be needed.")
|
||||
} else {
|
||||
fmt.Println("No recovery needed for notification_services rename.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
// Rollback doesn't make sense for a recovery step.
|
||||
fmt.Println("Rollback for migration 012b_recover_notification_services_rename is not applicable.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RecoverAuthProvidersRename checks for and corrects a specific inconsistent state
|
||||
// left by a potentially failed run of migration 012, where the auth_providers
|
||||
// table might have been left renamed as _auth_providers_old.
|
||||
func RecoverAuthProvidersRename() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "012c_recover_auth_providers_rename",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
fmt.Println("Running migration 012c: Checking for auth_providers rename recovery...")
|
||||
|
||||
var oldTableExists int
|
||||
tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='_auth_providers_old'").Scan(&oldTableExists)
|
||||
|
||||
var newTableExists int
|
||||
tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='auth_providers'").Scan(&newTableExists)
|
||||
|
||||
if oldTableExists > 0 && newTableExists == 0 {
|
||||
fmt.Println("Found _auth_providers_old table but not auth_providers. Attempting recovery rename...")
|
||||
if err := tx.Exec("ALTER TABLE _auth_providers_old RENAME TO auth_providers").Error; err != nil {
|
||||
return fmt.Errorf("failed to rename _auth_providers_old back to auth_providers: %w", err)
|
||||
}
|
||||
fmt.Println("Successfully renamed _auth_providers_old to auth_providers.")
|
||||
} else if oldTableExists > 0 && newTableExists > 0 {
|
||||
fmt.Println("Warning: Both auth_providers and _auth_providers_old tables exist. Manual inspection might be needed.")
|
||||
} else {
|
||||
fmt.Println("No recovery needed for auth_providers rename.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
// Rollback doesn't make sense for a recovery step.
|
||||
fmt.Println("Rollback for migration 012c_recover_auth_providers_rename is not applicable.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CleanupInvalidBooleans updates boolean columns represented as integers
|
||||
// to ensure they only contain valid values (0, 1, or NULL).
|
||||
func CleanupInvalidBooleans() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "013_cleanup_invalid_booleans",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
fmt.Println("Running migration 013: Cleaning up invalid boolean values...")
|
||||
|
||||
// Target: transfer_configs.delete_after_transfer
|
||||
// Set any non-NULL value that is not 0 or 1 to 0 (false)
|
||||
sql := `UPDATE transfer_configs
|
||||
SET delete_after_transfer = 0
|
||||
WHERE delete_after_transfer IS NOT NULL AND delete_after_transfer NOT IN (0, 1);`
|
||||
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return fmt.Errorf("failed to cleanup delete_after_transfer in transfer_configs: %w", err)
|
||||
}
|
||||
fmt.Println("Cleaned up invalid values in transfer_configs.delete_after_transfer.")
|
||||
|
||||
// Target: transfer_configs.archive_enabled
|
||||
sql = `UPDATE transfer_configs
|
||||
SET archive_enabled = 0
|
||||
WHERE archive_enabled IS NOT NULL AND archive_enabled NOT IN (0, 1);`
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return fmt.Errorf("failed to cleanup archive_enabled in transfer_configs: %w", err)
|
||||
}
|
||||
fmt.Println("Cleaned up invalid values in transfer_configs.archive_enabled.")
|
||||
|
||||
// Target: transfer_configs.skip_processed_files
|
||||
sql = `UPDATE transfer_configs
|
||||
SET skip_processed_files = 0
|
||||
WHERE skip_processed_files IS NOT NULL AND skip_processed_files NOT IN (0, 1);`
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return fmt.Errorf("failed to cleanup skip_processed_files in transfer_configs: %w", err)
|
||||
}
|
||||
fmt.Println("Cleaned up invalid values in transfer_configs.skip_processed_files.")
|
||||
|
||||
// Target: notification_services.is_enabled
|
||||
sql = `UPDATE notification_services
|
||||
SET is_enabled = 0
|
||||
WHERE is_enabled IS NOT NULL AND is_enabled NOT IN (0, 1);`
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
// Check if the table exists before failing hard
|
||||
var tableExists int
|
||||
tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='notification_services'").Scan(&tableExists)
|
||||
if tableExists == 0 {
|
||||
fmt.Println("Skipping cleanup for notification_services.is_enabled: table does not exist.")
|
||||
} else {
|
||||
return fmt.Errorf("failed to cleanup is_enabled in notification_services: %w", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Cleaned up invalid values in notification_services.is_enabled.")
|
||||
}
|
||||
|
||||
// Target: auth_providers.enabled
|
||||
sql = `UPDATE auth_providers
|
||||
SET enabled = 0
|
||||
WHERE enabled IS NOT NULL AND enabled NOT IN (0, 1);`
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
// Check if the table exists before failing hard
|
||||
var tableExists int
|
||||
tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='auth_providers'").Scan(&tableExists)
|
||||
if tableExists == 0 {
|
||||
fmt.Println("Skipping cleanup for auth_providers.enabled: table does not exist.")
|
||||
} else {
|
||||
return fmt.Errorf("failed to cleanup enabled in auth_providers: %w", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Cleaned up invalid values in auth_providers.enabled.")
|
||||
}
|
||||
|
||||
fmt.Println("Migration 013 completed successfully.")
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
// This migration cleans up data. Rolling back doesn't make sense
|
||||
// as we don't know the original invalid values.
|
||||
fmt.Println("Rollback for migration 013_cleanup_invalid_booleans is not applicable.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,22 @@ var migrations []*gormigrate.Migration
|
||||
func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
|
||||
// Add all migrations in order
|
||||
migrations = append(migrations,
|
||||
InitialSchema(), // 001
|
||||
UpdateGDriveType(), // 002
|
||||
Add2FA(), // 003
|
||||
AddAuditLogs(), // 004
|
||||
AddDefaultRoles(), // 005
|
||||
AddTimestampsToJobHistories(), // 006
|
||||
AddNotificationServices(), // 007
|
||||
AddUserNotifications(), // 008
|
||||
AddRcloneTables(), // 009
|
||||
AddRcloneCommandToConfig(), // 010
|
||||
AddAuthProviders(), // 011
|
||||
InitialSchema(), // 001
|
||||
UpdateGDriveType(), // 002
|
||||
Add2FA(), // 003
|
||||
AddAuditLogs(), // 004
|
||||
AddDefaultRoles(), // 005
|
||||
AddTimestampsToJobHistories(), // 006
|
||||
AddNotificationServices(), // 007
|
||||
AddUserNotifications(), // 008
|
||||
AddRcloneTables(), // 009
|
||||
AddRcloneCommandToConfig(), // 010
|
||||
AddAuthProviders(), // 011
|
||||
AlterBooleanDefaults(), // 012
|
||||
RecoverTransferConfigsRename(), // 012a
|
||||
RecoverNotificationServicesRename(), // 012b
|
||||
RecoverAuthProvidersRename(), // 012c
|
||||
CleanupInvalidBooleans(), // 013
|
||||
)
|
||||
|
||||
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
|
||||
|
||||
@@ -12,7 +12,7 @@ type NotificationService struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Type string `json:"type" gorm:"not null"` // email, webhook
|
||||
IsEnabled bool `json:"is_enabled" gorm:"default:true"`
|
||||
IsEnabled *bool `json:"is_enabled" gorm:"default:true"`
|
||||
Config map[string]string `json:"config" gorm:"-"`
|
||||
ConfigJSON string `json:"-" gorm:"column:config"`
|
||||
Description string `json:"description"`
|
||||
@@ -69,6 +69,8 @@ func (db *DB) GetNotificationServices(onlyEnabled bool) ([]NotificationService,
|
||||
query := db.DB
|
||||
|
||||
if onlyEnabled {
|
||||
// When using a pointer, we need to explicitly check for true
|
||||
// GORM handles the underlying SQL correctly for different dialects
|
||||
query = query.Where("is_enabled = ?", true)
|
||||
}
|
||||
|
||||
@@ -102,3 +104,21 @@ func (db *DB) UpdateNotificationService(service *NotificationService) error {
|
||||
func (db *DB) DeleteNotificationService(id uint) error {
|
||||
return db.Delete(&NotificationService{}, id).Error
|
||||
}
|
||||
|
||||
// --- NotificationService Helper Methods ---
|
||||
|
||||
// GetIsEnabled returns the value of IsEnabled with a default if nil
|
||||
func (n *NotificationService) GetIsEnabled() bool {
|
||||
if n.IsEnabled == nil {
|
||||
// If the pointer is nil, GORM might not have set it,
|
||||
// or it was explicitly set to nil. We assume the DB default (true)
|
||||
// if it's nil, aligning with the original gorm tag default.
|
||||
return true
|
||||
}
|
||||
return *n.IsEnabled
|
||||
}
|
||||
|
||||
// SetIsEnabled sets the IsEnabled field
|
||||
func (n *NotificationService) SetIsEnabled(value bool) {
|
||||
n.IsEnabled = &value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RcloneCommand represents a command available in rclone
|
||||
type RcloneCommand struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Name string `gorm:"not null;uniqueIndex"`
|
||||
Description string `gorm:"not null"`
|
||||
Category string `gorm:"not null;index"`
|
||||
IsAdvanced bool `gorm:"not null;default:false"`
|
||||
Flags []RcloneCommandFlag `gorm:"foreignKey:CommandID;constraint:OnDelete:CASCADE"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
// RcloneCommandFlag represents a flag that can be used with an rclone command
|
||||
type RcloneCommandFlag struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CommandID uint `gorm:"not null;index"`
|
||||
Command RcloneCommand `gorm:"foreignKey:CommandID"`
|
||||
Name string `gorm:"not null;index"`
|
||||
ShortName string
|
||||
Description string `gorm:"not null"`
|
||||
DataType string `gorm:"not null"` // string, int, bool, etc.
|
||||
IsRequired bool `gorm:"not null;default:false"`
|
||||
DefaultValue string
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
// --- Rclone Helper Methods ---
|
||||
|
||||
// GetUsageExample returns a human-readable usage example for a flag
|
||||
func (flag *RcloneCommandFlag) GetUsageExample() string {
|
||||
switch flag.DataType {
|
||||
case "bool":
|
||||
return flag.Name
|
||||
case "int":
|
||||
return fmt.Sprintf("%s=<number>", flag.Name)
|
||||
case "float":
|
||||
return fmt.Sprintf("%s=<decimal>", flag.Name)
|
||||
case "string":
|
||||
return fmt.Sprintf("%s=<text>", flag.Name)
|
||||
default:
|
||||
return fmt.Sprintf("%s=<value>", flag.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRcloneFlags parses a string of rclone flags into a map
|
||||
// Note: This is a general utility function, not tied to a specific struct instance.
|
||||
// It might be better placed in a more general utility package if one exists,
|
||||
// but keeping it here for now as per the original file structure.
|
||||
func ParseRcloneFlags(flagsStr string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
if flagsStr == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
// Split the flags string by spaces
|
||||
parts := strings.Fields(flagsStr)
|
||||
|
||||
for i := 0; i < len(parts); i++ {
|
||||
part := parts[i]
|
||||
|
||||
// Check if it's a flag (starts with --)
|
||||
if strings.HasPrefix(part, "--") {
|
||||
// Remove the -- prefix
|
||||
flagName := part // Keep the '--' prefix in the map key for consistency? Or remove? Plan used remove.
|
||||
// flagName := strings.TrimPrefix(part, "--") // Alternative: remove prefix
|
||||
|
||||
// Check if the flag has a value
|
||||
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "--") {
|
||||
// Next part is a value
|
||||
result[flagName] = parts[i+1]
|
||||
i++ // Skip the value in the next iteration
|
||||
} else {
|
||||
// Flag without value, treat as boolean true
|
||||
result[flagName] = "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --- Rclone Store Methods ---
|
||||
|
||||
// GetRcloneCommands returns all rclone commands
|
||||
func (db *DB) GetRcloneCommands() ([]RcloneCommand, error) {
|
||||
var commands []RcloneCommand
|
||||
err := db.Find(&commands).Error
|
||||
return commands, err
|
||||
}
|
||||
|
||||
// GetRcloneCommand returns a specific rclone command by ID
|
||||
func (db *DB) GetRcloneCommand(id uint) (*RcloneCommand, error) {
|
||||
var command RcloneCommand
|
||||
err := db.First(&command, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &command, nil
|
||||
}
|
||||
|
||||
// GetRcloneCommandByName returns a specific rclone command by name
|
||||
func (db *DB) GetRcloneCommandByName(name string) (*RcloneCommand, error) {
|
||||
var command RcloneCommand
|
||||
err := db.Where("name = ?", name).First(&command).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &command, nil
|
||||
}
|
||||
|
||||
// GetRcloneCommandsInCategory returns all commands in a specific category
|
||||
func (db *DB) GetRcloneCommandsInCategory(category string) ([]RcloneCommand, error) {
|
||||
var commands []RcloneCommand
|
||||
err := db.Where("category = ?", category).Find(&commands).Error
|
||||
return commands, err
|
||||
}
|
||||
|
||||
// GetRcloneCommandFlag returns a specific flag by ID
|
||||
func (db *DB) GetRcloneCommandFlag(id uint) (*RcloneCommandFlag, error) {
|
||||
var flag RcloneCommandFlag
|
||||
err := db.First(&flag, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &flag, nil
|
||||
}
|
||||
|
||||
// GetRcloneCommandFlagByName returns a specific flag by name for a command
|
||||
func (db *DB) GetRcloneCommandFlagByName(commandID uint, name string) (*RcloneCommandFlag, error) {
|
||||
var flag RcloneCommandFlag
|
||||
err := db.Where("command_id = ? AND name = ?", commandID, name).First(&flag).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &flag, nil
|
||||
}
|
||||
|
||||
// GetRcloneCommandFlags returns all flags for a specific command
|
||||
func (db *DB) GetRcloneCommandFlags(commandID uint) ([]RcloneCommandFlag, error) {
|
||||
var flags []RcloneCommandFlag
|
||||
err := db.Where("command_id = ?", commandID).Find(&flags).Error
|
||||
return flags, err
|
||||
}
|
||||
|
||||
// GetRcloneCommandWithFlags returns a command with all its flags
|
||||
func (db *DB) GetRcloneCommandWithFlags(commandID uint) (*RcloneCommand, error) {
|
||||
var command RcloneCommand
|
||||
err := db.Preload("Flags").First(&command, commandID).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &command, nil
|
||||
}
|
||||
|
||||
// BuildRcloneCommand builds an rclone command string with the specified command and flags
|
||||
func (db *DB) BuildRcloneCommand(commandName string, flags map[string]string) (string, error) {
|
||||
// Get the command details
|
||||
command, err := db.GetRcloneCommandByName(commandName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("command not found: %s", commandName)
|
||||
}
|
||||
|
||||
// Start building the command string
|
||||
cmdStr := "rclone " + command.Name
|
||||
|
||||
// Get all flags for this command
|
||||
allFlags, err := db.GetRcloneCommandFlags(command.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get flags for command: %v", err)
|
||||
}
|
||||
|
||||
// Create a map of flag details for easy lookup
|
||||
flagDetails := make(map[string]RcloneCommandFlag)
|
||||
for _, f := range allFlags {
|
||||
flagDetails[f.Name] = f
|
||||
}
|
||||
|
||||
// Add the flags to the command
|
||||
for name, value := range flags {
|
||||
// Check if the flag exists for this command
|
||||
flag, exists := flagDetails[name]
|
||||
if !exists {
|
||||
// Allow passing flags not explicitly defined in the DB (e.g., global flags)
|
||||
// Consider adding validation or logging for unknown flags if stricter control is needed
|
||||
cmdStr += " " + name
|
||||
if value != "true" { // Assume boolean flags are passed as "true" if value is needed
|
||||
cmdStr += " " + value
|
||||
}
|
||||
continue
|
||||
// return "", fmt.Errorf("invalid flag for command %s: %s", commandName, name)
|
||||
}
|
||||
|
||||
// Handle different flag types
|
||||
switch flag.DataType {
|
||||
case "bool":
|
||||
if value == "true" {
|
||||
cmdStr += " " + flag.Name // Use flag.Name which includes '--'
|
||||
}
|
||||
default:
|
||||
cmdStr += " " + flag.Name + " " + value // Use flag.Name which includes '--'
|
||||
}
|
||||
}
|
||||
|
||||
return cmdStr, nil
|
||||
}
|
||||
|
||||
// ValidateRcloneFlags validates if the provided flags are valid for the command
|
||||
func (db *DB) ValidateRcloneFlags(commandName string, flags map[string]string) (bool, map[string]string) {
|
||||
// Initialize errors map
|
||||
errorsMap := make(map[string]string)
|
||||
|
||||
// Get the command details
|
||||
command, err := db.GetRcloneCommandByName(commandName)
|
||||
if err != nil {
|
||||
errorsMap["command"] = "Command not found: " + commandName
|
||||
return false, errorsMap
|
||||
}
|
||||
|
||||
// Get all flags for this command
|
||||
allFlags, err := db.GetRcloneCommandFlags(command.ID)
|
||||
if err != nil {
|
||||
errorsMap["command"] = "Failed to get flags for command"
|
||||
return false, errorsMap
|
||||
}
|
||||
|
||||
// Create a map of flag details for easy lookup
|
||||
flagDetails := make(map[string]RcloneCommandFlag)
|
||||
for _, f := range allFlags {
|
||||
flagDetails[f.Name] = f // Assuming Name includes '--' prefix
|
||||
}
|
||||
|
||||
// Check each provided flag
|
||||
for name, value := range flags {
|
||||
// Check if the flag exists for this command
|
||||
flag, exists := flagDetails[name]
|
||||
if !exists {
|
||||
// Allow unknown flags for now, but could add an error here if needed
|
||||
// errorsMap[name] = "Invalid flag for command " + commandName
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate the flag value based on data type
|
||||
switch flag.DataType {
|
||||
case "int":
|
||||
if _, err := strconv.Atoi(value); err != nil {
|
||||
errorsMap[name] = "Value must be an integer"
|
||||
}
|
||||
case "float":
|
||||
if _, err := strconv.ParseFloat(value, 64); err != nil {
|
||||
errorsMap[name] = "Value must be a number"
|
||||
}
|
||||
case "bool":
|
||||
// For boolean flags passed in the map, the value should ideally be "true" or omitted
|
||||
// If present and not "true", it's likely an error or misuse.
|
||||
// Rclone CLI typically handles bool flags by presence/absence.
|
||||
// This validation might need refinement based on how flags are constructed before calling this.
|
||||
if value != "true" {
|
||||
// errorsMap[name] = "Boolean flag should have value 'true' or be omitted"
|
||||
}
|
||||
case "string":
|
||||
// Basic check: ensure value is not empty if flag requires a value
|
||||
// More complex validation (regex, length) could be added here
|
||||
if value == "" && flag.IsRequired { // Check if required string flags have values
|
||||
errorsMap[name] = "Value cannot be empty for required string flag"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing required flags
|
||||
for _, flag := range allFlags {
|
||||
if flag.IsRequired {
|
||||
if _, provided := flags[flag.Name]; !provided {
|
||||
// Check if the short name was provided instead
|
||||
shortNameProvided := false
|
||||
if flag.ShortName != "" {
|
||||
_, shortNameProvided = flags[flag.ShortName]
|
||||
}
|
||||
if !shortNameProvided {
|
||||
errorsMap[flag.Name] = "This flag is required"
|
||||
}
|
||||
} else if flag.DataType != "bool" && flags[flag.Name] == "" {
|
||||
// Required non-bool flags must have a value
|
||||
errorsMap[flag.Name] = "Value cannot be empty for required flag"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return len(errorsMap) == 0, errorsMap
|
||||
}
|
||||
|
||||
// GetRcloneCategories returns all unique categories of rclone commands
|
||||
func (db *DB) GetRcloneCategories() ([]string, error) {
|
||||
var categories []string
|
||||
err := db.Model(&RcloneCommand{}).Distinct("category").Pluck("category", &categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// GetRcloneCommandsByAdvanced returns commands filtered by their advanced status
|
||||
func (db *DB) GetRcloneCommandsByAdvanced(isAdvanced bool) ([]RcloneCommand, error) {
|
||||
var commands []RcloneCommand
|
||||
err := db.Where("is_advanced = ?", isAdvanced).Find(&commands).Error
|
||||
return commands, err
|
||||
}
|
||||
|
||||
// SearchRcloneCommands searches for commands by name or description
|
||||
func (db *DB) SearchRcloneCommands(query string) ([]RcloneCommand, error) {
|
||||
var commands []RcloneCommand
|
||||
searchQuery := "%" + query + "%"
|
||||
err := db.Where("name LIKE ? OR description LIKE ?", searchQuery, searchQuery).Find(&commands).Error
|
||||
return commands, err
|
||||
}
|
||||
|
||||
// GetRcloneCommandUsage returns a basic usage example for a command with its required flags
|
||||
func (db *DB) GetRcloneCommandUsage(commandID uint) (string, error) {
|
||||
command, err := db.GetRcloneCommandWithFlags(commandID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
usage := fmt.Sprintf("rclone %s [flags] <source> <dest>", command.Name)
|
||||
|
||||
// Add basic usage examples for required flags
|
||||
requiredFlags := []string{}
|
||||
for _, flag := range command.Flags {
|
||||
if flag.IsRequired {
|
||||
// Assuming GetUsageExample is defined on RcloneCommandFlag in rclone.go
|
||||
requiredFlags = append(requiredFlags, flag.GetUsageExample())
|
||||
}
|
||||
}
|
||||
|
||||
if len(requiredFlags) > 0 {
|
||||
usage += "\n\nRequired flags:\n " + strings.Join(requiredFlags, "\n ")
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// RenderRcloneCommandHelp generates a help text for a command with its flags
|
||||
func (db *DB) RenderRcloneCommandHelp(commandID uint) (string, error) {
|
||||
command, err := db.GetRcloneCommandWithFlags(commandID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Build the help text
|
||||
help := fmt.Sprintf("COMMAND: %s\n", command.Name)
|
||||
help += fmt.Sprintf("DESCRIPTION: %s\n\n", command.Description)
|
||||
help += "FLAGS:\n"
|
||||
|
||||
// Group flags by required status
|
||||
var requiredFlags, optionalFlags []RcloneCommandFlag
|
||||
for _, flag := range command.Flags {
|
||||
if flag.IsRequired {
|
||||
requiredFlags = append(requiredFlags, flag)
|
||||
} else {
|
||||
optionalFlags = append(optionalFlags, flag)
|
||||
}
|
||||
}
|
||||
|
||||
// Add required flags
|
||||
if len(requiredFlags) > 0 {
|
||||
help += " Required:\n"
|
||||
for _, flag := range requiredFlags {
|
||||
shortName := ""
|
||||
if flag.ShortName != "" {
|
||||
shortName = fmt.Sprintf(" (-%s)", flag.ShortName)
|
||||
}
|
||||
help += fmt.Sprintf(" %s%s - %s\n", flag.Name, shortName, flag.Description)
|
||||
if flag.DataType != "bool" && flag.DefaultValue != "" {
|
||||
help += fmt.Sprintf(" Default: %s\n", flag.DefaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add optional flags
|
||||
if len(optionalFlags) > 0 {
|
||||
help += "\n Optional:\n"
|
||||
for _, flag := range optionalFlags {
|
||||
shortName := ""
|
||||
if flag.ShortName != "" {
|
||||
shortName = fmt.Sprintf(" (-%s)", flag.ShortName)
|
||||
}
|
||||
help += fmt.Sprintf(" %s%s - %s\n", flag.Name, shortName, flag.Description)
|
||||
if flag.DataType != "bool" && flag.DefaultValue != "" {
|
||||
help += fmt.Sprintf(" Default: %s\n", flag.DefaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return help, nil
|
||||
}
|
||||
|
||||
// GetRcloneCommandFlagsMap returns all flags for a specific command as a map keyed by flag ID
|
||||
func (db *DB) GetRcloneCommandFlagsMap(commandID uint) (map[uint]RcloneCommandFlag, error) {
|
||||
flags, err := db.GetRcloneCommandFlags(commandID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flagsMap := make(map[uint]RcloneCommandFlag)
|
||||
for _, flag := range flags {
|
||||
flagsMap[flag.ID] = flag
|
||||
}
|
||||
|
||||
return flagsMap, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --- Role Store Methods ---
|
||||
|
||||
// CreateRole creates a new role record
|
||||
func (db *DB) CreateRole(role *Role) error {
|
||||
return db.Create(role).Error
|
||||
}
|
||||
|
||||
// GetRole retrieves a role by ID, preloading permissions
|
||||
func (db *DB) GetRole(id uint) (*Role, error) {
|
||||
var role Role
|
||||
// Assuming Permissions are handled correctly by GORM or custom type
|
||||
err := db.First(&role, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// GetRoleByName retrieves a role by name, preloading permissions
|
||||
func (db *DB) GetRoleByName(name string) (*Role, error) {
|
||||
var role Role
|
||||
err := db.Where("name = ?", name).First(&role).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// UpdateRole updates an existing role record
|
||||
func (db *DB) UpdateRole(role *Role) error {
|
||||
// Use Omit Users to prevent GORM from trying to update the many2many relationship directly here
|
||||
return db.Omit("Users").Save(role).Error
|
||||
}
|
||||
|
||||
// DeleteRole deletes a role after checking dependencies and removing assignments
|
||||
func (db *DB) DeleteRole(id uint) error {
|
||||
var role Role
|
||||
if err := db.First(&role, id).Error; err != nil {
|
||||
return fmt.Errorf("role not found: %w", err)
|
||||
}
|
||||
|
||||
if role.IsSystemRole() {
|
||||
return errors.New("cannot delete system role")
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx := db.Begin()
|
||||
if err := tx.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Manually delete role assignments from the join table
|
||||
if err := tx.Exec("DELETE FROM user_roles WHERE role_id = ?", id).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to delete role assignments: %w", err)
|
||||
}
|
||||
|
||||
// Delete the role itself
|
||||
if err := tx.Delete(&role).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to delete role: %w", err)
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// ListRoles retrieves all roles
|
||||
func (db *DB) ListRoles() ([]Role, error) {
|
||||
var roles []Role
|
||||
err := db.Find(&roles).Error
|
||||
return roles, err
|
||||
}
|
||||
|
||||
// GetUserRoles retrieves all roles assigned to a specific user ID
|
||||
func (db *DB) GetUserRoles(userID uint) ([]Role, error) {
|
||||
var user User
|
||||
// Preload the Roles association
|
||||
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
|
||||
// Handle case where user might not be found vs. other errors
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("user with ID %d not found", userID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user %d roles: %w", userID, err)
|
||||
}
|
||||
return user.Roles, nil
|
||||
}
|
||||
|
||||
// AssignRoleToUser assigns a role to a user, handling the join table
|
||||
func (db *DB) AssignRoleToUser(roleID, userID, assignedByID uint) error {
|
||||
var role Role
|
||||
if err := db.First(&role, roleID).Error; err != nil {
|
||||
return fmt.Errorf("role with ID %d not found: %w", roleID, err)
|
||||
}
|
||||
var user User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
return fmt.Errorf("user with ID %d not found: %w", userID, err)
|
||||
}
|
||||
|
||||
// Use GORM's Association API for many2many
|
||||
err := db.Model(&user).Association("Roles").Append(&role)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to assign role %d to user %d: %w", roleID, userID, err)
|
||||
}
|
||||
|
||||
// Optionally, log the assignment (consider moving audit logging to a dedicated service/hook)
|
||||
// db.Create(&AuditLog{...})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnassignRoleFromUser removes a role from a user, handling the join table
|
||||
func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error {
|
||||
var role Role
|
||||
if err := db.First(&role, roleID).Error; err != nil {
|
||||
return fmt.Errorf("role with ID %d not found: %w", roleID, err)
|
||||
}
|
||||
var user User
|
||||
// Need to preload roles to check if the association exists before deleting
|
||||
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
|
||||
return fmt.Errorf("user with ID %d not found: %w", userID, err)
|
||||
}
|
||||
|
||||
// Use GORM's Association API for many2many deletion
|
||||
err := db.Model(&user).Association("Roles").Delete(&role)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unassign role %d from user %d: %w", roleID, userID, err)
|
||||
}
|
||||
|
||||
// Optionally, log the unassignment
|
||||
// db.Create(&AuditLog{...})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TransferConfig holds the configuration for a data transfer operation
|
||||
type TransferConfig struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Name string `gorm:"not null" form:"name"`
|
||||
SourceType string `gorm:"not null" form:"source_type"`
|
||||
SourcePath string `gorm:"not null" form:"source_path"`
|
||||
SourceHost string `form:"source_host"`
|
||||
SourcePort int `gorm:"default:22" form:"source_port"`
|
||||
SourceUser string `form:"source_user"`
|
||||
SourcePassword string `form:"source_password" gorm:"-"` // Not stored in DB, only used for form
|
||||
SourceKeyFile string `form:"source_key_file"`
|
||||
// S3 source fields
|
||||
SourceBucket string `form:"source_bucket"`
|
||||
SourceRegion string `form:"source_region"`
|
||||
SourceAccessKey string `form:"source_access_key"`
|
||||
SourceSecretKey string `form:"source_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
||||
SourceEndpoint string `form:"source_endpoint"`
|
||||
// SMB source fields
|
||||
SourceShare string `form:"source_share"`
|
||||
SourceDomain string `form:"source_domain"`
|
||||
// FTP source fields
|
||||
SourcePassiveMode *bool `gorm:"default:true" form:"source_passive_mode"` // Already a pointer, no change needed here
|
||||
// OneDrive and Google Drive source fields
|
||||
SourceClientID string `form:"source_client_id"`
|
||||
SourceClientSecret string `form:"source_client_secret" gorm:"-"` // Not stored in DB, only used for form
|
||||
SourceDriveID string `form:"source_drive_id"` // For OneDrive
|
||||
SourceTeamDrive string `form:"source_team_drive"` // For Google Drive
|
||||
// Google Photos source fields
|
||||
SourceReadOnly *bool `form:"source_read_only"` // For Google Photos
|
||||
SourceStartYear int `form:"source_start_year"` // For Google Photos
|
||||
SourceIncludeArchived *bool `form:"source_include_archived"` // For Google Photos
|
||||
// General fields
|
||||
FilePattern string `gorm:"default:'*'" form:"file_pattern"`
|
||||
OutputPattern string `form:"output_pattern"` // Pattern for output filenames with date variables
|
||||
DestinationType string `gorm:"not null" form:"destination_type"`
|
||||
DestinationPath string `gorm:"not null" form:"destination_path"`
|
||||
DestHost string `form:"dest_host"`
|
||||
DestPort int `gorm:"default:22" form:"dest_port"`
|
||||
DestUser string `form:"dest_user"`
|
||||
DestPassword string `form:"dest_password" gorm:"-"` // Not stored in DB, only used for form
|
||||
DestKeyFile string `form:"dest_key_file"`
|
||||
// S3 destination fields
|
||||
DestBucket string `form:"dest_bucket"`
|
||||
DestRegion string `form:"dest_region"`
|
||||
DestAccessKey string `form:"dest_access_key"`
|
||||
DestSecretKey string `form:"dest_secret_key" gorm:"-"` // Not stored in DB, only used for form
|
||||
DestEndpoint string `form:"dest_endpoint"`
|
||||
// SMB destination fields
|
||||
DestShare string `form:"dest_share"`
|
||||
DestDomain string `form:"dest_domain"`
|
||||
// FTP destination fields
|
||||
DestPassiveMode *bool `gorm:"default:true" form:"dest_passive_mode"`
|
||||
// OneDrive and Google Drive destination fields
|
||||
DestClientID string `form:"dest_client_id"`
|
||||
DestClientSecret string `form:"dest_client_secret" gorm:"-"` // Not stored in DB, only used for form
|
||||
DestDriveID string `form:"dest_drive_id"` // For OneDrive
|
||||
DestTeamDrive string `form:"dest_team_drive"` // For Google Drive
|
||||
// Google Photos destination fields
|
||||
DestReadOnly *bool `form:"dest_read_only"` // For Google Photos
|
||||
DestStartYear int `form:"dest_start_year"` // For Google Photos
|
||||
DestIncludeArchived *bool `form:"dest_include_archived"` // For Google Photos
|
||||
// Security fields
|
||||
UseBuiltinAuthSource *bool `form:"use_builtin_auth_source"` // For Google and other OAuth services
|
||||
UseBuiltinAuthDest *bool `form:"use_builtin_auth_dest"` // For Google and other OAuth services
|
||||
GoogleDriveAuthenticated *bool // Whether Google Drive auth is completed
|
||||
// General fields
|
||||
ArchivePath string `form:"archive_path"`
|
||||
ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"`
|
||||
RcloneFlags string `form:"rclone_flags"`
|
||||
// Rclone command fields
|
||||
CommandID uint `gorm:"default:1" form:"command_id"` // Default to 'copy' command ID (1)
|
||||
CommandFlags string `form:"command_flags"` // JSON string of selected flags
|
||||
CommandFlagValues string `form:"command_flag_values"` // JSON string of flag values by ID
|
||||
DeleteAfterTransfer *bool `gorm:"default:false" form:"delete_after_transfer"`
|
||||
SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"`
|
||||
MaxConcurrentTransfers int `gorm:"default:4" form:"max_concurrent_transfers"` // Number of concurrent file transfers
|
||||
CreatedBy uint
|
||||
User User `gorm:"foreignkey:CreatedBy"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// --- TransferConfig Helper Methods ---
|
||||
|
||||
// GetSourcePassiveMode returns the value of SourcePassiveMode with a default if nil
|
||||
func (tc *TransferConfig) GetSourcePassiveMode() bool {
|
||||
if tc.SourcePassiveMode == nil {
|
||||
return true // Default to true if not set
|
||||
}
|
||||
return *tc.SourcePassiveMode
|
||||
}
|
||||
|
||||
// SetSourcePassiveMode sets the SourcePassiveMode field
|
||||
func (tc *TransferConfig) SetSourcePassiveMode(value bool) {
|
||||
tc.SourcePassiveMode = &value
|
||||
}
|
||||
|
||||
// GetDestPassiveMode returns the value of DestPassiveMode with a default if nil
|
||||
func (tc *TransferConfig) GetDestPassiveMode() bool {
|
||||
if tc.DestPassiveMode == nil {
|
||||
return true // Default to true if not set
|
||||
}
|
||||
return *tc.DestPassiveMode
|
||||
}
|
||||
|
||||
// SetDestPassiveMode sets the DestPassiveMode field
|
||||
func (tc *TransferConfig) SetDestPassiveMode(value bool) {
|
||||
tc.DestPassiveMode = &value
|
||||
}
|
||||
|
||||
// GetGoogleDriveAuthenticated returns whether the transfer config has been authenticated with Google Drive
|
||||
func (tc *TransferConfig) GetGoogleDriveAuthenticated() bool {
|
||||
return tc.GoogleDriveAuthenticated != nil && *tc.GoogleDriveAuthenticated
|
||||
}
|
||||
|
||||
// SetGoogleDriveAuthenticated sets the Google Drive authentication status
|
||||
func (tc *TransferConfig) SetGoogleDriveAuthenticated(value bool) {
|
||||
tc.GoogleDriveAuthenticated = &value
|
||||
}
|
||||
|
||||
// GetGoogleAuthenticated is an alias for GetGoogleDriveAuthenticated for better semantics when working with Google Photos
|
||||
func (tc *TransferConfig) GetGoogleAuthenticated() bool {
|
||||
return tc.GetGoogleDriveAuthenticated()
|
||||
}
|
||||
|
||||
// SetGoogleAuthenticated is an alias for SetGoogleDriveAuthenticated for better semantics when working with Google Photos
|
||||
func (tc *TransferConfig) SetGoogleAuthenticated(value bool) {
|
||||
tc.SetGoogleDriveAuthenticated(value)
|
||||
}
|
||||
|
||||
// GetArchiveEnabled returns the value of ArchiveEnabled with a default if nil
|
||||
func (tc *TransferConfig) GetArchiveEnabled() bool {
|
||||
if tc.ArchiveEnabled == nil {
|
||||
return false // Default to false if not set
|
||||
}
|
||||
return *tc.ArchiveEnabled
|
||||
}
|
||||
|
||||
// SetArchiveEnabled sets the ArchiveEnabled field
|
||||
func (tc *TransferConfig) SetArchiveEnabled(value bool) {
|
||||
tc.ArchiveEnabled = &value
|
||||
}
|
||||
|
||||
// GetDeleteAfterTransfer returns the value of DeleteAfterTransfer with a default if nil
|
||||
func (tc *TransferConfig) GetDeleteAfterTransfer() bool {
|
||||
if tc.DeleteAfterTransfer == nil {
|
||||
return false // Default to false if not set
|
||||
}
|
||||
return *tc.DeleteAfterTransfer
|
||||
}
|
||||
|
||||
// SetDeleteAfterTransfer sets the DeleteAfterTransfer field
|
||||
func (tc *TransferConfig) SetDeleteAfterTransfer(value bool) {
|
||||
tc.DeleteAfterTransfer = &value
|
||||
}
|
||||
|
||||
// GetSkipProcessedFiles returns the value of SkipProcessedFiles with a default if nil
|
||||
func (tc *TransferConfig) GetSkipProcessedFiles() bool {
|
||||
if tc.SkipProcessedFiles == nil {
|
||||
return true // Default to true if not set
|
||||
}
|
||||
return *tc.SkipProcessedFiles
|
||||
}
|
||||
|
||||
// SetSkipProcessedFiles sets the SkipProcessedFiles field
|
||||
func (tc *TransferConfig) SetSkipProcessedFiles(value bool) {
|
||||
tc.SkipProcessedFiles = &value
|
||||
}
|
||||
|
||||
// GetUseBuiltinAuthSource returns the value of UseBuiltinAuthSource with a default if nil
|
||||
func (tc *TransferConfig) GetUseBuiltinAuthSource() bool {
|
||||
if tc.UseBuiltinAuthSource == nil {
|
||||
return true // Default to true if not set
|
||||
}
|
||||
return *tc.UseBuiltinAuthSource
|
||||
}
|
||||
|
||||
// SetUseBuiltinAuthSource sets the UseBuiltinAuthSource field
|
||||
func (tc *TransferConfig) SetUseBuiltinAuthSource(value bool) {
|
||||
tc.UseBuiltinAuthSource = &value
|
||||
}
|
||||
|
||||
// GetUseBuiltinAuthDest returns the value of UseBuiltinAuthDest with a default if nil
|
||||
func (tc *TransferConfig) GetUseBuiltinAuthDest() bool {
|
||||
if tc.UseBuiltinAuthDest == nil {
|
||||
return true // Default to true if not set
|
||||
}
|
||||
return *tc.UseBuiltinAuthDest
|
||||
}
|
||||
|
||||
// SetUseBuiltinAuthDest sets the UseBuiltinAuthDest field
|
||||
func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) {
|
||||
tc.UseBuiltinAuthDest = &value
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// --- TransferConfig Store Methods ---
|
||||
|
||||
// CreateTransferConfig creates a new transfer config record
|
||||
func (db *DB) CreateTransferConfig(config *TransferConfig) error {
|
||||
return db.Create(config).Error
|
||||
}
|
||||
|
||||
// GetTransferConfigs retrieves all transfer configs for a user
|
||||
func (db *DB) GetTransferConfigs(userID uint) ([]TransferConfig, error) {
|
||||
var configs []TransferConfig
|
||||
err := db.Where("created_by = ?", userID).Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
|
||||
// GetTransferConfig retrieves a single transfer config by ID
|
||||
func (db *DB) GetTransferConfig(id uint) (*TransferConfig, error) {
|
||||
var config TransferConfig
|
||||
err := db.First(&config, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// UpdateTransferConfig updates an existing transfer config record
|
||||
func (db *DB) UpdateTransferConfig(config *TransferConfig) error {
|
||||
return db.Save(config).Error
|
||||
}
|
||||
|
||||
// DeleteTransferConfig deletes a transfer config record after checking dependencies
|
||||
func (db *DB) DeleteTransferConfig(id uint) error {
|
||||
// First check if any jobs are using this config
|
||||
var count int64
|
||||
// Need to check both ConfigID and ConfigIDs list
|
||||
// This check might need refinement depending on how ConfigIDs is used reliably
|
||||
if err := db.Model(&Job{}).Where("config_id = ? OR config_ids LIKE ?", id, "%"+strconv.FormatUint(uint64(id), 10)+"%").Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("failed to check for dependent jobs: %v", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("cannot delete config: %d jobs are using this configuration", count)
|
||||
}
|
||||
|
||||
// Delete the config
|
||||
return db.Delete(&TransferConfig{}, id).Error
|
||||
}
|
||||
|
||||
// GetConfigRclonePath returns the path to the rclone config file for a given transfer config
|
||||
func (db *DB) GetConfigRclonePath(config *TransferConfig) string {
|
||||
// Get data directory from environment or use default
|
||||
dataDir := os.Getenv("DATA_DIR")
|
||||
if dataDir == "" {
|
||||
dataDir = "./data"
|
||||
}
|
||||
|
||||
// Store configs in the data directory
|
||||
return filepath.Join(dataDir, "configs", fmt.Sprintf("config_%d.conf", config.ID))
|
||||
}
|
||||
|
||||
// GenerateRcloneConfig generates the rclone config file content based on TransferConfig
|
||||
// This function now primarily focuses on generating the content string or calling rclone config create
|
||||
func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||
configPath := db.GetConfigRclonePath(config)
|
||||
|
||||
// Get the directory part of the path
|
||||
configDir := filepath.Dir(configPath)
|
||||
|
||||
// Ensure configs directory exists
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create configs directory: %v", err)
|
||||
}
|
||||
|
||||
// Get the rclone path from the environment variable or use the default path
|
||||
rclonePath := os.Getenv("RCLONE_PATH")
|
||||
if rclonePath == "" {
|
||||
rclonePath = "rclone"
|
||||
}
|
||||
|
||||
sourceName := fmt.Sprintf("source_%d", config.ID)
|
||||
// Generate rclone config using rclone CLI for source
|
||||
switch config.SourceType {
|
||||
case "sftp", "hetzner":
|
||||
args := []string{
|
||||
"config", "create", sourceName, "sftp",
|
||||
"host", config.SourceHost,
|
||||
"user", config.SourceUser,
|
||||
"port", fmt.Sprintf("%d", config.SourcePort),
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
if config.SourcePassword != "" {
|
||||
args = append(args, "pass", config.SourcePassword)
|
||||
}
|
||||
if config.SourceKeyFile != "" {
|
||||
args = append(args, "key_file", config.SourceKeyFile)
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create source config (sftp): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "s3":
|
||||
args := []string{
|
||||
"config", "create", sourceName, "s3",
|
||||
"provider", "AWS", // Assuming AWS provider, adjust if needed
|
||||
"env_auth", "false",
|
||||
"access_key_id", config.SourceAccessKey,
|
||||
"secret_access_key", config.SourceSecretKey,
|
||||
"region", config.SourceRegion,
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
if config.SourceEndpoint != "" {
|
||||
args = append(args, "endpoint", config.SourceEndpoint)
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create source config (s3): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "wasabi":
|
||||
args := []string{
|
||||
"config", "create", sourceName, "s3",
|
||||
"provider", "Wasabi",
|
||||
"env_auth", "false",
|
||||
"access_key_id", config.SourceAccessKey,
|
||||
"secret_access_key", config.SourceSecretKey,
|
||||
"region", config.SourceRegion,
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
endpoint := config.SourceEndpoint
|
||||
if endpoint == "" {
|
||||
endpoint = "s3.wasabisys.com"
|
||||
}
|
||||
args = append(args, "endpoint", endpoint)
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create source config (wasabi): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "b2":
|
||||
args := []string{
|
||||
"config", "create", sourceName, "b2",
|
||||
"account", config.SourceAccessKey, // B2 Account ID
|
||||
"key", config.SourceSecretKey, // B2 Application Key
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
if config.SourceEndpoint != "" {
|
||||
args = append(args, "endpoint", config.SourceEndpoint)
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create source config (b2): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "minio":
|
||||
args := []string{
|
||||
"config", "create", sourceName, "s3",
|
||||
"provider", "Minio",
|
||||
"env_auth", "false",
|
||||
"access_key_id", config.SourceAccessKey,
|
||||
"secret_access_key", config.SourceSecretKey,
|
||||
"endpoint", config.SourceEndpoint,
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
// Add region if specified
|
||||
if config.SourceRegion != "" {
|
||||
args = append(args, "region", config.SourceRegion)
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create source config (minio): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "webdav", "nextcloud": // Handle both webdav and nextcloud similarly
|
||||
// Construct the WebDAV URL
|
||||
// Parse the provided source URL, assuming it includes the scheme
|
||||
inputURL := config.SourceHost
|
||||
parsedURL, err := url.Parse(inputURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse source URL '%s': %v", inputURL, err)
|
||||
}
|
||||
// Validate that both scheme and host are present
|
||||
if parsedURL.Scheme == "" || parsedURL.Host == "" {
|
||||
return fmt.Errorf("invalid source URL '%s': must include scheme (http/https) and host", inputURL)
|
||||
}
|
||||
// Use the scheme and host from the parsed URL
|
||||
webdavURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
|
||||
|
||||
// Determine vendor based on type
|
||||
vendor := "other" // Default vendor
|
||||
if config.SourceType == "nextcloud" {
|
||||
vendor = "nextcloud"
|
||||
|
||||
// Construct the full Nextcloud path using the parsed base URL
|
||||
webdavURL = fmt.Sprintf("%s/remote.php/dav/files/%s/", webdavURL, config.SourceUser)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"config", "create", sourceName, "webdav",
|
||||
"url", webdavURL,
|
||||
"vendor", vendor,
|
||||
"user", config.SourceUser,
|
||||
"pass", config.SourcePassword, // rclone obscures this
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
errorMsg := fmt.Sprintf("failed to create source config (%s): %v", config.SourceType, err)
|
||||
// Check if output contains useful info, especially for auth errors
|
||||
if len(output) > 0 {
|
||||
errorMsg += fmt.Sprintf("\nOutput: %s", output)
|
||||
}
|
||||
return fmt.Errorf(errorMsg)
|
||||
}
|
||||
case "local":
|
||||
// For local source, ensure the section exists but might not need specific rclone config create
|
||||
content := fmt.Sprintf("[%s]\ntype = local\n\n", sourceName)
|
||||
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write source config (local): %v", err)
|
||||
}
|
||||
default:
|
||||
// Handle unknown or unsupported source types if necessary
|
||||
return fmt.Errorf("unsupported source type for rclone config generation: %s", config.SourceType)
|
||||
|
||||
}
|
||||
|
||||
destName := fmt.Sprintf("dest_%d", config.ID)
|
||||
// Generate rclone config using rclone CLI for destination
|
||||
switch config.DestinationType {
|
||||
case "sftp", "hetzner":
|
||||
args := []string{
|
||||
"config", "create", destName, "sftp",
|
||||
"host", config.DestHost,
|
||||
"user", config.DestUser,
|
||||
"port", fmt.Sprintf("%d", config.DestPort),
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
if config.DestPassword != "" {
|
||||
args = append(args, "pass", config.DestPassword)
|
||||
}
|
||||
if config.DestKeyFile != "" {
|
||||
args = append(args, "key_file", config.DestKeyFile)
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create destination config (sftp): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "s3":
|
||||
args := []string{
|
||||
"config", "create", destName, "s3",
|
||||
"provider", "AWS", // Assuming AWS provider
|
||||
"env_auth", "false",
|
||||
"access_key_id", config.DestAccessKey,
|
||||
"secret_access_key", config.DestSecretKey,
|
||||
"region", config.DestRegion,
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
if config.DestEndpoint != "" {
|
||||
args = append(args, "endpoint", config.DestEndpoint)
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create destination config (s3): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "wasabi":
|
||||
args := []string{
|
||||
"config", "create", destName, "s3",
|
||||
"provider", "Wasabi",
|
||||
"env_auth", "false",
|
||||
"access_key_id", config.DestAccessKey,
|
||||
"secret_access_key", config.DestSecretKey,
|
||||
"region", config.DestRegion,
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
endpoint := config.DestEndpoint
|
||||
if endpoint == "" {
|
||||
endpoint = "s3.wasabisys.com"
|
||||
}
|
||||
args = append(args, "endpoint", endpoint)
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create destination config (wasabi): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "b2":
|
||||
args := []string{
|
||||
"config", "create", destName, "b2",
|
||||
"account", config.DestAccessKey, // B2 Account ID
|
||||
"key", config.DestSecretKey, // B2 Application Key
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
if config.DestEndpoint != "" {
|
||||
args = append(args, "endpoint", config.DestEndpoint)
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create destination config (b2): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "minio":
|
||||
args := []string{
|
||||
"config", "create", destName, "s3",
|
||||
"provider", "Minio",
|
||||
"env_auth", "false",
|
||||
"access_key_id", config.DestAccessKey,
|
||||
"secret_access_key", config.DestSecretKey,
|
||||
"endpoint", config.DestEndpoint,
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
// Add region if specified
|
||||
if config.DestRegion != "" {
|
||||
args = append(args, "region", config.DestRegion)
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create destination config (minio): %v\nOutput: %s", err, output)
|
||||
}
|
||||
case "webdav", "nextcloud": // Combined case for WebDAV and Nextcloud
|
||||
// Parse and reconstruct the WebDAV URL robustly
|
||||
// Parse the provided destination URL, assuming it includes the scheme
|
||||
inputURL := config.DestHost
|
||||
parsedURL, err := url.Parse(inputURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse destination URL '%s': %v", inputURL, err)
|
||||
}
|
||||
// Validate that both scheme and host are present
|
||||
if parsedURL.Scheme == "" || parsedURL.Host == "" {
|
||||
return fmt.Errorf("invalid destination URL '%s': must include scheme (http/https) and host", inputURL)
|
||||
}
|
||||
// Use the scheme and host from the parsed URL
|
||||
webdavURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
|
||||
|
||||
// Determine vendor based on type
|
||||
vendor := "other" // Default vendor
|
||||
if config.DestinationType == "nextcloud" {
|
||||
vendor = "nextcloud"
|
||||
|
||||
webdavURL = fmt.Sprintf("%s/remote.php/dav/files/%s/", webdavURL, config.DestUser) // Corrected variable
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"config", "create", destName, "webdav",
|
||||
"url", webdavURL, // Use the parsed and reconstructed URL
|
||||
"vendor", vendor,
|
||||
"user", config.DestUser,
|
||||
"pass", config.DestPassword, // rclone obscures this
|
||||
"--non-interactive",
|
||||
"--config", configPath,
|
||||
"--log-level", "ERROR",
|
||||
}
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
errorMsg := fmt.Sprintf("failed to create destination config (%s): %v", config.DestinationType, err)
|
||||
if len(output) > 0 {
|
||||
errorMsg += fmt.Sprintf("\nOutput: %s", output)
|
||||
}
|
||||
return fmt.Errorf(errorMsg)
|
||||
}
|
||||
case "local":
|
||||
// Append local config section
|
||||
content := fmt.Sprintf("\n[%s]\ntype = local\n", destName)
|
||||
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open config file for appending (local dest): %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
return fmt.Errorf("failed to write destination config (local): %v", err)
|
||||
}
|
||||
default:
|
||||
// Handle unknown or unsupported destination types if necessary
|
||||
return fmt.Errorf("unsupported destination type for rclone config generation: %s", config.DestinationType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StoreGoogleDriveToken stores the Google Drive auth token for a config
|
||||
func (db *DB) StoreGoogleDriveToken(configIDStr string, token string) error {
|
||||
configID, err := strconv.ParseUint(configIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config ID: %v", err)
|
||||
}
|
||||
|
||||
config, err := db.GetTransferConfig(uint(configID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config: %v", err)
|
||||
}
|
||||
|
||||
authenticated := true
|
||||
config.GoogleDriveAuthenticated = &authenticated
|
||||
|
||||
if err := db.UpdateTransferConfig(config); err != nil {
|
||||
return fmt.Errorf("failed to update config: %v", err)
|
||||
}
|
||||
|
||||
configPath := db.GetConfigRclonePath(config)
|
||||
existingConfig := ""
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read existing config: %v", err)
|
||||
}
|
||||
existingConfig = string(data)
|
||||
}
|
||||
|
||||
configDir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
destName := fmt.Sprintf("dest_%d", config.ID)
|
||||
newConfig := fmt.Sprintf("[%s]\ntype = drive\ntoken = %s\n", destName, token)
|
||||
|
||||
if config.DestClientID != "" && config.DestClientSecret != "" {
|
||||
newConfig += fmt.Sprintf("client_id = %s\nclient_secret = %s\n", config.DestClientID, config.DestClientSecret)
|
||||
}
|
||||
if config.DestDriveID != "" {
|
||||
newConfig += fmt.Sprintf("root_folder_id = %s\n", config.DestDriveID)
|
||||
}
|
||||
if config.DestTeamDrive != "" {
|
||||
newConfig += fmt.Sprintf("team_drive = %s\n", config.DestTeamDrive)
|
||||
}
|
||||
|
||||
var content string
|
||||
sectionHeader := fmt.Sprintf("[%s]", destName)
|
||||
if strings.Contains(existingConfig, sectionHeader) {
|
||||
parts := strings.SplitN(existingConfig, sectionHeader, 2)
|
||||
nextSectionIdx := strings.Index(parts[1], "[")
|
||||
if nextSectionIdx != -1 {
|
||||
content = parts[0] + newConfig + parts[1][nextSectionIdx:]
|
||||
} else {
|
||||
content = parts[0] + newConfig
|
||||
}
|
||||
} else {
|
||||
content = existingConfig + "\n" + newConfig
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateRcloneConfigWithToken generates a rclone config file for a transfer config with a provided token
|
||||
// Note: This seems partially redundant with StoreGoogleDriveToken and GenerateRcloneConfig. Consolidate if possible.
|
||||
func (db *DB) GenerateRcloneConfigWithToken(config *TransferConfig, token string) error {
|
||||
configPath := db.GetConfigRclonePath(config)
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("failed to get config path")
|
||||
}
|
||||
|
||||
token = strings.TrimSpace(token)
|
||||
token = strings.ReplaceAll(token, "\n", "")
|
||||
token = strings.ReplaceAll(token, "\r", "")
|
||||
|
||||
var configType, section, clientID, clientSecret string
|
||||
var readOnly, includeArchived *bool
|
||||
var startYear int
|
||||
|
||||
// Determine if source or destination needs token update
|
||||
if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" {
|
||||
configType = config.DestinationType
|
||||
section = "dest"
|
||||
clientID = config.DestClientID
|
||||
clientSecret = config.DestClientSecret
|
||||
readOnly = config.DestReadOnly
|
||||
startYear = config.DestStartYear
|
||||
includeArchived = config.DestIncludeArchived
|
||||
} else if config.SourceType == "gdrive" || config.SourceType == "gphotos" {
|
||||
configType = config.SourceType
|
||||
section = "source"
|
||||
clientID = config.SourceClientID
|
||||
clientSecret = config.SourceClientSecret
|
||||
readOnly = config.SourceReadOnly
|
||||
startYear = config.SourceStartYear
|
||||
includeArchived = config.SourceIncludeArchived
|
||||
} else {
|
||||
return fmt.Errorf("config is not for Google Drive or Google Photos")
|
||||
}
|
||||
|
||||
contentBytes, err := os.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) { // Allow file not existing yet
|
||||
return fmt.Errorf("failed to read config file: %v", err)
|
||||
}
|
||||
content := string(contentBytes)
|
||||
|
||||
var sectionContent string
|
||||
sectionHeader := fmt.Sprintf("[%s_%d]", section, config.ID)
|
||||
|
||||
if configType == "gdrive" {
|
||||
sectionContent = sectionHeader + "\ntype = drive\n"
|
||||
if clientID != "" {
|
||||
sectionContent += fmt.Sprintf("client_id = %s\n", clientID)
|
||||
}
|
||||
if clientSecret != "" {
|
||||
sectionContent += fmt.Sprintf("client_secret = %s\n", clientSecret)
|
||||
}
|
||||
sectionContent += fmt.Sprintf("token = %s\n", token)
|
||||
if section == "source" && config.SourceTeamDrive != "" {
|
||||
sectionContent += fmt.Sprintf("team_drive = %s\n", config.SourceTeamDrive)
|
||||
}
|
||||
if section == "dest" && config.DestTeamDrive != "" {
|
||||
sectionContent += fmt.Sprintf("team_drive = %s\n", config.DestTeamDrive)
|
||||
}
|
||||
if section == "dest" && config.DestDriveID != "" {
|
||||
sectionContent += fmt.Sprintf("root_folder_id = %s\n", config.DestDriveID)
|
||||
} // Use DestDriveID for root_folder_id
|
||||
} else if configType == "gphotos" {
|
||||
sectionContent = sectionHeader + "\ntype = google photos\n"
|
||||
if clientID != "" {
|
||||
sectionContent += fmt.Sprintf("client_id = %s\n", clientID)
|
||||
}
|
||||
if clientSecret != "" {
|
||||
sectionContent += fmt.Sprintf("client_secret = %s\n", clientSecret)
|
||||
}
|
||||
sectionContent += fmt.Sprintf("token = %s\n", token)
|
||||
if readOnly != nil && *readOnly {
|
||||
sectionContent += "read_only = true\n"
|
||||
}
|
||||
if startYear > 0 {
|
||||
sectionContent += fmt.Sprintf("start_year = %d\n", startYear)
|
||||
}
|
||||
if includeArchived != nil && *includeArchived {
|
||||
sectionContent += "include_archived = true\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Replace or append logic
|
||||
sectionPattern := regexp.MustCompile(fmt.Sprintf(`(?m)^%s[^\[]*`, regexp.QuoteMeta(sectionHeader))) // Match section start to next section or EOF
|
||||
if sectionPattern.MatchString(content) {
|
||||
content = sectionPattern.ReplaceAllString(content, sectionContent)
|
||||
} else {
|
||||
if content != "" && !strings.HasSuffix(content, "\n\n") { // Ensure separation
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
content += "\n"
|
||||
}
|
||||
content += sectionContent
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
configDir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
// Write the updated config file
|
||||
if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { // Use 0600 for sensitive files
|
||||
return fmt.Errorf("failed to write updated config file: %v", err)
|
||||
}
|
||||
|
||||
// Update the authentication status in DB
|
||||
authenticated := true
|
||||
if config.DestinationType == "gdrive" || config.DestinationType == "gphotos" {
|
||||
config.SetGoogleAuthenticated(authenticated)
|
||||
} else if config.SourceType == "gdrive" || config.SourceType == "gphotos" {
|
||||
config.SetGoogleAuthenticated(authenticated)
|
||||
}
|
||||
// Persist the change (assuming UpdateTransferConfig saves the whole object)
|
||||
if err := db.UpdateTransferConfig(config); err != nil {
|
||||
return fmt.Errorf("failed to update config authentication status: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGDriveCredentialsFromConfig extracts Google Drive client ID and secret from an existing rclone config file
|
||||
func (db *DB) GetGDriveCredentialsFromConfig(config *TransferConfig) (string, string) {
|
||||
configPath := db.GetConfigRclonePath(config)
|
||||
if configPath == "" {
|
||||
return "", ""
|
||||
}
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
sourceSectionName := fmt.Sprintf("[source_%d]", config.ID)
|
||||
destSectionName := fmt.Sprintf("[dest_%d]", config.ID)
|
||||
var inSourceSection, inDestSection bool
|
||||
var sourceClientID, sourceClientSecret, destClientID, destClientSecret string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
inSourceSection = line == sourceSectionName
|
||||
inDestSection = line == destSectionName
|
||||
continue
|
||||
}
|
||||
if inSourceSection {
|
||||
if strings.HasPrefix(line, "client_id") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
sourceClientID = strings.TrimSpace(parts[1])
|
||||
}
|
||||
} else if strings.HasPrefix(line, "client_secret") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
sourceClientSecret = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
if inDestSection {
|
||||
if strings.HasPrefix(line, "client_id") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
destClientID = strings.TrimSpace(parts[1])
|
||||
}
|
||||
} else if strings.HasPrefix(line, "client_secret") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
destClientSecret = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
if sourceClientID != "" && sourceClientSecret != "" && destClientID != "" && destClientSecret != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if destClientID != "" && destClientSecret != "" {
|
||||
return destClientID, destClientSecret
|
||||
}
|
||||
if sourceClientID != "" && sourceClientSecret != "" {
|
||||
return sourceClientID, sourceClientSecret
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User represents a user account in the system
|
||||
type User struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Email string `gorm:"unique;not null"`
|
||||
PasswordHash string `gorm:"not null"`
|
||||
IsAdmin *bool `gorm:"default:false"`
|
||||
LastPasswordChange time.Time
|
||||
FailedLoginAttempts int `gorm:"default:0"`
|
||||
AccountLocked *bool `gorm:"default:false"`
|
||||
LockoutUntil *time.Time
|
||||
Theme string `gorm:"default:'light'"`
|
||||
TwoFactorSecret string `gorm:"type:varchar(32)"`
|
||||
TwoFactorEnabled bool `gorm:"default:false"`
|
||||
BackupCodes string `gorm:"type:text"` // Comma-separated backup codes
|
||||
Roles []Role `gorm:"many2many:user_roles"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PasswordHistory stores previous passwords for a user
|
||||
type PasswordHistory struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
UserID uint `gorm:"not null"`
|
||||
User User `gorm:"foreignkey:UserID"`
|
||||
PasswordHash string `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PasswordResetToken stores tokens for password reset requests
|
||||
type PasswordResetToken struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
UserID uint `gorm:"not null"`
|
||||
User User `gorm:"foreignkey:UserID"`
|
||||
Token string `gorm:"not null"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
Used *bool `gorm:"default:false"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// --- User Helper Methods ---
|
||||
|
||||
// GetIsAdmin returns the value of IsAdmin with a default if nil
|
||||
func (u *User) GetIsAdmin() bool {
|
||||
if u.IsAdmin == nil {
|
||||
return false // Default to false if not set
|
||||
}
|
||||
return *u.IsAdmin
|
||||
}
|
||||
|
||||
// SetIsAdmin sets the IsAdmin field
|
||||
func (u *User) SetIsAdmin(value bool) {
|
||||
u.IsAdmin = &value
|
||||
}
|
||||
|
||||
// GetAccountLocked returns the value of AccountLocked with a default if nil
|
||||
func (u *User) GetAccountLocked() bool {
|
||||
if u.AccountLocked == nil {
|
||||
return false // Default to false if not set
|
||||
}
|
||||
return *u.AccountLocked
|
||||
}
|
||||
|
||||
// SetAccountLocked sets the AccountLocked field
|
||||
func (u *User) SetAccountLocked(value bool) {
|
||||
u.AccountLocked = &value
|
||||
}
|
||||
|
||||
// HasRole checks if the user has a specific role
|
||||
func (u *User) HasRole(roleName string) bool {
|
||||
for _, role := range u.Roles {
|
||||
if role.Name == roleName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPermission checks if the user has a specific permission through any of their roles
|
||||
func (u *User) HasPermission(permission string) bool {
|
||||
for _, role := range u.Roles {
|
||||
if role.HasPermission(permission) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRoles returns all roles assigned to the user
|
||||
// Note: This requires preloading Roles when fetching the user
|
||||
func (u *User) GetRoles(tx *gorm.DB) ([]Role, error) {
|
||||
var roles []Role
|
||||
err := tx.Model(u).Association("Roles").Find(&roles)
|
||||
return roles, err
|
||||
}
|
||||
|
||||
// AssignRole assigns a role to the user
|
||||
func (u *User) AssignRole(tx *gorm.DB, roleID uint, assignedByID uint) error {
|
||||
var role Role
|
||||
if err := tx.First(&role, roleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Assuming Role struct has AssignToUser method (from role.go)
|
||||
return role.AssignToUser(tx, u.ID, assignedByID)
|
||||
}
|
||||
|
||||
// UnassignRole removes a role from the user
|
||||
func (u *User) UnassignRole(tx *gorm.DB, roleID uint, unassignedByID uint) error {
|
||||
var role Role
|
||||
if err := tx.First(&role, roleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Assuming Role struct has UnassignFromUser method (from role.go)
|
||||
return role.UnassignFromUser(tx, u.ID, unassignedByID)
|
||||
}
|
||||
|
||||
// SetPassword sets the user's password with secure hashing
|
||||
func (u *User) SetPassword(password string) error {
|
||||
// Validate password length
|
||||
if len(password) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters long")
|
||||
}
|
||||
|
||||
// Hash the password using bcrypt
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Store the hashed password
|
||||
u.PasswordHash = string(hashedPassword)
|
||||
u.LastPasswordChange = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword verifies if the provided password matches the stored hash
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// --- PasswordResetToken Helper Methods ---
|
||||
|
||||
// GetUsed returns the value of Used with a default if nil
|
||||
func (t *PasswordResetToken) GetUsed() bool {
|
||||
if t.Used == nil {
|
||||
return false // Default to false if not set
|
||||
}
|
||||
return *t.Used
|
||||
}
|
||||
|
||||
// SetUsed sets the Used field
|
||||
func (t *PasswordResetToken) SetUsed(value bool) {
|
||||
t.Used = &value
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- User Store Methods ---
|
||||
|
||||
// CreateUser creates a new user record
|
||||
func (db *DB) CreateUser(user *User) error {
|
||||
return db.Create(user).Error
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by their email address
|
||||
func (db *DB) GetUserByEmail(email string) (*User, error) {
|
||||
var user User
|
||||
// Preload Roles to ensure they are available for permission checks
|
||||
err := db.Preload("Roles").Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by their ID
|
||||
func (db *DB) GetUserByID(id uint) (*User, error) {
|
||||
var user User
|
||||
// Preload Roles
|
||||
err := db.Preload("Roles").First(&user, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates an existing user record
|
||||
func (db *DB) UpdateUser(user *User) error {
|
||||
// Use Omit to prevent accidentally changing Roles association directly
|
||||
// Role assignments should use AssignRole/UnassignRole methods
|
||||
return db.Omit("Roles").Save(user).Error
|
||||
}
|
||||
|
||||
// --- PasswordResetToken Store Methods ---
|
||||
|
||||
// CreatePasswordResetToken creates a new password reset token record
|
||||
func (db *DB) CreatePasswordResetToken(token *PasswordResetToken) error {
|
||||
return db.Create(token).Error
|
||||
}
|
||||
|
||||
// GetPasswordResetToken retrieves a valid, unused password reset token
|
||||
func (db *DB) GetPasswordResetToken(token string) (*PasswordResetToken, error) {
|
||||
var resetToken PasswordResetToken
|
||||
err := db.Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&resetToken).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resetToken, nil
|
||||
}
|
||||
|
||||
// MarkPasswordResetTokenAsUsed marks a password reset token as used
|
||||
func (db *DB) MarkPasswordResetTokenAsUsed(tokenID uint) error {
|
||||
return db.Model(&PasswordResetToken{}).Where("id = ?", tokenID).Update("used", true).Error
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createTestConfig(enabled bool) *config.Config {
|
||||
return &config.Config{
|
||||
BaseURL: "http://localhost:8080",
|
||||
Email: config.EmailConfig{
|
||||
Enabled: enabled,
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "user",
|
||||
Password: "password",
|
||||
FromEmail: "noreply@example.com",
|
||||
FromName: "GoMFT Test",
|
||||
RequireAuth: true,
|
||||
EnableTLS: true,
|
||||
ReplyTo: "support@example.com",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
cfg := createTestConfig(true)
|
||||
service := NewService(cfg)
|
||||
require.NotNil(t, service)
|
||||
assert.Equal(t, cfg, service.Config)
|
||||
}
|
||||
|
||||
func TestGeneratePasswordResetEmailHTML(t *testing.T) {
|
||||
cfg := createTestConfig(true)
|
||||
service := NewService(cfg)
|
||||
|
||||
testUsername := "testuser"
|
||||
testResetLink := "http://localhost:8080/reset-password?token=testtoken123"
|
||||
testAppName := "GoMFT"
|
||||
testYear := time.Now().Year()
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Username": testUsername,
|
||||
"ResetLink": testResetLink,
|
||||
"AppName": testAppName,
|
||||
"Year": testYear,
|
||||
"ExpiresHours": 0.25,
|
||||
}
|
||||
|
||||
htmlContent, err := service.generatePasswordResetEmailHTML(data)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, htmlContent)
|
||||
|
||||
// Basic checks for content presence
|
||||
assert.Contains(t, htmlContent, "Reset Your Password")
|
||||
assert.Contains(t, htmlContent, fmt.Sprintf("Hello %s", testUsername))
|
||||
assert.Contains(t, htmlContent, testResetLink) // Check link appears (both in button and text)
|
||||
assert.Contains(t, htmlContent, fmt.Sprintf("href=\"%s\"", testResetLink))
|
||||
assert.Contains(t, htmlContent, fmt.Sprintf("© %d %s", testYear, testAppName))
|
||||
assert.Contains(t, htmlContent, "This link will expire in 15 minutes.")
|
||||
|
||||
// Test without username
|
||||
dataNoUser := map[string]interface{}{
|
||||
"ResetLink": testResetLink,
|
||||
"AppName": testAppName,
|
||||
"Year": testYear,
|
||||
"ExpiresHours": 0.25,
|
||||
}
|
||||
htmlContentNoUser, err := service.generatePasswordResetEmailHTML(dataNoUser)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, htmlContentNoUser, "Hello,") // Should just say Hello,
|
||||
assert.NotContains(t, htmlContentNoUser, fmt.Sprintf("Hello %s", testUsername))
|
||||
}
|
||||
|
||||
func TestGenerateTestEmailHTML(t *testing.T) {
|
||||
cfg := createTestConfig(true)
|
||||
service := NewService(cfg)
|
||||
|
||||
testSubject := "My Test Subject"
|
||||
testMessage := "This is the test message body."
|
||||
testAppName := "GoMFT"
|
||||
testYear := time.Now().Year()
|
||||
testCurrentTime := time.Now().Format(time.RFC1123Z) // Use the same format
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Subject": testSubject,
|
||||
"Message": testMessage,
|
||||
"AppName": testAppName,
|
||||
"Year": testYear,
|
||||
"SMTPServer": cfg.Email.Host,
|
||||
"SMTPPort": cfg.Email.Port,
|
||||
"FromEmail": cfg.Email.FromEmail,
|
||||
"CurrentTime": testCurrentTime,
|
||||
}
|
||||
|
||||
htmlContent, err := service.generateTestEmailHTML(data)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, htmlContent)
|
||||
|
||||
// Basic checks for content presence
|
||||
assert.Contains(t, htmlContent, fmt.Sprintf("<title>%s</title>", testSubject))
|
||||
assert.Contains(t, htmlContent, fmt.Sprintf("<h1>%s</h1>", testSubject))
|
||||
assert.Contains(t, htmlContent, testMessage)
|
||||
assert.Contains(t, htmlContent, fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port))
|
||||
assert.Contains(t, htmlContent, cfg.Email.FromEmail)
|
||||
assert.Contains(t, htmlContent, testCurrentTime)
|
||||
assert.Contains(t, htmlContent, fmt.Sprintf("© %d %s", testYear, testAppName))
|
||||
}
|
||||
|
||||
func TestSendPasswordResetEmail_Disabled(t *testing.T) {
|
||||
cfg := createTestConfig(false) // Email disabled
|
||||
service := NewService(cfg)
|
||||
|
||||
toEmail := "test@example.com"
|
||||
username := "testuser"
|
||||
resetToken := "disabledtoken123"
|
||||
|
||||
err := service.SendPasswordResetEmail(toEmail, username, resetToken)
|
||||
require.Error(t, err)
|
||||
|
||||
expectedErrorSubstr := fmt.Sprintf("email service is disabled, reset link would be: %s/reset-password?token=%s",
|
||||
cfg.BaseURL, resetToken)
|
||||
assert.Contains(t, err.Error(), expectedErrorSubstr)
|
||||
}
|
||||
|
||||
func TestSendTestEmail_Disabled(t *testing.T) {
|
||||
cfg := createTestConfig(false) // Email disabled
|
||||
service := NewService(cfg)
|
||||
|
||||
toEmail := "test@example.com"
|
||||
|
||||
err := service.SendTestEmail(toEmail, "Test Subject", "Test Message")
|
||||
require.Error(t, err)
|
||||
assert.EqualError(t, err, "email service is disabled")
|
||||
}
|
||||
|
||||
// --- Placeholder/TODO for more complex tests ---
|
||||
|
||||
// TODO: TestSendPasswordResetEmail_Enabled - Requires mocking sendEmail or SMTP interactions
|
||||
// TODO: TestSendTestEmail_Enabled - Requires mocking sendEmail or SMTP interactions
|
||||
// TODO: TestSendEmail - Requires extensive mocking of net/smtp package
|
||||
|
||||
// Example structure for testing enabled path (without actual sending/mocking)
|
||||
// This verifies the function prepares the correct data before calling sendEmail
|
||||
func TestSendPasswordResetEmail_Enabled_DataPreparation(t *testing.T) {
|
||||
cfg := createTestConfig(true)
|
||||
service := NewService(cfg)
|
||||
|
||||
// We need a way to intercept the call to sendEmail or verify its inputs
|
||||
// For now, we just check that no error occurs up to that point
|
||||
// and that the HTML generation works (implicitly tested by TestGeneratePasswordResetEmailHTML)
|
||||
|
||||
toEmail := "recipient@example.com"
|
||||
username := "testuser-enabled"
|
||||
resetToken := "enabledtoken456"
|
||||
|
||||
// If generatePasswordResetEmailHTML works, this call should proceed
|
||||
// without error until the actual sendEmail call (which we aren't testing here)
|
||||
// A full test would mock sendEmail and verify the arguments passed to it.
|
||||
err := service.SendPasswordResetEmail(toEmail, username, resetToken)
|
||||
|
||||
// In a real scenario without mocking, this might fail if SMTP connection fails.
|
||||
// For this basic check, we assume HTML generation is the main potential failure point *before* sendEmail.
|
||||
// If TestGeneratePasswordResetEmailHTML passes, we expect no error *from generation*.
|
||||
// We cannot assert assert.NoError(t, err) reliably without mocking sendEmail.
|
||||
t.Logf("SendPasswordResetEmail (enabled) returned: %v (expected success or SMTP error)", err)
|
||||
// Asserting that the error, if any, is NOT related to template generation could be a weak check.
|
||||
if err != nil {
|
||||
assert.False(t, strings.Contains(err.Error(), "template"), "Error should be SMTP related, not template related")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestEmail_Enabled_DataPreparation(t *testing.T) {
|
||||
cfg := createTestConfig(true)
|
||||
service := NewService(cfg)
|
||||
|
||||
toEmail := "recipient@example.com"
|
||||
subject := "Specific Test Subject"
|
||||
message := "Specific test message."
|
||||
|
||||
// Test with specific subject and message
|
||||
err := service.SendTestEmail(toEmail, subject, message)
|
||||
t.Logf("SendTestEmail (enabled, specific) returned: %v (expected success or SMTP error)", err)
|
||||
if err != nil {
|
||||
assert.False(t, strings.Contains(err.Error(), "template"), "Error should be SMTP related, not template related")
|
||||
}
|
||||
|
||||
// Test with default subject and message
|
||||
errDefault := service.SendTestEmail(toEmail, "", "")
|
||||
t.Logf("SendTestEmail (enabled, default) returned: %v (expected success or SMTP error)", errDefault)
|
||||
if errDefault != nil {
|
||||
assert.False(t, strings.Contains(errDefault.Error(), "template"), "Error should be SMTP related, not template related")
|
||||
}
|
||||
// A full test would mock sendEmail and verify the subject/message passed (checking defaults).
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/web"
|
||||
)
|
||||
|
||||
var (
|
||||
// Global logger instance
|
||||
stdLogger *Logger
|
||||
|
||||
// Mutex to protect the logger
|
||||
loggerMutex sync.RWMutex
|
||||
|
||||
// Flag to prevent recursive logging
|
||||
isLogging sync.Mutex
|
||||
|
||||
// Log levels
|
||||
LevelDebug = "debug"
|
||||
LevelInfo = "info"
|
||||
LevelWarning = "warn"
|
||||
LevelError = "error"
|
||||
LevelFatal = "fatal"
|
||||
|
||||
// Regex to parse standard log lines (YYYY/MM/DD HH:MM:SS file:line msg)
|
||||
// Adjust if Lmicroseconds is used
|
||||
logLineRegex *regexp.Regexp
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Check log flags to build the correct regex
|
||||
flags := log.Flags()
|
||||
timestampFormat := `\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}`
|
||||
if flags&log.Lmicroseconds != 0 {
|
||||
timestampFormat += `\.\d{6}`
|
||||
}
|
||||
fileFormat := ``
|
||||
if flags&log.Lshortfile != 0 || flags&log.Llongfile != 0 {
|
||||
fileFormat = ` (.+?:\d+): ` // Group 1: file:line
|
||||
}
|
||||
// Regex captures: 1=file:line (optional), 2=message
|
||||
logLineRegex = regexp.MustCompile(fmt.Sprintf(`^%s%s(.*)$`, timestampFormat, fileFormat))
|
||||
}
|
||||
|
||||
// Logger is a custom logger that broadcasts to WebSocket and writes to file
|
||||
type Logger struct {
|
||||
fileWriter io.Writer
|
||||
broadcast bool
|
||||
}
|
||||
|
||||
// Setup initializes the global logger
|
||||
func Setup(logsDir string, broadcast bool) error {
|
||||
// Create logs directory if it doesn't exist
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create logs directory: %w", err)
|
||||
}
|
||||
|
||||
// Create or open the log file
|
||||
logFilePath := filepath.Join(logsDir, "scheduler.log")
|
||||
logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
||||
// Create a multi-writer to log to both stderr and file
|
||||
multiWriter := io.MultiWriter(os.Stderr, logFile)
|
||||
|
||||
// Initialize the logger with mutex protection
|
||||
loggerMutex.Lock()
|
||||
defer loggerMutex.Unlock()
|
||||
|
||||
stdLogger = &Logger{
|
||||
fileWriter: multiWriter,
|
||||
broadcast: broadcast,
|
||||
}
|
||||
|
||||
// Configure the standard log package to use our custom logger
|
||||
log.SetOutput(stdLogger)
|
||||
// Ensure standard flags are set (adjust regex if flags change)
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile | log.Lmicroseconds)
|
||||
|
||||
log.Printf("Logger initialized: broadcasting to WebSocket = %v, file = %s", broadcast, logFilePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write implements io.Writer interface for capturing standard log output
|
||||
func (l *Logger) Write(p []byte) (n int, err error) {
|
||||
// Write to the original outputs first
|
||||
n, err = l.fileWriter.Write(p)
|
||||
if err != nil {
|
||||
return n, err // Return error from underlying writer
|
||||
}
|
||||
|
||||
if !l.broadcast {
|
||||
return n, nil // Broadcasting disabled
|
||||
}
|
||||
|
||||
// Use a mutex to prevent recursive logging from BroadcastLog itself
|
||||
if !isLogging.TryLock() {
|
||||
return n, nil // Already processing a log, skip to avoid recursion
|
||||
}
|
||||
defer isLogging.Unlock()
|
||||
|
||||
// Parse the full log line
|
||||
logLine := string(p)
|
||||
level, source, message := parseLogEntry(logLine)
|
||||
|
||||
// *** DEBUG: Print parsed result to stderr ***
|
||||
fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER] Parsed: Level='%s', Source='%s', Message='%s'\n", level, source, strings.TrimSpace(message))
|
||||
|
||||
// --- TEMPORARILY DISABLED FILTER ---
|
||||
/*
|
||||
// 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" {
|
||||
fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER] Filtered out log from source '%s'\n", source)
|
||||
return n, nil
|
||||
}
|
||||
*/
|
||||
// --- END TEMPORARILY DISABLED FILTER ---
|
||||
|
||||
// Broadcast to WebSocket clients if handlers are initialized
|
||||
if handlers, ok := web.GetHandlersInstance(); ok && handlers != nil {
|
||||
fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER] Broadcasting: Level='%s', Source='%s'\n", level, source)
|
||||
handlers.BroadcastLog(level, message, source) // Pass parsed values
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[DEBUG-LOGGER] Skipped broadcast: handlers not ready\n")
|
||||
}
|
||||
|
||||
return n, nil // Return the number of bytes written and no error
|
||||
}
|
||||
|
||||
// parseLogEntry extracts level, source, and message from a standard Go log line
|
||||
func parseLogEntry(logLine string) (level, source, message string) {
|
||||
// Default values
|
||||
level = LevelInfo
|
||||
source = "system"
|
||||
message = strings.TrimSpace(logLine) // Use full line as message by default
|
||||
|
||||
matches := logLineRegex.FindStringSubmatch(logLine)
|
||||
flags := log.Flags()
|
||||
hasFileInfo := flags&log.Lshortfile != 0 || flags&log.Llongfile != 0
|
||||
|
||||
msgIndex := 1 // Index of the message part in regex matches
|
||||
if hasFileInfo {
|
||||
msgIndex = 2
|
||||
}
|
||||
|
||||
if len(matches) > msgIndex {
|
||||
rawMessage := strings.TrimSpace(matches[msgIndex])
|
||||
message = rawMessage // Assign raw message first
|
||||
|
||||
// Extract source from file info if present
|
||||
if hasFileInfo && len(matches) > 1 && matches[1] != "" {
|
||||
fileInfo := matches[1]
|
||||
parts := strings.Split(fileInfo, ":")
|
||||
if len(parts) > 0 {
|
||||
fileName := filepath.Base(parts[0])
|
||||
source = strings.TrimSuffix(fileName, ".go")
|
||||
}
|
||||
} else {
|
||||
// Attempt to infer source if no file info
|
||||
if strings.Contains(rawMessage, "scheduler") {
|
||||
source = "scheduler"
|
||||
} // Add other inferences if needed
|
||||
}
|
||||
|
||||
// Now, parse the level based on prefixes *within* the rawMessage
|
||||
parsedLevel, cleanMessage := parseLevelFromMessage(rawMessage)
|
||||
level = parsedLevel // Update level if prefix found
|
||||
message = cleanMessage // Update message to remove prefix
|
||||
|
||||
} else {
|
||||
// Regex didn't match, try basic prefix check on the whole line (fallback)
|
||||
level, message = parseLevelFromMessage(message) // Use original full message
|
||||
}
|
||||
|
||||
return level, source, message
|
||||
}
|
||||
|
||||
// parseLevelFromMessage checks for level prefixes within a message string
|
||||
func parseLevelFromMessage(msg string) (level string, cleanMsg string) {
|
||||
level = LevelInfo // Default
|
||||
cleanMsg = msg
|
||||
|
||||
// Check common prefixes
|
||||
if strings.HasPrefix(msg, "DEBUG:") {
|
||||
level = LevelDebug
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "DEBUG:"))
|
||||
} else if strings.HasPrefix(msg, "INFO:") {
|
||||
level = LevelInfo
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "INFO:"))
|
||||
} else if strings.HasPrefix(msg, "ERROR:") {
|
||||
level = LevelError
|
||||
cleanMsg = strings.TrimPrefix(msg, "ERROR:")
|
||||
} else if strings.HasPrefix(msg, "WARN:") {
|
||||
level = LevelWarning
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "WARN:"))
|
||||
} else if strings.HasPrefix(msg, "WARNING:") {
|
||||
level = LevelWarning
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "WARNING:"))
|
||||
} else if strings.HasPrefix(msg, "FATAL:") {
|
||||
level = LevelFatal
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "FATAL:"))
|
||||
} else if strings.HasPrefix(msg, "[debug]") {
|
||||
level = LevelDebug
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "[debug]"))
|
||||
} else if strings.HasPrefix(msg, "[info]") {
|
||||
level = LevelInfo
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "[info]"))
|
||||
} else if strings.HasPrefix(msg, "[warn]") {
|
||||
level = LevelWarning
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "[warn]"))
|
||||
} else if strings.HasPrefix(msg, "[warning]") {
|
||||
level = LevelWarning
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "[warning]"))
|
||||
} else if strings.HasPrefix(msg, "[error]") {
|
||||
level = LevelError
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "[error]"))
|
||||
} else if strings.HasPrefix(msg, "[fatal]") {
|
||||
level = LevelFatal
|
||||
cleanMsg = strings.TrimSpace(strings.TrimPrefix(msg, "[fatal]"))
|
||||
}
|
||||
|
||||
return level, cleanMsg
|
||||
}
|
||||
|
||||
// GetLogger returns the global logger instance
|
||||
func GetLogger() *Logger {
|
||||
loggerMutex.RLock()
|
||||
defer loggerMutex.RUnlock()
|
||||
return stdLogger
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func Debug(format string, v ...interface{}) {
|
||||
loggerMutex.RLock()
|
||||
defer loggerMutex.RUnlock()
|
||||
|
||||
if stdLogger == nil {
|
||||
// Fall back to standard logger if not initialized
|
||||
log.Printf("[debug] "+format, v...)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[debug] "+format, v...)
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func Info(format string, v ...interface{}) {
|
||||
loggerMutex.RLock()
|
||||
defer loggerMutex.RUnlock()
|
||||
|
||||
if stdLogger == nil {
|
||||
// Fall back to standard logger if not initialized
|
||||
log.Printf("[info] "+format, v...)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[info] "+format, v...)
|
||||
}
|
||||
|
||||
// Warn logs a warning message
|
||||
func Warn(format string, v ...interface{}) {
|
||||
loggerMutex.RLock()
|
||||
defer loggerMutex.RUnlock()
|
||||
|
||||
if stdLogger == nil {
|
||||
// Fall back to standard logger if not initialized
|
||||
log.Printf("[warn] "+format, v...)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[warn] "+format, v...)
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func Error(format string, v ...interface{}) {
|
||||
loggerMutex.RLock()
|
||||
defer loggerMutex.RUnlock()
|
||||
|
||||
if stdLogger == nil {
|
||||
// Fall back to standard logger if not initialized
|
||||
log.Printf("[error] "+format, v...)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[error] "+format, v...)
|
||||
}
|
||||
|
||||
// Fatal logs a fatal message and exits
|
||||
func Fatal(format string, v ...interface{}) {
|
||||
loggerMutex.RLock()
|
||||
defer loggerMutex.RUnlock()
|
||||
|
||||
if stdLogger == nil {
|
||||
// Fall back to standard logger if not initialized
|
||||
log.Fatalf("[fatal] "+format, v...)
|
||||
return
|
||||
}
|
||||
|
||||
log.Fatalf("[fatal] "+format, v...)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package rclone_service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// --- Mockable os/exec ---
|
||||
|
||||
// execCommandContext allows mocking exec.CommandContext during tests.
|
||||
var execCommandContext = exec.CommandContext
|
||||
|
||||
// cmdCombinedOutput allows mocking the CombinedOutput method during tests.
|
||||
var cmdCombinedOutput = (*exec.Cmd).CombinedOutput
|
||||
|
||||
// cmdRun allows mocking the Run method during tests.
|
||||
var cmdRun = (*exec.Cmd).Run
|
||||
|
||||
// --- Function Implementation ---
|
||||
|
||||
// TestRcloneConnection attempts to connect to a provider using temporary config created via `rclone config create`.
|
||||
// It returns success (bool), a message (string), and an error.
|
||||
func TestRcloneConnection(config db.TransferConfig, providerType string, dbInstance *db.DB) (bool, string, error) {
|
||||
var remoteName string
|
||||
var remotePath string
|
||||
var provider string
|
||||
var host, user, pass, keyFile, region, accessKey, secretKey, endpoint, domain, clientID, clientSecret, driveID, teamDrive string
|
||||
var port int
|
||||
var err error
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "gomft-rclone-test-")
|
||||
if err != nil {
|
||||
return false, "Failed to create temp directory for rclone config", err
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
tempConfigPath := filepath.Join(tempDir, "rclone_test.conf")
|
||||
|
||||
if providerType == "source" {
|
||||
remoteName = "testSource"
|
||||
remotePath = config.SourcePath
|
||||
provider = config.SourceType
|
||||
host = config.SourceHost
|
||||
port = config.SourcePort
|
||||
user = config.SourceUser
|
||||
pass = config.SourcePassword
|
||||
keyFile = config.SourceKeyFile
|
||||
region = config.SourceRegion
|
||||
accessKey = config.SourceAccessKey
|
||||
secretKey = config.SourceSecretKey
|
||||
endpoint = config.SourceEndpoint
|
||||
domain = config.SourceDomain
|
||||
clientID = config.SourceClientID
|
||||
clientSecret = config.SourceClientSecret
|
||||
driveID = config.SourceDriveID
|
||||
teamDrive = config.SourceTeamDrive
|
||||
} else if providerType == "destination" {
|
||||
remoteName = "testDest"
|
||||
remotePath = config.DestinationPath
|
||||
provider = config.DestinationType
|
||||
host = config.DestHost
|
||||
port = config.DestPort
|
||||
user = config.DestUser
|
||||
pass = config.DestPassword
|
||||
keyFile = config.DestKeyFile
|
||||
region = config.DestRegion
|
||||
accessKey = config.DestAccessKey
|
||||
secretKey = config.DestSecretKey
|
||||
endpoint = config.DestEndpoint
|
||||
domain = config.DestDomain
|
||||
clientID = config.DestClientID
|
||||
clientSecret = config.DestClientSecret
|
||||
driveID = config.DestDriveID
|
||||
teamDrive = config.DestTeamDrive
|
||||
} else {
|
||||
return false, "Invalid provider type specified", fmt.Errorf("unknown provider type: %s", providerType)
|
||||
}
|
||||
|
||||
rclonePath := os.Getenv("RCLONE_PATH")
|
||||
if rclonePath == "" {
|
||||
rclonePath = "rclone"
|
||||
}
|
||||
|
||||
primaryProvider := provider
|
||||
|
||||
// Convert hetzner to sftp
|
||||
if provider == "hetzner" {
|
||||
primaryProvider = "sftp"
|
||||
}
|
||||
|
||||
// S3 compatible providers
|
||||
if provider == "minio" || provider == "wasabi" {
|
||||
primaryProvider = "s3"
|
||||
}
|
||||
|
||||
createArgs := []string{
|
||||
"config", "create", remoteName, primaryProvider,
|
||||
"--config", tempConfigPath,
|
||||
"--non-interactive",
|
||||
"--log-level", "DEBUG",
|
||||
}
|
||||
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
var lsdArgs []string
|
||||
var stdout, stderr bytes.Buffer
|
||||
var lsdCmd *exec.Cmd
|
||||
var createCmd *exec.Cmd
|
||||
|
||||
switch provider {
|
||||
case "sftp", "hetzner":
|
||||
createArgs = append(createArgs, "host", host, "user", user)
|
||||
if port != 0 {
|
||||
createArgs = append(createArgs, "port", fmt.Sprintf("%d", port))
|
||||
}
|
||||
if pass != "" {
|
||||
createArgs = append(createArgs, "pass", pass)
|
||||
}
|
||||
if keyFile != "" {
|
||||
createArgs = append(createArgs, "key_file", keyFile)
|
||||
}
|
||||
case "s3":
|
||||
createArgs = append(createArgs, "provider", "AWS", "env_auth", "false")
|
||||
if accessKey != "" {
|
||||
createArgs = append(createArgs, "access_key_id", accessKey)
|
||||
}
|
||||
if secretKey != "" {
|
||||
createArgs = append(createArgs, "secret_access_key", secretKey)
|
||||
}
|
||||
if region != "" {
|
||||
createArgs = append(createArgs, "region", region)
|
||||
}
|
||||
if endpoint != "" {
|
||||
createArgs = append(createArgs, "endpoint", endpoint)
|
||||
}
|
||||
case "wasabi":
|
||||
createArgs = append(createArgs, "provider", "Wasabi", "env_auth", "false")
|
||||
if accessKey != "" {
|
||||
createArgs = append(createArgs, "access_key_id", accessKey)
|
||||
}
|
||||
if secretKey != "" {
|
||||
createArgs = append(createArgs, "secret_access_key", secretKey)
|
||||
}
|
||||
if region != "" {
|
||||
createArgs = append(createArgs, "region", region)
|
||||
}
|
||||
if endpoint != "" {
|
||||
createArgs = append(createArgs, "endpoint", endpoint)
|
||||
}
|
||||
case "minio":
|
||||
createArgs = append(createArgs, "provider", "Minio", "env_auth", "false")
|
||||
if accessKey != "" {
|
||||
createArgs = append(createArgs, "access_key_id", accessKey)
|
||||
}
|
||||
if secretKey != "" {
|
||||
createArgs = append(createArgs, "secret_access_key", secretKey)
|
||||
}
|
||||
if region != "" {
|
||||
createArgs = append(createArgs, "region", region)
|
||||
}
|
||||
if endpoint != "" {
|
||||
createArgs = append(createArgs, "endpoint", endpoint)
|
||||
}
|
||||
case "b2":
|
||||
createArgs = append(createArgs, "provider", "B2", "env_auth", "false")
|
||||
if accessKey != "" {
|
||||
createArgs = append(createArgs, "account", accessKey)
|
||||
}
|
||||
if secretKey != "" {
|
||||
createArgs = append(createArgs, "key", secretKey)
|
||||
}
|
||||
if endpoint != "" {
|
||||
createArgs = append(createArgs, "endpoint", endpoint)
|
||||
}
|
||||
if region != "" {
|
||||
createArgs = append(createArgs, "region", region)
|
||||
}
|
||||
case "ftp":
|
||||
createArgs = append(createArgs, "host", host, "user", user)
|
||||
if port != 0 {
|
||||
createArgs = append(createArgs, "port", fmt.Sprintf("%d", port))
|
||||
}
|
||||
if pass != "" {
|
||||
createArgs = append(createArgs, "pass", pass)
|
||||
}
|
||||
if config.GetSourcePassiveMode() || config.GetDestPassiveMode() {
|
||||
createArgs = append(createArgs, "passive_mode", "true")
|
||||
} else {
|
||||
createArgs = append(createArgs, "passive_mode", "false")
|
||||
}
|
||||
createArgs = append(createArgs, "explicit_tls", "true")
|
||||
case "smb":
|
||||
createArgs = append(createArgs, "host", host, "user", user)
|
||||
if port != 0 {
|
||||
createArgs = append(createArgs, "port", fmt.Sprintf("%d", port))
|
||||
}
|
||||
if pass != "" {
|
||||
createArgs = append(createArgs, "pass", pass)
|
||||
}
|
||||
if domain != "" {
|
||||
createArgs = append(createArgs, "domain", domain)
|
||||
}
|
||||
case "webdav":
|
||||
createArgs = append(createArgs, "url", host, "vendor", "other", "user", user)
|
||||
if pass != "" {
|
||||
createArgs = append(createArgs, "pass", pass)
|
||||
}
|
||||
case "nextcloud":
|
||||
createArgs = append(createArgs, "url", host, "vendor", "nextcloud", "user", user)
|
||||
if pass != "" {
|
||||
createArgs = append(createArgs, "pass", pass)
|
||||
}
|
||||
case "gdrive":
|
||||
createArgs = append(createArgs, "scope", "drive")
|
||||
if clientID != "" {
|
||||
createArgs = append(createArgs, "client_id", clientID)
|
||||
}
|
||||
if clientSecret != "" {
|
||||
createArgs = append(createArgs, "client_secret", clientSecret)
|
||||
}
|
||||
if driveID != "" {
|
||||
createArgs = append(createArgs, "root_folder_id", driveID)
|
||||
}
|
||||
if teamDrive != "" {
|
||||
createArgs = append(createArgs, "team_drive", teamDrive)
|
||||
}
|
||||
log.Println("Warning: Google Drive test may require pre-existing token or manual auth.")
|
||||
case "gphotos":
|
||||
if clientID != "" {
|
||||
createArgs = append(createArgs, "client_id", clientID)
|
||||
}
|
||||
if clientSecret != "" {
|
||||
createArgs = append(createArgs, "client_secret", clientSecret)
|
||||
}
|
||||
log.Println("Warning: Google Photos test may require pre-existing token or manual auth.")
|
||||
case "local":
|
||||
localConfigContent := fmt.Sprintf("[%s]\ntype = local\nnounc = true\n", remoteName)
|
||||
if err := os.WriteFile(tempConfigPath, []byte(localConfigContent), 0600); err != nil {
|
||||
return false, fmt.Sprintf("Failed to write temporary local config: %v", err), err
|
||||
}
|
||||
goto RunLsd
|
||||
default:
|
||||
return false, fmt.Sprintf("Provider type '%s' not yet supported for testing via 'rclone config create'", provider), fmt.Errorf("unsupported provider")
|
||||
}
|
||||
|
||||
log.Printf("Executing rclone config create command: %s %s", rclonePath, strings.Join(createArgs, " "))
|
||||
createCmd = execCommandContext(context.Background(), rclonePath, createArgs...)
|
||||
// Use the mockable function variable
|
||||
if output, err := cmdCombinedOutput(createCmd); err != nil {
|
||||
configContentBytes, _ := os.ReadFile(tempConfigPath)
|
||||
log.Printf("Temp config content on create error:\n---\n%s\n---", string(configContentBytes))
|
||||
return false, fmt.Sprintf("Failed to create temp config section: %v\nOutput: %s", err, string(output)), err
|
||||
}
|
||||
log.Printf("Successfully created temp config section for %s", remoteName)
|
||||
|
||||
RunLsd:
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
lsdArgs = []string{
|
||||
"--config", tempConfigPath,
|
||||
"lsd",
|
||||
fmt.Sprintf("%s:%s", remoteName, remotePath),
|
||||
"--low-level-retries", "1",
|
||||
"--retries", "1",
|
||||
}
|
||||
|
||||
log.Printf("Executing rclone lsd command: %s %s", rclonePath, strings.Join(lsdArgs, " "))
|
||||
lsdCmd = execCommandContext(ctx, rclonePath, lsdArgs...)
|
||||
|
||||
lsdCmd.Stdout = &stdout
|
||||
lsdCmd.Stderr = &stderr
|
||||
|
||||
// Use the mockable function variable
|
||||
err = cmdRun(lsdCmd)
|
||||
|
||||
stdoutStr := stdout.String()
|
||||
stderrStr := stderr.String()
|
||||
|
||||
log.Printf("Rclone lsd stdout:\n%s", stdoutStr)
|
||||
log.Printf("Rclone lsd stderr:\n%s", stderrStr)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return false, "Connection test timed out after 30 seconds.", context.DeadlineExceeded
|
||||
}
|
||||
// Check ctx.Err() as a fallback - This check might be redundant now
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return false, "Connection test timed out after 30 seconds.", ctx.Err()
|
||||
}
|
||||
|
||||
errMsg := fmt.Sprintf("Connection test failed: %v. Stderr: %s", err, stderrStr)
|
||||
if strings.Contains(stderrStr, "connect: connection refused") {
|
||||
errMsg = "Connection test failed: Connection refused by host."
|
||||
} else if strings.Contains(stderrStr, "no such host") || strings.Contains(stderrStr, "name resolution error") {
|
||||
errMsg = "Connection test failed: Hostname not found or DNS resolution error."
|
||||
} else if strings.Contains(stderrStr, "authentication failed") || strings.Contains(stderrStr, "login incorrect") || strings.Contains(stderrStr, "permission denied") {
|
||||
errMsg = "Connection test failed: Authentication failed (check credentials/permissions)."
|
||||
} else if strings.Contains(stderrStr, "directory not found") {
|
||||
errMsg = "Connection test failed: Directory/Path not found (check path)."
|
||||
} else if strings.Contains(stderrStr, "Couldn't find section") {
|
||||
errMsg = "Connection test failed: Invalid parameters provided for provider type."
|
||||
}
|
||||
return false, errMsg, err
|
||||
}
|
||||
|
||||
return true, "Connection test successful!", nil
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package rclone_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// --- Mock os/exec ---
|
||||
|
||||
// Note: The package-level variable 'execCommandContext' is defined in rclone_service.go
|
||||
// This helper function replaces it for the duration of a test.
|
||||
|
||||
// MockExecCommand replaces the package-level execCommandContext variable (defined in rclone_service.go)
|
||||
// with a function provided by the test and returns a function to restore the original.
|
||||
func MockExecCommand(mockFunc func(ctx context.Context, command string, args ...string) *exec.Cmd) (restore func()) {
|
||||
original := execCommandContext
|
||||
execCommandContext = mockFunc
|
||||
return func() { execCommandContext = original }
|
||||
}
|
||||
|
||||
// Helper function to find the actual rclone command within args, skipping flags.
|
||||
func findRcloneCommand(args []string) string {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
return arg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestTestRcloneConnection_Success_SFTP(t *testing.T) {
|
||||
config := db.TransferConfig{
|
||||
SourceType: "sftp",
|
||||
SourceHost: "testhost",
|
||||
SourceUser: "testuser",
|
||||
SourcePassword: "testpassword",
|
||||
}
|
||||
providerType := "source"
|
||||
var dbInstance *db.DB
|
||||
configCreateCalled := false
|
||||
|
||||
// Mock execCommandContext (only needed to return a basic cmd struct)
|
||||
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
||||
// Return a simple, non-nil command object. The actual execution is mocked below.
|
||||
return exec.Command("echo", "mocked")
|
||||
})
|
||||
defer restoreExec()
|
||||
|
||||
// Mock cmdCombinedOutput for config create
|
||||
originalCombinedOutput := cmdCombinedOutput
|
||||
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
|
||||
configCreateCalled = true
|
||||
return []byte(""), nil // Simulate success
|
||||
}
|
||||
defer func() { cmdCombinedOutput = originalCombinedOutput }() // Restore original
|
||||
|
||||
// Mock cmdRun for lsd
|
||||
originalRun := cmdRun
|
||||
cmdRun = func(c *exec.Cmd) error {
|
||||
if !configCreateCalled {
|
||||
t.Fatalf("lsd (Run) called before config create")
|
||||
}
|
||||
// Simulate success by returning nil error
|
||||
// We also need to simulate writing to stdout if the main func uses it
|
||||
if stdoutWriter, ok := c.Stdout.(interface{ WriteString(string) (int, error) }); ok {
|
||||
stdoutWriter.WriteString(" -1 2023-01-01 10:00:00 -1 some_dir\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() { cmdRun = originalRun }() // Restore original
|
||||
|
||||
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
if !success {
|
||||
t.Errorf("Expected success=true, but got false. Message: %s", msg)
|
||||
}
|
||||
if msg != "Connection test successful!" {
|
||||
t.Errorf("Expected success message, but got: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestRcloneConnection_ConfigCreateFail(t *testing.T) {
|
||||
config := db.TransferConfig{
|
||||
SourceType: "sftp",
|
||||
SourceHost: "testhost",
|
||||
SourceUser: "testuser",
|
||||
}
|
||||
providerType := "source"
|
||||
var dbInstance *db.DB
|
||||
expectedStderr := "invalid parameters"
|
||||
expectedErr := errors.New("exit status 1")
|
||||
|
||||
// Mock execCommandContext
|
||||
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
||||
return exec.Command("echo", "mocked")
|
||||
})
|
||||
defer restoreExec()
|
||||
|
||||
// Mock cmdCombinedOutput for config create failure
|
||||
originalCombinedOutput := cmdCombinedOutput
|
||||
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
|
||||
return []byte(expectedStderr), expectedErr // Simulate failure
|
||||
}
|
||||
defer func() { cmdCombinedOutput = originalCombinedOutput }()
|
||||
|
||||
// Mock cmdRun (should not be called)
|
||||
originalRun := cmdRun
|
||||
cmdRun = func(c *exec.Cmd) error {
|
||||
t.Fatalf("lsd (Run) called after config create failure")
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
defer func() { cmdRun = originalRun }()
|
||||
|
||||
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected an error from config create failure, but got nil")
|
||||
} else if !errors.Is(err, expectedErr) {
|
||||
t.Errorf("Expected error %v, got %v", expectedErr, err)
|
||||
}
|
||||
if success {
|
||||
t.Error("Expected success=false for config create failure, but got true")
|
||||
}
|
||||
if !strings.Contains(msg, "Failed to create temp config section") {
|
||||
t.Errorf("Expected message containing 'Failed to create temp config section', got: %q", msg)
|
||||
}
|
||||
// Note: The CombinedOutput mock returns stderr in the output byte slice
|
||||
if !strings.Contains(msg, expectedStderr) {
|
||||
t.Errorf("Expected message containing stderr %q, got: %q", expectedStderr, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestRcloneConnection_LsdTimeout(t *testing.T) {
|
||||
config := db.TransferConfig{
|
||||
SourceType: "sftp",
|
||||
SourceHost: "testhost",
|
||||
SourceUser: "testuser",
|
||||
SourcePassword: "pw",
|
||||
}
|
||||
providerType := "source"
|
||||
var dbInstance *db.DB
|
||||
|
||||
// Mock execCommandContext
|
||||
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
||||
return exec.Command("echo", "mocked")
|
||||
})
|
||||
defer restoreExec()
|
||||
|
||||
// Mock cmdCombinedOutput for config create success
|
||||
originalCombinedOutput := cmdCombinedOutput
|
||||
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
|
||||
return []byte(""), nil
|
||||
}
|
||||
defer func() { cmdCombinedOutput = originalCombinedOutput }()
|
||||
|
||||
// Mock cmdRun for lsd timeout
|
||||
originalRun := cmdRun
|
||||
cmdRun = func(c *exec.Cmd) error {
|
||||
// Simulate timeout error
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
defer func() { cmdRun = originalRun }()
|
||||
|
||||
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected a timeout error, but got nil")
|
||||
} else if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("Expected context.DeadlineExceeded error, got: %v (type: %T)", err, err)
|
||||
}
|
||||
if success {
|
||||
t.Error("Expected success=false for timeout, but got true")
|
||||
}
|
||||
if !strings.Contains(msg, "Connection test timed out") {
|
||||
t.Errorf("Expected message containing 'Connection test timed out', got: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestRcloneConnection_LsdAuthFail(t *testing.T) {
|
||||
config := db.TransferConfig{
|
||||
SourceType: "sftp",
|
||||
SourceHost: "testhost",
|
||||
SourceUser: "wronguser",
|
||||
SourcePassword: "wrongpassword",
|
||||
}
|
||||
providerType := "source"
|
||||
var dbInstance *db.DB
|
||||
expectedStderr := "authentication failed"
|
||||
expectedErr := errors.New("exit status 1")
|
||||
|
||||
// Mock execCommandContext
|
||||
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
||||
return exec.Command("echo", "mocked")
|
||||
})
|
||||
defer restoreExec()
|
||||
|
||||
// Mock cmdCombinedOutput for config create success
|
||||
originalCombinedOutput := cmdCombinedOutput
|
||||
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
|
||||
return []byte(""), nil
|
||||
}
|
||||
defer func() { cmdCombinedOutput = originalCombinedOutput }()
|
||||
|
||||
// Mock cmdRun for lsd failure
|
||||
originalRun := cmdRun
|
||||
cmdRun = func(c *exec.Cmd) error {
|
||||
// Simulate failure by returning error and writing to stderr buffer
|
||||
if stderrWriter, ok := c.Stderr.(interface{ WriteString(string) (int, error) }); ok {
|
||||
stderrWriter.WriteString(expectedStderr)
|
||||
}
|
||||
return expectedErr
|
||||
}
|
||||
defer func() { cmdRun = originalRun }()
|
||||
|
||||
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected an error from lsd auth failure, but got nil")
|
||||
} else if !errors.Is(err, expectedErr) {
|
||||
if !strings.Contains(err.Error(), "exit status 1") {
|
||||
t.Errorf("Expected error containing 'exit status 1', got: %v", err)
|
||||
}
|
||||
}
|
||||
if success {
|
||||
t.Error("Expected success=false for lsd auth failure, but got true")
|
||||
}
|
||||
// Check the parsed error message based on stderr
|
||||
if !strings.Contains(msg, "Authentication failed") {
|
||||
t.Errorf("Expected message containing 'Authentication failed', got: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestRcloneConnection_LocalSuccess(t *testing.T) {
|
||||
tempPath := t.TempDir()
|
||||
config := db.TransferConfig{
|
||||
SourceType: "local",
|
||||
SourcePath: tempPath,
|
||||
}
|
||||
providerType := "source"
|
||||
var dbInstance *db.DB
|
||||
|
||||
// Mock execCommandContext (only lsd should be called)
|
||||
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
||||
rcloneCmd := findRcloneCommand(args)
|
||||
if rcloneCmd != "lsd" {
|
||||
t.Fatalf("Unexpected command call for local provider: %q", rcloneCmd)
|
||||
}
|
||||
return exec.Command("echo", "mocked for lsd")
|
||||
})
|
||||
defer restoreExec()
|
||||
|
||||
// Mock cmdCombinedOutput (should not be called)
|
||||
originalCombinedOutput := cmdCombinedOutput
|
||||
cmdCombinedOutput = func(c *exec.Cmd) ([]byte, error) {
|
||||
t.Fatalf("CombinedOutput called unexpectedly for local provider")
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
defer func() { cmdCombinedOutput = originalCombinedOutput }()
|
||||
|
||||
// Mock cmdRun for lsd success
|
||||
originalRun := cmdRun
|
||||
cmdRun = func(c *exec.Cmd) error {
|
||||
// Simulate success
|
||||
if stdoutWriter, ok := c.Stdout.(interface{ WriteString(string) (int, error) }); ok {
|
||||
stdoutWriter.WriteString(" -1 2023-01-01 10:00:00 -1 some_local_dir\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() { cmdRun = originalRun }()
|
||||
|
||||
success, msg, err := TestRcloneConnection(config, providerType, dbInstance)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for local success, but got: %v", err)
|
||||
}
|
||||
if !success {
|
||||
t.Errorf("Expected success=true for local success, but got false. Message: %s", msg)
|
||||
}
|
||||
if msg != "Connection test successful!" {
|
||||
t.Errorf("Expected success message, but got: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add more tests for other providers (S3, FTP, WebDAV, etc.)
|
||||
// TODO: Add tests for destination providerType
|
||||
// TODO: Add tests for specific error string parsing (connection refused, dir not found)
|
||||
@@ -0,0 +1,206 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm" // Needed for DB interface method signature
|
||||
)
|
||||
|
||||
// --- Interfaces for Dependencies ---
|
||||
|
||||
// JobExecutorDB defines the database methods needed by JobExecutor.
|
||||
type JobExecutorDB interface {
|
||||
First(dest interface{}, conds ...interface{}) *gorm.DB // Used to load job details
|
||||
GetConfigsForJob(jobID uint) ([]db.TransferConfig, error)
|
||||
UpdateJobStatus(job *db.Job) error
|
||||
CreateJobHistory(history *db.JobHistory) error
|
||||
}
|
||||
|
||||
// JobExecutorCron defines the cron methods needed by JobExecutor.
|
||||
type JobExecutorCron interface {
|
||||
Entry(id cron.EntryID) cron.Entry
|
||||
}
|
||||
|
||||
// JobExecutorTransferExecutor defines the transfer executor methods needed by JobExecutor.
|
||||
type JobExecutorTransferExecutor interface {
|
||||
executeConfigTransfer(job db.Job, config db.TransferConfig, history *db.JobHistory)
|
||||
}
|
||||
|
||||
// JobExecutorNotifier defines the notification methods needed by JobExecutor.
|
||||
type JobExecutorNotifier interface {
|
||||
// SendNotifications is called within processConfiguration, which indirectly uses the Notifier interface
|
||||
// defined in transfer_executor.go. We need the same method here.
|
||||
SendNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig)
|
||||
}
|
||||
|
||||
// --- JobExecutor Implementation ---
|
||||
|
||||
// JobExecutor handles the execution logic for a single job run.
|
||||
type JobExecutor struct {
|
||||
db JobExecutorDB // Use interface
|
||||
logger *Logger // Logger remains concrete
|
||||
cron JobExecutorCron // Use interface
|
||||
jobs map[uint]cron.EntryID // Shared map from Scheduler
|
||||
jobMutex *sync.Mutex // Shared mutex from Scheduler
|
||||
transferExecutor JobExecutorTransferExecutor // Use interface
|
||||
notifier JobExecutorNotifier // Use interface
|
||||
}
|
||||
|
||||
// NewJobExecutor creates a new JobExecutor.
|
||||
func NewJobExecutor(
|
||||
database JobExecutorDB, // Accept interface
|
||||
logger *Logger,
|
||||
cron JobExecutorCron, // Accept interface
|
||||
jobsMap map[uint]cron.EntryID,
|
||||
jobMutex *sync.Mutex,
|
||||
transferExec JobExecutorTransferExecutor, // Accept interface
|
||||
notify JobExecutorNotifier, // Accept interface
|
||||
) *JobExecutor {
|
||||
return &JobExecutor{
|
||||
db: database,
|
||||
logger: logger,
|
||||
cron: cron,
|
||||
jobs: jobsMap,
|
||||
jobMutex: jobMutex,
|
||||
transferExecutor: transferExec,
|
||||
notifier: notify,
|
||||
}
|
||||
}
|
||||
|
||||
// executeJob orchestrates the execution of a job by processing its configurations.
|
||||
func (je *JobExecutor) executeJob(jobID uint) {
|
||||
je.logger.LogDebug("Entering executeJob for job ID %d", jobID)
|
||||
defer je.logger.LogDebug("Exiting executeJob for job ID %d", jobID)
|
||||
|
||||
je.logger.LogInfo("Starting execution of job %d", jobID)
|
||||
|
||||
// Get job details
|
||||
var job db.Job
|
||||
// Calls interface method - need to handle the *gorm.DB return value
|
||||
if err := je.db.First(&job, jobID).Error; err != nil {
|
||||
je.logger.LogError("Error loading job %d: %v", jobID, err)
|
||||
return
|
||||
}
|
||||
|
||||
je.logger.LogDebug("Loaded job details: %+v", job)
|
||||
|
||||
// Get all configurations associated with this job
|
||||
configs, err := je.db.GetConfigsForJob(jobID) // Calls interface method
|
||||
if err != nil {
|
||||
je.logger.LogError("Error loading configurations for job %d: %v", jobID, err)
|
||||
return
|
||||
}
|
||||
|
||||
je.logger.LogDebug("Loaded %d configurations for job %d", len(configs), jobID)
|
||||
|
||||
if len(configs) == 0 {
|
||||
je.logger.LogError("Error: job %d has no associated configurations", jobID)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the ordered config IDs from the job
|
||||
orderedConfigIDs := job.GetConfigIDsList()
|
||||
je.logger.LogDebug("Ordered config IDs for job %d: %v", jobID, orderedConfigIDs)
|
||||
|
||||
// Create a map of configs for easy lookup
|
||||
configMap := make(map[uint]db.TransferConfig)
|
||||
for _, config := range configs {
|
||||
configMap[config.ID] = config
|
||||
}
|
||||
|
||||
// Process configurations in the specified order
|
||||
var orderedConfigs []db.TransferConfig
|
||||
|
||||
// First, add configs in the order specified in the job's ConfigIDs
|
||||
for _, configID := range orderedConfigIDs {
|
||||
if config, exists := configMap[configID]; exists {
|
||||
orderedConfigs = append(orderedConfigs, config)
|
||||
delete(configMap, configID) // Remove from map to avoid duplicates
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining configs not in the ordered list (shouldn't happen, but just in case)
|
||||
for _, config := range configMap {
|
||||
orderedConfigs = append(orderedConfigs, config)
|
||||
}
|
||||
|
||||
je.logger.LogInfo("Processing job %d with %d configurations in specified order", jobID, len(orderedConfigs))
|
||||
|
||||
// Log the order of execution
|
||||
for i, config := range orderedConfigs {
|
||||
je.logger.LogDebug("Execution order %d/%d: Config ID %d (%s)", i+1, len(orderedConfigs), config.ID, config.Name)
|
||||
}
|
||||
|
||||
// Update job last run time
|
||||
startTime := time.Now()
|
||||
job.LastRun = &startTime
|
||||
if err := je.db.UpdateJobStatus(&job); err != nil { // Calls interface method
|
||||
je.logger.LogError("Error updating job last run time for job %d: %v", jobID, err)
|
||||
}
|
||||
|
||||
// Process each configuration in the specified order
|
||||
for i, config := range orderedConfigs {
|
||||
je.processConfiguration(&job, &config, i+1, len(orderedConfigs))
|
||||
}
|
||||
|
||||
// Update next run time after execution
|
||||
// Need access to the shared jobs map and mutex from Scheduler
|
||||
je.jobMutex.Lock()
|
||||
entryID, exists := je.jobs[jobID]
|
||||
je.jobMutex.Unlock()
|
||||
|
||||
if exists {
|
||||
entry := je.cron.Entry(entryID) // Calls interface method
|
||||
nextRun := entry.Next
|
||||
job.NextRun = &nextRun
|
||||
je.logger.LogInfo("Next run time for job %d: %v", jobID, nextRun)
|
||||
if err := je.db.UpdateJobStatus(&job); err != nil { // Calls interface method
|
||||
je.logger.LogError("Error updating job next run time for job %d: %v", jobID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processConfiguration processes a single configuration step within a job.
|
||||
func (je *JobExecutor) processConfiguration(job *db.Job, config *db.TransferConfig, index int, totalConfigs int) {
|
||||
je.logger.LogDebug("Processing configuration %d: %+v", config.ID, config)
|
||||
|
||||
je.logger.LogInfo("Processing configuration %d (%d/%d) for job %d: source=%s:%s, dest=%s:%s",
|
||||
config.ID,
|
||||
index,
|
||||
totalConfigs,
|
||||
job.ID,
|
||||
config.SourceType,
|
||||
config.SourcePath,
|
||||
config.DestinationType,
|
||||
config.DestinationPath,
|
||||
)
|
||||
|
||||
// Create job history entry for this configuration
|
||||
history := &db.JobHistory{
|
||||
JobID: job.ID,
|
||||
ConfigID: config.ID,
|
||||
StartTime: time.Now(),
|
||||
Status: "running",
|
||||
FilesTransferred: 0,
|
||||
BytesTransferred: 0,
|
||||
ErrorMessage: "",
|
||||
}
|
||||
if err := je.db.CreateJobHistory(history); err != nil { // Calls interface method
|
||||
je.logger.LogError("Error creating job history for job %d, config %d: %v", job.ID, config.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
je.logger.LogDebug("Creating job history record: %+v", history)
|
||||
|
||||
// Send webhook notification for job start
|
||||
// Notifier interface is used by TransferExecutor, which is called below.
|
||||
// We also added SendNotifications to the JobExecutorNotifier interface for completeness,
|
||||
// though it's primarily used within transferExecutor.
|
||||
je.notifier.SendNotifications(job, history, config) // Calls interface method
|
||||
|
||||
// Execute the configuration transfer
|
||||
je.transferExecutor.executeConfigTransfer(*job, *config, history) // Calls interface method
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings" // Added import
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --- Mock Implementations ---
|
||||
|
||||
// Mock JobExecutorDB
|
||||
var _ JobExecutorDB = (*mockJobExecutorDB)(nil)
|
||||
|
||||
type mockJobExecutorDB struct {
|
||||
mu sync.Mutex
|
||||
FirstFunc func(dest interface{}, conds ...interface{}) *gorm.DB
|
||||
GetConfigsForJobFunc func(jobID uint) ([]db.TransferConfig, error)
|
||||
UpdateJobStatusFunc func(job *db.Job) error
|
||||
CreateJobHistoryFunc func(history *db.JobHistory) error
|
||||
|
||||
// Store calls/data
|
||||
firstCalledWithDest interface{}
|
||||
firstCalledWithConds []interface{}
|
||||
configsForJobID uint
|
||||
updatedJobStatus *db.Job
|
||||
createdHistory *db.JobHistory
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) First(dest interface{}, conds ...interface{}) *gorm.DB {
|
||||
m.mu.Lock()
|
||||
m.firstCalledWithDest = dest
|
||||
m.firstCalledWithConds = conds
|
||||
m.mu.Unlock()
|
||||
if m.FirstFunc != nil {
|
||||
return m.FirstFunc(dest, conds...)
|
||||
}
|
||||
// Default: Simulate job found by populating dest
|
||||
if job, ok := dest.(*db.Job); ok && len(conds) > 0 {
|
||||
if jobID, ok := conds[0].(uint); ok {
|
||||
job.ID = jobID
|
||||
job.Name = fmt.Sprintf("Mock Job %d", jobID)
|
||||
job.ConfigIDs = "1,2" // Default config IDs
|
||||
enabled := true
|
||||
job.Enabled = &enabled
|
||||
return &gorm.DB{Error: nil} // Success
|
||||
}
|
||||
}
|
||||
return &gorm.DB{Error: gorm.ErrRecordNotFound} // Default not found
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) GetConfigsForJob(jobID uint) ([]db.TransferConfig, error) {
|
||||
m.mu.Lock()
|
||||
m.configsForJobID = jobID
|
||||
m.mu.Unlock()
|
||||
if m.GetConfigsForJobFunc != nil {
|
||||
return m.GetConfigsForJobFunc(jobID)
|
||||
}
|
||||
// Default: return some mock configs
|
||||
return []db.TransferConfig{
|
||||
{ID: 1, Name: "Config 1"}, // Corrected initialization
|
||||
{ID: 2, Name: "Config 2"}, // Corrected initialization
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) UpdateJobStatus(job *db.Job) error {
|
||||
m.mu.Lock()
|
||||
m.updatedJobStatus = job // Store last updated job
|
||||
m.mu.Unlock()
|
||||
if m.UpdateJobStatusFunc != nil {
|
||||
return m.UpdateJobStatusFunc(job)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) CreateJobHistory(history *db.JobHistory) error {
|
||||
m.mu.Lock()
|
||||
m.createdHistory = history // Store last created history
|
||||
m.mu.Unlock()
|
||||
if m.CreateJobHistoryFunc != nil {
|
||||
return m.CreateJobHistoryFunc(history)
|
||||
}
|
||||
history.ID = 999 // Assign mock ID
|
||||
return nil // Default success
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.firstCalledWithDest = nil
|
||||
m.firstCalledWithConds = nil
|
||||
m.configsForJobID = 0
|
||||
m.updatedJobStatus = nil
|
||||
m.createdHistory = nil
|
||||
}
|
||||
|
||||
// Mock JobExecutorCron
|
||||
var _ JobExecutorCron = (*mockJobExecutorCron)(nil)
|
||||
|
||||
type mockJobExecutorCron struct {
|
||||
mu sync.Mutex
|
||||
EntryFunc func(id cron.EntryID) cron.Entry
|
||||
|
||||
// Store calls
|
||||
entryCalledWithID cron.EntryID
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorCron) Entry(id cron.EntryID) cron.Entry {
|
||||
m.mu.Lock()
|
||||
m.entryCalledWithID = id
|
||||
m.mu.Unlock()
|
||||
if m.EntryFunc != nil {
|
||||
return m.EntryFunc(id)
|
||||
}
|
||||
// Default: return a basic entry with a future next run time
|
||||
return cron.Entry{
|
||||
ID: id,
|
||||
Next: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
}
|
||||
func (m *mockJobExecutorCron) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.entryCalledWithID = 0
|
||||
}
|
||||
|
||||
// Mock JobExecutorTransferExecutor
|
||||
var _ JobExecutorTransferExecutor = (*mockJobExecutorTransferExecutor)(nil)
|
||||
|
||||
type mockJobExecutorTransferExecutor struct {
|
||||
mu sync.Mutex
|
||||
ExecuteConfigTransferFunc func(job db.Job, config db.TransferConfig, history *db.JobHistory)
|
||||
|
||||
// Store calls
|
||||
executeConfigTransferCalls []map[string]interface{}
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorTransferExecutor) executeConfigTransfer(job db.Job, config db.TransferConfig, history *db.JobHistory) {
|
||||
m.mu.Lock()
|
||||
m.executeConfigTransferCalls = append(m.executeConfigTransferCalls, map[string]interface{}{
|
||||
"job": job, "config": config, "history": history,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
if m.ExecuteConfigTransferFunc != nil {
|
||||
m.ExecuteConfigTransferFunc(job, config, history)
|
||||
}
|
||||
// Default: Do nothing, just record the call
|
||||
}
|
||||
func (m *mockJobExecutorTransferExecutor) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.executeConfigTransferCalls = nil
|
||||
}
|
||||
|
||||
// Mock JobExecutorNotifier
|
||||
var _ JobExecutorNotifier = (*mockJobExecutorNotifier)(nil)
|
||||
|
||||
type mockJobExecutorNotifier struct {
|
||||
mu sync.Mutex
|
||||
SendNotificationsFunc func(job *db.Job, history *db.JobHistory, config *db.TransferConfig)
|
||||
|
||||
// Store calls
|
||||
sendNotificationsCalls []map[string]interface{}
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorNotifier) SendNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
|
||||
m.mu.Lock()
|
||||
m.sendNotificationsCalls = append(m.sendNotificationsCalls, map[string]interface{}{
|
||||
"job": job, "history": history, "config": config,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
if m.SendNotificationsFunc != nil {
|
||||
m.SendNotificationsFunc(job, history, config)
|
||||
}
|
||||
}
|
||||
func (m *mockJobExecutorNotifier) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sendNotificationsCalls = nil
|
||||
}
|
||||
|
||||
// --- Test Setup ---
|
||||
|
||||
type testJobExecutorComponents struct {
|
||||
db *mockJobExecutorDB
|
||||
logger *Logger
|
||||
logBuf *bytes.Buffer
|
||||
cron *mockJobExecutorCron
|
||||
transfer *mockJobExecutorTransferExecutor
|
||||
notifier *mockJobExecutorNotifier
|
||||
executor *JobExecutor
|
||||
jobsMap map[uint]cron.EntryID
|
||||
jobMutex *sync.Mutex
|
||||
}
|
||||
|
||||
func setupTestJobExecutor() testJobExecutorComponents {
|
||||
dbMock := &mockJobExecutorDB{}
|
||||
logger, logBuf := newTestLogger(LogLevelDebug)
|
||||
cronMock := &mockJobExecutorCron{}
|
||||
transferMock := &mockJobExecutorTransferExecutor{}
|
||||
notifierMock := &mockJobExecutorNotifier{}
|
||||
jobsMap := make(map[uint]cron.EntryID)
|
||||
var jobMutex sync.Mutex
|
||||
|
||||
executor := NewJobExecutor(dbMock, logger, cronMock, jobsMap, &jobMutex, transferMock, notifierMock)
|
||||
|
||||
return testJobExecutorComponents{
|
||||
db: dbMock,
|
||||
logger: logger,
|
||||
logBuf: logBuf,
|
||||
cron: cronMock,
|
||||
transfer: transferMock,
|
||||
notifier: notifierMock,
|
||||
executor: executor,
|
||||
jobsMap: jobsMap,
|
||||
jobMutex: &jobMutex,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestExecuteJob_Success(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
testJobID := uint(1)
|
||||
testCronEntryID := cron.EntryID(10)
|
||||
comps.jobsMap[testJobID] = testCronEntryID // Simulate job being scheduled
|
||||
|
||||
// Configure mocks
|
||||
comps.db.GetConfigsForJobFunc = func(jobID uint) ([]db.TransferConfig, error) {
|
||||
if jobID != testJobID {
|
||||
t.Errorf("GetConfigsForJob called with wrong jobID: got %d, want %d", jobID, testJobID)
|
||||
}
|
||||
// Return configs in a different order than job.ConfigIDs to test ordering logic
|
||||
return []db.TransferConfig{
|
||||
{ID: 2, Name: "Config 2"}, // Corrected initialization
|
||||
{ID: 1, Name: "Config 1"}, // Corrected initialization
|
||||
{ID: 3, Name: "Config 3 (Not in Job Order)"}, // Corrected initialization
|
||||
}, nil
|
||||
}
|
||||
// Ensure the job returned by First has the expected ConfigIDs order
|
||||
comps.db.FirstFunc = func(dest interface{}, conds ...interface{}) *gorm.DB {
|
||||
if job, ok := dest.(*db.Job); ok {
|
||||
job.ID = testJobID
|
||||
job.Name = "Test Job Success"
|
||||
job.ConfigIDs = "1,2" // Explicit order
|
||||
enabled := true
|
||||
job.Enabled = &enabled
|
||||
return &gorm.DB{Error: nil}
|
||||
}
|
||||
return &gorm.DB{Error: gorm.ErrRecordNotFound}
|
||||
}
|
||||
|
||||
// Execute the job
|
||||
comps.executor.executeJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
// 1. DB calls
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.firstCalledWithDest == nil {
|
||||
t.Error("DB First was not called")
|
||||
}
|
||||
if comps.db.configsForJobID != testJobID {
|
||||
t.Errorf("GetConfigsForJob not called with correct jobID: got %d, want %d", comps.db.configsForJobID, testJobID)
|
||||
}
|
||||
if comps.db.updatedJobStatus == nil {
|
||||
t.Error("DB UpdateJobStatus was not called")
|
||||
} else if comps.db.updatedJobStatus.LastRun == nil {
|
||||
t.Error("LastRun time was not updated")
|
||||
} else if comps.db.updatedJobStatus.NextRun == nil {
|
||||
t.Error("NextRun time was not updated")
|
||||
}
|
||||
if comps.db.createdHistory == nil {
|
||||
t.Error("DB CreateJobHistory was not called")
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
// 2. Cron calls
|
||||
comps.cron.mu.Lock()
|
||||
if comps.cron.entryCalledWithID != testCronEntryID {
|
||||
t.Errorf("Cron Entry not called with correct entryID: got %d, want %d", comps.cron.entryCalledWithID, testCronEntryID)
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
// 3. Notifier calls (via processConfiguration -> transferExecutor)
|
||||
comps.notifier.mu.Lock()
|
||||
// Expect one call per configuration processed (1, 2, then 3)
|
||||
if len(comps.notifier.sendNotificationsCalls) != 3 { // Expect 3 calls now
|
||||
t.Errorf("Expected 3 calls to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
// 4. TransferExecutor calls
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) != 3 { // Expect 3 calls now
|
||||
t.Errorf("Expected 3 calls to executeConfigTransfer, got %d", len(comps.transfer.executeConfigTransferCalls))
|
||||
} else {
|
||||
// Check order (1, 2, then 3)
|
||||
call1 := comps.transfer.executeConfigTransferCalls[0]
|
||||
call2 := comps.transfer.executeConfigTransferCalls[1]
|
||||
call3 := comps.transfer.executeConfigTransferCalls[2]
|
||||
if cfg1, ok := call1["config"].(db.TransferConfig); !ok || cfg1.ID != 1 {
|
||||
t.Errorf("Expected first transfer call for config ID 1, got %+v", call1["config"])
|
||||
}
|
||||
if cfg2, ok := call2["config"].(db.TransferConfig); !ok || cfg2.ID != 2 {
|
||||
t.Errorf("Expected second transfer call for config ID 2, got %+v", call2["config"])
|
||||
}
|
||||
if cfg3, ok := call3["config"].(db.TransferConfig); !ok || cfg3.ID != 3 {
|
||||
t.Errorf("Expected third transfer call for config ID 3, got %+v", call3["config"])
|
||||
}
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
|
||||
// 5. Logs
|
||||
logOutput := comps.logBuf.String()
|
||||
// Check for specific log messages in order
|
||||
expectedLogs := []string{
|
||||
fmt.Sprintf("Starting execution of job %d", testJobID),
|
||||
"Processing job 1 with 3 configurations in specified order", // Uses total configs found
|
||||
"Execution order 1/3: Config ID 1", // Uses total configs found
|
||||
"Processing configuration 1 (1/3) for job 1", // Log from processConfiguration
|
||||
"Execution order 2/3: Config ID 2", // Uses total configs found
|
||||
"Processing configuration 2 (2/3) for job 1", // Log from processConfiguration
|
||||
"Execution order 3/3: Config ID 3", // Uses total configs found
|
||||
"Processing configuration 3 (3/3) for job 1", // Log from processConfiguration for extra config
|
||||
fmt.Sprintf("Next run time for job %d", testJobID),
|
||||
}
|
||||
for _, expectedLog := range expectedLogs {
|
||||
if !strings.Contains(logOutput, expectedLog) {
|
||||
t.Errorf("Expected log message containing %q not found in output:\n%s", expectedLog, logOutput)
|
||||
}
|
||||
}
|
||||
// Removed extra closing brace
|
||||
}
|
||||
|
||||
func TestExecuteJob_JobNotFound(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
testJobID := uint(5)
|
||||
|
||||
// Configure mocks
|
||||
comps.db.FirstFunc = func(dest interface{}, conds ...interface{}) *gorm.DB {
|
||||
return &gorm.DB{Error: gorm.ErrRecordNotFound} // Simulate job not found
|
||||
}
|
||||
|
||||
comps.executor.executeJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, fmt.Sprintf("Error loading job %d: record not found", testJobID)) {
|
||||
t.Errorf("Expected 'Error loading job' log message not found in output:\n%s", logOutput)
|
||||
}
|
||||
// Ensure other dependent functions were not called
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.configsForJobID != 0 {
|
||||
t.Error("GetConfigsForJob should not have been called")
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) > 0 {
|
||||
t.Error("executeConfigTransfer should not have been called")
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestExecuteJob_ConfigLoadError(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
testJobID := uint(6)
|
||||
dbErr := errors.New("db connection failed")
|
||||
|
||||
// Configure mocks
|
||||
comps.db.GetConfigsForJobFunc = func(jobID uint) ([]db.TransferConfig, error) {
|
||||
return nil, dbErr // Simulate error loading configs
|
||||
}
|
||||
|
||||
comps.executor.executeJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, fmt.Sprintf("Error loading configurations for job %d: %v", testJobID, dbErr)) {
|
||||
t.Errorf("Expected 'Error loading configurations' log message not found in output:\n%s", logOutput)
|
||||
}
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) > 0 {
|
||||
t.Error("executeConfigTransfer should not have been called")
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestExecuteJob_NoConfigs(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
testJobID := uint(7)
|
||||
|
||||
// Configure mocks
|
||||
comps.db.GetConfigsForJobFunc = func(jobID uint) ([]db.TransferConfig, error) {
|
||||
return []db.TransferConfig{}, nil // Simulate empty config list
|
||||
}
|
||||
|
||||
comps.executor.executeJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, fmt.Sprintf("Error: job %d has no associated configurations", testJobID)) {
|
||||
t.Errorf("Expected 'no associated configurations' log message not found in output:\n%s", logOutput)
|
||||
}
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) > 0 {
|
||||
t.Error("executeConfigTransfer should not have been called")
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestProcessConfiguration_Success(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
job := db.Job{ID: 1}
|
||||
config := db.TransferConfig{ID: 10, Name: "Process Test"} // Corrected initialization
|
||||
index := 1
|
||||
totalConfigs := 1
|
||||
|
||||
comps.executor.processConfiguration(&job, &config, index, totalConfigs)
|
||||
|
||||
// Assertions
|
||||
// 1. DB CreateJobHistory called
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.createdHistory == nil {
|
||||
t.Fatal("CreateJobHistory was not called")
|
||||
}
|
||||
if comps.db.createdHistory.JobID != job.ID {
|
||||
t.Errorf("CreateJobHistory called with wrong JobID: got %d, want %d", comps.db.createdHistory.JobID, job.ID)
|
||||
}
|
||||
if comps.db.createdHistory.ConfigID != config.ID {
|
||||
t.Errorf("CreateJobHistory called with wrong ConfigID: got %d, want %d", comps.db.createdHistory.ConfigID, config.ID)
|
||||
}
|
||||
if comps.db.createdHistory.Status != "running" {
|
||||
t.Errorf("CreateJobHistory called with wrong Status: got %q, want 'running'", comps.db.createdHistory.Status)
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
// 2. Notifier SendNotifications called
|
||||
comps.notifier.mu.Lock()
|
||||
if len(comps.notifier.sendNotificationsCalls) != 1 {
|
||||
t.Fatalf("Expected 1 call to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
|
||||
}
|
||||
callArgs := comps.notifier.sendNotificationsCalls[0]
|
||||
if !reflect.DeepEqual(callArgs["job"], &job) {
|
||||
t.Errorf("SendNotifications called with wrong job: got %+v, want %+v", callArgs["job"], &job)
|
||||
}
|
||||
// Compare history partially as StartTime is dynamic
|
||||
if histArg, ok := callArgs["history"].(*db.JobHistory); !ok || histArg.JobID != job.ID || histArg.ConfigID != config.ID || histArg.Status != "running" {
|
||||
t.Errorf("SendNotifications called with wrong history: got %+v", callArgs["history"])
|
||||
}
|
||||
if !reflect.DeepEqual(callArgs["config"], &config) {
|
||||
t.Errorf("SendNotifications called with wrong config: got %+v, want %+v", callArgs["config"], &config)
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
// 3. TransferExecutor executeConfigTransfer called
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) != 1 {
|
||||
t.Fatalf("Expected 1 call to executeConfigTransfer, got %d", len(comps.transfer.executeConfigTransferCalls))
|
||||
}
|
||||
transferCallArgs := comps.transfer.executeConfigTransferCalls[0]
|
||||
// Need to compare job/config by value as they are passed by value to transferExecutor
|
||||
if !reflect.DeepEqual(transferCallArgs["job"], job) {
|
||||
t.Errorf("executeConfigTransfer called with wrong job: got %+v, want %+v", transferCallArgs["job"], job)
|
||||
}
|
||||
if !reflect.DeepEqual(transferCallArgs["config"], config) {
|
||||
t.Errorf("executeConfigTransfer called with wrong config: got %+v, want %+v", transferCallArgs["config"], config)
|
||||
}
|
||||
// Compare history partially
|
||||
if histArg, ok := transferCallArgs["history"].(*db.JobHistory); !ok || histArg.JobID != job.ID || histArg.ConfigID != config.ID || histArg.Status != "running" {
|
||||
t.Errorf("executeConfigTransfer called with wrong history: got %+v", transferCallArgs["history"])
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestProcessConfiguration_HistoryError(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
job := db.Job{ID: 1}
|
||||
config := db.TransferConfig{ID: 10, Name: "History Error Test"} // Corrected initialization
|
||||
index := 1
|
||||
totalConfigs := 1
|
||||
dbErr := errors.New("failed to create history")
|
||||
|
||||
// Configure mock
|
||||
comps.db.CreateJobHistoryFunc = func(history *db.JobHistory) error {
|
||||
return dbErr
|
||||
}
|
||||
|
||||
comps.executor.processConfiguration(&job, &config, index, totalConfigs)
|
||||
|
||||
// Assertions
|
||||
// 1. Check log for error
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, fmt.Sprintf("Error creating job history for job %d, config %d: %v", job.ID, config.ID, dbErr)) {
|
||||
t.Errorf("Expected 'Error creating job history' log message not found in output:\n%s", logOutput)
|
||||
}
|
||||
|
||||
// 2. Ensure Notifier and TransferExecutor were NOT called
|
||||
comps.notifier.mu.Lock()
|
||||
if len(comps.notifier.sendNotificationsCalls) > 0 {
|
||||
t.Error("SendNotifications should not have been called after history error")
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) > 0 {
|
||||
t.Error("executeConfigTransfer should not have been called after history error")
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// LogLevel represents the verbosity level of logging
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
// LogLevelError only logs errors
|
||||
LogLevelError LogLevel = iota
|
||||
// LogLevelInfo logs info and errors
|
||||
LogLevelInfo
|
||||
// LogLevelDebug logs everything including debug messages
|
||||
LogLevelDebug
|
||||
)
|
||||
|
||||
// BroadcastFunc is a function type that can be used to broadcast logs
|
||||
type BroadcastFunc func(level, message, source string)
|
||||
|
||||
// String returns the string representation of a log level
|
||||
func (l LogLevel) String() string {
|
||||
switch l {
|
||||
case LogLevelError:
|
||||
return "error"
|
||||
case LogLevelInfo:
|
||||
return "info"
|
||||
case LogLevelDebug:
|
||||
return "debug"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLogLevel parses a string into a LogLevel
|
||||
func ParseLogLevel(level string) LogLevel {
|
||||
switch strings.ToLower(level) {
|
||||
case "error":
|
||||
return LogLevelError
|
||||
case "info":
|
||||
return LogLevelInfo
|
||||
case "debug":
|
||||
return LogLevelDebug
|
||||
default:
|
||||
return LogLevelInfo // Default to info level
|
||||
}
|
||||
}
|
||||
|
||||
// Logger handles log output to file and console
|
||||
type Logger struct {
|
||||
Info *log.Logger
|
||||
Error *log.Logger
|
||||
Debug *log.Logger
|
||||
file *lumberjack.Logger
|
||||
logLevel LogLevel
|
||||
useBroadcast bool
|
||||
broadcastFn BroadcastFunc
|
||||
}
|
||||
|
||||
// SetBroadcastFunc sets the function to use for broadcasting logs
|
||||
func (l *Logger) SetBroadcastFunc(fn BroadcastFunc) {
|
||||
l.broadcastFn = fn
|
||||
l.useBroadcast = fn != nil
|
||||
|
||||
// Log the setting of the broadcast function to help with troubleshooting
|
||||
if fn != nil {
|
||||
fmt.Fprintf(os.Stderr, "[SCHEDULER-LOGGER] Broadcast function set successfully, logs will be streamed to WebSocket clients\n")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[SCHEDULER-LOGGER] Broadcast function cleared or set to nil\n")
|
||||
}
|
||||
}
|
||||
|
||||
// LogInfo logs an info message if the log level allows it
|
||||
func (l *Logger) LogInfo(format string, v ...interface{}) {
|
||||
if l.logLevel >= LogLevelInfo {
|
||||
msg := fmt.Sprintf(format, v...)
|
||||
l.Info.Println(msg)
|
||||
|
||||
// If broadcasting is enabled, call the broadcast function
|
||||
if l.useBroadcast && l.broadcastFn != nil {
|
||||
// Add debug output
|
||||
fmt.Fprintf(os.Stderr, "[SCHEDULER-LOGGER-BROADCAST] INFO: %s\n", msg)
|
||||
l.broadcastFn("info", msg, "scheduler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LogError logs an error message if the log level allows it
|
||||
func (l *Logger) LogError(format string, v ...interface{}) {
|
||||
if l.logLevel >= LogLevelError {
|
||||
msg := fmt.Sprintf(format, v...)
|
||||
l.Error.Println(msg)
|
||||
|
||||
// If broadcasting is enabled, call the broadcast function
|
||||
if l.useBroadcast && l.broadcastFn != nil {
|
||||
// Add debug output
|
||||
fmt.Fprintf(os.Stderr, "[SCHEDULER-LOGGER-BROADCAST] ERROR: %s\n", msg)
|
||||
l.broadcastFn("error", msg, "scheduler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LogDebug logs a debug message if the log level allows it
|
||||
func (l *Logger) LogDebug(format string, v ...interface{}) {
|
||||
if l.logLevel >= LogLevelDebug {
|
||||
msg := fmt.Sprintf(format, v...)
|
||||
l.Debug.Println(msg)
|
||||
|
||||
// If broadcasting is enabled, call the broadcast function
|
||||
if l.useBroadcast && l.broadcastFn != nil {
|
||||
// Add debug output
|
||||
fmt.Fprintf(os.Stderr, "[SCHEDULER-LOGGER-BROADCAST] DEBUG: %s\n", msg)
|
||||
l.broadcastFn("debug", msg, "scheduler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewLogger creates a new logger that writes to both file and console
|
||||
func NewLogger() *Logger {
|
||||
// Get data directory from environment or use default
|
||||
dataDir := os.Getenv("DATA_DIR")
|
||||
if dataDir == "" {
|
||||
dataDir = "./data"
|
||||
}
|
||||
|
||||
// Ensure logs directory exists
|
||||
logsDir := filepath.Join(dataDir, "logs")
|
||||
if envLogsDir := os.Getenv("LOGS_DIR"); envLogsDir != "" {
|
||||
logsDir = envLogsDir
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
fmt.Printf("Error creating logs directory: %v\n", err)
|
||||
}
|
||||
|
||||
// Get log rotation settings from environment or use defaults
|
||||
maxSize := 10 // Default: 10MB
|
||||
if envSize := os.Getenv("LOG_MAX_SIZE"); envSize != "" {
|
||||
if size, err := strconv.Atoi(envSize); err == nil && size > 0 {
|
||||
maxSize = size
|
||||
}
|
||||
}
|
||||
|
||||
maxBackups := 5 // Default: keep 5 backups
|
||||
if envBackups := os.Getenv("LOG_MAX_BACKUPS"); envBackups != "" {
|
||||
if backups, err := strconv.Atoi(envBackups); err == nil && backups >= 0 {
|
||||
maxBackups = backups
|
||||
}
|
||||
}
|
||||
|
||||
maxAge := 30 // Default: 30 days
|
||||
if envAge := os.Getenv("LOG_MAX_AGE"); envAge != "" {
|
||||
if age, err := strconv.Atoi(envAge); err == nil && age >= 0 {
|
||||
maxAge = age
|
||||
}
|
||||
}
|
||||
|
||||
compress := true // Default: compress logs
|
||||
if envCompress := os.Getenv("LOG_COMPRESS"); envCompress == "false" {
|
||||
compress = false
|
||||
}
|
||||
|
||||
// Get log level from environment or use default
|
||||
logLevel := LogLevelInfo // Default to info level
|
||||
if envLogLevel := os.Getenv("LOG_LEVEL"); envLogLevel != "" {
|
||||
logLevel = ParseLogLevel(envLogLevel)
|
||||
}
|
||||
|
||||
// Check if we should enable WebSocket broadcasting
|
||||
useBroadcast := true
|
||||
if envBroadcast := os.Getenv("LOG_BROADCAST"); envBroadcast == "false" {
|
||||
useBroadcast = false
|
||||
}
|
||||
|
||||
// Setup log rotation
|
||||
logFile := &lumberjack.Logger{
|
||||
Filename: filepath.Join(logsDir, "scheduler.log"),
|
||||
MaxSize: maxSize,
|
||||
MaxBackups: maxBackups,
|
||||
MaxAge: maxAge,
|
||||
Compress: compress,
|
||||
}
|
||||
|
||||
// Create multi-writer for both file and console
|
||||
consoleAndFile := io.MultiWriter(os.Stdout, logFile)
|
||||
|
||||
// Create loggers with different prefixes
|
||||
logger := &Logger{
|
||||
Info: log.New(consoleAndFile, "INFO: ", log.Ldate|log.Ltime),
|
||||
Error: log.New(consoleAndFile, "ERROR: ", log.Ldate|log.Ltime),
|
||||
Debug: log.New(consoleAndFile, "DEBUG: ", log.Ldate|log.Ltime),
|
||||
file: logFile,
|
||||
logLevel: logLevel,
|
||||
useBroadcast: useBroadcast,
|
||||
broadcastFn: nil, // Will be set later
|
||||
}
|
||||
|
||||
// Log rotation settings and log level
|
||||
if logLevel >= LogLevelInfo {
|
||||
logger.Info.Printf("Log rotation configured: file=%s, maxSize=%dMB, maxBackups=%d, maxAge=%d days, compress=%v, logLevel=%s, useBroadcast=%v",
|
||||
filepath.Join(logsDir, "scheduler.log"), maxSize, maxBackups, maxAge, compress, logLevel.String(), useBroadcast)
|
||||
}
|
||||
|
||||
if logLevel >= LogLevelDebug {
|
||||
logger.Debug.Printf("Log rotation details: file=%s, maxSize=%dMB, maxBackups=%d, maxAge=%d days, compress=%v",
|
||||
filepath.Join(logsDir, "scheduler.log"), maxSize, maxBackups, maxAge, compress)
|
||||
}
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
// Close closes the log file
|
||||
func (l *Logger) Close() {
|
||||
if l.file != nil {
|
||||
l.file.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// RotateLogs manually triggers log rotation
|
||||
func (l *Logger) RotateLogs() error {
|
||||
if l.file != nil {
|
||||
return l.file.Rotate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// Helper function to create a logger with a buffer for testing output
|
||||
func newTestLogger(level LogLevel) (*Logger, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
// Use a discard lumberjack logger for testing purposes
|
||||
discardLumberjack := &lumberjack.Logger{
|
||||
Filename: filepath.Join(os.TempDir(), "test-discard.log"), // Write to temp dir
|
||||
MaxSize: 1,
|
||||
MaxBackups: 1,
|
||||
MaxAge: 1,
|
||||
Compress: false,
|
||||
}
|
||||
// Ensure the temp file can be cleaned up
|
||||
os.Remove(discardLumberjack.Filename)
|
||||
|
||||
logger := &Logger{
|
||||
Info: log.New(&buf, "INFO: ", 0), // No flags for simpler matching
|
||||
Error: log.New(&buf, "ERROR: ", 0),
|
||||
Debug: log.New(&buf, "DEBUG: ", 0),
|
||||
file: discardLumberjack, // Use discard logger
|
||||
logLevel: level,
|
||||
}
|
||||
return logger, &buf
|
||||
}
|
||||
|
||||
func TestLogLevelString(t *testing.T) {
|
||||
tests := []struct {
|
||||
level LogLevel
|
||||
want string
|
||||
}{
|
||||
{LogLevelError, "error"},
|
||||
{LogLevelInfo, "info"},
|
||||
{LogLevelDebug, "debug"},
|
||||
{LogLevel(99), "unknown"}, // Test unknown level
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := tt.level.String(); got != tt.want {
|
||||
t.Errorf("LogLevel(%d).String() = %q, want %q", tt.level, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
levelStr string
|
||||
want LogLevel
|
||||
}{
|
||||
{"error", LogLevelError},
|
||||
{"ERROR", LogLevelError},
|
||||
{"info", LogLevelInfo},
|
||||
{"INFO", LogLevelInfo},
|
||||
{"debug", LogLevelDebug},
|
||||
{"DEBUG", LogLevelDebug},
|
||||
{"", LogLevelInfo}, // Default
|
||||
{"unknown", LogLevelInfo}, // Default
|
||||
{"warn", LogLevelInfo}, // Default
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := ParseLogLevel(tt.levelStr); got != tt.want {
|
||||
t.Errorf("ParseLogLevel(%q) = %v, want %v", tt.levelStr, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerOutputLevels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level LogLevel
|
||||
logFunc func(l *Logger, format string, v ...interface{})
|
||||
wantPrefix string
|
||||
wantMessage string
|
||||
}{
|
||||
// LogError tests
|
||||
{"ErrorLevel_LogError", LogLevelError, (*Logger).LogError, "ERROR: ", "error message 1"},
|
||||
{"InfoLevel_LogError", LogLevelInfo, (*Logger).LogError, "ERROR: ", "error message 2"},
|
||||
{"DebugLevel_LogError", LogLevelDebug, (*Logger).LogError, "ERROR: ", "error message 3"},
|
||||
// LogInfo tests
|
||||
{"ErrorLevel_LogInfo", LogLevelError, (*Logger).LogInfo, "", ""}, // Should not log
|
||||
{"InfoLevel_LogInfo", LogLevelInfo, (*Logger).LogInfo, "INFO: ", "info message 1"},
|
||||
{"DebugLevel_LogInfo", LogLevelDebug, (*Logger).LogInfo, "INFO: ", "info message 2"},
|
||||
// LogDebug tests
|
||||
{"ErrorLevel_LogDebug", LogLevelError, (*Logger).LogDebug, "", ""}, // Should not log
|
||||
{"InfoLevel_LogDebug", LogLevelInfo, (*Logger).LogDebug, "", ""}, // Should not log
|
||||
{"DebugLevel_LogDebug", LogLevelDebug, (*Logger).LogDebug, "DEBUG: ", "debug message 1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger, buf := newTestLogger(tt.level)
|
||||
defer logger.Close() // Close the discard logger
|
||||
|
||||
message := tt.wantMessage // Use the message intended for the successful case
|
||||
if tt.wantPrefix == "" {
|
||||
message = "should not appear" // Use a different message if it shouldn't log
|
||||
}
|
||||
|
||||
tt.logFunc(logger, "%s %d", message, 42) // Add formatting args
|
||||
|
||||
got := buf.String()
|
||||
expectedOutput := ""
|
||||
if tt.wantPrefix != "" {
|
||||
expectedOutput = tt.wantPrefix + message + " 42\n" // Include formatting args in expected output
|
||||
}
|
||||
|
||||
if got != expectedOutput {
|
||||
t.Errorf("Log output = %q, want %q", got, expectedOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoggerInitialization(t *testing.T) {
|
||||
// Temporarily set env vars for testing initialization
|
||||
os.Setenv("DATA_DIR", "/tmp/test_gomft_data")
|
||||
os.Setenv("LOGS_DIR", "/tmp/test_gomft_data/logs")
|
||||
os.Setenv("LOG_LEVEL", "debug")
|
||||
os.Setenv("LOG_MAX_SIZE", "5")
|
||||
os.Setenv("LOG_MAX_BACKUPS", "2")
|
||||
os.Setenv("LOG_MAX_AGE", "7")
|
||||
os.Setenv("LOG_COMPRESS", "false")
|
||||
|
||||
defer func() {
|
||||
// Clean up env vars and created directories
|
||||
os.Unsetenv("DATA_DIR")
|
||||
os.Unsetenv("LOGS_DIR")
|
||||
os.Unsetenv("LOG_LEVEL")
|
||||
os.Unsetenv("LOG_MAX_SIZE")
|
||||
os.Unsetenv("LOG_MAX_BACKUPS")
|
||||
os.Unsetenv("LOG_MAX_AGE")
|
||||
os.Unsetenv("LOG_COMPRESS")
|
||||
os.RemoveAll("/tmp/test_gomft_data")
|
||||
}()
|
||||
|
||||
// Capture stdout to check initialization logs
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
logger := NewLogger()
|
||||
defer logger.Close()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout // Restore stdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
initOutput := buf.String()
|
||||
|
||||
// Check log level
|
||||
if logger.logLevel != LogLevelDebug {
|
||||
t.Errorf("Expected log level %v, got %v", LogLevelDebug, logger.logLevel)
|
||||
}
|
||||
|
||||
// Check lumberjack config
|
||||
if logger.file.MaxSize != 5 {
|
||||
t.Errorf("Expected MaxSize 5, got %d", logger.file.MaxSize)
|
||||
}
|
||||
if logger.file.MaxBackups != 2 {
|
||||
t.Errorf("Expected MaxBackups 2, got %d", logger.file.MaxBackups)
|
||||
}
|
||||
if logger.file.MaxAge != 7 {
|
||||
t.Errorf("Expected MaxAge 7, got %d", logger.file.MaxAge)
|
||||
}
|
||||
if logger.file.Compress != false {
|
||||
t.Errorf("Expected Compress false, got %v", logger.file.Compress)
|
||||
}
|
||||
expectedLogPath := filepath.Join("/tmp/test_gomft_data/logs", "scheduler.log")
|
||||
if logger.file.Filename != expectedLogPath {
|
||||
t.Errorf("Expected Filename %q, got %q", expectedLogPath, logger.file.Filename)
|
||||
}
|
||||
|
||||
// Check if logs directory was created
|
||||
if _, err := os.Stat("/tmp/test_gomft_data/logs"); os.IsNotExist(err) {
|
||||
t.Errorf("Expected logs directory %q to be created", "/tmp/test_gomft_data/logs")
|
||||
}
|
||||
|
||||
// Check initialization log messages
|
||||
if !strings.Contains(initOutput, "Log rotation configured:") {
|
||||
t.Errorf("Expected initialization log message 'Log rotation configured:', but not found in output:\n%s", initOutput)
|
||||
}
|
||||
if !strings.Contains(initOutput, "logLevel=debug") {
|
||||
t.Errorf("Expected 'logLevel=debug' in initialization log, but not found in output:\n%s", initOutput)
|
||||
}
|
||||
if !strings.Contains(initOutput, "Log rotation details:") {
|
||||
t.Errorf("Expected initialization log message 'Log rotation details:', but not found in output:\n%s", initOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Testing Close() and RotateLogs() directly would require more complex mocking
|
||||
// of the lumberjack.Logger or filesystem interactions. For now, we focus on the
|
||||
// Logger wrapper's core logic (level handling, formatting).
|
||||
@@ -0,0 +1,75 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MetadataDB defines the database methods needed by MetadataHandler.
|
||||
// This allows for easier mocking during testing.
|
||||
type MetadataDB interface {
|
||||
GetFileMetadataByHash(hash string) (*db.FileMetadata, error)
|
||||
GetFileMetadataByJobAndName(jobID uint, fileName string) (*db.FileMetadata, error)
|
||||
}
|
||||
|
||||
// MetadataHandler handles checking file processing history.
|
||||
type MetadataHandler struct {
|
||||
db MetadataDB // Use the interface type
|
||||
logger *Logger // Added logger dependency
|
||||
}
|
||||
|
||||
// NewMetadataHandler creates a new MetadataHandler.
|
||||
func NewMetadataHandler(database MetadataDB, logger *Logger) *MetadataHandler { // Accept the interface type
|
||||
return &MetadataHandler{
|
||||
db: database,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// hasFileBeenProcessed checks if a file with the same hash has been processed before.
|
||||
func (mh *MetadataHandler) hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error) {
|
||||
if fileHash == "" {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// First try to find by hash (most reliable)
|
||||
metadata, err := mh.db.GetFileMetadataByHash(fileHash) // Calls the interface method
|
||||
if err == nil && metadata != nil {
|
||||
// Optional: Add logging here if needed
|
||||
mh.logger.LogDebug("Found existing metadata by hash for job %d, hash %s", jobID, fileHash)
|
||||
return true, metadata, nil
|
||||
}
|
||||
// Handle DB errors
|
||||
if err != nil {
|
||||
// If the error is specifically "record not found", it means not processed, which is not an error for this function.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil, nil // Not found, no error to return
|
||||
}
|
||||
// For any other DB error, log it and return it.
|
||||
mh.logger.LogError("Error checking metadata by hash for job %d, hash %s: %v", jobID, fileHash, err)
|
||||
return false, nil, err // Return the actual DB error
|
||||
}
|
||||
|
||||
// Should not be reached if err is nil and metadata is nil, but return false just in case.
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// checkFileProcessingHistory checks processing history for a given file name within a specific job.
|
||||
func (mh *MetadataHandler) checkFileProcessingHistory(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
// Try to find by job and filename
|
||||
metadata, err := mh.db.GetFileMetadataByJobAndName(jobID, fileName) // Calls the interface method
|
||||
if err == nil && metadata != nil {
|
||||
mh.logger.LogDebug("Found existing metadata by name for job %d, file %s", jobID, fileName)
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
mh.logger.LogError("Error checking metadata by name for job %d, file %s: %v", jobID, fileName, err)
|
||||
// Don't return error here, just indicate not found
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no history found for file %s in job %d", fileName, jobID)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm" // Keep for gorm.ErrRecordNotFound
|
||||
)
|
||||
|
||||
// --- Mock DB Implementation ---
|
||||
|
||||
// Ensure mockMetadataDB implements the MetadataDB interface
|
||||
var _ MetadataDB = (*mockMetadataDB)(nil)
|
||||
|
||||
type mockMetadataDB struct {
|
||||
GetFileMetadataByHashFunc func(hash string) (*db.FileMetadata, error)
|
||||
GetFileMetadataByJobAndNameFunc func(jobID uint, fileName string) (*db.FileMetadata, error)
|
||||
}
|
||||
|
||||
// Implement the MetadataDB interface methods
|
||||
func (m *mockMetadataDB) GetFileMetadataByHash(hash string) (*db.FileMetadata, error) {
|
||||
if m.GetFileMetadataByHashFunc != nil {
|
||||
return m.GetFileMetadataByHashFunc(hash)
|
||||
}
|
||||
return nil, errors.New("mock GetFileMetadataByHashFunc not implemented")
|
||||
}
|
||||
|
||||
func (m *mockMetadataDB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
if m.GetFileMetadataByJobAndNameFunc != nil {
|
||||
return m.GetFileMetadataByJobAndNameFunc(jobID, fileName)
|
||||
}
|
||||
return nil, errors.New("mock GetFileMetadataByJobAndNameFunc not implemented")
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestHasFileBeenProcessed(t *testing.T) {
|
||||
testJobID := uint(1)
|
||||
testHash := "testhash123"
|
||||
testMetadata := &db.FileMetadata{ID: 1, JobID: testJobID, FileHash: testHash, Status: "processed"}
|
||||
dbErr := errors.New("database error")
|
||||
|
||||
logger, _ := newTestLogger(LogLevelDebug) // Use helper from logger_test
|
||||
defer logger.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fileHash string
|
||||
mockDBFunc func(hash string) (*db.FileMetadata, error)
|
||||
wantProcessed bool
|
||||
wantMetadata *db.FileMetadata
|
||||
wantErr error
|
||||
wantLogMessage string // Optional: check log output
|
||||
}{
|
||||
{
|
||||
name: "Empty hash",
|
||||
fileHash: "",
|
||||
mockDBFunc: nil, // Not called
|
||||
wantProcessed: false,
|
||||
wantMetadata: nil,
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "Hash found",
|
||||
fileHash: testHash,
|
||||
mockDBFunc: func(hash string) (*db.FileMetadata, error) {
|
||||
if hash == testHash {
|
||||
return testMetadata, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
wantProcessed: true,
|
||||
wantMetadata: testMetadata,
|
||||
wantErr: nil,
|
||||
wantLogMessage: "Found existing metadata by hash",
|
||||
},
|
||||
{
|
||||
name: "Hash not found",
|
||||
fileHash: testHash,
|
||||
mockDBFunc: func(hash string) (*db.FileMetadata, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
wantProcessed: false,
|
||||
wantMetadata: nil,
|
||||
wantErr: nil, // Not found is not an error for this function's return
|
||||
},
|
||||
{
|
||||
name: "DB error",
|
||||
fileHash: testHash,
|
||||
mockDBFunc: func(hash string) (*db.FileMetadata, error) {
|
||||
return nil, dbErr
|
||||
},
|
||||
wantProcessed: false,
|
||||
wantMetadata: nil,
|
||||
wantErr: dbErr, // The DB error should be returned
|
||||
wantLogMessage: "Error checking metadata by hash",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockDB := &mockMetadataDB{ // Instantiate the mock implementing the interface
|
||||
GetFileMetadataByHashFunc: tt.mockDBFunc,
|
||||
}
|
||||
// Recreate logger and buffer for each test run to isolate logs
|
||||
logger, logBuf := newTestLogger(LogLevelDebug)
|
||||
defer logger.Close()
|
||||
|
||||
// Pass the mockDB which now satisfies the MetadataDB interface
|
||||
handler := NewMetadataHandler(mockDB, logger)
|
||||
|
||||
processed, metadata, err := handler.hasFileBeenProcessed(testJobID, tt.fileHash)
|
||||
|
||||
if processed != tt.wantProcessed {
|
||||
t.Errorf("hasFileBeenProcessed() processed = %v, want %v", processed, tt.wantProcessed)
|
||||
}
|
||||
if metadata != tt.wantMetadata {
|
||||
t.Errorf("hasFileBeenProcessed() metadata = %v, want %v", metadata, tt.wantMetadata)
|
||||
}
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("hasFileBeenProcessed() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
logOutput := logBuf.String()
|
||||
if tt.wantLogMessage != "" && !strings.Contains(logOutput, tt.wantLogMessage) {
|
||||
t.Errorf("Expected log message containing %q, but got:\n%s", tt.wantLogMessage, logOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFileProcessingHistory(t *testing.T) {
|
||||
testJobID := uint(1)
|
||||
testFileName := "testfile.txt"
|
||||
testMetadata := &db.FileMetadata{ID: 2, JobID: testJobID, FileName: testFileName, Status: "processed"}
|
||||
dbErr := errors.New("database error")
|
||||
notFoundErr := fmt.Errorf("no history found for file %s in job %d", testFileName, testJobID)
|
||||
|
||||
logger, _ := newTestLogger(LogLevelDebug) // Use helper from logger_test
|
||||
defer logger.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
jobID uint
|
||||
fileName string
|
||||
mockDBFunc func(jobID uint, fileName string) (*db.FileMetadata, error)
|
||||
wantMetadata *db.FileMetadata
|
||||
wantErr error // Check for specific error type/message
|
||||
wantLogMessage string // Optional: check log output
|
||||
}{
|
||||
{
|
||||
name: "History found",
|
||||
jobID: testJobID,
|
||||
fileName: testFileName,
|
||||
mockDBFunc: func(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
if jobID == testJobID && fileName == testFileName {
|
||||
return testMetadata, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
wantMetadata: testMetadata,
|
||||
wantErr: nil,
|
||||
wantLogMessage: "Found existing metadata by name",
|
||||
},
|
||||
{
|
||||
name: "History not found",
|
||||
jobID: testJobID,
|
||||
fileName: testFileName,
|
||||
mockDBFunc: func(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
wantMetadata: nil,
|
||||
wantErr: notFoundErr, // Expect the specific "no history found" error
|
||||
},
|
||||
{
|
||||
name: "DB error",
|
||||
jobID: testJobID,
|
||||
fileName: testFileName,
|
||||
mockDBFunc: func(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
return nil, dbErr
|
||||
},
|
||||
wantMetadata: nil,
|
||||
wantErr: notFoundErr, // Even with DB error, it returns "no history found"
|
||||
wantLogMessage: "Error checking metadata by name",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockDB := &mockMetadataDB{ // Instantiate the mock implementing the interface
|
||||
GetFileMetadataByJobAndNameFunc: tt.mockDBFunc,
|
||||
}
|
||||
// Recreate logger and buffer for each test run
|
||||
logger, logBuf := newTestLogger(LogLevelDebug)
|
||||
defer logger.Close()
|
||||
|
||||
// Pass the mockDB which now satisfies the MetadataDB interface
|
||||
handler := NewMetadataHandler(mockDB, logger)
|
||||
|
||||
metadata, err := handler.checkFileProcessingHistory(tt.jobID, tt.fileName)
|
||||
|
||||
if metadata != tt.wantMetadata {
|
||||
t.Errorf("checkFileProcessingHistory() metadata = %v, want %v", metadata, tt.wantMetadata)
|
||||
}
|
||||
|
||||
// Check error message specifically for "not found" cases
|
||||
if tt.wantErr != nil {
|
||||
if err == nil {
|
||||
t.Errorf("checkFileProcessingHistory() error = nil, want error containing %q", tt.wantErr.Error())
|
||||
} else if !strings.Contains(err.Error(), tt.wantErr.Error()) {
|
||||
t.Errorf("checkFileProcessingHistory() error = %q, want error containing %q", err.Error(), tt.wantErr.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("checkFileProcessingHistory() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
logOutput := logBuf.String()
|
||||
if tt.wantLogMessage != "" && !strings.Contains(logOutput, tt.wantLogMessage) {
|
||||
t.Errorf("Expected log message containing %q, but got:\n%s", tt.wantLogMessage, logOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"crypto/hmac" // Added import
|
||||
"crypto/sha256" // Added import
|
||||
"encoding/hex" // Added import
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --- Mock DB Implementation ---
|
||||
|
||||
// Ensure mockNotificationDB implements the NotificationDB interface
|
||||
var _ NotificationDB = (*mockNotificationDB)(nil)
|
||||
|
||||
type mockNotificationDB struct {
|
||||
GetNotificationServicesFunc func(enabledOnly bool) ([]db.NotificationService, error)
|
||||
UpdateNotificationServiceFunc func(service *db.NotificationService) error
|
||||
GetJobFunc func(jobID uint) (*db.Job, error)
|
||||
CreateJobNotificationFunc func(userID uint, jobID uint, historyID uint, notificationType db.NotificationType, title string, message string) error
|
||||
CreateFunc func(value interface{}) *gorm.DB
|
||||
|
||||
// Mutex to protect concurrent access to mock data if needed
|
||||
mu sync.Mutex
|
||||
// Store data for verification if needed
|
||||
updatedServices []*db.NotificationService
|
||||
createdNotifications []map[string]interface{}
|
||||
createdHistory *db.JobHistory
|
||||
}
|
||||
|
||||
func (m *mockNotificationDB) GetNotificationServices(enabledOnly bool) ([]db.NotificationService, error) {
|
||||
if m.GetNotificationServicesFunc != nil {
|
||||
return m.GetNotificationServicesFunc(enabledOnly)
|
||||
}
|
||||
return nil, errors.New("mock GetNotificationServicesFunc not implemented")
|
||||
}
|
||||
|
||||
func (m *mockNotificationDB) UpdateNotificationService(service *db.NotificationService) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.updatedServices = append(m.updatedServices, service) // Store for verification
|
||||
if m.UpdateNotificationServiceFunc != nil {
|
||||
return m.UpdateNotificationServiceFunc(service)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
|
||||
func (m *mockNotificationDB) GetJob(jobID uint) (*db.Job, error) {
|
||||
if m.GetJobFunc != nil {
|
||||
return m.GetJobFunc(jobID)
|
||||
}
|
||||
// Default mock behavior: return a basic job
|
||||
return &db.Job{ID: jobID, Name: "Mock Job", CreatedBy: 1}, nil
|
||||
}
|
||||
|
||||
func (m *mockNotificationDB) CreateJobNotification(userID uint, jobID uint, historyID uint, notificationType db.NotificationType, title string, message string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.createdNotifications = append(m.createdNotifications, map[string]interface{}{
|
||||
"userID": userID, "jobID": jobID, "historyID": historyID, "type": notificationType, "title": title, "message": message,
|
||||
})
|
||||
if m.CreateJobNotificationFunc != nil {
|
||||
return m.CreateJobNotificationFunc(userID, jobID, historyID, notificationType, title, message)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
|
||||
func (m *mockNotificationDB) Create(value interface{}) *gorm.DB {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if hist, ok := value.(*db.JobHistory); ok {
|
||||
m.createdHistory = hist // Store for verification if needed
|
||||
}
|
||||
if m.CreateFunc != nil {
|
||||
return m.CreateFunc(value)
|
||||
}
|
||||
// Default mock behavior: return success with no error
|
||||
return &gorm.DB{Error: nil}
|
||||
}
|
||||
|
||||
// Helper to reset mock state between tests
|
||||
func (m *mockNotificationDB) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.updatedServices = nil
|
||||
m.createdNotifications = nil
|
||||
m.createdHistory = nil
|
||||
}
|
||||
|
||||
// --- Test Helpers ---
|
||||
|
||||
func createTestJob(id uint, webhookEnabled bool, webhookURL string, notifySuccess bool, notifyFailure bool) *db.Job {
|
||||
job := db.Job{
|
||||
// Removed gorm.Model nesting, set ID directly
|
||||
ID: id,
|
||||
Name: "Test Job",
|
||||
WebhookEnabled: &webhookEnabled,
|
||||
WebhookURL: webhookURL,
|
||||
NotifyOnSuccess: ¬ifySuccess,
|
||||
NotifyOnFailure: ¬ifyFailure,
|
||||
CreatedBy: 1, // Assume user ID 1
|
||||
}
|
||||
return &job
|
||||
}
|
||||
|
||||
func createTestHistory(id uint, jobID uint, status string, errMsg string) *db.JobHistory {
|
||||
now := time.Now()
|
||||
hist := db.JobHistory{
|
||||
ID: id,
|
||||
JobID: jobID,
|
||||
Status: status,
|
||||
StartTime: now.Add(-1 * time.Minute),
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
if status != "running" {
|
||||
endTime := now
|
||||
hist.EndTime = &endTime
|
||||
}
|
||||
return &hist
|
||||
}
|
||||
|
||||
func createTestConfig(id uint) *db.TransferConfig {
|
||||
return &db.TransferConfig{
|
||||
// Removed gorm.Model nesting, set ID directly
|
||||
ID: id,
|
||||
Name: "Test Config",
|
||||
SourceType: "local",
|
||||
SourcePath: "/tmp/source",
|
||||
DestinationType: "local",
|
||||
DestinationPath: "/tmp/dest",
|
||||
}
|
||||
}
|
||||
|
||||
func createTestNotificationService(id uint, name, svcType string, enabled bool, triggers []string, config map[string]string) db.NotificationService {
|
||||
return db.NotificationService{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Type: svcType,
|
||||
IsEnabled: enabled, // Corrected field name
|
||||
EventTriggers: triggers,
|
||||
Config: config,
|
||||
// Initialize other fields as needed for tests, e.g., RetryPolicy
|
||||
RetryPolicy: "none",
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestSendJobWebhookNotification(t *testing.T) {
|
||||
logger, logBuf := newTestLogger(LogLevelDebug)
|
||||
defer logger.Close()
|
||||
mockDB := &mockNotificationDB{} // Not used directly by this function, but Notifier needs it
|
||||
notifier := NewNotifier(mockDB, logger)
|
||||
|
||||
var receivedPayload map[string]interface{}
|
||||
var receivedHeaders http.Header
|
||||
var receivedSignature string
|
||||
|
||||
// Create a mock HTTP server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeaders = r.Header
|
||||
receivedSignature = r.Header.Get("X-Hub-Signature-256")
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(bodyBytes, &receivedPayload)
|
||||
w.WriteHeader(http.StatusOK) // Respond with success
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
job := createTestJob(1, true, server.URL, true, true)
|
||||
job.WebhookSecret = "test-secret" // Add secret for signature testing
|
||||
job.WebhookHeaders = `{"X-Custom-Header": "CustomValue"}` // Add custom headers
|
||||
history := createTestHistory(10, 1, "completed", "")
|
||||
config := createTestConfig(5)
|
||||
|
||||
notifier.sendJobWebhookNotification(job, history, config)
|
||||
|
||||
// Assertions
|
||||
if receivedPayload == nil {
|
||||
t.Fatal("Webhook server did not receive a payload")
|
||||
}
|
||||
if receivedPayload["job_id"].(float64) != float64(job.ID) {
|
||||
t.Errorf("Expected job_id %d, got %v", job.ID, receivedPayload["job_id"])
|
||||
}
|
||||
if receivedPayload["status"] != history.Status {
|
||||
t.Errorf("Expected status %q, got %q", history.Status, receivedPayload["status"])
|
||||
}
|
||||
if receivedHeaders.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got %q", receivedHeaders.Get("Content-Type"))
|
||||
}
|
||||
if receivedHeaders.Get("User-Agent") != "GoMFT-Webhook/1.0" {
|
||||
t.Errorf("Expected User-Agent 'GoMFT-Webhook/1.0', got %q", receivedHeaders.Get("User-Agent"))
|
||||
}
|
||||
if receivedHeaders.Get("X-Custom-Header") != "CustomValue" {
|
||||
t.Errorf("Expected X-Custom-Header 'CustomValue', got %q", receivedHeaders.Get("X-Custom-Header"))
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
// Re-marshal the *received* payload to ensure byte-for-byte match for signature calculation
|
||||
payloadBytes, err := json.Marshal(receivedPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to re-marshal received payload for signature check: %v", err)
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(job.WebhookSecret))
|
||||
mac.Write(payloadBytes)
|
||||
expectedSignature := hex.EncodeToString(mac.Sum(nil)) // Use imported hex
|
||||
|
||||
if receivedSignature == "" {
|
||||
t.Error("Expected X-Hub-Signature-256 header, but it was missing")
|
||||
} else if receivedSignature != expectedSignature {
|
||||
t.Errorf("Signature mismatch: got %q, want %q. Payload received: %s", receivedSignature, expectedSignature, string(payloadBytes))
|
||||
}
|
||||
|
||||
// Check logs
|
||||
logOutput := logBuf.String()
|
||||
if !strings.Contains(logOutput, "Webhook notification for job 1 sent successfully") {
|
||||
t.Errorf("Expected success log message, but got:\n%s", logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGlobalNotifications_Webhook(t *testing.T) {
|
||||
logger, _ := newTestLogger(LogLevelDebug)
|
||||
defer logger.Close()
|
||||
|
||||
var receivedPayload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(bodyBytes, &receivedPayload)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
mockDB := &mockNotificationDB{}
|
||||
mockDB.GetNotificationServicesFunc = func(enabledOnly bool) ([]db.NotificationService, error) {
|
||||
allServices := []db.NotificationService{
|
||||
createTestNotificationService(1, "Test Webhook", "webhook", true, []string{"job_complete"}, map[string]string{"webhook_url": server.URL}),
|
||||
createTestNotificationService(2, "Disabled Webhook", "webhook", false, []string{"job_complete"}, map[string]string{"webhook_url": "http://disabled.invalid"}),
|
||||
createTestNotificationService(3, "Wrong Trigger", "webhook", true, []string{"job_error"}, map[string]string{"webhook_url": "http://wrongtrigger.invalid"}),
|
||||
}
|
||||
if enabledOnly {
|
||||
var enabledServices []db.NotificationService
|
||||
for _, s := range allServices {
|
||||
if s.IsEnabled { // Use IsEnabled field
|
||||
enabledServices = append(enabledServices, s)
|
||||
}
|
||||
}
|
||||
t.Logf("Mock GetNotificationServices(true) returning %d services", len(enabledServices)) // Add log
|
||||
return enabledServices, nil
|
||||
}
|
||||
t.Logf("Mock GetNotificationServices(false) returning %d services", len(allServices)) // Add log
|
||||
return allServices, nil
|
||||
}
|
||||
mockDB.UpdateNotificationServiceFunc = func(service *db.NotificationService) error {
|
||||
// Add debug logging
|
||||
t.Logf("UpdateNotificationServiceFunc called with service ID: %d, Name: %s, SuccessCount: %d", service.ID, service.Name, service.SuccessCount)
|
||||
if service.ID != 1 {
|
||||
t.Errorf("Expected UpdateNotificationService for ID 1, got %d", service.ID)
|
||||
}
|
||||
if service.SuccessCount != 1 {
|
||||
t.Errorf("Expected SuccessCount 1, got %d", service.SuccessCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
notifier := NewNotifier(mockDB, logger)
|
||||
|
||||
job := createTestJob(10, false, "", false, false) // Job-specific webhook disabled
|
||||
history := createTestHistory(100, 10, "completed", "")
|
||||
config := createTestConfig(50)
|
||||
|
||||
notifier.sendGlobalNotifications(job, history, config)
|
||||
|
||||
// Assertions
|
||||
if receivedPayload == nil {
|
||||
t.Fatal("Webhook server did not receive a payload from global notification")
|
||||
}
|
||||
if jobData, ok := receivedPayload["job"].(map[string]interface{}); ok {
|
||||
if jobData["id"].(float64) != float64(job.ID) {
|
||||
t.Errorf("Expected job.id %d, got %v", job.ID, jobData["id"])
|
||||
}
|
||||
if jobData["status"] != history.Status {
|
||||
t.Errorf("Expected job.status %q, got %q", history.Status, jobData["status"])
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Payload missing 'job' field or not a map")
|
||||
}
|
||||
|
||||
// Verify DB update was called correctly
|
||||
mockDB.mu.Lock()
|
||||
if len(mockDB.updatedServices) != 1 || mockDB.updatedServices[0].ID != 1 {
|
||||
t.Errorf("Expected 1 call to UpdateNotificationService for service ID 1, got %d calls", len(mockDB.updatedServices))
|
||||
}
|
||||
mockDB.mu.Unlock()
|
||||
}
|
||||
|
||||
// TODO: Add tests for other notification service types (email, pushbullet, ntfy, gotify, pushover)
|
||||
// TODO: Add tests for SendNotifications (combining job-specific and global)
|
||||
// TODO: Add tests for template variable replacement (replaceVariables, generateCustomPayload)
|
||||
// TODO: Add tests for createJobNotification and updateJobStatus (if kept)
|
||||
+122
-2502
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect" // Added import
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// --- Mock Implementations ---
|
||||
|
||||
// Mock SchedulerDB
|
||||
var _ SchedulerDB = (*mockSchedulerDB)(nil)
|
||||
|
||||
type mockSchedulerDB struct {
|
||||
mu sync.Mutex
|
||||
GetActiveJobsFunc func() ([]db.Job, error)
|
||||
UpdateJobStatusFunc func(job *db.Job) error
|
||||
|
||||
// Store calls/data
|
||||
getActiveJobsCalls int
|
||||
updatedJobStatus *db.Job
|
||||
}
|
||||
|
||||
func (m *mockSchedulerDB) GetActiveJobs() ([]db.Job, error) {
|
||||
m.mu.Lock()
|
||||
m.getActiveJobsCalls++
|
||||
m.mu.Unlock()
|
||||
if m.GetActiveJobsFunc != nil {
|
||||
return m.GetActiveJobsFunc()
|
||||
}
|
||||
// Default: return an empty list
|
||||
return []db.Job{}, nil
|
||||
}
|
||||
func (m *mockSchedulerDB) UpdateJobStatus(job *db.Job) error {
|
||||
m.mu.Lock()
|
||||
m.updatedJobStatus = job
|
||||
m.mu.Unlock()
|
||||
if m.UpdateJobStatusFunc != nil {
|
||||
return m.UpdateJobStatusFunc(job)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
func (m *mockSchedulerDB) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.getActiveJobsCalls = 0
|
||||
m.updatedJobStatus = nil
|
||||
}
|
||||
|
||||
// Mock SchedulerCron
|
||||
var _ SchedulerCron = (*mockSchedulerCron)(nil)
|
||||
|
||||
type mockSchedulerCron struct {
|
||||
mu sync.Mutex
|
||||
addFuncMock func(spec string, cmd func()) (cron.EntryID, error) // Renamed field
|
||||
removeFuncMock func(id cron.EntryID) // Renamed field
|
||||
entryFuncMock func(id cron.EntryID) cron.Entry // Renamed field
|
||||
stopFuncMock func() context.Context // Renamed field
|
||||
|
||||
// Store calls/data
|
||||
addedJobs map[string]func() // spec -> cmd
|
||||
removedIDs []cron.EntryID
|
||||
entryCalled cron.EntryID
|
||||
stopCalled bool
|
||||
}
|
||||
|
||||
func (m *mockSchedulerCron) AddFunc(spec string, cmd func()) (cron.EntryID, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.addedJobs == nil {
|
||||
m.addedJobs = make(map[string]func())
|
||||
}
|
||||
m.addedJobs[spec] = cmd
|
||||
// Use reflect to check if the mock function is set, avoiding the warning
|
||||
if reflect.ValueOf(m.addFuncMock).IsValid() && !reflect.ValueOf(m.addFuncMock).IsNil() {
|
||||
return m.addFuncMock(spec, cmd)
|
||||
}
|
||||
// Default: return a mock ID
|
||||
return cron.EntryID(len(m.addedJobs)), nil
|
||||
}
|
||||
func (m *mockSchedulerCron) Remove(id cron.EntryID) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.removedIDs = append(m.removedIDs, id)
|
||||
// Removed unused loop for spec, addedCmd
|
||||
if m.removeFuncMock != nil { // Use renamed field
|
||||
m.removeFuncMock(id)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerCron) Entry(id cron.EntryID) cron.Entry {
|
||||
m.mu.Lock()
|
||||
m.entryCalled = id
|
||||
m.mu.Unlock()
|
||||
if m.entryFuncMock != nil { // Use renamed field
|
||||
return m.entryFuncMock(id)
|
||||
}
|
||||
// Default: return entry with future time
|
||||
return cron.Entry{ID: id, Next: time.Now().Add(time.Hour)}
|
||||
}
|
||||
func (m *mockSchedulerCron) Stop() context.Context {
|
||||
m.mu.Lock()
|
||||
m.stopCalled = true
|
||||
m.mu.Unlock()
|
||||
if m.stopFuncMock != nil { // Use renamed field
|
||||
return m.stopFuncMock()
|
||||
}
|
||||
return context.Background() // Default context
|
||||
}
|
||||
func (m *mockSchedulerCron) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.addedJobs = nil
|
||||
m.removedIDs = nil
|
||||
m.entryCalled = 0
|
||||
m.stopCalled = false
|
||||
}
|
||||
|
||||
// Mock SchedulerLogger
|
||||
var _ SchedulerLogger = (*mockSchedulerLogger)(nil)
|
||||
|
||||
type mockSchedulerLogger struct {
|
||||
mu sync.Mutex
|
||||
LogInfoFunc func(format string, v ...interface{})
|
||||
LogErrorFunc func(format string, v ...interface{})
|
||||
LogDebugFunc func(format string, v ...interface{})
|
||||
CloseFunc func()
|
||||
RotateLogsFunc func() error
|
||||
// PrintlnFunc removed
|
||||
|
||||
// Store calls/data
|
||||
infoLogs []string
|
||||
errorLogs []string
|
||||
debugLogs []string
|
||||
closeCalled bool
|
||||
rotateCalled bool
|
||||
// printlnLogs removed
|
||||
}
|
||||
|
||||
func (m *mockSchedulerLogger) LogInfo(format string, v ...interface{}) {
|
||||
m.mu.Lock()
|
||||
m.infoLogs = append(m.infoLogs, fmt.Sprintf(format, v...))
|
||||
m.mu.Unlock()
|
||||
if m.LogInfoFunc != nil {
|
||||
m.LogInfoFunc(format, v...)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerLogger) LogError(format string, v ...interface{}) {
|
||||
m.mu.Lock()
|
||||
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, v...))
|
||||
m.mu.Unlock()
|
||||
if m.LogErrorFunc != nil {
|
||||
m.LogErrorFunc(format, v...)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerLogger) LogDebug(format string, v ...interface{}) {
|
||||
m.mu.Lock()
|
||||
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, v...))
|
||||
m.mu.Unlock()
|
||||
if m.LogDebugFunc != nil {
|
||||
m.LogDebugFunc(format, v...)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerLogger) Close() {
|
||||
m.mu.Lock()
|
||||
m.closeCalled = true
|
||||
m.mu.Unlock()
|
||||
if m.CloseFunc != nil {
|
||||
m.CloseFunc()
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerLogger) RotateLogs() error {
|
||||
m.mu.Lock()
|
||||
m.rotateCalled = true
|
||||
m.mu.Unlock()
|
||||
if m.RotateLogsFunc != nil {
|
||||
return m.RotateLogsFunc()
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
|
||||
// Println method removed
|
||||
func (m *mockSchedulerLogger) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.infoLogs = nil
|
||||
m.errorLogs = nil
|
||||
m.debugLogs = nil
|
||||
m.closeCalled = false
|
||||
m.rotateCalled = false
|
||||
// printlnLogs removed from Reset
|
||||
}
|
||||
|
||||
// Mock SchedulerJobExecutor
|
||||
var _ SchedulerJobExecutor = (*mockSchedulerJobExecutor)(nil)
|
||||
|
||||
type mockSchedulerJobExecutor struct {
|
||||
mu sync.Mutex
|
||||
ExecuteJobFunc func(jobID uint)
|
||||
|
||||
// Store calls
|
||||
executeJobCalls []uint
|
||||
}
|
||||
|
||||
func (m *mockSchedulerJobExecutor) executeJob(jobID uint) {
|
||||
m.mu.Lock()
|
||||
m.executeJobCalls = append(m.executeJobCalls, jobID)
|
||||
m.mu.Unlock()
|
||||
if m.ExecuteJobFunc != nil {
|
||||
m.ExecuteJobFunc(jobID)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerJobExecutor) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.executeJobCalls = nil
|
||||
}
|
||||
|
||||
// --- Test Setup ---
|
||||
|
||||
type testSchedulerComponents struct {
|
||||
db *mockSchedulerDB
|
||||
cron *mockSchedulerCron
|
||||
logger *mockSchedulerLogger
|
||||
executor *mockSchedulerJobExecutor
|
||||
jobsMap map[uint]cron.EntryID
|
||||
jobMutex *sync.Mutex
|
||||
scheduler *Scheduler
|
||||
}
|
||||
|
||||
func setupTestScheduler() testSchedulerComponents {
|
||||
dbMock := &mockSchedulerDB{}
|
||||
cronMock := &mockSchedulerCron{}
|
||||
loggerMock := &mockSchedulerLogger{}
|
||||
executorMock := &mockSchedulerJobExecutor{}
|
||||
jobsMap := make(map[uint]cron.EntryID)
|
||||
var jobMutex sync.Mutex
|
||||
|
||||
// Create scheduler with mocks
|
||||
scheduler := New(
|
||||
dbMock,
|
||||
cronMock,
|
||||
loggerMock,
|
||||
executorMock,
|
||||
jobsMap,
|
||||
&jobMutex,
|
||||
)
|
||||
|
||||
return testSchedulerComponents{
|
||||
db: dbMock,
|
||||
cron: cronMock,
|
||||
logger: loggerMock,
|
||||
executor: executorMock,
|
||||
jobsMap: jobsMap,
|
||||
jobMutex: &jobMutex,
|
||||
scheduler: scheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestNewScheduler_LoadJobs(t *testing.T) {
|
||||
dbMock := &mockSchedulerDB{}
|
||||
cronMock := &mockSchedulerCron{}
|
||||
loggerMock := &mockSchedulerLogger{}
|
||||
executorMock := &mockSchedulerJobExecutor{}
|
||||
jobsMap := make(map[uint]cron.EntryID)
|
||||
var jobMutex sync.Mutex
|
||||
|
||||
// Configure DB mock to return jobs
|
||||
enabled := true
|
||||
disabled := false
|
||||
dbMock.GetActiveJobsFunc = func() ([]db.Job, error) {
|
||||
return []db.Job{
|
||||
{ID: 1, Name: "Job 1", Schedule: "* * * * *", Enabled: &enabled},
|
||||
{ID: 2, Name: "Job 2", Schedule: "0 * * * *", Enabled: &enabled},
|
||||
{ID: 3, Name: "Job 3", Schedule: "*/5 * * * *", Enabled: &disabled}, // Disabled job
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create scheduler - this calls loadJobs internally
|
||||
_ = New(dbMock, cronMock, loggerMock, executorMock, jobsMap, &jobMutex)
|
||||
|
||||
// Assertions
|
||||
// 1. DB GetActiveJobs called once
|
||||
dbMock.mu.Lock()
|
||||
if dbMock.getActiveJobsCalls != 1 {
|
||||
t.Errorf("Expected GetActiveJobs to be called once, got %d", dbMock.getActiveJobsCalls)
|
||||
}
|
||||
dbMock.mu.Unlock()
|
||||
|
||||
// 2. Cron AddFunc called twice (for enabled jobs)
|
||||
cronMock.mu.Lock()
|
||||
if len(cronMock.addedJobs) != 2 {
|
||||
t.Errorf("Expected 2 jobs to be added to cron, got %d", len(cronMock.addedJobs))
|
||||
}
|
||||
// Job 1 schedule is 5 fields, should be converted to 6 by ScheduleJob logic.
|
||||
if _, ok := cronMock.addedJobs["0 * * * * *"]; !ok { // Check for 6-field version
|
||||
t.Errorf("Expected Job 1 schedule '0 * * * * *' to be added, got map: %v", cronMock.addedJobs)
|
||||
}
|
||||
// Job 2 schedule is 5 fields, should be converted to 6 by ScheduleJob logic.
|
||||
if _, ok := cronMock.addedJobs["0 0 * * * *"]; !ok { // Check for 6-field version
|
||||
t.Errorf("Expected Job 2 schedule '0 0 * * * *' to be added, got map: %v", cronMock.addedJobs)
|
||||
}
|
||||
cronMock.mu.Unlock()
|
||||
|
||||
// 3. Check logs
|
||||
loggerMock.mu.Lock()
|
||||
foundLoadLog := false
|
||||
foundDisabledLog := false
|
||||
foundLoadedCountLog := false
|
||||
for _, log := range loggerMock.infoLogs {
|
||||
if strings.Contains(log, "Loading scheduled jobs") {
|
||||
foundLoadLog = true
|
||||
}
|
||||
if strings.Contains(log, "Job 3 (Job 3) is disabled") {
|
||||
foundDisabledLog = true
|
||||
}
|
||||
if strings.Contains(log, "Loaded 2 jobs") {
|
||||
foundLoadedCountLog = true
|
||||
}
|
||||
}
|
||||
if !foundLoadLog {
|
||||
t.Error("Expected 'Loading scheduled jobs' log")
|
||||
}
|
||||
if !foundDisabledLog {
|
||||
t.Error("Expected 'Job 3 ... disabled' log")
|
||||
}
|
||||
if !foundLoadedCountLog {
|
||||
t.Error("Expected 'Loaded 2 jobs' log")
|
||||
}
|
||||
loggerMock.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestScheduleJob_Success(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
enabled := true
|
||||
job := db.Job{ID: 5, Name: "Test Sched", Schedule: "10 * * * *", Enabled: &enabled}
|
||||
|
||||
err := comps.scheduler.ScheduleJob(&job)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ScheduleJob failed: %v", err)
|
||||
}
|
||||
|
||||
// Assertions
|
||||
// 1. Cron AddFunc called
|
||||
comps.cron.mu.Lock()
|
||||
if len(comps.cron.addedJobs) != 1 {
|
||||
t.Fatalf("Expected 1 job added to cron, got %d", len(comps.cron.addedJobs))
|
||||
}
|
||||
// Job schedule is 5 fields, should be converted to 6 by ScheduleJob logic.
|
||||
if _, ok := comps.cron.addedJobs["0 10 * * * *"]; !ok { // Check for 6-field version
|
||||
t.Errorf("Expected schedule '0 10 * * * *' to be added, got map: %v", comps.cron.addedJobs)
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
// 2. Job map updated
|
||||
comps.jobMutex.Lock()
|
||||
if _, ok := comps.jobsMap[job.ID]; !ok {
|
||||
t.Errorf("Job ID %d not found in scheduler jobs map", job.ID)
|
||||
}
|
||||
comps.jobMutex.Unlock()
|
||||
|
||||
// 3. DB UpdateJobStatus called with NextRun set
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.updatedJobStatus == nil {
|
||||
t.Error("UpdateJobStatus was not called")
|
||||
} else if comps.db.updatedJobStatus.ID != job.ID {
|
||||
t.Errorf("UpdateJobStatus called with wrong job ID: got %d, want %d", comps.db.updatedJobStatus.ID, job.ID)
|
||||
} else if comps.db.updatedJobStatus.NextRun == nil {
|
||||
t.Error("UpdateJobStatus called but NextRun was not set")
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestScheduleJob_Disabled(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
disabled := false
|
||||
job := db.Job{ID: 6, Name: "Disabled Sched", Schedule: "* * * * *", Enabled: &disabled}
|
||||
|
||||
err := comps.scheduler.ScheduleJob(&job)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ScheduleJob failed for disabled job: %v", err)
|
||||
}
|
||||
|
||||
// Assertions
|
||||
comps.cron.mu.Lock()
|
||||
if len(comps.cron.addedJobs) != 0 {
|
||||
t.Errorf("Expected 0 jobs added to cron for disabled job, got %d", len(comps.cron.addedJobs))
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
comps.jobMutex.Lock()
|
||||
if _, ok := comps.jobsMap[job.ID]; ok {
|
||||
t.Errorf("Disabled Job ID %d should not be in scheduler jobs map", job.ID)
|
||||
}
|
||||
comps.jobMutex.Unlock()
|
||||
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.updatedJobStatus != nil {
|
||||
t.Error("UpdateJobStatus should not be called for disabled job")
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
comps.logger.mu.Lock()
|
||||
foundDisabledLog := false
|
||||
for _, log := range comps.logger.infoLogs {
|
||||
if strings.Contains(log, fmt.Sprintf("Job %d is disabled, skipping scheduling", job.ID)) {
|
||||
foundDisabledLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundDisabledLog {
|
||||
t.Error("Expected 'disabled, skipping' log message")
|
||||
}
|
||||
comps.logger.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestScheduleJob_InvalidCron(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
enabled := true
|
||||
job := db.Job{ID: 7, Name: "Invalid Sched", Schedule: "invalid cron string", Enabled: &enabled}
|
||||
|
||||
err := comps.scheduler.ScheduleJob(&job)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("ScheduleJob succeeded with invalid cron, expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid cron expression") {
|
||||
t.Errorf("Expected error containing 'invalid cron expression', got: %v", err)
|
||||
}
|
||||
|
||||
// Assertions
|
||||
comps.cron.mu.Lock()
|
||||
if len(comps.cron.addedJobs) != 0 {
|
||||
t.Errorf("Expected 0 jobs added to cron for invalid schedule, got %d", len(comps.cron.addedJobs))
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestUnscheduleJob(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
testJobID := uint(8)
|
||||
testEntryID := cron.EntryID(88)
|
||||
|
||||
// Pre-populate the map
|
||||
comps.jobsMap[testJobID] = testEntryID
|
||||
|
||||
comps.scheduler.UnscheduleJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
// 1. Cron Remove called
|
||||
comps.cron.mu.Lock()
|
||||
foundRemoved := false
|
||||
for _, id := range comps.cron.removedIDs {
|
||||
if id == testEntryID {
|
||||
foundRemoved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRemoved {
|
||||
t.Errorf("Expected cron Remove to be called with EntryID %d", testEntryID)
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
// 2. Job removed from map
|
||||
comps.jobMutex.Lock()
|
||||
if _, ok := comps.jobsMap[testJobID]; ok {
|
||||
t.Errorf("Job ID %d should have been removed from scheduler jobs map", testJobID)
|
||||
}
|
||||
comps.jobMutex.Unlock()
|
||||
}
|
||||
|
||||
func TestStop(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
comps.scheduler.Stop()
|
||||
|
||||
// Assertions
|
||||
comps.cron.mu.Lock()
|
||||
if !comps.cron.stopCalled {
|
||||
t.Error("Expected cron Stop to be called")
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
comps.logger.mu.Lock()
|
||||
if !comps.logger.closeCalled {
|
||||
t.Error("Expected logger Close to be called")
|
||||
}
|
||||
comps.logger.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestRunJobNow(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
testJobID := uint(9)
|
||||
|
||||
err := comps.scheduler.RunJobNow(testJobID)
|
||||
if err != nil {
|
||||
t.Fatalf("RunJobNow failed: %v", err)
|
||||
}
|
||||
|
||||
// Allow time for goroutine to potentially start
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Assertions
|
||||
comps.executor.mu.Lock()
|
||||
foundCall := false
|
||||
for _, id := range comps.executor.executeJobCalls {
|
||||
if id == testJobID {
|
||||
foundCall = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundCall {
|
||||
t.Errorf("Expected executor executeJob to be called with JobID %d", testJobID)
|
||||
}
|
||||
comps.executor.mu.Unlock()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os" // Added import
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
// Removed unused: encoding/json, path/filepath, reflect, time, gorm.io/gorm
|
||||
)
|
||||
|
||||
// --- Mock Implementations ---
|
||||
|
||||
// Mock TransferDB
|
||||
var _ TransferDB = (*mockTransferDB)(nil)
|
||||
|
||||
type mockTransferDB struct {
|
||||
mu sync.Mutex
|
||||
GetConfigRclonePathFunc func(config *db.TransferConfig) string
|
||||
GetRcloneCommandFunc func(id uint) (*db.RcloneCommand, error)
|
||||
UpdateJobHistoryFunc func(history *db.JobHistory) error
|
||||
CreateFileMetadataFunc func(metadata *db.FileMetadata) error
|
||||
GetRcloneCommandFlagsMapFunc func(commandID uint) (map[uint]db.RcloneCommandFlag, error)
|
||||
|
||||
// Store calls/data for verification
|
||||
updatedHistory *db.JobHistory
|
||||
createdMetadata []*db.FileMetadata
|
||||
rcloneConfigPath string
|
||||
}
|
||||
|
||||
func (m *mockTransferDB) GetConfigRclonePath(config *db.TransferConfig) string {
|
||||
if m.GetConfigRclonePathFunc != nil {
|
||||
return m.GetConfigRclonePathFunc(config)
|
||||
}
|
||||
m.rcloneConfigPath = "/tmp/mock_rclone.conf" // Default mock path
|
||||
return m.rcloneConfigPath
|
||||
}
|
||||
func (m *mockTransferDB) GetRcloneCommand(id uint) (*db.RcloneCommand, error) {
|
||||
if m.GetRcloneCommandFunc != nil {
|
||||
return m.GetRcloneCommandFunc(id)
|
||||
}
|
||||
// Default: return a basic command if ID > 0
|
||||
if id > 0 {
|
||||
return &db.RcloneCommand{ID: id, Name: "copy"}, nil
|
||||
}
|
||||
return nil, errors.New("mock GetRcloneCommand not found")
|
||||
}
|
||||
func (m *mockTransferDB) UpdateJobHistory(history *db.JobHistory) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.updatedHistory = history // Store last updated history
|
||||
if m.UpdateJobHistoryFunc != nil {
|
||||
return m.UpdateJobHistoryFunc(history)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
func (m *mockTransferDB) CreateFileMetadata(metadata *db.FileMetadata) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.createdMetadata = append(m.createdMetadata, metadata) // Store created metadata
|
||||
if m.CreateFileMetadataFunc != nil {
|
||||
return m.CreateFileMetadataFunc(metadata)
|
||||
}
|
||||
metadata.ID = uint(len(m.createdMetadata)) // Assign a mock ID
|
||||
return nil // Default success
|
||||
}
|
||||
func (m *mockTransferDB) GetRcloneCommandFlagsMap(commandID uint) (map[uint]db.RcloneCommandFlag, error) {
|
||||
if m.GetRcloneCommandFlagsMapFunc != nil {
|
||||
return m.GetRcloneCommandFlagsMapFunc(commandID)
|
||||
}
|
||||
// Corrected type name
|
||||
return make(map[uint]db.RcloneCommandFlag), nil // Default empty map
|
||||
}
|
||||
func (m *mockTransferDB) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.updatedHistory = nil
|
||||
m.createdMetadata = nil
|
||||
m.rcloneConfigPath = ""
|
||||
}
|
||||
|
||||
// Mock TransferNotifier
|
||||
var _ TransferNotifier = (*mockTransferNotifier)(nil)
|
||||
|
||||
type mockTransferNotifier struct {
|
||||
mu sync.Mutex
|
||||
SendNotificationsFunc func(job *db.Job, history *db.JobHistory, config *db.TransferConfig)
|
||||
CreateJobNotificationFunc func(job *db.Job, history *db.JobHistory) error
|
||||
|
||||
// Store calls/data for verification
|
||||
sendNotificationsCalls []map[string]interface{}
|
||||
createNotificationCalls []map[string]interface{}
|
||||
}
|
||||
|
||||
func (m *mockTransferNotifier) SendNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sendNotificationsCalls = append(m.sendNotificationsCalls, map[string]interface{}{
|
||||
"job": job, "history": history, "config": config,
|
||||
})
|
||||
if m.SendNotificationsFunc != nil {
|
||||
m.SendNotificationsFunc(job, history, config)
|
||||
}
|
||||
}
|
||||
func (m *mockTransferNotifier) createJobNotification(job *db.Job, history *db.JobHistory) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.createNotificationCalls = append(m.createNotificationCalls, map[string]interface{}{
|
||||
"job": job, "history": history,
|
||||
})
|
||||
if m.CreateJobNotificationFunc != nil {
|
||||
return m.CreateJobNotificationFunc(job, history)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
func (m *mockTransferNotifier) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sendNotificationsCalls = nil
|
||||
m.createNotificationCalls = nil
|
||||
}
|
||||
|
||||
// Mock TransferMetadataHandler
|
||||
var _ TransferMetadataHandler = (*mockTransferMetadataHandler)(nil)
|
||||
|
||||
type mockTransferMetadataHandler struct {
|
||||
mu sync.Mutex
|
||||
HasFileBeenProcessedFunc func(jobID uint, fileHash string) (bool, *db.FileMetadata, error)
|
||||
CheckFileProcessingHistoryFunc func(jobID uint, fileName string) (*db.FileMetadata, error)
|
||||
}
|
||||
|
||||
func (m *mockTransferMetadataHandler) hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error) {
|
||||
if m.HasFileBeenProcessedFunc != nil {
|
||||
return m.HasFileBeenProcessedFunc(jobID, fileHash)
|
||||
}
|
||||
return false, nil, nil // Default: not processed
|
||||
}
|
||||
func (m *mockTransferMetadataHandler) checkFileProcessingHistory(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
if m.CheckFileProcessingHistoryFunc != nil {
|
||||
return m.CheckFileProcessingHistoryFunc(jobID, fileName)
|
||||
}
|
||||
return nil, fmt.Errorf("mock history not found for %s", fileName) // Default: not found
|
||||
}
|
||||
func (m *mockTransferMetadataHandler) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// Reset any stored state if needed in the future
|
||||
}
|
||||
|
||||
// --- Mock os/exec ---
|
||||
|
||||
// MockExecCommand replaces the package-level execCommandContext variable (defined in transfer_executor.go)
|
||||
// with a function provided by the test and returns a function to restore the original.
|
||||
// Note: This helper replaces the *command creation*. The provided mockFunc
|
||||
// needs to return an *exec.Cmd. To mock the *execution result* (like CombinedOutput),
|
||||
// use the TestHelperProcess approach below or a similar technique.
|
||||
func MockExecCommand(mockFunc func(ctx context.Context, command string, args ...string) *exec.Cmd) (restore func()) {
|
||||
original := execCommandContext
|
||||
execCommandContext = mockFunc
|
||||
return func() { execCommandContext = original }
|
||||
}
|
||||
|
||||
// TestHelperProcess isn't a real test, but a helper process function.
|
||||
// It's triggered when tests run this binary with specific arguments and env vars.
|
||||
// Based on env vars like GO_TEST_HELPER_PROCESS_WANT_ERROR, it prints to stdout/stderr
|
||||
// and exits with 0 or 1.
|
||||
func TestHelperProcess(t *testing.T) {
|
||||
// Check if this invocation is intended to be the helper process
|
||||
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate command execution based on environment variables
|
||||
mockOutput := os.Getenv("GO_TEST_HELPER_PROCESS_OUTPUT")
|
||||
mockStderr := os.Getenv("GO_TEST_HELPER_PROCESS_STDERR")
|
||||
wantError := os.Getenv("GO_TEST_HELPER_PROCESS_WANT_ERROR") == "1"
|
||||
|
||||
// Print the mock output/stderr
|
||||
fmt.Fprint(os.Stdout, mockOutput)
|
||||
fmt.Fprint(os.Stderr, mockStderr)
|
||||
|
||||
// Exit with appropriate code
|
||||
if wantError {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// --- Test Setup ---
|
||||
|
||||
type testExecutorComponents struct {
|
||||
db *mockTransferDB
|
||||
logger *Logger
|
||||
logBuf *bytes.Buffer
|
||||
metadata *mockTransferMetadataHandler
|
||||
notifier *mockTransferNotifier
|
||||
executor *TransferExecutor
|
||||
}
|
||||
|
||||
func setupTestExecutor() testExecutorComponents {
|
||||
dbMock := &mockTransferDB{}
|
||||
logger, logBuf := newTestLogger(LogLevelDebug)
|
||||
metadataMock := &mockTransferMetadataHandler{}
|
||||
notifierMock := &mockTransferNotifier{}
|
||||
executor := NewTransferExecutor(dbMock, logger, metadataMock, notifierMock)
|
||||
|
||||
return testExecutorComponents{
|
||||
db: dbMock,
|
||||
logger: logger,
|
||||
logBuf: logBuf,
|
||||
metadata: metadataMock,
|
||||
notifier: notifierMock,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestExecuteSimpleCommand_Success(t *testing.T) {
|
||||
comps := setupTestExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
job := db.Job{ID: 1, Name: "Simple Job"}
|
||||
config := db.TransferConfig{ID: 10, SourceType: "local", SourcePath: "/src", DestinationType: "local", DestinationPath: "/dst"}
|
||||
history := &db.JobHistory{ID: 100, JobID: 1, ConfigID: 10}
|
||||
configPath := "/tmp/test_rclone.conf"
|
||||
cmdName := "ls"
|
||||
cmdType := "listing"
|
||||
expectedOutput := "file1.txt\nfile2.txt\n"
|
||||
|
||||
// Mock exec.CommandContext using the TestHelperProcess strategy
|
||||
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
||||
cs := []string{"-test.run=TestHelperProcess", "--"} // Args for test binary
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], cs...) // Run the test binary itself
|
||||
cmd.Env = []string{ // Set env vars for the helper process
|
||||
"GO_TEST_HELPER_PROCESS=1",
|
||||
fmt.Sprintf("GO_TEST_HELPER_PROCESS_OUTPUT=%s", expectedOutput),
|
||||
"GO_TEST_HELPER_PROCESS_WANT_ERROR=0",
|
||||
}
|
||||
return cmd
|
||||
})
|
||||
defer restoreExec()
|
||||
|
||||
// Replace the direct call to exec.Command with our context-aware version
|
||||
// This requires modifying the code under test slightly, or ensuring execCommandContext is used.
|
||||
// Assuming TransferExecutor uses execCommandContext internally (needs verification/refactor)
|
||||
// For now, we proceed assuming the mock intercepts correctly.
|
||||
// If TransferExecutor directly calls exec.Command, this mock won't work without refactoring TransferExecutor.
|
||||
|
||||
// Let's assume TransferExecutor needs refactoring to use execCommandContext.
|
||||
// We'll add a TODO in the original code and proceed with the test logic.
|
||||
// TODO: Refactor TransferExecutor to use execCommandContext instead of exec.Command
|
||||
|
||||
comps.executor.executeSimpleCommand(cmdName, cmdType, job, config, history, configPath)
|
||||
|
||||
// Assertions
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.updatedHistory == nil {
|
||||
t.Fatal("Expected UpdateJobHistory to be called, but it wasn't")
|
||||
}
|
||||
if comps.db.updatedHistory.Status != "completed" {
|
||||
t.Errorf("Expected history status 'completed', got %q", comps.db.updatedHistory.Status)
|
||||
}
|
||||
// Note: FilesTransferred calculation based on output lines happens *after* CombinedOutput
|
||||
// in the original code. Our mock simulates CombinedOutput directly.
|
||||
// The test needs to align with how the code under test processes the output.
|
||||
// For "listing", it counts lines.
|
||||
if comps.db.updatedHistory.FilesTransferred != 2 { // Based on lines in expectedOutput
|
||||
t.Errorf("Expected FilesTransferred 2, got %d", comps.db.updatedHistory.FilesTransferred)
|
||||
}
|
||||
if !strings.Contains(comps.db.updatedHistory.ErrorMessage, expectedOutput) {
|
||||
t.Errorf("Expected history ErrorMessage to contain command output %q, got %q", expectedOutput, comps.db.updatedHistory.ErrorMessage)
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
comps.notifier.mu.Lock()
|
||||
if len(comps.notifier.sendNotificationsCalls) != 1 {
|
||||
t.Errorf("Expected 1 call to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, "Successfully executed command 'ls'") {
|
||||
t.Errorf("Expected success log message, but got:\n%s", logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSimpleCommand_Failure(t *testing.T) {
|
||||
comps := setupTestExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
job := db.Job{ID: 2, Name: "Fail Job"}
|
||||
config := db.TransferConfig{ID: 20, SourceType: "local", SourcePath: "/src", DestinationType: "local", DestinationPath: "/dst"}
|
||||
history := &db.JobHistory{ID: 200, JobID: 2, ConfigID: 20}
|
||||
configPath := "/tmp/test_rclone.conf"
|
||||
cmdName := "copy"
|
||||
cmdType := "transfer"
|
||||
expectedStderr := "Some rclone error"
|
||||
|
||||
// Mock exec.CommandContext using the TestHelperProcess strategy for failure
|
||||
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
||||
cs := []string{"-test.run=TestHelperProcess", "--"}
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], cs...)
|
||||
cmd.Env = []string{
|
||||
"GO_TEST_HELPER_PROCESS=1",
|
||||
fmt.Sprintf("GO_TEST_HELPER_PROCESS_STDERR=%s", expectedStderr),
|
||||
"GO_TEST_HELPER_PROCESS_WANT_ERROR=1", // Indicate failure
|
||||
}
|
||||
return cmd
|
||||
})
|
||||
defer restoreExec()
|
||||
|
||||
// TODO: Refactor TransferExecutor to use execCommandContext instead of exec.Command
|
||||
|
||||
comps.executor.executeSimpleCommand(cmdName, cmdType, job, config, history, configPath)
|
||||
|
||||
// Assertions
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.updatedHistory == nil {
|
||||
t.Fatal("Expected UpdateJobHistory to be called, but it wasn't")
|
||||
}
|
||||
if comps.db.updatedHistory.Status != "failed" {
|
||||
t.Errorf("Expected history status 'failed', got %q", comps.db.updatedHistory.Status)
|
||||
}
|
||||
// The error message should contain the stderr output captured by CombinedOutput
|
||||
if !strings.Contains(comps.db.updatedHistory.ErrorMessage, "Command Error:") || !strings.Contains(comps.db.updatedHistory.ErrorMessage, expectedStderr) {
|
||||
t.Errorf("Expected history ErrorMessage to contain 'Command Error:' and stderr %q, got %q", expectedStderr, comps.db.updatedHistory.ErrorMessage)
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
comps.notifier.mu.Lock()
|
||||
if len(comps.notifier.sendNotificationsCalls) != 1 {
|
||||
t.Errorf("Expected 1 call to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, "Error executing command 'copy'") {
|
||||
t.Errorf("Expected error log message, but got:\n%s", logOutput)
|
||||
}
|
||||
if !strings.Contains(logOutput, expectedStderr) {
|
||||
t.Errorf("Expected stderr %q in log output, but got:\n%s", expectedStderr, logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add tests for executeConfigTransfer (file-by-file)
|
||||
// - Success case
|
||||
// - Error during lsjson
|
||||
// - Error parsing lsjson
|
||||
// - No files found
|
||||
// - Error during individual file transfer
|
||||
// - Skipping processed files (hash match)
|
||||
// - Skipping processed files (name match, skip enabled)
|
||||
// - Re-processing file (skip disabled)
|
||||
// - Archiving success
|
||||
// - Archiving failure
|
||||
// - Deleting success
|
||||
// - Deleting failure
|
||||
// - Concurrent transfers limit
|
||||
// - Output pattern usage
|
||||
// - Filter usage
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user